首页
社区
课程
招聘
[原创] 看雪 2022 KCTF 春季赛 第五题 危机四伏
2022-5-20 04:58 9121

[原创] 看雪 2022 KCTF 春季赛 第五题 危机四伏

2022-5-20 04:58
9121

(看到出题战队顿感不妙,必是一道硬题)

 

(p.s. 本贴算不上writeup,只是一篇零散的记录)

 

惯例 IDA,main 函数 F5 报错 "function frame is wrong"

 

不过我们还有 Ghidra,虽然反编译效果远不如 IDA,但是对于某些畸形文件有奇效(例如,IDA 要求一个函数的范围是连续的,Ghidra 就没有这个限制)。

 

Ghidra 看 main 函数的逻辑(可以配合 IDA,重新标记变量类型和函数签名),很普通的 fgets 获取 name 和 key 的输入,然后 name paddnig到 16 字节,key hexdecode 后是 32 字节。(p.s. 这里的 hexdecode 不限制大小写,所以题目多解)
接下来初始化了一串常量,调用疑似 zlib 算法解码。
能看到 main 函数还调用了 VirtualAlloc,大概猜测前面的逻辑是解码 shellcode。

 

中间抛出了 C++ 异常。找到了几篇个人感觉不错的文章,对程序执行流程和相关的数据结构都有比较系统的介绍:

可以在 IDA 里沿着 _main_SEH 结构体一路找到所有的 catch 块与对应的 throw,不过 Ghidra 直接把所有的 catch 块都识别为了函数,且这些块都在 main 函数后面。顺着往下看,在 0x401efa 处发现了 retf 指令(这大概就是 IDA F5 失败的原因)。

 

(以上其实全都没用,还不如一开始就直接动态调试)

 

x32dbg调试,hexdecode 后的 32 字节的 key 保存在 0x8934b9 处。在 0x401efa 处下断点,按 shift+F9 无视异常执行,发现 retf 的目标地址就是之前 VirtualAlloc 出来的内存开头,先 dump 出来。
retf 指令之前有 push 0x33,所以 retf 指令修改了 cs 寄存器把架构从 32位切换到了 64位。
按 64 位架构反编译,IDA 的效果不好(需要不停的手动 make code),Ghidra 相当舒服(能自动按照 jmp 指令生成基本块)。
shellcode 由大量基本块构成,每个块只有若干条指令,然后 jmp 到其他块,以此类推。Ghidra 识别为了函数,但是基本块太多,反编译时报了 "Decompiler process died" 错误。

 

(看着汇编指令,梦回去年春季赛第七题,完全一样的混淆,瞬间绝望)

 

这种切换架构的程序目前只有 WinDbg (x64) 能够正常调试(虽然体验比 x64dbg 差很多)。对 0x8934b9 下数据断点,确实能在 shellcode 里的访存指令断下来。
而且当 shellcode 结束后,0x8934b9 开始的长 32字节的值会变成 base64 码表的字符。

 

单步调试跟了一段时间后放弃,找出自己去年的 writeup,先按老方法 unicorn 跑一遍 trace,得到了 400多万 行汇编(麻了);然后再筛一遍 taint,筛完竟然还剩 47万 多行汇编。
看了下筛选结果,大部分都是死代码(所以,不做死代码消除根本达不到化简的效果,而且脚本存在大量bug,跑出来的结果应该也严重失实)

 

走投无路之时,不知道为啥试了下把题目给的公开输入的key输入到去年题目的exp中,没想到输出的竟然就是题目给的公开输入的name!今年的题目算法与去年的(至少主要部分)完全一样!

 

去年的算法,从key得到name的过程大概有4个阶段:

  • 第一阶段:hexdecode + 一些可逆的变换,得到 32字节的ascii字串
  • 第二阶段:换表base64 解码,得到 24字节
  • 第三阶段:前8字节和后16字节分别做变换,其中后16字节变换后就是padding过的name

动态调试可发现第一处 retf (0x401efa) 只做了第一阶段的变换,从 0x8934b9 获取到的变换后的值与去年的算法相同。
同理,第二处 retf (0x402158) 完成了第二和第三阶段的变换,可以发现后16字节的结果写入了 0x8995f1 处,前8字节的结果写入了0x8935d9

 

继续分析 main 函数,发现程序在 0x4021a1 处验证了 0x8935d9 处的8个字节都是0,在 0x40229d 处验证了 0x8995f1 与输入的 name padding之后的值相等。两个验证都通过则输出"Succeed!"。

 

观察发现 0x8995f1 的16字节结果与去年的算法相同,而0x8935d9 处的8个字节结果与去年的算法不同。8字节的变换是一个循环异或再异或常量,合理猜测算法没变,只是常量变了。因为第二阶段的算法没有变化,所以稍微推导一下即可得到新的“常量”。(实际上不是常量,而是随着后16字节变化的,但是当name固定为KCTF时可以按常量处理)

 

exp:(仅适用于name输入为KCTF的情况;与去年的算法相比只有doencode3/dodecode3的常量不同)

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
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
def p16(n):
    n &= 0xffff
    return n.to_bytes(2, 'little')
 
def u16(s):
    assert(len(s) == 2)
    return int.from_bytes(s, 'little')
 
def p64(n):
    n &= 0xffffffffffffffff
    return n.to_bytes(8, 'little')
 
def u64(s):
    assert(len(s) == 8)
    return int.from_bytes(s, 'little')
 
def rol16(n, k):
    n &= 0xffff
    return ((n << k) | (n >> (16-k))) & 0xffff
 
def ror16(n, k):
    n &= 0xffff
    return ((n >> k) | (n << (16-k))) & 0xffff
 
def doencrypt1(buf, indextable):
    assert(len(buf) == 16)
 
    buf1 = bytearray(buf)
 
    v1 = u16(buf1[0:2])    # s1 ^ k4
    v2 = u16(buf1[2:4])    # s2 ^ k4
    v3 = u16(buf1[4:6])    # s3 + k3
    v4 = u16(buf1[6:8])    # s4 - k3
    v5 = u16(buf1[8:0xa])   # s5 + k2
    v6 = u16(buf1[0xa:0xc])    # s6 + k2
    v7 = u16(buf1[0xc:0xe])    # s7 ^ k1
    v8 = u16(buf1[0xe:0x10])    # s8 ^ k1
 
    t1 = v2 ^ v1    # s2 ^ s1
    t2 = (v4 + v3) & 0xffff    # s4 + s3
    t3 = (v5 - v6) & 0xffff    # s5 - s6
    cl = bin(v8 ^ v7).lstrip("0b").count('1')    # s8 ^ s7# hamming distance
 
    v7 = ror16(v7, cl)
    v8 = ror16(v8, cl)
 
    k1 = ( (t2 & t1) | ((~t1) & t3) ) & 0xffff
    k2 = ( ((k1 * t1) >> cl) + 0x18) & 0xffff
    k3 = ( k2 ^ t3 ) & 0xffff    # 0x79c6
    k4 = ( (k2 & k1) | (k3 & (k2 | k1)) ) & 0xffff
 
    s1 = p16(v1 ^ k4)
    s2 = p16(v2 ^ k4)
    s3 = p16(v3 - k3)
    s4 = p16(v4 + k3)
    s5 = p16(v5 - k2)
    s6 = p16(v6 - k2)
    s7 = p16(v7 ^ k1)
    s8 = p16(v8 ^ k1)
 
    buf1[indextable[0]] = s1[1]
    buf1[indextable[8]] = s1[0]
    buf1[indextable[1]] = s2[1]
    buf1[indextable[9]] = s2[0]
    buf1[indextable[2]] = s3[1]
    buf1[indextable[10]] = s3[0]
    buf1[indextable[3]] = s4[1]
    buf1[indextable[11]] = s4[0]
    buf1[indextable[4]] = s5[1]
    buf1[indextable[12]] = s5[0]
    buf1[indextable[5]] = s6[1]
    buf1[indextable[13]] = s6[0]
    buf1[indextable[6]] = s7[1]
    buf1[indextable[14]] = s7[0]
    buf1[indextable[7]] = s8[1]
    buf1[indextable[15]] = s8[0]
 
    return buf1
 
 
def dodecrypt1(serial, indextable):
    assert(len(serial) == 16)
 
    buf1 = bytearray(serial)
 
    s1 = (buf1[indextable[0]] << 8) | buf1[indextable[8]]
    s2 = (buf1[indextable[1]] << 8) | buf1[indextable[9]]
    s3 = (buf1[indextable[2]] << 8) | buf1[indextable[10]]
    s4 = (buf1[indextable[3]] << 8) | buf1[indextable[11]]
    s5 = (buf1[indextable[4]] << 8) | buf1[indextable[12]]
    s6 = (buf1[indextable[5]] << 8) | buf1[indextable[13]]
    s7 = (buf1[indextable[6]] << 8) | buf1[indextable[14]]
    s8 = (buf1[indextable[7]] << 8) | buf1[indextable[15]]
 
    '''
    s1 = u16(buf1[0xb:0xd])    # 0x80c8
    s2 = u16(buf1[9:0xb])    # 0x8b84
    s3 = u16(buf1[7:9])    # 0xefa8
    s4 = u16(buf1[5:7])    # 0xf12e
    s5 = u16(buf1[3:5])    # 0x0x7a
    s6 = u16(buf1[1:3])    # 0xba4d
    s7 = (buf1[0] << 8) | buf1[0xf]    # 0x7d70
    s8 = u16(buf1[0xd:0xf])    # 0x31b8
    '''
 
    t1 = s2 ^ s1    # 0xb4c
    t2 = (s4 + s3) & 0xffff    # 0xe0d6
    t3 = (s5 - s6) & 0xffff    # 0xe22d
    cl = bin(s8 ^ s7).lstrip("0b").count('1')    # 6    # hamming distance
 
    print(hex(s1), hex(s2), hex(s3), hex(s4), hex(s5), hex(s6), hex(s7), hex(s8))
    print(hex(t1), hex(t2), hex(t3), hex(cl))
 
    k1 = ( (t2 & t1) | ((~t1) & t3) ) & 0xffff    # 0xe065
    buf1[0xc:0xc+2] = p16(rol16(s7 ^ k1, cl))
    buf1[0xe:0xe+2] = p16(rol16(s8 ^ k1, cl))
 
    k2 = ( ((k1 * t1) >> cl) + 0x18) & 0xffff    # 0x9beb
    buf1[0x8:0x8+2] = p16(s5 + k2)
    buf1[0xa:0xa+2] = p16(s6 + k2)
 
    k3 = ( k2 ^ t3 ) & 0xffff    # 0x79c6
    buf1[0x6:0x6+2] = p16(s4 - k3)
    buf1[0x4:0x4+2] = p16(s3 + k3)
 
    k4 = ( (k2 & k1) | (k3 & (k2 | k1)) ) & 0xffff
    buf1[0x0:0x0+2] = p16(s1 ^ k4)
    buf1[0x2:0x2+2] = p16(s2 ^ k4)
 
    print(hex(k1), hex(k2), hex(k3), hex(k4))
 
    return buf1
 
def doencode2(s):
    assert(len(s) % 3 == 0)
    chartable = [149, 226, 128, 198, 234, 195, 213, 141, 158, 197, 179, 98, 100, 77, 118, 186, 146, 253, 222, 127, 66, 114, 129, 173, 121, 84, 115, 133, 134, 94, 241, 132, 106, 245, 99, 216, 254, 168, 192, 200, 79, 201, 199, 3, 123, 229, 223, 2, 0, 13, 31, 60, 19, 34, 37, 59, 43, 23, 170, 160, 246, 151, 89, 88, 109, 15, 12, 6, 61, 27, 14, 33, 20, 38, 45, 4, 18, 28, 9, 1, 46, 51, 53, 35, 32, 11, 56, 26, 50, 44, 57, 124, 209, 242, 92, 117, 161, 8, 36, 16, 40, 41, 63, 49, 7, 10, 47, 17, 25, 30, 62, 21, 29, 42, 58, 52, 22, 5, 54, 55, 39, 48, 24, 108, 74, 122, 68, 152, 150, 105, 196, 235, 204, 73, 190, 181, 72, 113, 148, 225, 163, 177, 120, 250, 83, 70, 64, 203, 188, 71, 131, 193, 238, 249, 232, 97, 169, 251, 194, 210, 76, 85, 218, 247, 126, 217, 143, 172, 227, 82, 96, 155, 233, 86, 156, 137, 87, 180, 81, 125, 176, 116, 142, 162, 157, 237, 182, 224, 95, 252, 75, 110, 165, 65, 208, 167, 187, 240, 140, 145, 101, 185, 212, 230, 135, 184, 191, 248, 236, 159, 154, 211, 111, 147, 93, 102, 136, 67, 91, 80, 243, 130, 183, 206, 103, 231, 244, 255, 175, 205, 214, 220, 171, 104, 90, 138, 221, 239, 228, 189, 78, 164, 119, 178, 69, 166, 153, 112, 219, 107, 215, 144, 207, 174, 139, 202]
    rchartable = [None]*256
    for i, c in enumerate(chartable):
        rchartable[c] = i
 
    r = b""
    for i in range(0, len(s), 3):
        x, y, z = s[i], s[i+1], s[i+2]
        a = (x & 0x3) | ((z & 0xf) << 2)
        b = (y & 0x3c) | ((x & 0xc) >> 2)
        c = (x >> 4) | ((y & 0x3) << 4)
        d = ((y & 0xc0) >> 2) | (z >> 4)
        r += bytes([rchartable[a], rchartable[b], rchartable[c], rchartable[d]])
    return r
 
 
def dodecode2(s):
    assert(len(s) % 4 == 0)
    chartable = [149, 226, 128, 198, 234, 195, 213, 141, 158, 197, 179, 98, 100, 77, 118, 186, 146, 253, 222, 127, 66, 114, 129, 173, 121, 84, 115, 133, 134, 94, 241, 132, 106, 245, 99, 216, 254, 168, 192, 200, 79, 201, 199, 3, 123, 229, 223, 2, 0, 13, 31, 60, 19, 34, 37, 59, 43, 23, 170, 160, 246, 151, 89, 88, 109, 15, 12, 6, 61, 27, 14, 33, 20, 38, 45, 4, 18, 28, 9, 1, 46, 51, 53, 35, 32, 11, 56, 26, 50, 44, 57, 124, 209, 242, 92, 117, 161, 8, 36, 16, 40, 41, 63, 49, 7, 10, 47, 17, 25, 30, 62, 21, 29, 42, 58, 52, 22, 5, 54, 55, 39, 48, 24, 108, 74, 122, 68, 152, 150, 105, 196, 235, 204, 73, 190, 181, 72, 113, 148, 225, 163, 177, 120, 250, 83, 70, 64, 203, 188, 71, 131, 193, 238, 249, 232, 97, 169, 251, 194, 210, 76, 85, 218, 247, 126, 217, 143, 172, 227, 82, 96, 155, 233, 86, 156, 137, 87, 180, 81, 125, 176, 116, 142, 162, 157, 237, 182, 224, 95, 252, 75, 110, 165, 65, 208, 167, 187, 240, 140, 145, 101, 185, 212, 230, 135, 184, 191, 248, 236, 159, 154, 211, 111, 147, 93, 102, 136, 67, 91, 80, 243, 130, 183, 206, 103, 231, 244, 255, 175, 205, 214, 220, 171, 104, 90, 138, 221, 239, 228, 189, 78, 164, 119, 178, 69, 166, 153, 112, 219, 107, 215, 144, 207, 174, 139, 202]
 
    tmp = bytearray(len(s))
    for i, c in enumerate(s):
        tmp[i] = chartable[c]
 
    for c in tmp:
        assert(c < 0x40)
 
    r = b""
    for i in range(0, len(tmp), 4):
        a, b, c, d = tmp[i], tmp[i+1], tmp[i+2], tmp[i+3]
        x = ((c << 4) & 0xff) | ((b & 0x3) << 2) | (a & 0x3)
        y = ((d & 0xf0) << 2) | (b & 0x3c) | ((c & 0x3f) >> 4)
        z = ((d << 4) & 0xff) | ((a & 0x3f) >> 2)
        r += bytes([x, y, z])
    return r
 
def doencode3(r, constn=0x8267f5d9b0ea143c):
    assert(len(r) == 8)
    r = p64(u64(r)^constn)
    s = bytearray(8)
    s[3] = r[0]
    s[2] = r[2] ^ s[3]
    s[1] = r[1] ^ s[2]
    s[0] = r[3] ^ s[1]
    s[7] = r[4]
    s[6] = r[6] ^ s[7]
    s[5] = r[5] ^ s[6]
    s[4] = r[7] ^ s[5]
    return s
 
def dodecode3(s, constn=0x8267f5d9b0ea143c):
    assert(len(s) == 8)
    r = bytearray(8)
    r[0] = s[3]
    r[1] = s[1] ^ s[2]
    r[2] = s[2] ^ s[3]
    r[3] = s[0] ^ s[1]
    r[4] = s[7]
    r[5] = s[5] ^ s[6]
    r[6] = s[6] ^ s[7]
    r[7] = s[4] ^ s[5]
    return p64(u64(r)^constn)
 
def name_to_serial(name):
    assert(len(name) <= 16)
 
    #buf5 = bytearray(b"\x9a\x1e\x1d\x1c\x1b\x1a\x19\x18\x17\x16\x15\x14\x13\x12\x11\x10")
    buf5 = bytearray(16-i for i in range(16))
    buf5[:len(name)] = name
    if len(name) < 16:
        buf5[len(name)] = 0
    print(buf5.hex())
 
    # stage4
 
    indextable3 = [0xb, 0xd, 0xf, 0x1, 0x3, 0x5, 0x7, 0x9, 0xc, 0xe, 0x0, 0x2, 0x4, 0x6, 0x8, 0xa]
    buf3_2 = doencrypt1(buf5, indextable3)
    print(buf3_2.hex())
 
    # stage3
 
    print("buf5", buf5[:8])
    #buf3_1 = doencode3(buf5[:8])
    buf3_1 = doencode3(b'\0'*8, u64(bytes.fromhex("5E 87 C4 F6 8F C6 60 81".replace(" ",""))))
    print(buf3_1.hex())
 
    buf3 = buf3_1 + buf3_2
 
    # stage2
 
    buf2 = doencode2(buf3)
    print(buf2.hex())
 
    # stage1
 
    indextable1 = [0xc, 0xa, 0x8, 0x6, 0x4, 0x2, 0x0, 0xe, 0xb, 0x9, 0x7, 0x5, 0x3, 0x1, 0xf, 0xd]
    indextable2 = [0xb, 0x9, 0x7, 0x5, 0x3, 0x1, 0xf, 0xd, 0x8, 0x6, 0x4, 0x2, 0x0, 0xe, 0xc, 0xa]
 
    serial = doencrypt1(buf2[:16], indextable1) + doencrypt1(buf2[16:], indextable2)
    print("serial")
    print(serial.hex())
 
    return serial
 
def serial_to_name(serial):
    assert(len(serial) == 32)
 
    # stage1
 
    indextable1 = [0xc, 0xa, 0x8, 0x6, 0x4, 0x2, 0x0, 0xe, 0xb, 0x9, 0x7, 0x5, 0x3, 0x1, 0xf, 0xd]
    indextable2 = [0xb, 0x9, 0x7, 0x5, 0x3, 0x1, 0xf, 0xd, 0x8, 0x6, 0x4, 0x2, 0x0, 0xe, 0xc, 0xa]
 
    buf2 = dodecrypt1(serial[:16], indextable1) + dodecrypt1(serial[16:], indextable2)
    print("buf2:", buf2.hex(), buf2)
 
    # stage2
 
    buf3 = dodecode2(buf2)
    print("buf3", buf3.hex())
 
    # stage3
 
    print(buf3[:8].hex())
    buf4 = dodecode3(buf3[:8])
 
    # stage4
 
    indextable3 = [0xb, 0xd, 0xf, 0x1, 0x3, 0x5, 0x7, 0x9, 0xc, 0xe, 0x0, 0x2, 0x4, 0x6, 0x8, 0xa]
    buf5 = dodecrypt1(buf3[8:], indextable3)
 
    print(buf5)
 
    # stage5
 
    #assert(buf4 == buf5[:8])
 
    return bytes(buf5)
 
print(name_to_serial(b"KCTF").hex().upper())

题目答案:

1
2
3
Input name:KCTF
Input key:39ED62B341BC560217EAB3BF90265D101067856B36495264144A5B487264CB4B
Succeed!

================================================================================

 

破解VMP类型的混淆,感觉最优的做法应该就是充分利用编译器的优化能力去除冗余指令。

 

之前在公众号看到了的一篇推文 [原创]angr符号变量转LLVM IR,作者 R1mao 实现了把 angr 的符号变量转换为 LLVM IR 的Python程序。文中还提到了一个项目 VMProtect-devirtualization ,给出了完整的基于符号执行和LLVM重编译的示例。
R1mao 到目前为止在论坛发了3篇文章,都是非常硬核的干货好文,值得学习)

 

除了符号执行,还可以考虑 retdec (retdec反编译器的特点是支持重新编译)(参考此帖评论区)

 

(注:以上方案都未在本题尝试,希望有兴趣的人可以折腾下看看效果)


[竞赛]2024 KCTF 大赛征题截止日期08月10日!

最后于 2022-5-20 05:00 被mb_mgodlfyn编辑 ,原因:
收藏
免费 2
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回