逆向工程入门指北

已经给出源代码,加密逻辑为异或22,那再异或回来就拿到flag了。

1
2
3
4
enflag = [123, 121, 115, 117, 98, 112, 109, 100, 37, 96, 37, 100, 101, 37, 73, 39, 101, 73, 119, 73, 122, 121, 120, 113, 73, 122, 121, 120, 113, 73, 97, 119, 111, 73, 98, 121, 73, 115, 110, 102, 122, 121, 100, 115, 107, 22]
flag = ''.join(chr(x ^ 22) for x in enflag)
print(flag)
# moectf{r3v3rs3_1s_a_long_long_way_to_explore}

xor

拖到IDA里F5一下先。

程序比较输入逐字节异或0x24后的结果与0x1400022B8处开始的44字节是否相等。把这些字节异或0x24即可得到flag。

双击跳转到对应内存区域,右键Convert可以把它导出成各种数据类型。

1
2
3
4
enflag = [0x49, 0x4B, 0x41, 0x47, 0x50, 0x42, 0x5F, 0x41, 0x1C, 0x16, 0x46, 0x10, 0x13, 0x1C, 0x40, 0x09, 0x42, 0x16, 0x46, 0x1C, 0x09, 0x10, 0x10, 0x42, 0x1D, 0x09, 0x46, 0x15, 0x14, 0x14, 0x09, 0x17, 0x16, 0x14, 0x41, 0x40, 0x40, 0x16, 0x14, 0x47, 0x12, 0x40, 0x14, 0x59, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
flag = ''.join(chr(x ^ 0x24) for x in enflag)
print(flag)
# moectf{e82b478d-f2b8-44f9-b100-320edd20c6d0}

upx

先拖到DIE里看看。正如标题所言,程序使用了upx压缩。

咱们先upx -d脱一下壳,然后拖到IDA里。

嘿!直接把flag告诉咱了。

moectf{ec5390dd-f8cf-4b02-bc29-3bb0c5604c29}

dynamic

标题提示需要动调,不过还是先拖到IDA看看。看到一堆回调,这里就不放了。

那我们先Shift+F12看看字符串。

咱跳过去看看引用。

估计这块就是真正的主函数了,再跳到sub_140011DC0这儿。

推测字节数组v5就是加密后的flag。根据输出提示这块应该是先用sub_14001129E解密了v5,然后又调用它进行加密。密钥v6还藏了个小彩蛋。

然后x64dbg在第二次加密前下个断点即可。

在“符号”这儿拿到程序的基址,到IDA里rebase一下(编辑->段->重新设置基址)。

然后在0x7FF695761EEB下断点,拿到flag了!

moectf{18d4c944-947c-4808-9536-c7d34d6b3827}

upx-revenge

还是先upx -d,发现报错,不然咋叫revenge呢!

估摸着是修改了特征码或者别的啥,拖到010editor看看。

upx区段名被修改了。直接把vmp0vmp1改回UPX0UPX1看看成不成。成了。

拖IDA里又直接告诉flag了。

moectf{554ea35c-a1bb-4d8f-a323-bd697564bf27}

TEA

拖到IDA看看。

程序先让咱输入16进制的x,y,z。估计是保存到v9,v10,v11里了。那么它们分别谁是谁呢?

看一下汇编。可以看到lea rdx, [rsp+48h+...]的片段。这实际上就是lea rdx, [rbp+...]

Windows x64传参顺序为rcx,rdx,r8,r9,...,这意味着三次调用的第二个参数分别是指向v10,v11,v9的指针,接下来的v4v5xxxxxxxxyyyyzzzz

(当然完全可以动调或者靠猜啥的也行)

接下来就是一个对[v4,v5]的tea加密了,密钥是base64xorteaxtea-0x61C88647+0x9E3779B9效果是一样的。这是一个原封不动的tea。

假如这就是一个全新但可逆的由cfbb本人设计的算法,我们该怎么解密?

我们就从第32次循环开始倒推。这时$v_3=32\cdot delta$,$v_5$被加后的值只依赖于手头已有的$v_3$和$v_4$的末值,那么用$v_5$减去加上的值就得到了这一轮加密之前的$v_5$,类似地可以得到$v_4$。如此循环就能得到$v_4,v_5$的初值。这种加解密过程极为相似的分组密码结构被称为Feistel结构。那么我们可以编写如下解密算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

int v3 = -32 * 1640531527;
unsigned int v4 = 676078132, v5 = 957400408;

int main() {
for (int i = 0; i < 32; ++i) {
v5 -= (v4 + v3) ^ (16 * v4 + 1634038898) ^ ((v4 >> 5) + 1634038904);
v4 -= (v5 + v3) ^ (16 * v5 + 1702060386) ^ ((v5 >> 5) + 1870148662);
v3 += 1640531527;
}
printf("moectf{%8x-%4x-%4x-9c42-caf30620caaf}", v4, v5 >> 16, v5 & 0xffff);
return 0;
}
// moectf{836153a5-8e00-49bd-9c42-caf30620caaf}

xtea

拖到IDA老样子看字符串跳过去。

0x140022000处存着加密后的flag。仿照我们上一题TEA的流程,我把sub_14001119F改成解密函数,把这里整个流程倒过来执行一遍(目的和源也交换)不就解密好了?进去看看。

这其实就是xtea算法,不过这里$delta$换成了0xCCFFBBBB()。就算你完全不知道,解密流程就和TEA一样把加密过程倒过来就好了!

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
#include <stdio.h>
#include <string.h>

char Str[48] = {
0xA3, 0x69, 0x96, 0x26, 0xBD, 0x78, 0x0B, 0x3D, 0x9D, 0xA5, 0x28, 0x62
};
char Src[32] = {0};
char v14[28] = {0xBD, 0x78, 0x0B, 0x3D, 0x9D, 0xA5, 0x28, 0x62};
int v12[12] = {2, 0 ,2, 4};

void sub_1400148C0(unsigned int a1, unsigned int *a2, unsigned char *a3)
{
unsigned int v5;
unsigned int v6;
unsigned int v7;
v6 = a2[1];
v5 = *a2;
v7 = -855655493 * a1;
for (int i = 0; i < a1; ++i)
{
v6 -= (*(unsigned int*)(a3 + 4 * ((v7 >> 11) & 3)) + v7) ^ (v5 + ((v5 >> 5) ^ (16 * v5)));
v7 += 855655493;
v5 -= (*(unsigned int*)(a3 + 4 * (v7 & 3)) + v7) ^ (v6 + ((v6 >> 5) ^ (16 * v6)));
}
a2[1] = v6;
*a2 = v5;
}

int main() {
sub_1400148C0(32, v14, v12);
memcpy(&Str[4], v14, 8);
memcpy(Src, Str, 8);
sub_1400148C0(32, Src, v12);
memcpy(Str, Src, 8);
printf("moectf{%s}", Str);
return 0;
}
// moectf{moectf2024!!}

xxtea

拖到IDA,又是熟悉的配方。v9是加密后的flag。

点进去看是xxtea。但是这次解密算法也被包含在程序里了(if那儿)!我们只要把v10的值改成其相反数,再把这里sub_14001105F的第一个参数改成v9就能在v9[rbp+8]拿到flag。patch一下或者动调都可以。

moectf{j9h8hg75nky6vhkslh5v5awibr4i}

d0tN3t

题目告诉咱是.NET逆向了,拖到dnSpy(或者ILSpy)。

逻辑清晰明了,直接写脚本:

1
2
3
4
array = [...]
flag = ''.join(chr(((((x ^ (i * i)) % (1 << 8)) ^ 114) - 114)) for (i, x) in enumerate(array))
print(flag)
# moectf{7ce581d2-b2ab-4ceb-9bbe-435873083db6}

rc4

老样子看字符串。

输入flag,调用sub_401360RC4_1s_4w3s0m3对输入进行rc4加密,结果存放在v14所指内存中。sub_404748应该是strlensub_401B01估计是mallocsub_404679应当是memcmp。最后比较v14v16所指内存存放内容是否一致。

而rc4是一种自逆的对称加密算法,即对于明文$m$,密钥$k$,密文$c$,有$Enc_k(Enc_k(m))=m$,也就是$Enc_k(c)=m$,将参数中的明文地址v17替换成密文地址v16就能直接在v14[rbp-88h]处所指的内容得到flag。

v16[rbp-78h],那直接patch一下把上面这句换成mov rsi, [rbp-78h]即可(刚好4字节)。

然后pwndbg在0x401540处下断就可以看到flag了。

moectf{why_Rc4_haS_The_Rev32sabl3_pr0ceSS}

逆向工程进阶指北

加密就这句。

*(p + i) = (*(p + i) * 0xccffbbbb + 0xdeadc0de) ^ 0xdeadbeef + 0xd3906

注意+的优先级高于^。脱掉异或和加法,最后还要脱掉乘法——怎么脱?

注意这都是unsigned int的运算,即已知$b=xa\bmod 2^{32}$。求$x$只需要求$ba^{-1}\bmod 2^{32}$,而$\mathrm{0xccffbbbb}^{-1}\bmod 2^{32}=\mathrm{0x8d61d173}$,那么最后乘0x8d61d173就可以得到原数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>

unsigned char a[45] = {0};
unsigned int b[] = {0xb5073388 , 0xf58ea46f , 0x8cd2d760 , 0x7fc56cda , 0x52bc07da , 0x29054b48 ,
0x42d74750 , 0x11297e95 , 0x5cf2821b , 0x747970da , 0x64793c81};

int main() {
unsigned int *p = (unsigned int *)a;
for (int i = 0; i < 11; ++i)
*(p + i) = ((b[i] ^ (0xdeadbeef + 0xd3906)) - 0xdeadc0de) * 0x8d61d173;
printf("%s", a);
return 0;
}
// moectf{c5f44c32-cbb9-444e-aef4-c0fa7c7a6b7a}

moedaily

顶级好活之Excel逆向。

先看看各单元格做了什么。显然D/E14-D/E19D11的内容按4字节分组。

可以猜测F/G14-F/G19是各4字节对应的无符号数,点开一看果真如此(以F14为例),它将每4个字符转换为对应的无符号数(小端)。

=CODE(MID(D14,1,1))+CODE(MID(D14,2,1))*256+CODE(MID(D14,3,1))*256*256+CODE(MID(D14,4,1))*256*256*256

再看一下D12,这里理应有判断D11内容是否正确的逻辑。可以看到它将H/I14-H/I19的内容与指定值比较来判断正确与否,那么H/I14-H/I19应该就是F/G14-F/G19加密后的结果。

=IF(LEN(D11)=48,IF(AND(AND(AND(AND(AND(AND(H14=1397140385,I14=2386659843),AND(H15=962571399,I15=3942687964)),AND(H16=3691974192,I16=863943258)),AND(H17=216887638,I17=3212824238)),AND(H18=3802077983,I18=1839161422)),AND(H19=1288683919,I19=3222915626)),"恭喜你,拿到了真的FLAG","FLAG输入错了,再试试"),"flag长度不对")

如果修改一下输入,会发现H/I14-H/I19的密文中仅有对应的两个单元格会被改变,这意味着加密方式应是某种块间无关的64位分组加密,我们只要分析任意一行两个单元格的加密逻辑即可。看看H14I14

=s3cr3t!E54=s3cr3t!F54

s3cr3t中定位到E54

=BITAND(E53+BITXOR(BITLSHIFT(F53,4)+C38,BITXOR(F53+D54,BITRSHIFT(F53,5)+D38)),4294967295)

E54 = (E53 + ((F53 << 4) + C38) ^ ((F53 + D54) ^ ((F53 << 5) + D38))) & 0xFFFFFFFF

看到上面这个应该就很眼熟了,这不就是TEA加密么?接着看F54

=BITAND(F53+BITXOR(BITLSHIFT(E54,4)+E38,BITXOR(E54+D54,BITRSHIFT(E54,5)+F38)),4294967295)

F54 = (F53 + ((E54 << 4) + E38) ^ ((E54 + D54) ^ ((E54 << 5) + F38))) & 0xFFFFFFFF

检查D54可以看到D54 = 114514 + D53。推测key[] = {C38, D38, E38, F38}delta = 114514

往上看32轮看到A38B38,可以看到它们来自E18F18,而上面是用同样的keydeltaA2B2进行加密,而它们来自Sheet1F14G14

由此可以推测进行了两次TEA加密,编写如下解密脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
enc = [[1397140385, 2386659843], [962571399, 3942687964], [3691974192, 863943258], [216887638, 3212824238], [3802077983, 1839161422], [1288683919, 3222915626]]
delta = 114514
keys = [[114514, 1919810], [415144, 19883]]
flag = b''
for block in enc:
a0, a1 = block
for it in range(2):
for i in range(64, 0, -1):
key = keys[(i + 1) % 2]
a0, a1 = (a1 - (((a0 << 4) + key[0]) ^ (a0 + delta * ((i + 1) // 2)) ^ ((a0 >> 5) + key[1]))) % (1 << 32), a0
flag += a0.to_bytes(4, 'little') + a1.to_bytes(4, 'little')
print(flag)
# moectf{3xC3l_1S_n0t_just_f0r_d41ly_w0rk_bu7_R3V}

moejvav

java逆向,拖到jadx里康康。一开始把输入字符的ASCII值异或202再加32来加密。

然后是一段仿虚拟机的代码,从vmInsn数组中取指取操作数并执行相应操作。可以看到这个try-catch块实际上和switch insn差不多。需要注意insn取3, 4, 5时(即e4, e5, e6对应的catch块)操作可能是不可逆的,不过实际执行时没有用到这些“指令”。

从上边可以看到insn取0时会从array取出一个值存到store,取6时会比较store与操作数是否相等,从而此时的操作数应为输入第二次加密后的值。

那么我们可以顺序执行这些“指令”,每当遇到操作6时就把操作数存到store,并把上一个操作0至此的所有指令全部倒序执行一遍(当然操作2要替换成减),此后((store - 32) ^ 202) % 256就是flag中一个字符的ASCII值。从而可以编写如下脚本拿到flag:

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
vmInsn = [...]
flag = ''
i = 0
while i < 310:
insn = vmInsn[i]
i += 1
if (insn == 114514):
break
if insn == 0:
ops = [(0, 0)]
elif insn != 6:
ops.append((insn, vmInsn[i]))
i += 1
elif insn == 6:
store = vmInsn[i]
i += 1
for j in range(len(ops) - 1, -1, -1):
op, num = ops[j]
if op == 0:
break
elif op == 1:
store ^= num
elif op == 2:
store -= num
elif op == 3 or op == 4 or op == 5:
print('Irreversible')
flag += chr(((store - 32) ^ 202) % (1 << 8))
print(flag)
# moectf{jvav_eXcEpt10n_h4ndl3r_1s_s0_c00o0o1}

sm4

key对输入的48字节进行加密(sm4,不过不重要,因为程序包含了解密函数)。

但是需要注意Data_plain的长度只有48字节,这样key的第一个字节会被覆盖为0x00

下面调用decode_fun又对encode_Result解密并将结果存放在decode_Result,可以直接把encode_Result替换成密文enc[rbp-60h],这样就能拿到flag。

给它们都patch掉!顺便下面那个printf也改一下让它直接输出flag。

然后运行一下就有flag了:

moectf{Congratulations_you_are_an_SM4_master!!!}

ezMAZE

拖DIE,有upx。

脱掉upx扔IDA,一个迷宫。起点(2, 2),终点(75, 55)。

地图大小80*56,sub_140001190应该是用来判断下一个位置是否有效的。

sub_140001190的逻辑:地图上的的点是byte_140005000数组中的比特位。检查(x, y)对应比特位,为1不能走;为0能走并把该位标记为1之后不能再走。

然后把这个逻辑抄过来,再写个BFS跑出最短路径的走法,输入就能拿到flag(这里下标稍微改了下):

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
import queue


def solve(maze):
directions = [(0, -1, 'w'), (0, 1, 's'), (-1, 0, 'a'), (1, 0, 'd')]
start, end = (1, 1), (74, 54)
q = queue.Queue()
vis = [[0 for j in range(56)] for i in range(80)]
q.put((start, ''))
while q:
(x, y), path = q.get()
if (x, y) == end:
return path
for dx, dy, d in directions:
x_, y_ = x + dx, y + dy
if 0 <= x_ < 80 and 0 <= y_ < 56 and maze[x_][y_] == 0 and vis[x_][y_] == 0:
vis[x_][y_] = 1
q.put(((x_, y_), path + d))
return 'Err'


compressed = [...]
maze = [[0 for _ in range(56)] for __ in range(80)]
for i in range(1, 81):
for j in range(1, 57):
maze[i - 1][j - 1] = (compressed[10 * j - 10 + (i - 1) // 8] >> (7 - (i - 1) % 8)) & 1
print(solve(maze))
# sddddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddsssdsdssddddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddsssdddwdddddddddddddddwwaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaaawwddddddddddddddddwwwwaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaawwdddddddddddddddwwddddddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddssaaaaaaaaaaaaaaassdddddddddssssaaaaaaaaaassdddddddssaaaaaassddddddddddddssaaaaaaaaaaaassdddddddddddsssssaaaaaaaaaaaaaassaaaaaaaaaaaaaaassdddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddssssaaaaaaaaaaaaaaaassddddddddddddddddssaaaaaaaaaaaaaaassdddddddddddddddssaaaaaaaaaaaaaaassaaaaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaaawwawawwwaaaaaaaaaaaaaawwddddddddddddddwwaaaaaaaaaaaaaawwddddddddddddddwwaaaaaaaaaaaaaaaasssssssssssssssssssssssssddddddddddddddddddddddddddddddddddwddddddddddddddddddwdddwwwdddddddddddddddwwaaaaaaaaaaaaaaawwddddddwwaaaaaaawwddddddddddddddssdddwwwawwwaaaaaaaaaaaaaaawwdddddddddddddddwwaaaaaaaaaaaaaaawwdddddddddddddddwwwwwwwwwwwwwwwwwwwwwdddssssssssssssssssssssssssssssssssssssssssssss

moectf{the_18446744024826406994_amazing_maze!!}

Just-Run-It

只要成功运行这四个程序就能拿到flag。可是事情也没有太简单。

0x0.exe能直接在win64运行,这没啥好说的。

在wsl试图运行0x1.elf,依赖有点问题。

不过还好只是加了个upx,脱壳之后可以拿到。

0x2.apk需要在Android 14上运行。

而我没有实体机,Android Studio也因为各种原因无法启动ARM架构的模拟器,反编译工具也无法反编译程序的关键部分——

然后我就找了个在线的模拟器凑合了:

0x3.riscv64.elf是一个RISC-V架构下的64位Linux程序,IDA无法反编译(字符串里还有”zaku~zaku~“的雌小鬼字样)

那么要想拿到flag,最方便的方式就是在qemu创建一个RISC-V架构的64位虚拟机并运行这个程序(而不是阅读RISC-V汇编)

这里有一个比较方便的教程。照着做就有如下一长串命令来安装并启动虚拟机:

1
qemu-system-riscv64 -M virt -m 2048M -smp 4  -bios fw_jump.bin -kernel u-boot.bin -drive file=ubuntu-22.04.4-preinstalled-server-riscv64+unmatched.img,format=raw,if=virtio -drive file=riscv_disk.img,format=raw,if=virtio -netdev user,id=net0,hostfwd=tcp::2222-:22 -device virtio-net-device,netdev=net0 -nographic -device virtio-rng-pci

上面的命令中hostfwd参数将宿主机2222端口转发到客户机22即ssh端口,我们能通过scp将0x3.riscv64.elf传输给客户机:

1
scp -P 2222 0x3.riscv64.elf ubuntu@localhost:~/

然后就能拿到最后一块flag碎片了。

显然前面三块碎片应该是ASCII字符串的hex,第四块是ASCII值,拼起来拿到原字符串。

bW9lY3RmezU5ZmE2MDJjLTYyNGEtNDBiNy04YTVjLWUzNWU1NzRjZjliOX0=

一眼base64,解码拿到flag。

moectf{59fa602c-624a-40b7-8a5c-e35e574cf9b9}

SecretModule

模块刷入时会执行customize.sh,我们直接阅读这个脚本。

1
2
#!/bin/bash
eval "$(printf '...' | base64 -d | bunzip2 -c)"

脚本自解压并用eval执行,被执行的代码为:

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
testk() {
echo "Welcome to the Secret module!But before you begin,you need to prove your self."
(/system/bin/getevent -lc 1 2>&1 | /system/bin/grep VOLUME | /system/bin/grep " DOWN" > $MODPATH/events) || return 1
return 0
}

choose() {
while true; do
/system/bin/getevent -lc 1 2>&1 | /system/bin/grep VOLUME | /system/bin/grep " DOWN" > $MODPATH/events
if (`cat $MODPATH/events 2>/dev/null | /system/bin/grep VOLUME >/dev/null`); then
break
fi
done
if (`cat $MODPATH/events 2>/dev/null | /system/bin/grep VOLUMEUP >/dev/null`); then
echo "114514"
else
echo "1919810"
fi
}

if testk; then
ui_print "Great! Now enter the secret."

else
ui_print "Legacy Device. Use a newer device to do this challenge"
exit
fi

concatenated=""

for i in 1 2 3 4 5 6 7
do
result=$(choose)
concatenated="${concatenated}${result}"
done

input_str=$(echo -n $concatenated | md5sum | awk '{print $1}')
sec="77a58d62b2c0870132bfe8e8ea3ad7f1"
if test $input_str = $sec
then
echo 'You are right!Flag is'
echo "moectf{$concatenated}"
else
echo 'Wrong. Try again.'
exit
fi

脚本监听音量上下键按键事件,按上给字符串$concatenated拼接114514,按下拼接1919810,重复7次并检查$concatenated的MD5值是否为$sec,最终flag为moectf{$concatenated},从而可以写如下脚本爆破:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from Crypto.Hash import MD5
s1 = '114514'
s2 = '1919810'
sec = '77a58d62b2c0870132bfe8e8ea3ad7f1'
for i in range(1 << 7):
s = ''
for j in range(7):
if i & (1 << j):
s += s1
else:
s += s2
digest = MD5.new()
digest.update(s.encode())
if digest.hexdigest() == sec:
print('moectf{' + s + '}')
break
# moectf{114514114514191981011451411451419198101919810}

Cython-Strike: Bomb Defusion

.pyd放DIE里看看,一个开了Themida保护的DLL库。

拖到IDA,发现.data段没有被保护

装好模块之后用dir看一下模块提供的类和方法。

尝试使用一下这些方法。

之后使用read_memory方法发现读出来的内容就是之前IDA里看到的那段。

...#de.i.e MAX_... 0xffffff...unsign.d int ma..;...int p.ant_b..b(unsigned int input){if .input <= MAX_...) {ma.. = input;r..urn 0;} e.se.{...ret..n -1;} }...void expl.de_b.m...oid).{...void def..e_..mb(v.id){...}...check_p..d(uns.gned.int in.u.).{if.(input.> MAX_...).{...explode_b..b();...}...if.((in..t ^ ma.. == 114) && (input << (ma.. % 5) + 1 == 60578736)).{defuse_b..b();r..urn..}...explode_b..b();...retu..;...}............................................................................................

这应该差不多是enter_pwd里的逻辑,b.enter_pwd(60578736 >> (b.mask % 5) + 1)就能拿到flag了。至于in..t ^ ma.. == 114这个判断,应该是有另外一个名字差不多的变量蒙混过关的。

moectf{CoUnter_TerR0rists_w1n}

SMCProMax

上来看到这个sub_401000。首先调用VirtualProtect修改相应页面内存保护选项为读写执行,然后给0x40105E0x401427的所有字节全部异或0x90,还原代码并执行。

那直接用IDA给它patch回去。

1
2
3
4
5
from idc import *
from ida_bytes import *

for i in range(0x40105E, 0x401428):
patch_byte(i, get_wide_byte(i) ^ 0x90)

往后看一看可以发现这里才应该是sub_401000结束的地方。

再修改一下函数结束地址和函数类型让IDA正常反编译。

然后就能正常阅读了。虽然不知道为什么,我设置了合适的参数,但它还是buffer给标成retaddr[1](让我强迫症看着有点不爽)

开头先给buffer[23]异或0x12,然后对buffer中40字节每4字节以相同逻辑加密,并与给定密文比较。

这个if-else看起来似乎有些难搞,但稍微仔细一点会发现根据v25 & 1就能判断它前一轮加密进入了哪个分支。

上轮进入else分支时必有v25 < 0v25 & (1 << 31) != 0,而乘2即左移一位时会抹掉最高位,因此还原时需要恢复;若是上轮进入if分支,则直接右移一位即可。那么我们就能编写如下解密程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

unsigned int enc[10] = {2053092702, -490481854, -1704322843, -1418679088, 86802781, 987171458, 965631658, 264545711, -1342106783, -825370173};

unsigned int decrypt(unsigned int x) {
for (int i = 0; i < 32; ++i)
x = (x & 1) ? ((1 << 31) + ((x ^ 0xC4F3B4B3) >> 1)) : (x >> 1);
return x;
}

int main() {
for (int i = 0; i < 10; ++i)
enc[i] = decrypt(enc[i]);
*((unsigned char *)enc + 23) ^= 0x12;
printf("%s", (char *)enc);
return 0;
}
// moectf{y0u_mu5t_know_vvZAt_1s__SMC__n0w}

xor(大嘘)

扔进来一看似乎人畜无害,简单的加密和比较。

看看sub_401100,似乎也很简单,就异或一下嘛。

不过拿到了假flag:

This_1s_a_f4k3_flag_plzTry_ag4in

怎么回事呢,来直接看看汇编。

call $+5其实就是push 0x401159(也不一定,基址可能有变化,总之是下一条指令的地址)并执行下一条指令。

然后add [esp], 6让刚才push的地址刚好跳过addretn占用的这6字节。

从而最后retn会让eip指向retn的下一条指令。

而IDA遇到retn就会认定这是一个函数结束的位置,反编译就会被这种junk code干扰。直接把它们都nop掉,再修改一下函数结束地址就好了。

可以看到除了开头那轮异或还进行了一次TEA和一轮异或。那么可以编写如下程序解密:

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 <stdio.h>

unsigned char a1[33] = {0x3C, 0x0D, 0x05, 0x1F, 0x30, 0x6E, 0x1E, 0x30, 0x04, 0x3C, 0x12, 0x52, 0x59, 0x03, 0x6D, 0x52, 0x04, 0x04, 0x0B, 0x33, 0x1F, 0x33, 0x17, 0x3B, 0x17, 0x1A, 0x2B, 0x07, 0x55, 0x04, 0x5B, 0x5A, 0};
unsigned int dword_404078[] = {0x6C6C6568, 0x6F6D5F6F, 0x66746365, 0x34323032};

int main() {
char v8[32];
int v2;
unsigned int v4, v5;

v8[...] = ...;
for (int i = 0; i < 32; ++i)
a1[i] ^= v8[i];
for (int i = 0; i < 4; ++i) {
v4 = *(unsigned int*)(a1 + 8 * i + 4);
v5 = *(unsigned int*)(a1 + 8 * i);
v2 = -1640531527 * 32;
for (int j = 0; j < 32; ++j) {
v4 -= (dword_404078[3] + (v5 >> 5)) ^ (v2 + v5) ^ (dword_404078[2] + 16 * v5);
v5 -= (dword_404078[1] + (v4 >> 5)) ^ (v2 + v4) ^ (dword_404078[0] + 16 * v4);
v2 += 1640531527;
}
*(unsigned int*)(a1 + 8 * i + 4) = v4;
*(unsigned int*)(a1 + 8 * i) = v5;
}
for (int i = 0; i < 32; ++i)
a1[i] ^= *((unsigned char*)dword_404078 + i % 16);
printf("%s", a1);
return 0;
}
// moectf{how_an_easy_junk_and_tea}

babe-z3

加了upx,脱掉扔IDA,可以看到输入按8字节分组放在sv11v12v13中。

然后判断一系列条件是否成立,注意最后一个if表明第一个条件需要不成立。

然后把前七个约束添加到z3里就能求解了。这里sv11v12v13都是64位位向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from z3 import *


def f(x):
return solver.model()[x].as_long().to_bytes(8, 'little').decode()


solver = Solver()
s = BitVec('s', 64)
v11 = BitVec('v11', 64)
v12 = BitVec('v12', 64)
v13 = BitVec('v13', 64)
solver.add((v12 & v13 ^ (v11 | ~(v13 + v11)) & s & v12) != 0xD81AC01FBBA91837)
solver.add((v11 ^ (v12 & (s + v12) | v13 & ~(v12 & s) | v11 & (v11 + v13) & ~s)) == 0x7071001344417B54)
solver.add(((v12 - v13) ^ (s - v11)) == 0x3FE01013130FFD3)
solver.add((s + v12 - v13 + v11) * (v13 + s + v11 - v12) == 0x1989A41A9049C5C9)
solver.add((v12 + v11 + s + v13) % 0x1BF52 == 21761)
solver.add(v13 * v12 * v11 * s % 0x1D4B42 == 827118)
solver.add((v12 & s & v13 | (~(v11 | s) | v11 & v12) & v11 & s) == 0x2024243035302131)
if solver.check() == sat:
print('moectf{' + f(s) + f(v11) + f(v12) + f(v13) + '}')
# moectf{9c0525dcbadf4cbd9715067159453e74}

moeprotector

静态调试

脱掉upx,扔到IDA,看起来没什么有用的代码。

但是在汇编里可以看到,这段代码被包裹在一个__try块中(这只是非常粗浅的理解),可以发现__except包裹的语句才是真正需要执行的部分。这段代码在输入0时抛出除0异常进而执行__except部分。

__except里也套了__try...__except

__try中这一部分检查输入长度是否为0x39,不是就打印nonono, length is wrong...。不论是不是都抛出异常跳到loc_4017FD

loc_4017FD中调用了sub_4010E0,这个函数我会在之后分析。

让我们看看loc_401830做了什么。

Win32下FS段寄存器(Win64下是GS)指向TEB (Thread Environment Block) 结构体,它储存关于当前线程的各种信息。

该结构体0x30字节偏移处是线程所在进程的PEB (Process Environment Block) 结构体所在地址,这个结构体储存关于当前进程的各种信息。

而PEB的2字节偏移处是BeingDebugged,调试器附加时该项非0,因此可以测试它来进行基础的反调试。

程序继续检测调试器,这次把ProcessParameters拿出来用了。它是PEB的0x10字节偏移处一个_RTL_USER_PROCESS_PARAMETERS 结构体的地址。这个结构体储存进程创建相关的参数。

然后检测该结构体8字节偏移处的Flags的第14位是否为1。

我找遍互联网也没找到这是干什么用的,但是查到ScyllaHide反反调试时有把该位置1的操作。我猜测该位记录进程是否使用默认的PEB(GPT这样说,不过听起来有一定合理性)

这次用到了PEB的0x18字节偏移处的ProcessHeap。它是Windows进程(默认的)堆,堆头有一些信息可以用来反调试。

这里测试堆0x0C字节偏移处的Flags是否为HEAP_GROWABLE(2)来反调试,它是正常情况下的值。

然后就遇到了一堆向量化指令,初看一脸懵逼。

但是由于输入长度刚好多1字节,对未被编译器优化的最后1字节的处理给了我们充分的提示。

上面这段汇编基本上在做的是byte_40543C[0x38] = (byte_40543C[0x38] ^ (0x38 + 0x15)) + 0x14

有理由猜想上面的向量化指令被优化前就是byte_40543C[i] = (byte_40543C[i] ^ (i + 0x15)) + 0x14。粗略阅读一下上面的汇编会发现的确如此(不过要直接理解真有点麻烦的)

后面还有两段几乎一样的代码,只不过0x15被换成了0x1A0x19。至于中间没有操作byte_40543C的部分直接忽略就可以了。

最后程序比较byte_403658byte_40543C处的0x39字节内容是否相同。

拿这个密文解密3次就能拿到flag了,解密程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

unsigned char byte_403658[] = {
0xC7, 0xC4, 0xC9, 0xCE, 0xC2, 0xD1, 0x8B, 0x66, 0x6B, 0x8D, 0xB0, 0x45, 0xF9, 0x84, 0xFF, 0xB2,
0x51, 0xAB, 0xB3, 0x4C, 0x33, 0xA8, 0x61, 0x0E, 0xC5, 0x3B, 0x5B, 0xF9, 0x11, 0x82, 0x8B, 0x8E,
0x7A, 0x23, 0x68, 0x7A, 0x21, 0x1F, 0x87, 0x91, 0x46, 0x8D, 0x90, 0xA4, 0xA5, 0xE0, 0x35, 0xD9,
0x41, 0x4E, 0x44, 0xF1, 0x37, 0xAF, 0x26, 0x3A, 0x8F
};
unsigned char keys[] = {0x19, 0x1A, 0x15};

int main() {
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 0x39; ++j)
byte_403658[j] = (byte_403658[j] - 0x14) ^ (j + keys[i]);
printf("%s", byte_403658);
return 0;
}
// moectf{w1Nd0Ws_S3H_15_A_g0oD_m37h0d_70_h4nd13_EXCEPTI0NS}

sub_4010E0,以及反反调试

它到底做了什么?其实动调比较容易看出来,不过我尝试静调弄懂原理。

fs:30h处是PEB的地址,_PEB + 0Ch是一个_PEB_LDR_DATA结构体的地址Ldr,该结构体主要按不同顺序维护三个双向链表,包含进程所加载的模块的信息。

_PEB_LDR_DATA + 0Ch是按模块加载顺序组织的链表表头InLoadOrderModuleList,链表每一个节点都是一个_LDR_MODULE结构体,储存一个模块的信息并包含指向前后节点的指针。

这个链表前两个表项分别与程序自身和NTDLL.DLL相关,第三个与KERNEL32.DLL相关(这里取的就是第三个)。

_LDR_MODULE + 18h是模块基址BaseAddress(前0x18字节是3个链表的前后节点的指针),所以这里就是取了KERNEL32.DLL的基址存放到[ebp-20h]

注意mov esi, ecx,在调用sub_4010E0前有一句mov ecx, offset unk_4053B8esi在之后被多次使用并用被来填充0x4053B8开始的这段内存。

然后把基址也存到edx[ebp-8h]。分析这之后的部分就需要对PE文件结构有一些了解了。

32位PE文件结构图

PE文件以DOS头即_IMAGE_DOS_HEADER结构体开头,_IMAGE_DOS_HEADER + 3Che_lfanew,它是NT头(一个_IMAGE_NT_HEADERS结构体)的地址(装载后只是偏移)。DOS头和NT头之间有DOS Stub,它是程序在DOS系统上所执行的内容。

_IMAGE_NT_HEADERS + 78h指向可选NT头(一个_IMAGE_OPTIONAL_HEADER结构体)的DataDirectory数组的第一项。

DataDirectory是一个长度为16的_IMAGE_DATA_DIRECTORY结构体数组,这个结构体存放PE文件各种表的偏移和大小。

它的第一项就是导出表(一个_IMAGE_EXPORT_DIRECTORY结构体)的偏移和大小。导出表存放着PE文件导出的函数名称和偏移等信息。

接着把NumberOfFunctionsNumberOfNamesAddressOfFunctionsAddressOfNamesAddressOfNameOrdinals加载到栈上。

然后就是一堆反编译出来比较奇怪的代码,但还是能看出来大概什么意思。(就不放图了)

总之就是在AddressOfNames按名查找GetProcAddress的位置,然后在AddressOfNameOrdinals根据这个位置查到函数地址在AddressOfFunctions中的位置,最后从AddressOfFunctions拿到GetProcAddress的地址并把它存放在0x4053B8 + 0Ch

之后调用GetProcAddress(kernel32.dll基址, "GetModuleHandleW")并把地址存放在0x4053B8 + 5Ch

最后反复调用GetModuleHandleWGetProcAddress,将KERNEL32.DLLKERNELBASE.DLLNTDLL.DLLUSER32.DLL中一些函数的地址存放在0x4053B8(这里只是静态)开始的内存区域,供以后调用。(过程这就不放了,F5能看个大概)

如果中间出现问题,sub_4010E0返回0,程序最后打印nonono

调试器附加时,程序异常会先抛给调试器,Shift+F9(或者直接把异常处理者设置成被调试者)可以把异常扔回给SEH处理。

第一次加密前的反调试措施在静态分析部分已描述过,只要手动修改相应的值(或者ScyllaHide把PEB勾上)就能绕过。

在第一次加密后,程序利用之前获得的地址先后调用了CreateMutexWSetHandleInformationNtCloseCloseHandle

前两个没啥影响,但调用NtCloseCloseHandle时,如果附加了调试器,释放无效句柄(这里分别是0xDEADC0DEesi中的垃圾值)会抛出EXCEPTION_INVALID_HANDLE异常;如果未附加,它们只是返回FALSE。ScyllaHide勾选NtClose就能给它hook掉,或者IDA静态patch掉也ok。

第二次加密后,程序先后调用了NtQueryInformationProcessCreateFileW

程序调用NtQueryInformationProcess(-1, 7, ebp-48h, 4, 0)-1指代当前进程的句柄,传入ProcessDebugPort(7)使函数在ebp-48h写入进程调试器的端口号,-1说明被调试,第四个参数是写入字节数,第五个是成功写入字节数存放的地址。想要绕过它你可以:

  1. 修改[ebp-48h]为0。
  2. 静态patch。
  3. ScyllaHide勾选NtQueryInformationProcess。

然后调用CreateFileW("\\\\.\\TitanHide", 0xC0000000, 0, 0, 3, 0, 0)独占读写方式创建已经存在的设备\\.\TitanHide的一个句柄。

通常程序利用创建调试器未关闭的设备 / 文件句柄时,函数返回-1来反调试。但是这个\\.\TitanHide本来就不存在,因此正常情况下才会返回-1。

第三次加密后调用NtTerminateProcess(0, 0),第一个参数为进程句柄,为0即未指定会结束调用进程以外的所有进程,这对调试好像并没有什么影响。

进入sub_4015C0就是弹窗了,不是很重要就不分析了。

最后推荐一个总结了一些反调试技巧的网站