reversing.kr Replace Walkthrough

黑盒实验

拿到程序后运行,出现一个包含输入框对话框,不能输入字母和特殊符号,只能输入0-9这10个数字。

尝试输入数字提交后,程序崩溃。连续实验了几次都一样,包括留空提交。一开始以为是兼容性问题,用兼容模式尝试运行,结果仍然一样。

调试和分析

首先查壳,拖入PEiD,Microsoft Visual C++ 6.0,无壳。

拖入OD,查找Name,根据只能输入数字为线索,找到GetDlgItemInt,跟到代码,下断。程序跑起来,输入任意数字后单步步过运行,逐渐缩小范围,多次测试后找到使程序崩溃的地方——call Replace.0040466F,进去看程序代码:

0040466F   $  C600 90                     mov byte ptr ds:[eax],0x90
00404672   ?  C3                          retn
00404673   ?  0081 05D08440               add byte ptr ds:[ecx+0x4084D005],al

这段代码做了一件很好玩的事:把1个byte的0x90填充到ds:[eax]处。ds:[eax]说明这个具体的位置是根据当时eax寄存器的值确定的,0x90如果在数据段就是个不可打印的数据,而在代码段则是NOP指令。这里的设想一定要大胆,很可能这段话是用来填充代码段的——也就是说,为了“废掉”某个或者某段指令(当然NOP在Exploit的时候很可能做为模糊跳转技术中的缓冲)。不过目前状态eax的值不在ds指向的范围内,因此造成程序崩溃。

虽然现在找出了崩溃点,不过貌似没什么作用,我们重新加载程序起来,输入123456,还是断在刚才的地方:

00401050   .  6A 00         push 0x0                                 ; /IsSigned = FALSE
00401052   .  6A 00         push 0x0                                 ; |pSuccess = NULL
00401054   .  68 EA030000   push 0x3EA                               ; |ControlID = 3EA (1002.)
00401059   .  56            push esi                                 ; |hWnd
0040105A   .  FF15 9C504000 call dword ptr ds:[<&USER32.GetDlgItemIn>; \GetDlgItemInt
00401060   .  A3 D0844000   mov dword ptr ds:[0x4084D0],eax
00401065   .  E8 05360000   call Replace.0040466F
0040106A   .  33C0          xor eax,eax
0040106C   .  E9 1F360000   jmp Replace.00404690
00401071   >  EB 11         jmp XReplace.00401084
00401073   .  68 34 60 40 0>ascii "h4`@",0                           ;  Correct!
00401078   .  68 E9030000   push 0x3E9                               ; |ControlID = 3E9 (1001.)
0040107D   .  56            push esi                                 ; |hWnd
0040107E   .  FF15 A0504000 call dword ptr ds:[<&USER32.SetDlgItemTe>; \SetDlgItemTextA

大致看一下这段代码,获取输入数据后call一次,jmp2次之后就到了Correct提示的地方。这个大致的代码分布记在心中,继续往下分析。

可以看到在GetDlgItemInt后把值写到了ds:[0x4084D0],长度为dword。在数据窗口跳转到这个地址(可以在数据上下内存断点做监控):

004084D0  40 E2 01 00 00 00 00 00 00 00 00 00 00 00 00     @?.............

发现写入值为01E240(就是16进制的123456)。这里注意几点,第一是存储的顺序为little-endian,第二是因为读入的是int所以长度为dword,第三直接存储了这个数字而没有转化成字符串。

之后进入一个函数call Replace.0040466F,跟进去:

0040466F   $  E8 06000000   call Replace.0040467A
00404674      81            db 81
00404675   .  05 D0844000   add eax,Replace.004084D0
0040467A   .  C705 16604000>mov dword ptr ds:[0x406016],0x619060EB
00404684   .  E8 00000000   call Replace.00404689
00404689  /$  FF05 D0844000 inc dword ptr ds:[0x4084D0]
0040468F  \.  C3            retn

可以看到0040466F的函数用call的办法跳转到了0040467A(call当jmp用),这样反汇编器在反汇编的时候会从0040467A这个地址开始汇编,而被截断处前面这段机器码由于无法构成一个或几个完整的指令,因此被反汇编成了一个数据(db 81)+一句指令。之后程序把0x619060EB这个立即数写入ds:[0x406016],似乎没什么用。之后又通过call来当jmp来用,跳转到下面一句话,刚才在ds:[0x4084D0]保存的值自增1。之后retn又跳回00404689再执行一遍自增。之后再retn,此时return到了00404674

但是00404674db 81啊,不是指令!对,这就是这题最有意思的地方,一段机器码用不同的方式解析,得到不同的运行结果。此时需要让OD重新理解一下这段代码,在00404674上BackSpace,此时,还是上面那段机器码,但反汇编代码变成了这样:

0040466F   $  E8 06000000   call Replace.0040467A
00404674      8105 D0844000>add dword ptr ds:[0x4084D0],0x601605C7
0040467E   ?  40            inc eax
0040467F   ?  00EB          add bl,ch
00404681   ?  60            pushad
00404682   ?  90            nop
00404683   ?  61            popad
00404684   .  E8 00000000   call Replace.00404689
00404689  /$  FF05 D0844000 inc dword ptr ds:[0x4084D0]
0040468F  \.  C3            retn

这段代码把ds:[0x4084D0]里面的值加了0x601605C7,之后这些指令似乎没作用,到00404689完成自增,retn,再自增,然后这个奇怪的函数总算返回了。

还记得上面说过的两个jmp吗?现在进入第一个jmp:

00404690   > \A1 D0844000   mov eax,dword ptr ds:[0x4084D0]
00404695   .  68 9F464000   push Replace.0040469F
0040469A   .  E8 EAFFFFFF   call Replace.00404689
0040469F   .  C705 6F464000>mov dword ptr ds:[0x40466F],0xC39000C6
004046A9   .  E8 C1FFFFFF   call Replace.0040466F
004046AE   .  40            inc eax
004046AF   .  E8 BBFFFFFF   call Replace.0040466F
004046B4   .  C705 6F464000>mov dword ptr ds:[0x40466F],0x6E8
004046BE   .  58            pop eax
004046BF   .  B8 FFFFFFFF   mov eax,-0x1
004046C4   .^ E9 A8C9FFFF   jmp Replace.00401071

首先取出ds:[0x4084D0]中的值放入eax,之后又跳到前面那个ds:[0x4084D0]自增的地方,自增了1次。接着执行0040469F,这句指令把0xC39000C6写入ds:[0x40466F],0x40466F就是前面程序崩溃的地址啊!也就是说,ds:[0x40466F]处是代码(数据段当代码段执行),所以0xC39000C6其实是当做指令来使用的(0xC39000C6就是让程序崩溃的那段程序的机器码),果然之后马上call了0040466F。然后eax增1,再call 0040466F,再把0x6E8放入ds:[0x40466F](依然是当做指令执行的数据,替换崩溃代码)。之后善后返回到00401071

这里先不要执行,顺一下思路:此处取出ds:[0x4084D0]中的内容到eax后,执行崩溃函数,然后eax=eax+1,再执行崩溃函数。而前面分析崩溃函数可以用来抹去代码段中一个byte,这里调用两次就是2byte。回去看获取用户输入后的那段代码:

...
00401071   >  EB 11         jmp XReplace.00401084
...

这是第二个jmp,两个字节机器码,目前这个jmp返回后就会执行这个00401071的jmp,而这个jmp把我们原来可以顺序执行到的Correct给绕开了!所以,我们要“废掉”这个jmp。于是乎,回到EIP所在位置,之后把eax改成00401071(第二个jmp的地址),执行后跳回00401071,这个时候程序变成了:

00401071   > /90            nop
00401072   ? |90            nop
00401073   . |68 34 60 40 0>ascii "h4`@",0                           ;  Correct!
00401078   . |68 E9030000   push 0x3E9                               ; |ControlID = 3E9 (1001.)
0040107D   . |56            push esi                                 ; |hWnd
0040107E   . |FF15 A0504000 call dword ptr ds:[<&USER32.SetDlgItemTe>; \SetDlgItemTextA
00401084   > |B8 01000000   mov eax,0x1
00401089   . |90            nop

第二个可恶的jmp被成功干掉,并顺利执行了Correct!

到这里就快成功了!下面我们需要做很重要的一步,让程序自己算出正确的eax并完成整个逻辑,最终弹出正确的提示。

回过头完整整理一下程序流程:获取用户输入 --> 存入ds:[0x4084D0](命名为a) --> a=a+2 --> a=a+0x601605C7 --> a=a+2 --> a作为目标地址(0x00401071)抹去2个byte --> Done!所以我们需要算出a,a+4+0x601605C7=0x00401071。注意,这里的a的长度是dword,也就是unsigned int。打开calc,算出a为FFFFFFFFA02A0AA6,截取后32bit得到A02A0AA6,转到十进制为2687109798。重新打开程序,输入并获得Correct!

总结

这题个人感觉还是非常有意思的,不光一段机器码反复利用,还直接把数据当代码解释(可用于动态生成代码执行)。

这里还有一个细节:为何代码会被反复调用两次。答案很简单,因为利用call当jmp使用还是和直接jmp有区别的,每次call会使得返回地址压栈,因此执行到retn的时候会跳到call之后的语句再执行一遍(这个技巧不知道会不会用于编译器的优化,但是很可能会用于外壳的编写)。

另外这道题花费时间较长,超过前4个Easy题所用的时间的总和。虽然我写出来的时候逻辑还算是比较简单和清晰的,但实际需要反复调试很多次来理清思路。所以,逆向的Debug是个脑力活,也是体力活T^T。