【文章标题】: Genocide Crackme #15 分析补完 —— 算法分析详解
【文章作者】: 书呆彭
【软件名称】: Genocide Crackme #15
【编写语言】: Delphi
【作者声明】: 只是感兴趣,没有其他目的。失误之处敬请诸位大侠赐教!
--------------------------------------------------------------------------------
【详细过程】
上次我写了个定位关键代码的东西,能帮上大家,真是很荣幸。
http://bbs.pediy.com/showthread.php?t=75638
我原以为这个算法分析的部分比较容易,所以就略去了。
后来看到有朋友回帖要让把算法分析的过程也说说。
想一想,也是,自己当时刚接触这个的时候,对着大段的汇编代码,也曾经十分头疼。
而一些高手写的文章,要么不屑于多说,因为对高手来说,阅读汇编代码跟阅读C代码一样简单;要么就是粘贴大段代码,把分析思路写在注释里,但一般只写分析如果,讲分析技巧的不是很多。
这给刚入门的新人造成不小的困难,也是新人特别难过的一道槛。
在下不才,虽不是什么高手,但毕竟阅读汇编代码也有不少心得了。就应大家的要求,正好以这个简单的CrackMe为例,详细地说说如何“读懂”汇编代码中的算法的技巧问题。
此文着重于讲解技巧,不会采用粘贴完整的代码的方式,请大家自己用调试器找到完整的代码来看。
我就按我的分析过程讲了,以文字为主,必要时粘贴代码片段。对代码的分析不放在注释当中。
我也不知道这种写法大家看起来有没有困难。如果有什么意见,尽管请提就是了。
上一篇文章已经找到了关键的代码位置,就接着上一次直接讲了。
程序的关键函数是Form1.OnKeyPress()函数。我们在这里下断点后,运行程序,随便按键(我按下的是‘a’键),被OD中断下来。
像上次讲的,我们看一下寄存器,发现ECX是我们感兴趣的。
于是我们先看看函数中前几行代码对ECX的处理。
先看到这一行
00429964 >MOV EBX, ECX
好,那我们现在需要关注的是ECX,EBX两个寄存器了。再往下看,又看到
0042997D >MOV DL, BYTE PTR DS:[EBX]
0042997F >CALL gncrk.004036D4
根据猜测,此时EBX指向一个包含我们输入的字符的字符串
而这两条指令的意思很明显,那就是取我们所按键的 字符,然后用它作参数调用函数004036D4。
根据经验,Delphi的编译器一般是用寄存器来传递参数的。前三个参数分别通过EAX,EDX,ECX来传递的。
这个函数还有其它参数吗?我们再往上看几行代码,发现还给给寄存器EAX进行了赋值,相关代码完整如下:
0042997A >LEA EAX, DWORD PTR SS:[EBP-68]
0042997D >MOV DL, BYTE PTR DS:[EBX]
0042997F >CALL gncrk.004036D4
那么这个函数的第一个参数EAX,是一个地址 SS:[EBP-68]
第二个参数DL,是一个char型的,正是我们输入的字符。
那么关键的问题来了,这个函数是功能是什么呢?
我们用鼠标点一下这一行,按ENTER,又是一个函数,再ENTER,又是几个函数,,,,
如果一直进去看,一会就记不住我们进去几层了。不到不得已,我一般不采取这种“笨办法”,那怎么办???
一个字,“猜”。
我们来想一下,第一个参数是一个指针,指向[EBP-68],是一个局部变量
我们写程序时用指针一般是用做输出的,所以我们“大胆”猜测,[EBP-68]这个变量用来接收函数的返回值。
我们就从返回值来猜测,很多函数都用这种“猜测”的方法就能猜个八九不离十。
我们把光标移动到这行:
0042997A >LEA EAX, DWORD PTR SS:[EBP-68]
然后按ENTER,我们看到数据窗口来到了 0012FD3C,也就是变量[EBP-68]的位置:
0012FD3C 00 00 00 00 B0 FD 12 00 B4 FE 12 00 14 2B A0 00 ....褒.逮.+?
初始值是0,然后我们用F8走过call那条指令,果然这个变量被赋值了,现在变成了:
0012FD3C 20 10 A0 00 B0 FD 12 00 B4 FE 12 00 14 2B A0 00 ?褒.逮.+?
它的值现在是00A01020,这个值是什么意思???怎么猜啊???
这里又有一个经验的问题。我一看这个值它就像个堆空间的指针。我们在数据窗口CTRL+G,输入00A01020,来到了:
00A01020 61 00 20 53 24 10 A0 00 24 10 A0 00 28 00 00 00 a. S$?$?(...
这像是一个长度为1的字符串,内容刚好是我们输入的字符。是不是呢???再确定一下吧。再F9一次,这次按个,比如‘1’,然后在OD里重复上面的步骤,用F8走过那个CALL。
这回[EBP-68]的值是00A01030,来到这里一看,果然是我们期望的字符串“1”:
00A01030 31 00 00 00 34 10 A0 00 34 10 A0 00 18 00 00 00 1...4?4?...
那好,我们来到004036D4,按分号(:),给它加个标签,比如我叫它 CharToStr,现在原来的代码成了:
0042997A >LEA EAX, DWORD PTR SS:[EBP-68]
0042997D >MOV DL, BYTE PTR DS:[EBX]
0042997F >CALL <gncrk.CharToString>
现在我们记住了一个变量,即[EBP-68],所以往下看代码时就需要注意这个值。
我们发现它马上就被使用了。
00429984 >MOV EDX, DWORD PTR SS:[EBP-68]
00429987 >MOV EAX, gncrk.0042B714
0042998C >CALL gncrk.004037B4
这里又遇到两个地址,一个是变量0042B714,一个是函数004037B4
我们先看看变量0042B714是个什么东西:
0042B714 20 10 A0 00 FF FF FF FF 00 00 00 00 00 00 00 00 ?........
而00A01020指向一个字符串,内容是我们刚才输入的“a”。
那这个是我们上一次按键输入的内容呢,还是我们前面所有输入的内容呢?我们还需要验证一下。再次F9,再按一个键,这次我按下了‘B’,然后在OD中查看,0042B714处的值还是00A01020:
00A01020 61 31 00 53 24 10 A0 00 24 10 A0 00 28 00 00 00 a1.S$?$?(...
看到了吧,这就是我们刚才输入的“a1”,同时我很有理由相信函数004037B4就是我们熟悉的strcat()。
我们再次F8过去,再看数据窗口,已经变成了:
00A01020 61 31 42 00 12 00 00 00 01 00 00 00 01 00 00 00 a1B..........
正是我们期待的“a1B”。
我们每推断出一个名字,就立即加上标签。数据和函数都加。加完标签后,现在这段代码是不是很容易懂了??
00429984 >MOV EDX, DWORD PTR SS:[EBP-68]
00429987 >MOV EAX, <gncrk.pInputBuffer>
0042998C >CALL <gncrk.strcat>
再往下看,下面是一个条件分支:
00429991 >CMP DWORD PTR DS:[42B710], 0C
00429998 >JNZ gncrk.00429B02
现在有一个地址,它是个变量,0042B710。而当这个变量值不等于12时,会向下跳转,到跳转的目标地址看一下,发现已经接近retn了。
我们现在知道了,我们每按一个键,程序就把它追加到pInputBuffer之中。
由此们很自然地想到,变量42B710记录的是我们输入了多少个字符,而且正确的字符应该是12个,否则就不进行处理。
我们先暂时把它命名为inputCount,然后我们发现,上面的我们跳过的代码中有一行发生了变化,它的地址用标签名代替了,这一行是:
00429974 >INC DWORD PTR DS:[<inputCount>]
这条指令使我有百分之九十的把握相信,这个变量确实是inputCount。
这里也看出使用标签不仅使代码更容易理解,而且还有其它的好处。
如果不使用标签,那么 INC 和 CMP 这两条指令中的地址,我们很难注意到它们是同一个地址,因为汇编代码中的地址太多了,根本无法去记住。
而当我们给这个地址加上标签后,所有引用这个变量的地方,OD全部会用标签名来显示,我们马上就知道了在一个地方赋值的变量都在哪里使用了。
再回到程序。既然它要求输入长度为12,那我们就多输入几次。
当然,每输入一次,被OD断下后再按一次F9,然后再输入下一次,再断,再F9。这是一种方法。
我采用的方法是OD的条件断点功能。在上一篇文章中已经说过了。这里假设我们已经输入了12个字符。
然后F8到
00429998 />JNZ gncrk.00429B02
OD显示跳转的箭头是灰色,不跳。我们再往下分析。下面几行代码是一个循环:
0042999E >MOV EBX, 0C
004299A3 >MOV EAX, DWORD PTR DS:[<inputCount>]
004299A8 >XOR EDX, EDX
004299AA >MOV DWORD PTR SS:[EBP+EAX*4-68], EDX
004299AE >DEC EBX
004299AF ^ >JNZ SHORT gncrk.004299A3
这几行代码的功能,我想大家都能很容易看出来吧,对了,它是作者故意扰乱我们的,是一段完全可以忽略的代码。
过了这几行,再往下看:
004299B1 >MOV EAX, DWORD PTR DS:[<pInputBuffer>]
004299B6 >MOV AL, BYTE PTR DS:[EAX]
004299B8 >XOR AL, 47
004299BA >AND EAX, 0FF
004299BF >MOV DWORD PTR SS:[EBP-34], EAX
004299C2 >MOV EAX, DWORD PTR DS:[<pInputBuffer>]
004299C7 >MOV AL, BYTE PTR DS:[EAX+1]
004299CA >XOR AL, 45
004299CC >AND EAX, 0FF
004299D1 >MOV DWORD PTR SS:[EBP-30], EAX
004299D4 >MOV EAX, DWORD PTR DS:[<pInputBuffer>]
004299D9 >MOV AL, BYTE PTR DS:[EAX+2]
004299DC >XOR AL, 4E
004299DE >AND EAX, 0FF
004299E3 >MOV DWORD PTR SS:[EBP-2C], EAX
。。。
这可不是垃圾代码了。像这样的代码,共有12段,它们的结构相同,只是每段中异或的常数不同(注:上一篇文章中我说用笔抄几个常数,就是指这12个常数)
所有代码实现的功能就是把pInputBuffer中的字节进行异或加密,然后扩展成为32位整数值,再复制到栈上的局部空间。
输入的12个字节分别被复制到栈上的 [EBP-34],[EBP-30]……[EBP-8];
我们记这12个整数为一个数组,记为 int array1[12];
再往下看:
00429A88 >MOV EAX, DWORD PTR SS:[EBP-34] ; array1[0]
00429A8B >XOR EAX, DWORD PTR SS:[EBP-30] ; ^= array1[1]
00429A8E >MOV DWORD PTR SS:[EBP-64], EAX ; [EBP-64]=
这三条指令的伪代码描述是: local_64 = array1[0] ^ array1[1];
其中local_64表示一个局部变量,由于我们目前不知道它的意义,暂且这么表示。在IDA中,一般表示为Var_64.
接下来的循环有点意思,为了清晰,我在中间空了一行,实际中这是一整段代码:
00429A91 >MOV EBX, 0A
00429A96 >LEA EDX, DWORD PTR SS:[EBP-2C]
00429A99 >LEA EAX, DWORD PTR SS:[EBP-64]
00429A9C /MOV ECX, DWORD PTR DS:[EDX]
00429A9E |XOR ECX, DWORD PTR DS:[EAX]
00429AA0 |MOV DWORD PTR DS:[EAX+4], ECX
00429AA3 |ADD EAX, 4
00429AA6 |ADD EDX, 4
00429AA9 |DEC EBX
00429AAA ^ \JNZ SHORT gncrk.00429A9C
先看这一个循环是做什么的。
分析循环过程,我的方法是先不管循环的初始条件和结束条件,以及循环控制变量的自增,只看循环体内的代码。
循环体内的代码非常简单,把[EDX]中的32位值拿出来,跟[EAX]中的值进行异或,然后存入[EAX+4]中。
而每次循环,EDX和EAX都增加4,即指向下数组的下一个位置。
而循环次数呢,是10(0x0A)。
之后我们再看循环的初始条件
00429A96 >LEA EDX, DWORD PTR SS:[EBP-2C]
00429A99 >LEA EAX, DWORD PTR SS:[EBP-64]
EDX最开始指向[EBP-2C],我们上面已经分析了,就是 EBX = &array1[2];
EAX最开始指向[EBP-64],我们上面把它暂命名为local_64,现在看来它也是个数组,那么就叫array2吧,注意这个数组元素是11。
我们把这个循环也写成伪C代码吧,同时把循环之前的那几句代码加上,得到这整段代码的功能描述:
array2[0] = array1[0] ^ array1[1];
int i,* pSrc,*pDst;
for ( i = 0x0A, pSrc = & array1[2], pDst= &array2[0] ; i > 0; --i )
{
pDst[1] = pDst[0] ^ pSrc[0];
}
再下面是最后一次处理了:
00429AAC >MOV EBX, 0B ; for (int i = 11; i > 0; --i)
00429AB1 >LEA ESI, DWORD PTR SS:[EBP-64]
00429AB4 /LEA EDX, DWORD PTR SS:[EBP-68]
00429AB7 |MOV EAX, DWORD PTR DS:[ESI]
00429AB9 |CALL gncrk.00406528
00429ABE |MOV EDX, DWORD PTR SS:[EBP-68]
00429AC1 |LEA EAX, DWORD PTR SS:[EBP-4]
00429AC4 |CALL <gncrk.strcat>
00429AC9 |ADD ESI, 4
00429ACC |DEC EBX
00429ACD ^ \JNZ SHORT gncrk.00429AB4
这段又是做什么呢???还是分析循环,我们看到在循环体中有两次函数调用。
第二次调用的函数我们已经标出来了(记得吗,在前面),即 strcat()函数
现在我们要再次施展我们的“猜测”大法,来得到第一个函数的内容。
我们先进这个函数里看一下:
00406528 >ADD ESP, -8
0040652B >PUSH 0
0040652D >MOV DWORD PTR SS:[ESP+4], EAX
00406531 >MOV BYTE PTR SS:[ESP+8], 0
00406536 >LEA ECX, DWORD PTR SS:[ESP+4]
0040653A >MOV EAX, EDX
0040653C >MOV EDX, gncrk.00406554 ; ASCII "%d"
00406541 >CALL gncrk.00406DD0
00406546 >POP ECX
00406547 >POP EDX
00406548 >RETN
眼睛一亮,对不对???
我们看到了一个非常熟悉的字符串——"%d",不用说,CALL gncrk.00406DD0,一定是sprintf了。
那这个函数我们怎么命名呢?? 其实它就是itoa()。我们同样给它加上标签,再回到刚才的代码,是不是豁然开朗???
00429AAC >MOV EBX, 0B ; for (int i = 11; i > 0; --i)
00429AB1 >LEA ESI, DWORD PTR SS:[EBP-64]
00429AB4 >LEA EDX, DWORD PTR SS:[EBP-68]
00429AB7 >MOV EAX, DWORD PTR DS:[ESI]
00429AB9 >CALL <gncrk.itoa>
00429ABE >MOV EDX, DWORD PTR SS:[EBP-68]
00429AC1 >LEA EAX, DWORD PTR SS:[EBP-4]
00429AC4 >CALL <gncrk.strcat>
00429AC9 >ADD ESI, 4
00429ACC >DEC EBX
00429ACD ^ >JNZ SHORT gncrk.00429AB4
这里用到的变量是[EBP-68],我们记为tmpstr;
以及[EBP-4],我们记为string1。
再往下,我们便看到胜利的曙光了:
00429ACF >MOV EAX, DWORD PTR SS:[EBP-4]
00429AD2 >MOV EDX, gncrk.00429B48 ; ASCII "2517170143162210624"
00429AD7 >CALL gncrk.004038BC
00429ADC /JNZ SHORT gncrk.00429AF1
00429ADE |PUSH 0
00429AE0 |PUSH gncrk.00429B5C ; ASCII "Congratulations!!!"
00429AE5 |PUSH gncrk.00429B70 ; ASCII "Good work!"
00429AEA |PUSH 0
00429AEC |CALL <JMP.&USER32.MessageBoxA>
00429AF1 \MOV EAX, <gncrk.pInputBuffer>
00429AF6 >CALL <gncrk.free> ; 这个是释放内存的,猜测
不用多说,CALL gncrk.004038BC一定是strcmp()函数(我没有用实验验证,而是后面算出注册码后间接地证明了)。
虽然已无必要,我们还是给它加个标签吧。
到这里,我们的算法已经大白于天下了。
先把输入的12个字符,分别用异或的方式处理后扩展为整数,得到数组array1。
array1的开头两个元素异或的结果得到另一个数组array2的第一个元素,而array2后续的每一个元素都是前一元素与array1中顺序往下排的元素的异或值。
最后,把array2的元素依次转化成字符串,并拼接,得到一个字符串string1。
如果string1恰好是“2517170143162210624”这个值,则输入的字符序列被接受。
好了,我的分析就讲到这里了。至于从“2517170143162210624”这个串反推注册码的算法,就留给读者了,因为它并不复杂。
小提示:需要将这个串切割成为11个子串。原则上可以任意划分,但是如果逆推出的字符序列包含不可打印字符,或者说无法从键盘直接输入的字符,是不可以。
具体每个子串应该满足什么条件就呢?所以实际上可行的划分只有几种而已。
就提示到这里。
顺便给出一组可以满足条件的注册码吧,括号中间的,注意半角符号,字母大写(又一个提示):
【_DFORGU\SNII】
--------------------------------------------------------------------------------
【经验总结】
本文详细地分析介绍了这个CrackMe的分析过程,详细到我的每一步具体思路都包括进来了。
所以虽然短短几行代码,却写了很长的东西。如果采取直接粘代码,然后在注释中解释的写法,相信会短得多,不过正如文
章开头所说,这有违我写此文的目的。
如果你的水平在我之上,分析这个小程序完全不在话下,就略过不看就是了。
另外,我是第一次写这样的文章,不知道效果如何,欢迎大家提意见。
需要说明的是,文章中提到的strcat和sprintf,我在上一篇文章中说是operator+(string,string)和string::format()
实际上Delphi中的字符串不像C中那样,是“以空字符'\0'结束的字节数组”,而更像是C++中的std::string
本文为了使只学过C的读者便于理解,就用C的方式来说了。
实际上大家在内存中定位到字符串的缓冲区,往前翻一下,就会看到,在字符串之前有一个整数,用来存放字符串的长度的
。所以实际上,上面提到的函数虽然是Delphi的函数,但相比于C,它与C++更接近。
--------------------------------------------------------------------------------
【版权声明】: 本文原创于看雪技术论坛, 转载请注明作者并保持文章的完整, 谢谢!
2008年10月31日 21:20:16
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课