这是看雪CTF2017比赛的第11题。题目地址:传送门
应某童鞋的要求,写个新手能跟着照做的题解,我想就拿这个题试试看吧。毕竟我也常有这样的苦恼,大神们觉得很普通的操作过程,一句话说完了,我百思不得其道,有点同病相怜吧。
初探
直接ida载入,发现入口被改,此时入口是直接调用有代码修改功能的函数sub_44701F
。此函数先读取程序文件.text
区段开始的内容,再进行异或及算术运算,把运算结果复制到当前运行程序的相应地址空间。然后还进行了部分api的加载,最后跳转到像是VC程序OEP特征的入口。部分代码如下:
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
| memcpy(result, (char *)lpBaseAddress + 0x1000, 0x45000u); v1 = 0; v2 = Dst; while ( v1 < 0x11400 ) { *v2 ^= v1 + 0x19930721; *v2 += v1; *v2 -= 0x13579246; ++v2; ++v1; } v3 = (char *)Dst; result = (void *)sub_447000(Dst); if ( result && (ntHeader = (int)&v3[*((_DWORD *)v3 + 0xF)], v24 = *(_WORD *)(ntHeader + 6), v23 = *(_WORD *)(ntHeader + 0x14), ImageBase = *(char **)(ntHeader + 0x34), Entry = &ImageBase[*(_DWORD *)(ntHeader + 0x28)], SizeOfImage = *(_DWORD *)(ntHeader + 0x50), Size = *(_DWORD *)(ntHeader + 0x54), Buf1 = &ImageBase[*(_DWORD *)(ntHeader + 0x80)], (result = (void *)VirtualProtect(ImageBase, 0x45000u, 0x40u, &flOldProtect)) != 0) ) { memcpy(ImageBase, Dst, Size); s_hdr = (IMAGE_SECTION_HEADER *)(v23 + ntHeader + 0x18); v18 = s_hdr; v6 = 0; while ( v6 < v24 ) { v7 = v6; memcpy(&ImageBase[s_hdr->VirtualAddress], (char *)Dst + s_hdr->PointerToRawData, s_hdr->SizeOfRawData); v6 = v7 + 1; ++s_hdr; } v8 = Buf1; while ( memcmp(v8, &Buf2, 0x14u) ) { if ( *(_DWORD *)&ImageBase[*((_DWORD *)v8 + 4)] ) { v9 = LoadLibraryA(&ImageBase[*((_DWORD *)v8 + 3)]); if ( v9 ) { hModule = v9; v10 = *(_DWORD *)v8; if ( !*(_DWORD *)v8 ) v10 = *((_DWORD *)v8 + 4); v11 = &ImageBase[v10]; v12 = &ImageBase[*((_DWORD *)v8 + 4)]; while ( *(_DWORD *)v11 ) { if ( *(_DWORD *)v11 & 0x80000000 ) v13 = (const CHAR *)(*(_DWORD *)v11 & 0xFFFF); else v13 = &ImageBase[*(_DWORD *)v11 + 2]; *(_DWORD *)v12 = GetProcAddress(hModule, v13); v11 += 4; v12 += 4; } v8 = (char *)v8 + 0x14; } } else { v8 = (char *)v8 + 20; } } if ( Dst ) { VirtualFree(Dst, 0, 0x8000u); lpBaseAddress = 0; } if ( lpBaseAddress ) { UnmapViewOfFile(lpBaseAddress); lpBaseAddress = 0; } if ( hFileMappingObject ) { CloseHandle(hFileMappingObject); hFileMappingObject = 0; } if ( hFile != (HANDLE)-1 ) { CloseHandle(hFile); hFile = (HANDLE)-1; } result = (void *)((int (__thiscall *)(int))Entry)(savedregs); }
|
看到这,我就想如果我直接把计算后的结果dump出来,是不是就可以了。至于加载的api什么的看看情况再说吧。
我就是这么做的,dump出来,程序直接能运行,图标也似乎正常了。没看出哪有区别。但是为了保险起见,静态都是用的这个dump出来的看的,动态都是用的原程序,那也只是多了个加断点的过程,其它并不影响。在最后我特意验证了下,程序功能似乎更改了。
正确流程寻找
OD载入程序,一路粗跟,最后来到401690
函数,检查两个全局变量是否为0,第一个变量439B50
为0就直接返回了;第二个变量439B51
不为零,后面似乎还有几个函数调用,但是没有启动窗体。
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
| 00401690 /$ 55 push ebp 00401691 |. 8BEC mov ebp,esp 00401693 |. 83EC 70 sub esp,0x70 00401696 |. 803D 509B4300>cmp byte ptr ds:[0x439B50],0x0 0040169D |. 0F84 88000000 je 11-ToBeB.0040172B 004016A3 |. E8 08FFFFFF call 11-ToBeB.004015B0 004016A8 |. 803D 519B4300>cmp byte ptr ds:[0x439B51],0x0 004016AF |. 74 1C je short 11-ToBeB.004016CD 004016B1 |. 6A 00 push 0x0 ; /lParam = NULL 004016B3 |. 68 40174000 push 11-ToBeB.00401740 ; |DlgProc = 11-ToBeB.00401740 004016B8 |. 6A 00 push 0x0 ; |hOwner = NULL 004016BA |. 6A 67 push 0x67 ; |pTemplate = 0x67 004016BC |. FF75 08 push [arg.1] ; |hInst = NULL 004016BF |. FF15 94A14200 call dword ptr ds:[0x42A194] ; \DialogBoxParamW 004016C5 |. 33C0 xor eax,eax 004016C7 |. 8BE5 mov esp,ebp 004016C9 |. 5D pop ebp ; 0019FEC4 004016CA |. C2 1000 retn 0x10 004016CD |> 8D4D 90 lea ecx,[local.28] 004016D0 |. E8 0BF10000 call 11-ToBeB.004107E0 004016D5 |. E8 C6FEFFFF call 11-ToBeB.004015A0 004016DA |. 8D4D EC lea ecx,[local.5] 004016DD |. 8B40 30 mov eax,dword ptr ds:[eax+0x30] 004016E0 |. 50 push eax 004016E1 |. E8 AAEB0000 call 11-ToBeB.00410290 004016E6 |. 8D4D 90 lea ecx,[local.28] 004016E9 |. E8 C2FE0000 call 11-ToBeB.004115B0 004016EE |. 8D45 EC lea eax,[local.5] 004016F1 |. 50 push eax 004016F2 |. 8D4D 94 lea ecx,[local.27] 004016F5 |. E8 66180000 call 11-ToBeB.00402F60 004016FA |. 8D4D EC lea ecx,[local.5] 004016FD |. E8 2EEC0000 call 11-ToBeB.00410330 00401702 |. FF35 A0404300 push dword ptr ds:[0x4340A0] ; 11-ToBeB.00401C30 00401708 |. 8D4D 90 lea ecx,[local.28] 0040170B |. E8 30FF0000 call 11-ToBeB.00411640 ; !!!!! 00401710 |. 6A 20 push 0x20 00401712 |. 8D4D 90 lea ecx,[local.28] 00401715 |. E8 26F40000 call 11-ToBeB.00410B40 0040171A |. 50 push eax 0040171B |. 8D4D 90 lea ecx,[local.28] 0040171E |. E8 EDFC0000 call 11-ToBeB.00411410 ; 新线程 00401723 |. 8D4D 90 lea ecx,[local.28] 00401726 |. E8 F5F10000 call 11-ToBeB.00410920 0040172B |> 33C0 xor eax,eax 0040172D |. 8BE5 mov esp,ebp 0040172F |. 5D pop ebp ; 0019FEC4 00401730 \. C2 1000 retn 0x10
|
继续走,窗体创建,设etDlgItemTextW
api断点(至于GetDlgItemTextW
肯定不是,看看就知道),输入确定。来到401740
,还是看伪代码吧。方便看些:
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
| if ( a2 == 272 ) return 1; if ( a2 != 273 ) return 0; if ( (_WORD)a3 != 1000 ) { if ( (_WORD)a3 == 2 ) EndDialog(hDlg, 2); return 0; } String = 0; memset(&v7, 0, 0x1FCu); GetDlgItemTextW(hDlg, 1001, &String, 510); v4 = sub_401A40(&String, 0x451E8B10u / dword_437A08); *(_DWORD *)Caption = 0x793A63D0; if ( v4 ) { memset(&v9, 0, 0x1FAu); *(_DWORD *)Text = 0x518C6CE8; *(_DWORD *)&Text[2] = 0x529F6210; memset(&v11, 0, 0x1F6u); MessageBoxW(hDlg, Text, Caption, 0); EndDialog(hDlg, 2); result = 0; } else { memset(&v9, 0, 0x1FAu); *(_DWORD *)Text = 0x518C6CE8; *(_DWORD *)&Text[2] = 0x8D255931; memset(&v11, 0, 0x1F6u); MessageBoxW(hDlg, Text, Caption, 0); result = 0; } return result;
|
很明白,取输入,然后进401A40
进行计算,这个计算函数比较简单,主要是算术及异或计算,最后与一定值比较。我不知道别人有没有算,反正我是没有计算的,这个简单得太假,而且当时我猜测可能算不出什么来。在研究这个计算代码的时候,发现401A40
下面有函数有明显的字符范围的检查(这个就是常看汇编的好处啊,光盯伪代码肯定发现不了)。这就更肯定了,此处算法为假算法。
再联想到窗体如果不创建,那后面调用的那些函数是干什么的呢,窗体创建了,那串函数就没用了。
先看看控制窗体启动的flag byte_439B51
是在哪设置的。只有一个写的地方40145D
,在401240
函数中。设置过程大概是:先获取本进程快照,然后获取快照中的进程句柄,然后判断此进程的父进程是不是explorer.exe
或cmd.exe
,如果是并且检查一个堆数据成功则flag为0(检查堆数据这个我一直不理解),否则为1。第一个flag的值也是在此设置的,似乎不影响程序的正常运行,就没有关注。再继续溯源,回到401000
,41B35A
,41B28A
,4181DF
,最后来到的函数4181DF
是VC入口的___tmainCRTStartup
。
上面的发现可以推断出两点:一,这应该可以用作反调试,二,此程序有两个进程。
直接运行了下程序,确实有两个进程。我尝试对两个进程进行了附加,一个有窗体,一个没有窗体。没有窗体的可以进行附加,入口为程序壳的入口地址(实际过程中此处还没有发现dump出来的程序不一样,为了保险用的原始程序,包括后面的一些都是用的原始程序);而有窗体的程序不能附加。不能附加说明什么问题:进程已经处于调试状态,调试者当然是父进程。
我又用OD改名和不改名分别跟了下这个过程,因为检查堆数据的问题,flag一直为1。
到这里再思考下,现在不管flag什么条件下为0,有一点可以肯定,正确流程应该是flag先为0,开启另一个进程启动窗体,非窗体进程就是窗体进程的父进程,父进程对子进程进行调试,修改流程。但是由于子进程不能附加,所以是看不了代码的。下一步要做的就是:掌握父进程对子进程的修改或想办法搞到子进程的代码。
考虑到跟踪父进程太累,先使用dump的办法。用petools对刚运行的程序进行dump,没进行任何修复改动,直接进ida,发现本来401740
中是这样的
1 2 3 4
| String = 0; memset(&v7, 0, 0x1FCu); GetDlgItemTextW(hDlg, 1001, &String, 510); v4 = sub_401A40(&String, 0x451E8B10u / dword_437A08);
|
后来变成了这样:
1 2 3
| sub_418360(&v8, 0, 508); v745FB940(a2, 1001, &v7, 510, a1, *(_DWORD *)&v7, v9, v10); v5 = sub_401A40(&v7, 0x451E8B10 / 0u);
|
dword_437A08
已经为0。
还是要看父进程,除0异常应该被调试者接收吧。
还有一个办法,如果父进程对子进程进行了更改,然后并未还原呢?抱着试试看的想法,我随便输入并确定,提示错误,然后再dump。果然不一样了。这里提醒一下,第二次dump出来的文件的PE头的dosheader
已经被修改了,需要还原下。下面对比下原始与dump出来的变化情况。
为了方便看,我直接上汇编了,开始是这样的:
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
| .text:004017B2 mov eax, 451E8B10h .text:004017B7 mov ecx, dword_437A08 .text:004017BD div ecx .text:004017BF mov [ebp+hDlg], eax .text:004017C2 push [ebp+hDlg] .text:004017C5 lea eax, [ebp+String] .text:004017CB push eax .text:004017CC call sub_401A40 .text:00401A40 push ebp .text:00401A41 mov ebp, esp .text:00401A43 sub esp, 0Ch .text:00401A46 push ebx .text:00401A47 push esi .text:00401A48 push edi .text:00401A49 mov edi, [ebp+arg_0] .text:00401A4C xor edx, edx .text:00401A4E xor esi, esi .text:00401A50 mov [ebp+var_8], edx .text:00401A53 mov [ebp+var_4], esi .text:00401A56 xor ecx, ecx .text:00401A58 lea ebx, [edi+2] .text:00401A5B jmp short loc_401A60
|
第一次dump后是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| .text:004017B2 mov eax, 451E8B10h .text:004017B7 mov ecx, ds:dword_437A08 .text:004017BD div ecx .text:004017BF mov [ebp+arg_0], eax .text:004017C2 push [ebp+arg_0] .text:004017C5 lea eax, [ebp+var_600] .text:004017CB push eax .text:004017CC call sub_401A40 .text:00401A40 push ebp .text:00401A41 mov ebp, esp .text:00401A43 sub esp, 0Ch .text:00401A46 push ebx .text:00401A47 push esi .text:00401A48 push edi .text:00401A49 mov edi, [ebp+arg_0] .text:00401A4C xor edx, edx .text:00401A4E xor esi, esi .text:00401A50 mov [ebp+var_8], edx .text:00401A53 mov [ebp+var_4], esi .text:00401A56 xor ecx, ecx .text:00401A58 lea ebx, [edi+2] .text:00401A5B jmp short loc_401A60
|
唯一的区别就是dword_437A08
变成了0。第二次dump是这样的:
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
| .text:004017B2 mov eax, 451E8B10h .text:004017B7 mov ecx, ds:dword_437A08 .text:004017BD nop .text:004017BE nop .text:004017BF mov [ebp+arg_0], eax .text:004017C2 push [ebp+arg_0] .text:004017C5 lea eax, [ebp+var_600] .text:004017CB push eax .text:004017CC call sub_401A40 .text:00401A40 push ebp .text:00401A41 mov ebp, esp .text:00401A43 jmp loc_402303 .text:00402300 push ebp .text:00402301 mov ebp, esp .text:00402303 .text:00402303 loc_402303: ; CODE XREF: sub_401A40+3j .text:00402303 sub esp, 424h .text:00402309 mov ecx, ds:dword_43AAFC .text:0040230F mov [ebp+var_80], 0 .text:00402316 mov [ebp+var_34], 0C9DDE6h .text:0040231D mov [ebp+var_3C], 19170839h .text:00402324 mov [ebp+var_38], 0E1C01506h .text:0040232B mov eax, [ecx+3Ch] .text:0040232E add eax, 78h .text:00402331 mov [ebp+var_84], 0 .text:0040233B add eax, ecx
|
这一次变化比较大,除操作已经被nop掉了,401A40
调用实际上已经被改成了402300
调用。其它地方有没有更改不太清楚,但是更改的可能性比较小,改动开始没有显式调用的函数反倒可能会显露目标,而且这种手法明显只是为了隐藏真实的检验流程,目标已经达到(另外,我当时也简单对比下402300
,没看出什么改动)。暂定先尝试手动完成父进程完成的工作,看程序运行情况,及检验流程情况。
直接改成这样:
1 2 3 4 5 6 7
| 004017BD 90 nop 004017BE 90 nop 004017BF |. 8945 08 mov [arg.1],eax 004017C2 |. FF75 08 push [arg.1] 004017C5 |. 8D85 00FAFFFF lea eax,[local.384] 004017CB |. 50 push eax 004017CC E8 2F0B0000 call 11-ToBeB.00402300
|
算法
动态前先看下静态的情况,函数过程比较复杂,代码量比较大。我们从输入输出入手,函数参数只有一个输入的字串,返回是True
则成功,函数对参数及返回读写的区域在4029D5
到402B34
之间。所以这部分是重点跟的。
动态跟了下这个函数开头,此处利用开始壳读取并修改的文件内容(从这里可以看出,开始从堆中dump出来的程序是不能完成验证的),加载库及其它操作。直接跳过,在下断4029D5
。
这里开始的算法比较简单。就是用输入前8位作为一个hex数a1,9-10位为一个hex数a2。对一堆区0x5000 byte的数据进行运算,最后与byte_4340B0
处的数据比较前0x60个字节。运算部分的代码翻译过来如下:
1 2 3 4 5 6
| int j = 0 for (int i = a1; i < a1 +0x5000/4;i++) { output[j] = (a2*0x1010101+i)^output[j]; j++; }
|
反解,得到前10字节,0x75A29C09,注意,在unhex的函数中均将输入以大写hex进行转换,反解代码如下:
1 2 3 4 5 6 7 8 9
| m1 = 0x83F08EA7 m2 = 0x3F0FBA29 c1 = 0x1070EC81 c2 = 0x55530000 d = 0x1010101 for i in range(0x100): if ((i*d+m1)^c1)&0xffffffff == (((i*d+m2)^c2)&0xffffffff)-1: print hex(i) print hex(((i*d+m1)^c1)&0xffffffff)
|
后面memset两个栈区后,将第11位开始的输入unhex(也要求输入是大写hex)。接着读入!HelloHaniella!
常量,并将硬编码的19970907
写入栈区,并调用前面刚运算完的堆区,原来上面是为后面解码代码的。
进去看了看,算法比较明显,DES。原因有2:一是19970907
应该是作为密钥的,进去后立即被变换成56bit;二是再看下变换表,就是DES的表。
后面还有IP变换表,S-BOX变换表,移位表,P变换表等,我一一比对,没有改动。这里有个细节有注意了下,轮密钥是从后面开始用的,说明这是解密过程。下面的代码就是轮密钥与扩展密文的异或过程,轮密钥从后往前用的:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 0068101A 8BCA mov ecx,edx 0068101C 8DB424 300C0000 lea esi,dword ptr ss:[esp+0xC30] 00681023 8DBC24 900C0000 lea edi,dword ptr ss:[esp+0xC90] 0068102A F3:A5 rep movs dword ptr es:[edi],dword ptr ds> 0068102C 33F6 xor esi,esi 0068102E 8B7C24 14 mov edi,dword ptr ss:[esp+0x14] 00681032 8BCE mov ecx,esi 00681034 2BCF sub ecx,edi 00681036 8A8C0C 50100000 mov cl,byte ptr ss:[esp+ecx+0x1050] 0068103D 308C34 900C0000 xor byte ptr ss:[esp+esi+0xC90],cl 00681044 46 inc esi 00681045 83FE 30 cmp esi,0x30 00681048 ^ 7C E4 jl short 0068102E
|
DES加密完成后,与!HelloHaniella!
按byte比较,直到遇到0,结束比较,0也进行了比较。
1 2 3 4 5 6 7 8 9 10 11 12
| 00402AF2 |> /8A10 /mov dl,byte ptr ds:[eax] ;加密串 00402AF4 |. |3A11 |cmp dl,byte ptr ds:[ecx] ;常量 00402AF6 |. |75 30 |jnz short 11-ToBeB.00402B28 00402AF8 |. |84D2 |test dl,dl 00402AFA |. |74 12 |je short 11-ToBeB.00402B0E 00402AFC |. |8A50 01 |mov dl,byte ptr ds:[eax+0x1] 00402AFF |. |3A51 01 |cmp dl,byte ptr ds:[ecx+0x1] 00402B02 |. |75 24 |jnz short 11-ToBeB.00402B28 00402B04 |. |83C0 02 |add eax,0x2 00402B07 |. |83C1 02 |add ecx,0x2 00402B0A |. |84D2 |test dl,dl 00402B0C |.^\75 E4 \jnz short 11-ToBeB.00402AF2
|
到这里,我直接将检验值进行des加密,结果竟然不对。此处我花了太多时间,重新对比变换表,及算法过程。确实没有发现问题。没办法,我直接将改待解密数据为检验值,将轮密钥前后倒置,最后看了结果与我算的还是不一样。因为此,我花了半天时间。
最后直接一步一步跟,并写了des,打出各个过程中值,进行比对。解密完成也没有发现不对。后面再跟,发现最后结果提取作了手脚,结果数组下标为偶数的进行了取反。我的天,这是玩心理学啊。其代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 0068248C 8BD6 mov edx,esi 0068248E 81E2 01000080 and edx,0x80000001 00682494 79 05 jns short 0068249B 00682496 4A dec edx 00682497 83CA FE or edx,-0x2 0068249A 42 inc edx 0068249B 75 11 jnz short 006824AE 0068249D 389C34 10020000 cmp byte ptr ss:[esp+esi+0x210],bl 006824A4 0f95c1 setne cl 006824A7 888C34 10020000 mov byte ptr ss:[esp+esi+0x210],cl 006824AE 46 inc esi 006824AF 83FE 40 cmp esi,0x40 006824B2 ^ 7C D8 jl short 0068248C
|
DES的过程如下,数字为偏移:
1 2 3 4 5 6 7 8 9 10 11
| 06cd 轮密钥完成 0f6c 扩展表加载完成 0f99 ip置换完成 101a R扩展 103d 扩展输入与轮密钥异或 轮密钥从16组开始用 22e4 s盒加载完成 23aa s盒转换完成 23cc p盒置换完成 2400 R与L异或完成 2413 R L交换 2426 一次round结束
|
将检验值对应位取反后进行DES加密,得到80217C048420956C15DA309FF2B69170
,最终key为75A29C09E180217C048420956C15DA309FF2B69170
。
最后
得到了正确的key,试了下最开始从壳入口附近dump的出的程序,果然验证不能通过。
另外,由于最后程序没有验证没有固定长度,开始也没有检验长度,所以后面再加des的分组,就会造成多解,加一组,那多解数量也是十分巨大的。
说明:本文首发于看雪论坛。