记录对某软件进行逆向,得到其加密函数的过程。
本文仅用于技术探讨,禁止用于非法用途!
此次不需要修改程序内容,所以不需要备份。
本文假设读者已经有了初步的汇编语言、C 语言知识。
上次破除 Strawberry Feels 的光盘加载 算是一次入门,而且真的是入门中的入门。不久之后开「恋と選挙とチョコレート」(恋爱与选举与巧克力),进去的时候发现启动的程序是一个 Macromedia 的遗留物,算是解释器一类的东西,并没有一个检索过程。考虑到有可能是通过插件实现的附加功能,而这个的反编译实在是太难,就没有继续做下去了。
下一步我盯上了学校里的一个软件。这个软件工作流程的其中一个部分就是将输入的数据加密,加密过程是我不知道的,不过输入和输出我都知道。最后的输出明显是经过转义的,这一步也很明显。核心就是加密过程。
经过设置断点调试(通过查找输出的格式字符串,一共4个,通过运行来确认),找到了加密的函数,位于偏移 0x00401340。Olly Debug 的输出如下:
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 CPU Disasm Address Hex dump Command Comments 00401340 /$ 51 PUSH ECX ; app.00401340(guessed Arg1,Arg2,Arg3) 00401341 |. 55 PUSH EBP 00401342 |. 8B6C24 10 MOV EBP,DWORD PTR SS:[ARG.2] 00401346 |. 56 PUSH ESI 00401347 |. 57 PUSH EDI 00401348 |. 8BFD MOV EDI,EBP 0040134A |. 83C9 FF OR ECX,FFFFFFFF 0040134D |. 33C0 XOR EAX,EAX 0040134F |. F2:AE REPNE SCAS BYTE PTR ES:[EDI] 00401351 |. 8B4424 14 MOV EAX,DWORD PTR SS:[ARG.1] 00401355 |. F7D1 NOT ECX 00401357 |. 83C1 FE ADD ECX,-2 0040135A |. 894C24 0C MOV DWORD PTR SS:[LOCAL.0],ECX 0040135E |. 8BF1 MOV ESI,ECX 00401360 |. 8A08 MOV CL,BYTE PTR DS:[EAX] 00401362 |. 84C9 TEST CL,CL 00401364 |. 74 5D JE SHORT 004013C3 00401366 |. 8B5424 1C MOV EDX,DWORD PTR SS:[ARG.3] 0040136A |. 53 PUSH EBX 0040136B |. 895424 1C MOV DWORD PTR SS:[ARG.2],EDX 0040136F |> 8A042E /MOV AL,BYTE PTR DS:[EBP+ESI] 00401372 |. 8BD6 |MOV EDX,ESI 00401374 |. 32C1 |XOR AL,CL 00401376 |. 8AC8 |MOV CL,AL 00401378 |. 24 0F |AND AL,0F 0040137A |. C0F9 04 |SAR CL,4 0040137D |. 80E1 0F |AND CL,0F 00401380 |. 04 36 |ADD AL,36 00401382 |. 80C1 63 |ADD CL,63 00401385 |. 81E2 01000080 |AND EDX,80000001 0040138B |. 79 05 |JNS SHORT 00401392 0040138D |. 4A |DEC EDX 0040138E |. 83CA FE |OR EDX,FFFFFFFE 00401391 |. 42 |INC EDX 00401392 |> 8AD8 |MOV BL,AL 00401394 |. 75 02 |JNE SHORT 00401398 00401396 |. 8AD9 |MOV BL,CL 00401398 |> 8B7C24 1C |MOV EDI,DWORD PTR SS:[ARG.2] 0040139C |. 881F |MOV BYTE PTR DS:[EDI],BL 0040139E |. 47 |INC EDI 0040139F |. 85D2 |TEST EDX,EDX 004013A1 |. 74 02 |JE SHORT 004013A5 004013A3 |. 8AC1 |MOV AL,CL 004013A5 |> 8807 |MOV BYTE PTR DS:[EDI],AL 004013A7 |. 47 |INC EDI 004013A8 |. 4E |DEC ESI 004013A9 |. 897C24 1C |MOV DWORD PTR SS:[ARG.2],EDI 004013AD |. 79 04 |JNS SHORT 004013B3 004013AF |. 8B7424 10 |MOV ESI,DWORD PTR SS:[LOCAL.0] 004013B3 |> 8B4424 18 |MOV EAX,DWORD PTR SS:[ARG.1] 004013B7 |. 40 |INC EAX 004013B8 |. 894424 18 |MOV DWORD PTR SS:[ARG.1],EAX 004013BC |. 8A08 |MOV CL,BYTE PTR DS:[EAX] 004013BE |. 84C9 |TEST CL,CL 004013C0 |.^ 75 AD \JNE SHORT 0040136F 004013C2 |. 5B POP EBX 004013C3 |> 5F POP EDI 004013C4 |. 5E POP ESI 004013C5 |. 5D POP EBP 004013C6 |. 59 POP ECX 004013C7 \. C3 RETN
其调用如下:
1 2 3 4 5 6 CPU Disasm Address Hex dump Command Comments 0040523E |. 51 PUSH ECX ; |/Arg3 => OFFSET LOCAL.334 0040523F |. 52 PUSH EDX ; ||Arg2 => OFFSET LOCAL.346 00405240 |. 50 PUSH EAX ; ||Arg1 00405241 |. E8 FAC0FFFF CALL 00401340 ; |\app.00401340
所以函数原型就是(返回值看对 EAX 的操作,或者最后栈的平衡):
1 void encrypt (char *text, char *key, char *buffer) ;
接下来的数据的段(segment)不显式写出了,具体指向哪个段视上下文而定。
通过调试和对前面一点代码的分析,可以知道 Arg1 是指向明文的指针,Arg2 是指向密钥的指针,Arg3 是指向结果缓冲区的指针。其中,密钥的构造方式如下。获取当前 UTC 时间的 UNIX 时间戳,转换单位为分钟(忽略小数),然后转换为字符串。怎么得到的?多次调试发现这密钥不怎么 变化,而且稍稍估计一下就发现变化的时间大约是按照分钟计算。得到的密钥是个字符串,而且这个大小……看看本地时间的 UNIX 时间戳 就会发现可能的关系。我当时感觉这可能是分钟,在 VB 里做了一下验证:
1 2 Debug.Print DateDiff("n" , #1970-01-01# , DateAdd("h" , -8 , Now)) Debug.Print DateDiff("n" , #1970-01-01# , Now)
OK,现在我们知道了三个参数,开始看函数。
函数首先将 EBP 赋值为指向密钥(Arg2)的指针:
1 MOV EBP,DWORD PTR SS:[ARG.2]
然后计算密钥的长度(注意此时 EBP 的值为密钥的地址):
1 2 3 4 5 6 MOV EDI,EBP OR ECX,FFFFFFFF XOR EAX,EAX REPNE SCAS BYTE PTR ES:[EDI] MOV EAX,DWORD PTR SS:[ARG.1] ; 这句是为了后面准备的,不属于长度计算的语句组 NOT ECX
这里就有一个知识点,scas 和 repne 的配合使用计算字符串长度 。知道这一点后,就可以推断出,在执行完这一段之后 ECX 的值为字符串长度加上1。于是我们可以写出这一段的伪代码:
然后是设置一个计数器变量:
1 2 3 ADD ECX,-2 MOV DWORD PTR SS:[LOCAL.0],ECX MOV ESI,ECX
也可以直接翻译成伪代码:
1 2 local[0 ] = ecx = ecx - 2 ; esi = ecx;
因为前面知道了 ECX 的值的意义与字符串长度有关,这里可以知道如果 ESI 是一个计数器变量的话,arg2 + esi 就指向了密钥的最后一个有效字符(除了 '\0' 之外的字符)。大概可以猜到接下来会对字符串进行逆序遍历。
接下来就是一个典型的防御性(defensive)编程的代码(别忘了根据前面的代码,此时 EAX 的值是指向明文的指针):
1 2 3 MOV CL,BYTE PTR DS:[EAX] TEST CL,CL JE SHORT 004013C3
004013C3H 位于接下去的循环的结束,因此这里的判断就是为了防止在明文为空字符串时进行加密。伪代码如下:
下一段是对参数进行了赋值:
1 2 3 4 MOV EDX,DWORD PTR SS:[ARG.3] ; 下面一句也和意图不那么相关。不过因为后面会改变 BL,间接改变了 EBX,最后还会有一次 POP 恢复 EBX 为初始值。local[4] 是唯一一个没有改变的局部变量。 PUSH EBX MOV DWORD PTR SS:[ARG.2],EDX
翻译成伪代码就是:
没错,由于 X86 指令集的限制,栈变量之间的赋值要两条汇编语句。
然后就到了最激动人心的时刻,进入到循环体内部!
在循环体内部,用的临时变量是 AL 和 CL(应该是编译器优化的结果)。其中,AL 与密钥有关,CL 与明文有关。
其他的重要变量是 BL(用于输出)、ESI(用于进行密钥的逆序遍历)、EDI(用于指示输出偏移)和 EDX(作用后面说)。
首先给 AL 赋值(CL 已经预先指向了明文内的某个字符):
1 MOV AL,BYTE PTR DS:[EBP+ESI]
考虑到此时 EBP 指向密钥的首地址,ESI 是偏移(计数器),所以就应该翻译成:
然后是将 EDX 设置为当前 ESI 计数器的值(这里很重要 ):
接下来就是喜闻乐见的异或操作:
嗯,这一段加密的基础就是这里的异或啦。
然后取高低位,并变换到可打印字符上:
1 2 3 4 5 6 MOV CL,AL AND AL,0F SAR CL,4 AND CL,0F ADD AL,36 ADD CL,63
上面的 36 和 63 都是十六进制数,我后面写验证代码的时候被坑过。
好了,难点到了。猜猜接下来这一段是在干什么?提示:此时 EDX 的值是一个类似计数器的东西(看上面说“很重要”的位置)。
1 2 3 4 5 AND EDX,80000001 JNS SHORT 00401392 ; 00401392H 是紧接在 INC EDX 后的语句的地址 DEC EDX OR EDX,FFFFFFFE INC EDX
公布答案:这是一个带符号取模的操作 。我真佩服那些编译器的设计者,能设计出产生如此优化的代码的编译器。
对应的伪代码如下:
是不是很奇妙?几个加减/位操作就将一个带符号模2的操作解决了!
我之前没有理解这一段。看看我一开始逆向时写的伪代码和注释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 { edx = edx < 0 ? 0x80000001 : 1 ; (edx &= 0x80000001 ;) if (edx < 0 ) { { edx--; edx |= 0xfffffffe ; edx++; } => { edx = -1 ; (结合上面的情况) } } } => { edx = edx >= 0 ? 1 : -1 ; }
我没有想清楚在不同的数的情况下,EDX 最终的值是多少。这也直接导致了后面几个判断 EDX 是否为零(JE/JNE)的地方我就注释为“永远为true”/“永远为false”。根据这些伪代码写的验证代码自然就错了。一看,第一个字符是对的,后面就好像出问题了。和验证代码一起进行单步调试的时候,发现验证代码里的 AL 和 CL 的值是正确的,但是输出的字符错了。于是我就想到是不是这一段的问题。在某次调试中,第二次循环时,我发现有一个我标记为“永远为false”的一条跳转指令居然成了“Jump is taken”(Olly Debug 提示),然后我突然发现了 EDX 居然有成 0 的情况。但是第二次循环时计数器肯定不是0,而 EDX 不是 1 也不是 -1。于是根据我写的返回值判断,加上0这个确实存在的输出,这可能是一个模2的输出。于是重新考虑这一段,发现确实是一个带符号模2的过程。后来确认是模2。
好了,最难的地方过去了。接下来就是输出至缓冲区。
1 2 3 4 5 6 7 8 9 10 11 12 13 MOV BL,AL JNE SHORT 00401398 MOV BL,CL MOV EDI,DWORD PTR SS:[ARG.2] MOV BYTE PTR DS:[EDI],BL INC EDI TEST EDX,EDX JE SHORT 004013A5 MOV AL,CL MOV BYTE PTR DS:[EDI],AL INC EDI DEC ESI MOV DWORD PTR SS:[ARG.2],EDI
翻译成伪代码就是下面的样子(当前 arg2 的值也成了指向缓冲区的指针,EDI 的值是指向缓冲区的下一个输入位置的指针,ESI 是字符相对密钥的偏移/计数器):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 bl = al; if (edx == 0 ) { bl = cl; } edi = key; *edi = bl; edi++; if (edx != 0 ) { al = cl; } *edi = al; edi++; esi--; key += 2 ;
然后是一个循环加密的保证(此时 SF 标志由前面的 DEC ESI 这一句决定):
1 2 JNS SHORT 004013B3 ; 004013B3H 是这两句后面的第一句指令的地址 MOV ESI,DWORD PTR SS:[LOCAL.0]
所以就是:
1 2 3 if (esi < 0 ) { esi = local[0 ]; }
然后就是读取下一个字符,准备下一次循环:
1 2 3 4 5 MOV EAX,DWORD PTR SS:[ARG.1] INC EAX MOV CL,BYTE PTR DS:[EAX] TEST CL,CL JNE SHORT 0040136F ; 跳转到循环开头
伪代码:
1 2 3 4 text++; if (cl = *text) { goto __loop_begin; }
其中后面一段和循环开头的判断结合,可以简写为:
1 2 3 4 5 6 7 8 9 cl = *text; if (!cl) return ;do { text++; cl = *text; } while (cl);
至此我们可以看清加密函数的结构(可以将 j 近似看做 ESI),略微转换一下:
1 2 3 4 5 6 7 i = 0 , j = len (time) - 1 while (i < len (password)) append(enc(password[i], time[j])) i++ j-- if (j < 0 ) j = len (time) - 1
是一个循环加密过程:
如果明文长度小于等于密钥长度,则没有完整循环;
如果明文长度大于密钥长度,则密钥会被循环。
当然设计这个东西的人还是挺聪明的,选择了利用时间来产生密钥的方法(而不是静态密钥),而且考虑到减轻双方的验证负担和实际的验证频率,单位选择了分钟。
你有可能会问:既然密钥是基于时间的,那么如果在时间上出了差错导致解密失败,如何解决呢?
我已经通过几次试验摸清了过程,不过继续下去就真侵犯了对方的权益了,所以我在这里不公开那些内容。
我当时看到了 AL 和 CL 在代码中的赋值(尤其是用作两个临时变量,而且出现在循环中),第一感觉就是:循环加密!这和我以前看的 Enigma 的工作原理有点像,所以脑海里冒出了这种可能性。(不过 Enigma 的密码轮转起来,那个“循环”可就不是这里的静态循环了。)
附上我的逆向记录的一部分(已经修正),读汇编反求的:
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 91 92 93 94 95 96 void encrypt (char *text, char *time, char *buffer) { push(ecx); (local[0 ] = ecx;) push(ebp); (local[1 ] = ebp;) ebp = time; (ebp = arg2;) push(esi); (local[2 ] = esi;) push(edi); (local[3 ] = edi;) { edi = ebp (= time); ecx = 0xffffffff ; (ecx |= 0xffffffff ;) eax = 0 ; (eax ^= eax; 真实意图是让 al = 0 ,见 repne scas) repne; eax = text; (eax = arg1;) ecx = -ecx; } => { ecx = strlen (time) + 1 ; eax = text; } ecx -= 2 ; local[0 ] = ecx; esi = ecx; (esi = strlen (time) - 1 ;) cl = text[0 ]; if (cl != '\0' ) { edx = buffer; (edx = arg3;) push(ebx); (local[4 ] = ebx;) time = buffer; (arg2 = edx;) while (cl != 0 ) { al = time[i]; (al = *(ebp + esi);) edx = i; (edx = esi;) tmp0 = time[i] ^ cl; (al = al ^ cl;) tmp1 = tmp0; (cl = al;) tmp0 &= 0x0f ; (al &= 0x0f ;) tmp1 >>= 4 ; (cl >>= 4 ;) tmp1 &= 0x0f ; (cl &= 0x0f ;) tmp0 += 0x36 ; (al += 36 H;) tmp1 += 0x63 ; (cl += 63 H;) { edx = edx < 0 ? 0x80000001 : 1 ; (edx &= 0x80000001 ;) if (edx < 0 ) { { edx--; edx |= 0xfffffffe ; edx++; } => { edx = -1 ; (结合上面的情况) } } } => { edx = i % 2 ; (edx = edx % 2 ;) } bl = al; if (i % 2 == 0 ) { (if (edx == 0 )) bl = cl; } edi = time; (= buffer) *edi = bl; edi++; if (i % 2 != 0 ) { (if edx != 0 ) tmp0 = tmp1; (al = cl;) } *edi = tmp0; (*edi = al;) edi++; i--; (esi--;) time = time + 2 ; (arg2 = edi;) if (i < 0 ) { i = local[0 ]; (esi = local[0 ];) } { eax = text; (eax = arg1;) eax++; text = eax; } => { text++; } tmp1 = *text; (cl = *text;) } pop(ebx); } pop(edi); pop(esi); pop(ebp); pop(ecx); }
最后是加密函数的 C# 版本,已经通过了验证:
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 static string Encrypt (string text, string key ){ var sb = new StringBuilder(text.Length * 2 ); var len = key.Length; byte tmp0, tmp1; byte bl; int i = key.Length - 1 , j = 0 ; while (true ) { tmp0 = (byte )(key[i] ^ text[j]); tmp1 = tmp0; tmp0 = (byte )(tmp0 & 0x0f ); tmp1 = (byte )(tmp1 >> 4 ); tmp0 += 0x36 ; tmp1 += 0x63 ; bl = (i % 2 == 0 ? tmp1 : tmp0); sb.Append((char )bl); if (i % 2 != 0 ) { tmp0 = tmp1; } sb.Append((char )tmp0); i--; j++; if (j > text.Length - 1 ) { break ; } if (i < 0 ) { i = key.Length - 1 ; } } return sb.ToString(); }
随意写的,没有防御性代码,而且从中还可以看出汇编的痕迹。
写了这篇文章,算是不辜负我昨晚八点到凌晨两点的努力吧。