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
。
但是00404674
是db 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。