2023hxpctf复现

2023hxpctf

web

valentine

该题是一个ejs模板注入,根据官方wp,灵感来源于https://eslam.io/posts/ejs-server-side-template-injection-rce/

整个链子是

1
process.mainModule.require('child_process').execSync('touch /tmp/pwned')

当时chatgpt直接给我了一个链子

1
require('child_process').exec('ls -la', function(error, stdout, stderr) { console.log(stdout) }

但是我绕不过<%=name %>这个东西,需要使用delimiter更改分隔符

https://www.youtube.com/watch?v=diln4KD57xA

2023-03-13 203355

https://hxp.io/blog/101/hxp-CTF-2022-valentine/

1
<.= global.process.mainModule.constructor._load(`child_process`).execSync(`/readflag`).toString() .>

官方wp中使用-,我不清楚是不是有什么讲究,一般按原理来说是=

传入的是

1
delimite=.

原理分析

根据https://eslam.io/posts/ejs-server-side-template-injection-rce/中的

1
2
var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];

这段代码定义了一个名为 _OPTS_PASSABLE_WITH_DATA 的变量,它是一个包含多个字符串元素的数组。

这个数组的元素都是字符串类型的选项名称,用于指定 EJS 模板引擎的渲染选项。这些选项包括:

  • delimiter: 用于指定 EJS 模板中的分隔符,默认值为 %
  • scope: 用于指定模板渲染时的作用域,默认值为 null
  • context: 用于指定模板渲染时的上下文对象,默认值为 null
  • debug: 用于指定是否启用调试模式,默认值为 false
  • compileDebug: 用于指定是否启用编译调试模式,默认值为 undefined
  • client: 用于指定是否生成客户端代码,默认值为 false
  • _with: 用于指定是否将数据对象添加到作用域链中,默认值为 true
  • rmWhitespace: 用于指定是否删除标签间的空格和换行符,默认值为 false
  • strict: 用于指定是否启用严格模式,默认值为 false
  • filename: 用于指定模板的文件名,默认值为 undefined
  • async: 用于指定是否异步执行模板渲染,默认值为 false

这些选项可以在调用 EJS 模板引擎的 rendercompile 方法时进行设置,以便根据需要自定义模板引擎的行为。在get传参中可以直接使用,就像debug=true就会进入ejs的调试

这题分析过程中,delimiter作为用于指定 EJS 模板中的分隔符的标识,就能够利用将本来无法生成模板的<$ ... $>的$转变为模板,然后使用RCE链子获得注入

misc

Secure-Flag-Dispenser

只有一道题目的misc,其中涉及了逆向,密码和dns反向查找

逆向不会,导致算法不知道是个啥,dns查找也是第一次听说,就简单记录一下官方思路:

https://hxp.io/blog/106/hxp-CTF-2022-Secure-Flag-Dispenser/

二进制文件逆向出来的

1
2
3
4
5
将标志和预共享密钥读psk入内存
在端口 42424 上设置监听套接字并接受一个连接
检查客户端是否从 🔒.hxp.io 连接
如果是,则使用pskAES 密钥加密标志并将其发送给客户端
如果不是,用教科书RSA包装随机生成的密钥,将包装密钥和AES加密标志发送给客户端

具体来说,首先将flag,psk读取到内存中

然后创建了一个套接字(socket),设置了套接字选项,将套接字绑定到一个IP地址和端口号,监听传入的连接请求,接受一个传入的连接请求,并使用getentropy函数生成16个随机字节。

然后使用getnameinfo函数将网络地址转换为主机名,并将其与字符串“🔒.hxp.io”进行比较。如果比较成功,代码将初始化几个变量,包括AES加密的密钥,并在CBC模式下使用AES加密对硬编码字符串“hxp{n0t_4_fl4g}”进行加密。然后将加密结果打印到套接字。 如果比较失败,代码会为RSA加密初始化几个变量,将十六进制字符串转换为RSA对象,并执行RSA加密。

使用BN_bin2bn()将16个随机字节的randbytes数组转换为一个大整数msg。然后它使用BN_mod_exp()执行模幂运算,用公钥(rsa_e,rsa_n)加密msg。 生成的加密消息enc_msgenc_msg_bs使用BN_bn2hex()转换为十六进制字符串,并使用dprintf()打印到控制台。 接下来,使用AES_set_encrypt_key()从randbytes派生128位AES密钥,并使用带有该密钥的AES-CBC对明文消息“hxp{n0t_4_fl4g}”进行加密,并作为十六进制字符串打印到控制台。 最后,使用OpenSSL函数OPENSSL_free()和BN_free()释放所有分配的内存,该函数返回0。它还将一些内存位置设置为零,并在返回之前写入文件描述符。

二进制文件的行为取决于客户端是从 🔒.hxp.io 还是从其他地方连接。根据getnameinfo(3)函数,它通过执行反向 DNS 查找来实现。

1
2
3
4
5
6
7
8
dig -x 92.243.26.60

; <<>> DiG 9.18.12-1-Debian <<>> -x 92.243.26.60
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12079
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
dig: 'xn--ls8haaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xn--ls8haaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.xn--ls8haaaaaaaaaaaaaa.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.xn--a.kirschju.re.' is not a legal IDNA2008 name (string contains a disallowed character), use +noidnout

利用 OpenSSL CVE CVE-2022-3602 和 CVE-2022-3786。其中之一允许使用(点)字符溢出 OpenSSL 中的堆栈分配缓冲区.。当将来自反向 DNS 请求的值插入易受攻击的程序时,可以看到randbytes堆栈帧最顶部(1680 字节)的变量.几乎完全被 s 覆盖。这意味着我们知道用于加密发送给客户端的标志的 AES 密钥!

这里没看懂

知识点:

1
2
3
getnameinfo(3)函数用于以与协议无关的方式将套接字地址转换为相应的主机和服务。它与getaddrinfo(3)函数相反,后者用于执行相反的转换(将主机和服务转换为套接字地址)。

dig可以用于反向 DNS 查找

最后的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/env python3

from Crypto.Cipher import AES

enc = bytes.fromhex("e86ff18a103d528ac01d0fbba5d55491f678ee3a7c6dd53135243ddf2e7852b7daa32347eaad1c6c869d6d569e366578c0a442da2e091a24eed12b1e7772a9fb")


# We need to recover three bytes from the AES key
for i in range(2**24):
k = b"." * 13 + int.to_bytes(i, 3, "little")
aes = AES.new(k, iv = b"hxp{n0t_4_fl4g}\x00", mode = AES.MODE_CBC)
dec = aes.decrypt(enc)
if b"hxp" in dec:
print(dec)
1
k = b"." * 13 + int.to_bytes(i, 3, "little")

一个是缓冲区溢出,加上拼接的密钥,暴力破解

enc通过pcap文件可以在tcp流中找到

很难啊,二进制太薄弱了。

crypto

yor

一共两种解法

当然都需要大量的数据,申请得到最长的密文*50

第一种利用z3约束器,限制明文是0x20到0x70,然后增加用密钥按位或运算与密文得到明文的条件得到明文(麻烦,而且不一定对)

第二种很简单,本质上密钥和明文进行的是或运算,不是异或运算(看本质)

所以可以利用只有0|0才会是0的情况,在按或运算中如过明文位上出现1,绝对就是1,0有可能是0或者1

那么使用与运算只有1&1才会是1的情况,可以利用大量的加密数据爆破出明文

1
2
3
4
5
6
7
8
9
10
cts = [bytes.fromhex(a.decode()) for a in enc]

out = [0xFF] * len(cts[0])
print(out)
for i in range(len(cts[0])):
for ct in cts:
out[i] &= ct[i]

print("".join([chr(c) for c in out]))

另外,说一下本地利用pwnlib获取静态的crypto环境

1
2
3
4
5
#!/usr/bin/env python3
import pwnlib.tubes.process
import pwnlib.tubes.remote
a=pwnlib.tubes.process.process('./vuln.py')
print(a.recvline())

这样就可以相当于开了pwn的本地环境了

然后还可以利用bash运行存储数据

1
for i in $(seq 1 50); do python you_exp.py; done > out.txt

本题需要得到固定长度的密文,这样才能确保用同一个明文加密

re

required

第一次写逆向题目,那么简单学习一下,实际上pwn和re是两个方向,但是很多re题目是二进制的所以导致pwn手也是re手。

本题就不是c/c++的逆向,而是js逆向

参考连接:https://www.52pojie.cn//thread-1759330-1-1.html

docker文件如下,其中比较重要的是run.sh

1
2
3
4
5
6
7
8
9
10
11
12
# see docker-compose.yml

FROM node:19

RUN useradd --create-home --shell /bin/bash ctf
WORKDIR /home/ctf

COPY flag files/* /home/ctf/

USER ctf

CMD sh run.sh

run.sh

1
2
3
4
5
6
7
#!/bin/bash

if [ "$(node required.js)" = "0xd19ee193b461fd8d1452e7659acb1f47dc3ed445c8eb4ff191b1abfa7969" ]; then
echo ":)"
else
echo ":("
fi

也就是说如果运行node requird.js = “0xd1…”就会得到flag

再看一下js文件

1
2
3
4
5
6
7
f=[...require('fs').readFileSync('./flag')]
require('./28')(753,434,790)
require('./157')(227,950,740)
require('./736')(722,540,325)
require('./555')(937,26,229)
require('./394')(192,733,981)
....

从flag文件中读取,运行大量的js文件进行加密,也就是说run.sh判断的是密文

问题:

1
2
1. 大量的js文件互相调用,难以判断
2. 如何逆出flag

那么跟着加密js慢慢看

28.js

1
2
3
4
5
6
7
8
9
10
module.exports = (i, j, t) => (
(i += []),
j + "",
(t = (t + {}).split("[")[0]),
(o = {}),
Object.entries(require("./289")(i, j)).forEach(([K, V]) =>
Object.entries(V).forEach(([k, v]) => ((o[K] = o[K] || {}), (o[K][k] = v)))
),
require(`./${i}`)
);

没有存在数据处理,创造了一些对象,调用了289.js,最后返回./${i}

289.js

1
2
3
4
5
6
7
8
module.exports = (i, j, t) => (
(i += []),
j + "",
(t = (t + {}).split("[")[0]),
JSON.parse(
`{"__proto__":{"data":{"name":"./${i}","exports":{".": "./${j}.js"}},"path": "./"}}`
)
);

json.parse解析了字符串,其实就是调用了变量j的js,name被命名为变量i,不知道干啥用。

434.js

1
module.exports=(i,j,t)=>(i%=30,j%=30,t%=30,i+=[],j+"",t=(t+{}).split("[")[0],f[j]^=f[i])

出现了加密计算:

1
f[j]^=f[i])

之后再看一些语句,都是一直调用某个js,然后创建对象,嵌套调用js,直到下一个加密

那么事情就简单很多了,将加密流程输出,写出逆向过程就行了

那就需要判断出有多少种的加密了

1
2
3
4
5
6
7
8
f[i]*=-1,f[i]&=0xff
f[i]+=f[j],f[i]&=0xff
f[i]*=f[25],f[i]&=0xff
f[i]*=f[30],f[i]&=0xff
f[i]/=f[15],f[i]&=0xff
f[i]*=f[13],f[i]&=0xff
....
似乎手动不太行

脚本改一下,使用console.log显示出全部的加密过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import glob
import re

js_files = glob.glob("*.js")

for file in js_files:
if file != 'required.js':
f = open(file, "r")
content = f.read()
if 'i%=30' in content:
pattern = r'f\[.*'
match = re.search(pattern, content)
pattern = re.escape('split("[")[0],')
match1 = re.search(pattern, content)
patch = match.group(0).replace('i', '\" + i + \"').replace('j', '\" + j + \"').replace('t', '\" + t + \"')
p = 'split("[")[0],' + 'console.log("' + patch + '"),'
print(p)
content = content.replace('split("[")[0],',p)
f = open(file, "w")
f.write(content)

然后写出全部的逆过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import re
with open("1.txt", "r") as f:
lines = f.readlines()

# 交换最后一行和第一行
lines[0], lines[-1] = lines[-1], lines[0]

# 倒置除第一行和最后一行以外的所有行
lines[1:-1] = reversed(lines[1:-1])

with open("filename.txt", "w") as f:
f.writelines(lines)

parterm1 = r'f\[\d+\]=~f\[\d+\]&0xff'
# ma1=re.search(parterm1,'f[0]=~f[0]&0xff')

parterm4 = r'f\[\d+\]\^\=f\[\d+\]'
# ma4= re.search(parterm4,'f[24]^=f[12]')

parterm5 = r'f\[\d+\]=\(\(\(f\[\d+\]\*0x0802&0x22110\)\|\(f\[\d+\]\*0x8020\&0'
# ma5 = re.search(parterm5,'f[15]=(((f[15]*0x0802&0x22110)|(f[15]*0x8020&0x88440))*0x10101>>>16)&0xff')

parterm2 = r'f\[\d+\]\-\=f\[\d+\],f\[\d+\]&=0xff'
# ma2= re.search(parterm2,'f[15]-=f[17],f[15]&=0xff')

parterm3 = r'f\[\d+\]\+\=f\[\d+\],f\[\d+\]&=0xff'
# ma3= re.search(parterm3,'f[15]+=f[17],f[15]&=0xff')


parterm6 = r'f\[\d+\]=f\[\d+\]\<\<1\&0xff\|f\[\d+\]\>\>7'
# ma6 = re.search(parterm6,'f[11]=f[11]<<1&0xff|f[11]>>7')

parterm7 = r'f\[\d+\]=f\[\d+\]\<\<7\&0xff\|f\[\d+\]\>\>1'
# ma7 = re.search(parterm7,'f[11]=f[11]<<7&0xff|f[11]>>1')


parterm8 = r'f\[\d+\]=f\[\d+\]\^\(f\[\d+\]\>\>1\)'
# ma8 = re.search(parterm8,'f[14]=f[14]^(f[14]>>1)')


f = open("filename.txt", "r")
w = open("2.txt", "w")
while True:
line = f.readline()
if not line:
break
lines = line.replace('\n', '')
if re.search(parterm1, lines) or re.search(parterm4, lines) or re.search(parterm5, lines):
w.write(line)
elif re.search(parterm3, lines):
pattern = r"\d+"
matches = re.findall(pattern, lines)
num1 = int(matches[0])
num2 = int(matches[1])
new_str = re.sub(rf"f\[{num1}\]\+=f\[{num2}\]", rf"f[{num1}]-=f[{num2}]", lines)
w.write(new_str + "\n")
elif re.search(parterm2, lines):
pattern = r"\d+"
matches = re.findall(pattern, lines)
num1 = int(matches[0])
num2 = int(matches[1])
new_str = re.sub(rf"f\[{num1}\]-=f\[{num2}\]", rf"f[{num1}]+=f[{num2}]", lines)
w.write(new_str + "\n")
elif re.search(parterm6, lines):
pattern = r"\d+"
matches = re.findall(pattern, lines)
new_str = re.sub(rf"<<1", rf"<<7", lines)
new_str = re.sub(rf">>7", rf">>1", new_str)
w.write(new_str + "\n")
elif re.search(parterm7, lines):
pattern = r"\d+"
matches = re.findall(pattern, lines)
new_str = re.sub(rf"<<7", rf"<<1", lines)
new_str = re.sub(rf">>1", rf">>7", new_str)

w.write(new_str + "\n")
elif re.search(parterm8, lines):
pattern = r"\d+"
matches = re.findall(pattern, lines)
num = int(matches[0])
new_str = 'for (var i = 0; i <= 255; i++) {\n' + 'if ((i^(i>>1)) == f[' + str(num) + ']) {\n' + 'f[' + str(
num) + ']=i\n'
new_str = new_str + 'break\n' + "}}"
w.write(new_str + "\n")

将密文转为整型

1
2
3
s=bytes.fromhex("d19ee193b461fd8d1452e7659acb1f47dc3ed445c8eb4ff191b1abfa7969")
for i in s:
print(int(i),end=',')

放入处理好的js文件当中,最后添加console.log(f);

node exp.js得到

1
2
3
4
5
6
7
[
104, 120, 112, 123, 67, 97, 110,
110, 48, 116, 95, 102, 49, 110,
100, 95, 109, 48, 100, 117, 108,
101, 95, 39, 102, 108, 52, 103,
39, 125
]

解密可得

1
2
3
4
5
6
7
8
9
t=[
104, 120, 112, 123, 67, 97, 110,
110, 48, 116, 95, 102, 49, 110,
100, 95, 109, 48, 100, 117, 108,
101, 95, 39, 102, 108, 52, 103,
39, 125
]
for i in t:
print(chr(i),end='')