逆向工程实战 - 某软件加密的过程

记录对某软件进行逆向,得到其加密函数的过程。

本文仅用于技术探讨,禁止用于非法用途!

此次不需要修改程序内容,所以不需要备份。

本文假设读者已经有了初步的汇编语言、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

这里就有一个知识点,scasrepne 的配合使用计算字符串长度。知道这一点后,就可以推断出,在执行完这一段之后 ECX 的值为字符串长度加上1。于是我们可以写出这一段的伪代码:

1
ecx = strlen(arg2) + 1; // 注意这里的“+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
if (*arg1) {
// 加密循环体
}

下一段是对参数进行了赋值:

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

翻译成伪代码就是:

1
arg2 = arg3;

没错,由于 X86 指令集的限制,栈变量之间的赋值要两条汇编语句。

然后就到了最激动人心的时刻,进入到循环体内部!

在循环体内部,用的临时变量是 ALCL(应该是编译器优化的结果)。其中,AL 与密钥有关,CL 与明文有关。

其他的重要变量是 BL(用于输出)、ESI(用于进行密钥的逆序遍历)、EDI(用于指示输出偏移)和 EDX(作用后面说)。

首先给 AL 赋值(CL 已经预先指向了明文内的某个字符):

1
MOV AL,BYTE PTR DS:[EBP+ESI]

考虑到此时 EBP 指向密钥的首地址,ESI 是偏移(计数器),所以就应该翻译成:

1
al = key[esi];

然后是将 EDX 设置为当前 ESI 计数器的值(这里很重要):

1
MOV EDX,ESI

接下来就是喜闻乐见的异或操作:

1
XOR AL,CL

嗯,这一段加密的基础就是这里的异或啦。

然后取高低位,并变换到可打印字符上:

1
2
3
4
5
6
MOV CL,AL
AND AL,0F
SAR CL,4
AND CL,0F
ADD AL,36
ADD CL,63

上面的 3663 都是十六进制数,我后面写验证代码的时候被坑过。

好了,难点到了。猜猜接下来这一段是在干什么?提示:此时 EDX 的值是一个类似计数器的东西(看上面说“很重要”的位置)。

1
2
3
4
5
AND EDX,80000001
JNS SHORT 00401392 ; 00401392H 是紧接在 INC EDX 后的语句的地址
DEC EDX
OR EDX,FFFFFFFE
INC EDX

公布答案:这是一个带符号取模的操作。我真佩服那些编译器的设计者,能设计出产生如此优化的代码的编译器。

对应的伪代码如下:

1
2
// 和数学上意义相同,奇数返回±1(依据符号而定),偶数返回0
edx = edx % 2;

是不是很奇妙?几个加减/位操作就将一个带符号模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”。根据这些伪代码写的验证代码自然就错了。一看,第一个字符是对的,后面就好像出问题了。和验证代码一起进行单步调试的时候,发现验证代码里的 ALCL 的值是正确的,但是输出的字符错了。于是我就想到是不是这一段的问题。在某次调试中,第二次循环时,我发现有一个我标记为“永远为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
// (此时 key 等于 buffer)
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]; // 此时 local[0] 等于密钥长度减1
}

然后就是读取下一个字符,准备下一次循环:

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

是一个循环加密过程:

  • 如果明文长度小于等于密钥长度,则没有完整循环;
  • 如果明文长度大于密钥长度,则密钥会被循环。

当然设计这个东西的人还是挺聪明的,选择了利用时间来产生密钥的方法(而不是静态密钥),而且考虑到减轻双方的验证负担和实际的验证频率,单位选择了分钟。

你有可能会问:既然密钥是基于时间的,那么如果在时间上出了差错导致解密失败,如何解决呢?

我已经通过几次试验摸清了过程,不过继续下去就真侵犯了对方的权益了,所以我在这里不公开那些内容。


我当时看到了 ALCL 在代码中的赋值(尤其是用作两个临时变量,而且出现在循环中),第一感觉就是:循环加密!这和我以前看的 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
// 加密函数的调用位于00405241H,函数地址位于00401340H
// EDX: 密钥A,内容为当前的时间戳(单位:分钟,取整数),可以推断得到,也可以参照004051EAH的对 time() 的调用
// EAX: 密码(明文)
// ECX: 指向一个64字节的缓冲区的指针

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; // cl 偏移指向最后一个有效字符(除了'\0'外的字符)
local[0] = ecx;
esi = ecx; (esi = strlen(time) - 1;)
cl = text[0]; // (byte tmp0 = text[0];)
if (cl != '\0') { // if (tmp0 != '\0')
edx = buffer; (edx = arg3;)
push(ebx); (local[4] = ebx;)
time = buffer; (arg2 = edx;)

// i = esi = strlen(time) - 1
// tmp0 => al, tmp1 => cl
while (cl != 0) {
al = time[i]; (al = *(ebp + esi);) // esi <=> i in iteration
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 += 36H;)
tmp1 += 0x63; (cl += 63H;)
// tmpbool = edx >= 0;
{
edx = edx < 0 ? 0x80000001 : 1; (edx &= 0x80000001;)
if (edx < 0) {
{
edx--;
edx |= 0xfffffffe;
edx++;
} => {
edx = -1; (结合上面的情况) // 错误
}
}
} => {
// 错误
// edx = edx >= 0 ? 1 : -1;
// 应该正确理解 edx &= 0x80000001 和 jns, dec, or, inc 这一段
// 其实是一个包含了负数情况在内的模2的过程,edx 保存了结果
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];) // 此时 local[0] 保存着原始的 time 字符串的长度
}
{
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;
// al -> key, cl -> text
// tmp0 -> al, tmp1 -> cl
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();
}

随意写的,没有防御性代码,而且从中还可以看出汇编的痕迹。

写了这篇文章,算是不辜负我昨晚八点到凌晨两点的努力吧。

分享到 评论