0%

CNSS Recruit 2024 Re WP

HARD写不出来了所以来写博客

👸闻香识女人(一)

用 IDA 打开就是。

👸闻香识女人(二)

用 IDA 打开,按照提示走。
值得注意的是第三点并不是指函数的交叉引用。

👸 闻香识女人(三)

报错数组越界,直接 Patch Program 把 200 改了就好。

👸 闻香识女人(四)

加密算法直接给出来了,只要通过动态调试得到加密后的字符串再解密就好了。

Windows 没办法运行 ELF 文件,只能用 Remote Linux Debuger。

然后打断点,找到 string,使用快捷键 a 将数据转化为字符串,然后解密即可。

👐 没有人比我更懂 2048

还是 Patch Program,把目标分数改小就行了。

🤣 7he M0s7 e@sY C艹

涉及到了 C++ 类和析构函数,但是不知道有什么意义。

有眼睛就行。

🧋 asm 奶茶

学就完了,略。

❤️ Enigma

正好之前读一本好像叫《密码学浅谈》的书读到了 Enigma,但没什么用。

先把文件拖到 jd-gui 看源代码,然后把源码复制到 Eclipse。已经可以看到正确的 key 和 ciper 了。而且发现有 encrypt 和 encode 两层加密。

Machine 的代码有点小复杂,但我们只需要管 encrypt 和 encode 里的东西。用眼睛看,发现 encrypt 调用 encryptChar 依次加密每个字符,加密完后 indexCC++。有封装好的函数,那就简单了,检查每一个可见字符,看它加密完是不是就是 ciper 里的字符就搞定了。encode 更是一目了然,无需多言。

所以直接对原始的 ciper 异或,把结果用上面的方法处理一下就好了。

解密后发现结果不对,但确实是 meaningful_sentence,于是解密 Hint,稍作修改得到答案。

所以“加密的性质”其实可以不管的。

🍋 Lemonade

以为 MID 应该比较难,这题应该想办法改下代码,结果:

alt text

🤡👈🤣

我的流程是这样的:

先丢 jadx-gui 看代码,看不懂,模拟器运行一下,发现 114514,然后再看代码找到关键逻辑,感觉和 👐 没有人比我更懂 2048 很像,于是尝试 Patch Program。先是尝试用 jadx-gui 导出 Gradle 项目,但是失败了,还是用 apktools 解包然后改 dex 文件,重新打包签名,再用模拟器运行得到代码。

中间出了个小插曲,我抄网上的命令给 apktools 加了个 -r 参数,结果运行的时候白屏了。。。

后来查了发现 -r 参数是不反编译资源。emmm… 不太懂怎么会出错。

🐍 Snake’s Luv Letter

Python 字节码,学就完了,学完人肉翻译。

或者也可以用 uncompyle6 反编译。

并不能用 uncompyle6 反编译,因为下载的是一个 txt 文件,里面的内容相当于用 dis 反汇编 pyc 文件的结果,而 uncompyle6 只能处理原始的 pyc 文件。

👓 Vr Mvp

小小虚拟机,跟着流程走发现是个循环,很容易还原出对应的代码,然后写个解密函数就完了。

🦋 FlowerDance

先脱壳。

又小丑了,第一次看就发现了 114514,但是不知道 SMC,所以以为还有别的花指令,然后跟着流程找了半天🤡👈🤣

先浅学一下前置知识,然后看函数列表,首先发现一个 start 类的构造函数和析构函数,点进去看,发现花指令,NOP 掉,然后 F5 反汇编,发现 VirtualProtect 函数,根据前置知识,这代表有 SMC,果然析构函数干了一些奇怪的事。这时候我们祭出 IDA Python,把这个析构函数干的事给干了,点进被修改的地址,发现是一堆不明所以的数据,再根据不知道从哪看到的东西,这很可能是段代码,于是选中按 c 转成代码,然后按部就班解密就行。

😇 Good Exception!

看到 Base64,首先尝试直接解密,不出意外得到了一个假的 flag:

1
cnss{This_is_a_fake_flag.Try_Harder.}

看了下表是正确的,就看了下前置知识和 Hint,猜测需要用到动态调试,遂尝试动态调试,输入一个 cnss 打头的字符串,和结果不一样,就知道有问题。

搜了下前置知识,看到有点眼花,先等等再研究。

题面提到原题是 Sh1no 出的,于是想翻一翻 Sh1no 的博客,顺便翻了一下 CNSS 其他成员的博客。

没有找到原题,但是看到 Sh1no 的某 WP 提到了 int 3 指令。当时没有注意,后来看汇编看到了 int 3,于是知道这是进行了反动态调试处理。

第二天早上重新翻了下汇编代码,发现 key[i%12] 中的 key 只有 5 个字节,感到不解,然后往下看,看到一个 X-ref,接着找到一个奇怪的东西:

alt text

用眼睛看,发现异常处理是改变了 ciper,即 flag 加密后的值,只需要找出正确的 ciper,然后解密即可。

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
#include <cstdio>

char ciper[] = "Y25zc3tUaGlzX2lzX2FfZmFrZV9mbGFnLlRyeV9IYXJkZXIufQ==";
char key[] = { 0x49, 0x4C, 0x6F, 0x76, 0x65, 0x53, 0x68, 0x31, 0x4E, 0x30, 0x3F, 0x21 };
char alpha[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
long long v2[6];

int main() {
v2[0] = 0x5D036D100B122C24ll;
v2[1] = 0x1E526F774A020C73ll;
v2[2] = 0x20708730B4B3E5All;
v2[3] = 0x595538461B032726ll;
v2[4] = 0x4F0775711E045877ll;
v2[5] = 0x406155744D24700Ell;
for (int j = 0; j < 48; j++) {
ciper[j] = *((char *)v2 + j) ^ 0xE;
}
// 小端序
ciper[51] = 0x5 ^ 0xE;
ciper[50] = 0x15 ^ 0xE;
ciper[49] = 0x2C ^ 0xE;
ciper[48] = 0x76 ^ 0xE;
for (int i = 0; i < 52; i++) {
int tmp = key[i % 12];
ciper[i] ^= tmp;
}
for (int i = 0; i < 52; i++) {
putchar(ciper[i]);
}
return 0;
}

虽然做出来了,但是还是不太懂断点和异常处理的机制,害怕被 Sh1n0 关爱,后面还是得学习一下。

后记:

回来看了一下这题很多是误打误撞搞出来的,一开始我甚至没有注意到硬件断点(因为我一开始动态调试下断点的位置就是预先设置的硬件断点那里,我还以为 Cyril 预判了我的断点🙈)。

实际上 magic 构造函数中注册了一个异常处理函数和一个硬件断点。在 base64 反编译的界面可以看到一个 __debugbreak(),即汇编中的 INT 3,这是一个软件断点。遇到硬件断点时, _scanf 改变 key 数组的内容,并改变 rip 跳过一个语句(当时我只是觉得这个语句不合逻辑,会溢出,所以删了😝);遇到软件断点时,则改变 ciper 数组的内容。

我当时什么也不会,居然也给我做出来了🤓

😍 DANCE WITH UID

吸取 🦋 FlowerDance 的教训,准备先学 hint,看懵了,感觉少了点前置知识,于是先放弃。后来 Cryill 说不需要什么前置知识,然后又开始做。

刚开始抱着视死如归的心态,打开 IDA 从 start 函数开始看,一句一句查这些汇编语句是什么意思(前面只学了一点点),结果好像都没什么用(。然后发现运行到 _setuid 的时候就找不到从哪里继续了,于是想到动态调试。

动态调试发现到了一个巨大的函数,应该就是所谓主分发器。这个程序变量数不多(不像某 ez WASM),运行几次后发现每次进入一个之后都会重新 setuid,然后经过函数前面的一些操作,rax 的值就变成了上一个分支最后的 uid。

然后就很简单了,只要像 Vr Mvp 一样模拟就可以了,甚至不需要再动态调试。

最后可以得出加密函数:

1
2
3
4
5
6
7
8
9
10
11
void encode() {
for (int i = 0; i < 27; i++) {
str[i] = str[i] ^ str[i+1];
}
for (int i = 0; i < 28; i++) {
str[i] = (str[i] << 4) | (str[i] >> 4);
}
for (int i = 1; i < 28; i++) {
str[i] = str[i] ^ str[i-1];
}
}

再写出相应的解密函数:

1
2
3
4
5
6
7
8
9
10
11
12
void decode() {
for (int i = 27; i >= 1; i--) {
str[i] = ciper[i] ^ ciper[i-1];
}
str[0] = ciper[0];
for (int i = 0; i < 28; i++) {
str[i] = (str[i] << 4) | (str[i] >> 4);
}
for (int i = 26; i >= 0; i--) {
str[i] = str[i] ^ str[i+1];
}
}

有个要注意的点是数据类型,用 char 显示超出范围,于是换成 stdint.h 里的 uint16_t,但是还是出现了一个乱码,非常奇怪啊,但是这个乱码非常好猜,直接猜出来交了就完了。

这题我愿称之为动态调试版 Vr Mvp。感觉 Hard 比 Boss 是正常的吗 (虽然我没做 Extra)

后记:

期中考后再看这道题,略微学习了一下 angr(但是没用到) 和 ollvm 控制流平坦化。在程序里找到了这样一个东西:

研究了一下,这个程序应该是用 setuidgetuid 实现了和 switch 一样的作用,就是控制流平坦化的简单变形。

贴上代码:

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
import idc
import idaapi
import idautils

"""
注意事项:
先把 IDA 的注释关了,不然在 setuid 的时候不能获取 uid
大致思路:
可以发现这就是用 setuid 代替了 switch 的一个控制流平坦化。只要找出 uid 对应的函数地址,把所有 setuid 换成 jmp,顺便 nop 掉一些没用的东西就行了。
# 第一步,记录 uid 到函数地址的映射
# 第二步,把各个函数里的 setuid 换成 uid 对应函数的地址,主分发器只留一个跳转到开始的函数
# 第三步,将 main 函数中的 setuid 替换为 jmp 语句
"""

addr1 = 0x00000000000019B2
addr2 = 0x0000000000001C6E # 从这里开始没有 mov,直接 cmp 了

uid_to_addr = dict() # uid 到地址的映射
searched_uid = [0x5B1972C2] # 记录搜过的 uid

# 辅助函数:把 IDA 中的十六进制数转为 32 位有符号十进制数
def hex_to_dec(str):
if str[:2] == '0F':
return int(str[:-1], 16) - 0x100000000
return int(str[:-1], 16)

# 第一步,记录 uid 到函数地址的映射
for uid in searched_uid: # 类似 BFS?
cur_addr = addr1

# 第一步,在主分发器中找到当前 uid 对应的标签
while True:
cmd = idc.GetDisasm(cur_addr)
cmd = cmd.split(' ')
if (cur_addr < addr2 and cmd[0] == 'mov') or (cur_addr >= addr2 and cmd[0] == 'cmp'):
# print(cmd)
if uid == hex_to_dec(cmd[-1]):
break
cur_addr = idc.next_head(cur_addr)

label = ''
while True:
cmd = idc.GetDisasm(cur_addr)
if 'jz' in cmd:
label = cmd.split(' ')[-1]
break
cur_addr = idc.next_head(cur_addr)

print(label)
# 第二步,在函数中找到下一个 uid

# 跳第一次
cur_addr = idc.get_name_ea_simple(label)

# 跳第二次
cmd = idc.GetDisasm(cur_addr)
label = cmd.split(' ')[-1]
cur_addr = idc.get_name_ea_simple(label)
uid_to_addr[uid] = cur_addr

func = idaapi.get_func(cur_addr)
while cur_addr != func.end_ea:
if 'setuid' in idc.GetDisasm(idc.next_head(cur_addr)):
cmd = idc.GetDisasm(cur_addr)
next_uid = hex_to_dec(cmd.split(' ')[-1])
if not next_uid in searched_uid:
searched_uid.append(next_uid)
cur_addr = idc.next_head(cur_addr)

print(searched_uid)
print(uid_to_addr)

# 第二步,把各个函数里的 setuid 换成 uid 对应函数的地址,把 ud2 nop 掉,主分发器只留一个跳转到开始的函数

# 全都 nop 掉!
cur_addr = 0x0000000000001997
end_addr = 0x0000000000002141 # 再往后就是 return 了
while cur_addr < end_addr:
ida_bytes.patch_byte(cur_addr, 0x90)
cur_addr += 1

# 改函数
for uid in searched_uid:
func = idaapi.get_func(uid_to_addr[uid])
cur_addr = func.start_ea
while cur_addr != func.end_ea:
# call setuid 换成 jmp
if 'setuid' in idc.GetDisasm(idc.next_head(cur_addr)):
cmd = idc.GetDisasm(cur_addr)
next_uid = hex_to_dec(cmd.split(' ')[-1])
size = idc.get_item_size(cur_addr) + idc.get_item_size(idc.next_head(cur_addr))
# 先 nop
for i in range(size + 1):
ida_bytes.patch_byte(cur_addr + i, 0x90)
# 改
ida_bytes.patch_byte(cur_addr, 0xe9)
ida_bytes.patch_dword(cur_addr + 1, uid_to_addr[next_uid] - (cur_addr + 5))
# elif 'ud2' in idc.GetDisasm(cur_addr):
# size = idc.get_item_size(cur_addr)
# for i in range(size + 1):
# ida_bytes.patch_byte(cur_addr + i, 0x90)
cur_addr = idc.next_head(cur_addr)

# 留一个 jmp 到开始的函数
cur_addr = 0x0000000000001997
ida_bytes.patch_byte(cur_addr, 0xe9)
ida_bytes.patch_dword(cur_addr + 1, uid_to_addr[searched_uid[0]] - (cur_addr + 5))

# 第三步,将 main 函数中的 setuid 替换为 jmp 语句
aim_addr = 0x000000000000198F
main_addr = 0x000000000000229A
func = idaapi.get_func(main_addr)
cur_addr = func.start_ea
while cur_addr != func.end_ea:
if 'setuid' in idc.GetDisasm(idc.next_head(cur_addr)):
size = idc.get_item_size(cur_addr) + idc.get_item_size(idc.next_head(cur_addr))
# 先 nop
for i in range(size + 1):
ida_bytes.patch_byte(cur_addr + i, 0x90)
# 改 jmp
ida_bytes.patch_byte(cur_addr, 0xe9)
ida_bytes.patch_dword(cur_addr + 1, aim_addr - (cur_addr + 5))
break
cur_addr = idc.next_head(cur_addr)

# 第四步:undefine 所有函数,然后重新创建一个函数
for uid in searched_uid:
idaapi.del_func(uid_to_addr[uid])
# idaapi.del_func(main_addr)
# idaapi.add_func(main_addr)
"""
原先的思路:
# 第二步,将主分发器全部 nop 掉,替换成 uid 对应的函数内容,并把函数中的 uid 换成 jmp。
# # 全都 nop 掉!
# # 先给每个函数分配空间
# # 再把函数中的 call setuid 换成 jmp
# # 同时对于其他 call 指令,重新计算偏移量
# # call setuid 换成 jmp
# # ud2 nop
# # 最后用函数内容填充主分发器
但是发现各种 mov 也是靠偏移量寻址的,都改了工作量有点大
"""

最后 flag 交了没给我加分qwq

后记

招新结束了,很可惜没有做出两道 HARD,唉唉。

这几个月时不时看看 Cyril 的博客,更新好勤奋啊,唉唉。

本来想做 web 方向,可惜学不明白,唉唉。

本来还打算学学 pwn 的,寒假一定要开始学了,唉唉。

大家都好强啊,唉唉。