NepCTF2023 RE(九龙拉棺)
九龙拉棺
查壳

程序分析
main函数分析
程序首先获取了系统特权,然后执行了八个线程,然后读取输入并判断输入的字符长度是不是80,最后做了一堆赋值的操作。然后执行了WaitForMultipleObjects,等待所有线程运行结束,然后有个判断dword_409808的值,从if和else的语句中大概也能猜出一个是输出success,一个是输出no之类的语句。

八个线程分析
再观察一下每个线程,除了第四个线程sub_4020B0以外,每个线程都是以如下代码的格式开头(有的不会执行sub_4015E0函数),可以看出这些线程都是由dword_409804变量来控制执行顺序的。也就是说虽然是八个线程,实际上就像是两个线程在执行一样:sub_4020B0线程一直会运行,其他七个线程都需要等待dword_409804变量的值,每个线程运行完都会给他赋值用以通知下个线程运行。从main函数可以看出执行WaitForMultipleObjects函数之前,先执行了dword_409804++,让他从1开始运行。
1 | sub_4015E0(); |
我们可以使用交叉引用来验证我们的猜想(如下,我给每个线程重重新命名为thread_1-thread_8,把dword_409804变量命名为index):

每个线程都有对index变量进行比较和赋值的地方,唯独没有线程给他赋值为2,也就是上述提到的thread_4函数,他从开始就一直在运行了。
反调试
分析出上述逻辑之后,我们接下来就有两个切入点:
- 看一下上述代码块中部分线程执行了,而部分线程没有执行的sub_4015E0()函数干了什么
- 看一下那个特立独行的4号线程到底干了什么,为什么做了排好队的良好青年
首先看第一个:sub_4015E0() 函数

这个函数逻辑很简单:读取线程的Context, 查看Dr7 寄存器是不是0。若不是,则判断该线程设置了硬件断点。会尝试使用SetThreadContext 来设置Dr7 取消硬件断点,如果还是0,就退出程序。
Dr7寄存器实际上是存储了硬件断点的一些属性,里面都是一些控制位。如果后续需要用到硬件断点的话,这个Dr7最好还是不要被他还原成0比较好;为了调试,如果设置了硬件断点,程序最好也不要退出。所以这一段代码直接patch一下:
图一:

图二:

上图图一是patch之前的,图二是patch之后的,把给Dr7=0赋值的语句直接nop掉(其实setthreadcontext也可以nop掉);然后把jz条件改成jmp,这样就不管Dr7寄存器的值是多少,都不会进入exit分支了。同时我们还可以把函数名改为check_hw_bp()。
再来看一下四号线程thread_4做了什么:
这段代码开头部分就是在解析自己的PE结构,结合010可以看出他最后获取的是.text代码段的大小,“(size_text & 0xFFFFF000) + 4096”是为了对齐:
之后的两个循环,第一个循环时获取text代码段中的数据然后进行累加,并将累加的结果存下来,然后又经历了一次循环,执行同样的操作:再次获取代码段的数据然后累加,并将累加的结果和第一次循环的结果做比较,如果不同就exit(下图LABEL 6处执行的就是exit函数)。然后sleep(500),重复取值累加比较的操作。
这段代码的目的也是反调试,因为我们知道调试器的原理时将断点处的代码保存在寄存器中,然后往断点位置插入int 3(0xCC),这样执行到断点位置程序就会停下来,当继续运行的时候会把原本的代码还原到代码段中再继续运行。所以如果下了断点,那么text代码段的数据累加之后的结果一定会变,程序检测到变化也就退出了。
这段也比较好绕过,我们可以看到出题人在程序的最后放了 index=\=2 的判断,如果相等就直接将循环break掉,然后将index的值赋为3,然后退出线程。所以我们这里可以将index==2改成index!=2即可,因为从上述对index的交叉引用中可以看到没有人给他赋值为2,同时不要忘了将最后的index=3给nop掉,不然他会触发其他线程的执行逻辑。

调试分析
利用上述方法过掉两个反调试之后就可以开始调试了。由于我在调试分析的时候,在线程4开始的地方下了断点,然后运行到index=\=2时直接改ZF寄存器绕过了,break掉之后在index=3的位置直接修改汇编,将3改为了1,这样就模拟了main函数里面的执行WaitForMultipleObjects函数之前,index++的操作。
接下里就是一系列算法识别:
index=\=1的时候触发的是7号线程thread_7:thread_7取了一个超大数组的值然后执行RC4解密,将结果存入了block变量中,然后将index设置为3

index=\=3的时候触发的是6号线程thread_6:thread_6将block的值用base32解密,将解密的结果存回block中,然后将index设置为5
index=\=5的时候触发的是2号线程thread_2:thread_2将block的值用base58解密,将解密的结果存回block中,然后将index设置为4
index=\=4的时候触发的是3号线程thread_3:thread_3将block的值用base64解密,将解密的结果存回block中,然后将index设置为6
index=\=6的时候触发的是3号线程thread_1:thread_1创建了一个子进程,然后将block的值写入子进程的内存块中,然后暂停当前线程运行子进程,最后将index设置为8
所以过掉四号线程的反调试之后,可以直接在1号线程下断点,执行到创建子进程的位置,可以读取处block数据的大小为0x3000,从内存中也可以很容易的看出”MZ”,”This program …”的标志。


然后将这块内存保存下来就行了,ida->edit->export data,选择raw bytes保存到文件export_results.exe中。

过会再去分析那个dump出来的exe,我们现在index这条路走完,index=\=8的时候触发的是5号线程thread_5:thread_5将lpBaseAddress地址的值取出来,进行xtea解密,解密的结果和v12相同,而lpBaseAddress的值在最开始的main函数里面,会将我们的输入存入lpBaseAddress内存中,所以这一块存的就是我们的输入。
最后他会把index设为9,调用的就是thread_8,这个函数就是判断的一些标志位,然后修改了一下dword_9B9808的值,用以控制main函数中输出success还是nonono
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
68check_hw_bp();
while ( index != 8 )
{
Sleep(0x1F4u);
check_hw_bp();
}
v12[0] = 0x88AFD2D6;
v12[1] = 0x3FBE45A7;
v12[2] = 0x27AAD1B9;
v12[3] = 0x8CB3E51E;
v12[4] = 0x9348FFA;
v12[5] = 0xE19F3C42;
v12[6] = 0xFFDD0D86;
v12[7] = 0xEDB97383;
v12[8] = 0x12C4C0BF;
v12[9] = 0x1B67BD19;
v12[10] = 0xF7A514D6;
v12[11] = 0x18F95254;
v12[12] = 0xAB100CB0;
v12[13] = 0xCBA137;
v12[14] = 0x2A91712;
v12[15] = 0xC58D0D9E;
v0 = malloc(0x40u);
Block = v0;
if ( !v0 )
ExitProcess(0xFFFFFF9D);
v1 = (char *)lpBaseAddress + *((_DWORD *)lpBaseAddress + 126) + 4;
v2 = 0;
*v0 = *v1;
v11 = 0;
v0[1] = v1[1];
v0[2] = v1[2];
v0[3] = v1[3];
do
{
v3 = *((_DWORD *)v0 + 2 * v2);
v10 = (unsigned int *)v0 + 2 * v2;
v4 = 0;
v9 = (unsigned int *)v0 + 2 * v2 + 1;
v5 = *v9;
v6 = 32;
do
{
v4 -= 0x61C88647;
v3 += (v4 + v5) ^ (16 * v5 + 1) ^ ((v5 >> 5) + 2);
v5 += (v4 + v3) ^ (16 * v3 + 3) ^ ((v3 >> 5) + 4);
--v6;
}
while ( v6 );
v0 = Block;
*v10 = v3;
*v9 = v5;
v2 = v11 + 1;
v11 = v2;
}
while ( v2 < 8 );
for ( i = 0; i < 16; ++i )
{
if ( *((_DWORD *)Block + i) != v12[i] )
{
free(Block);
ExitProcess(0xFFFFFFFE);
}
}
free(Block);
if ( byte_9B9810 != 1 )
*(_WORD *)lpBaseAddress = 257;
index = 9;根据上述逻辑我们可以写出解密脚本:
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
42a = [0x88AFD2D6,
0x3FBE45A7,
0x27AAD1B9,
0x8CB3E51E,
0x9348FFA,
0xE19F3C42,
0xFFDD0D86,
0xEDB97383,
0x12C4C0BF,
0x1B67BD19,
0xF7A514D6,
0x18F95254,
0xAB100CB0,
0xCBA137,
0x2A91712,
0xC58D0D9E]
flag1 = ''
for i in range(0, len(a), 2):
v3 = a[i] # 你知道的v3的最终值
v5 = a[i+1] # 你知道的v5的最终值
v4 = 0 # v4的初始值
for _ in range(32): # 32次迭代
v4 = (v4 - 0x61C88647)&0xffffffff
# 反向循环
for _ in range(32): # 32次迭代
v5 = (v5 - (((v4 + v3)& 0xFFFFFFFF) ^ ((16 * v3 + 3)& 0xFFFFFFFF) ^ (((v3 >> 5) + 4)& 0xFFFFFFFF)) & 0xFFFFFFFF) & 0xFFFFFFFF
v3 = (v3 - (((v4 + v5)& 0xFFFFFFFF) ^ ((16 * v5 + 1)& 0xFFFFFFFF) ^ (((v5 >> 5) + 2)& 0xFFFFFFFF)) & 0xFFFFFFFF) & 0xFFFFFFFF
v4 = (v4 + 0x61C88647)&0xffffffff
byte_values = [(v3 >> (8 * i)) & 0xFF for i in range(3, -1, -1)]
string_value = ''.join(chr(b) for b in byte_values)
flag1 += string_value[::-1]
byte_values = [(v5 >> (8 * i)) & 0xFF for i in range(3, -1, -1)]
string_value = ''.join(chr(b) for b in byte_values)
flag1 += string_value[::-1]
print(flag1)
# NepCTF{c9cdnwdi3iu41m0pv3x7kllzu8pdq6mt9n2nwjdp6kat8ent4dhn5r158太可惜了我们得到的结果不全,长度也不足80。所以接下来还是得分析dump出来的子进程,子进程用ida打开会发现逻辑很简单,通过dll获取一些函数的地址,最终也会通过下列函数来验证

很好,也是个xtea,一些参数不一样而已,我们一样可以写出解密脚本
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
43b = [0x1DC74989,
0xD979AF77,
0x888D136D,
0x8E26DB7F,
0xC10C3CC9,
0xC3845D40,
0xC6E04459,
0xA2EBDF07,
0xD484388D,
0x12F956A2,
0x5ED7EE59,
0x43137F85,
0xEF43F9F0,
0xB29683AA,
0x8E3640B4,
0xC2D36177]
flag2 = ''
for i in range(0, len(b), 2):
v3 = b[i] # 你知道的v3的最终值
v5 = b[i+1] # 你知道的v5的最终值
v4 = 0 # v4的初始值
for _ in range(32): # 32次迭代
v4 = (v4 - 0x61C88647)&0xffffffff
# 反向循环
for _ in range(32): # 32次迭代
v5 = (v5 - (((v4 + v3)& 0xFFFFFFFF) ^ ((16 * v3 + 0x56)& 0xFFFFFFFF) ^ (((v3 >> 5) + 0x78)& 0xFFFFFFFF)) & 0xFFFFFFFF) & 0xFFFFFFFF
v3 = (v3 - (((v4 + v5)& 0xFFFFFFFF) ^ ((16 * v5 + 0x12)& 0xFFFFFFFF) ^ (((v5 >> 5) + 0x34)& 0xFFFFFFFF)) & 0xFFFFFFFF) & 0xFFFFFFFF
v4 = (v4 + 0x61C88647)&0xffffffff
byte_values = [(v3 >> (8 * i)) & 0xFF for i in range(3, -1, -1)]
string_value = ''.join(chr(b) for b in byte_values)
flag2 += string_value[::-1]
byte_values = [(v5 >> (8 * i)) & 0xFF for i in range(3, -1, -1)]
string_value = ''.join(chr(b) for b in byte_values)
flag2 += string_value[::-1]
print(flag2)
# iu41m0pv3x7kllzu8pdq6mt9n2nwjdp6kat8ent4dhn5r158iz2f0cmr0u7yxyq}
lpBaseAddress追踪
虽然上述方式能求出flag,但是有个小问题,子进程如何获取lpBaseAddress的值的,在创建子进程的过程中并没有发现传参的逻辑。
在子进程中可以发现,执行check函数的参数,是通过MapViewOfFile函数从内存映像中获取的值,那么这个内存映像就一定是父进程和子进程的共享内存空间。

回到父进程,通过交叉引用找给lpBaseAddress赋值的地方,可以发现在main方法之前,做初始化的时候,创建了一块内存映像,并且取了一个随机数作为偏移存在(lpBaseAddress+126)的位置上,在main函数里面,就是先从这里去了一个值作为偏移,然后再将用户的输入存入其中。

解密脚本
最终完整的解密脚本如下,将两个flag公共部分去掉,然后拼起来就行了:
1 | a = [0x88AFD2D6, |
总结:
赛题总结
初始化的时候先提权,然后创建了八个线程,八个线程当中4号线程刚开始就一直执行,通过累加比对text代码段的值是否发生变化的方式来检测用户是否下了断点,剩余的七个线程都sleep等待一个index变量来唤醒。
接着读取用户的输入存入一块共享内存映像当中,然后将index变量设置为1唤醒其中某个线程开始工作,每个线程工作完就会设置index变量的值用于唤醒下一个线程工作。
4号线程可以通过patch的方式绕过使其执行完毕,退出线程,这样就绕过了反调试,剩下的线程当中还有个检测是否下了硬件断点的函数,这个也可以通过patch的方式绕过。
绕过反调试之后,将index设置为1调试程序,会发现程序取了一个大数组经过RC4->base32->base58->base64的方式进行处理,最终得到0x3000字节的数据,并且这块数据是个exe格式的数据,然后程序会用这块数据启动一个子进程去运行。之后主进程会读取用户的输入做一次xtea的解密校验。
子进程当中会读取最开始创建的共享内存映像中的用户的输入做xtea解密校验。两次解密校验的过程结合起来就是个完整的flag。
做题总结
作为完美世界的粉丝,看到九龙拉棺,我DNA都动了起来,作为这天三部曲的核心线索,九龙抬棺绝对是最神秘的存在,当时脑子里一万个想法飘过。然后竟然是九个线程拉着个子进程走,不过也是个很不错的题目思路,也从中学到了共享内存的方案。
