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

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

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

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

本文假设读者已经有了初步的汇编语言、C 语言知识。

上次破除 Strawberry Feels 的光盘加载算是一次入门,而且真的是入门中的入门。不久之后开「恋と選挙とチョコレート」(恋爱与选举与巧克力),进去的时候发现启动的程序是一个 Macromedia 的遗留物,算是解释器一类的东西,并没有一个检索过程。考虑到有可能是通过插件实现的附加功能,而这个的反编译实在是太难,就没有继续做下去了。

下一步我盯上了学校里的一个软件。这个软件工作流程的其中一个部分就是将输入的数据加密,加密过程是我不知道的,不过输入和输出我都知道。最后的输出明显是经过转义的,这一步也很明显。核心就是加密过程。

经过设置断点调试(通过查找输出的格式字符串,一共4个,通过运行来确认),找到了加密的函数,位于偏移 0x00401340。Olly Debug 的输出如下:

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

其调用如下:

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 的操作,或者最后栈的平衡):

void encrypt(char *text, char *key, char *buffer);

接下来的数据的段(segment)不显式写出了,具体指向哪个段视上下文而定。

通过调试和对前面一点代码的分析,可以知道 Arg1 是指向明文的指针,Arg2 是指向密钥的指针,Arg3 是指向结果缓冲区的指针。其中,密钥的构造方式如下。获取当前 UTC 时间的 UNIX 时间戳,转换单位为分钟(忽略小数),然后转换为字符串。怎么得到的?多次调试发现这密钥不怎么变化,而且稍稍估计一下就发现变化的时间大约是按照分钟计算。得到的密钥是个字符串,而且这个大小……看看本地时间的 UNIX 时间戳就会发现可能的关系。我当时感觉这可能是分钟,在 VB 里做了一下验证:

Debug.Print DateDiff("n", #1970-01-01#, DateAdd("h", -8, Now))
Debug.Print DateDiff("n", #1970-01-01#, Now)

OK,现在我们知道了三个参数,开始看函数。

函数首先将 EBP 赋值为指向密钥(Arg2)的指针:

MOV EBP,DWORD PTR SS:[ARG.2]

然后计算密钥的长度(注意此时 EBP 的值为密钥的地址):

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。于是我们可以写出这一段的伪代码:

ecx = strlen(arg2) + 1; // 注意这里的“+1”

然后是设置一个计数器变量:

ADD ECX,-2
MOV DWORD PTR SS:[LOCAL.0],ECX
MOV ESI,ECX

也可以直接翻译成伪代码:

local[0] = ecx = ecx - 2;
esi = ecx;

因为前面知道了 ECX 的值的意义与字符串长度有关,这里可以知道如果 ESI 是一个计数器变量的话,arg2 + esi 就指向了密钥的最后一个有效字符(除了 '\0' 之外的字符)。大概可以猜到接下来会对字符串进行逆序遍历。

接下来就是一个典型的防御性(defensive)编程的代码(别忘了根据前面的代码,此时 EAX 的值是指向明文的指针):

MOV CL,BYTE PTR DS:[EAX]
TEST CL,CL
JE SHORT 004013C3

004013C3H 位于接下去的循环的结束,因此这里的判断就是为了防止在明文为空字符串时进行加密。伪代码如下:

if (*arg1) {
    // 加密循环体
}

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

MOV EDX,DWORD PTR SS:[ARG.3]
; 下面一句也和意图不那么相关。不过因为后面会改变 BL,间接改变了 EBX,最后还会有一次 POP 恢复 EBX 为初始值。local[4] 是唯一一个没有改变的局部变量。
PUSH EBX
MOV DWORD PTR SS:[ARG.2],EDX

翻译成伪代码就是:

arg2 = arg3;

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

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

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

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

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

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

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

al = key[esi];

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

MOV EDX,ESI

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

XOR AL,CL

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

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

MOV CL,AL
AND AL,0F
SAR CL,4
AND CL,0F
ADD AL,36
ADD CL,63

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

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

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

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

对应的伪代码如下:

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

是不是很奇妙?几个加减/位操作就将一个带符号模2的操作解决了!

我之前没有理解这一段。看看我一开始逆向时写的伪代码和注释:

{
    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。

好了,最难的地方过去了。接下来就是输出至缓冲区。

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 是字符相对密钥的偏移/计数器):

// (此时 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 这一句决定):

JNS SHORT 004013B3 ; 004013B3H 是这两句后面的第一句指令的地址
MOV ESI,DWORD PTR SS:[LOCAL.0]

所以就是:

if (esi < 0) {
    esi = local[0]; // 此时 local[0] 等于密钥长度减1
}

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

MOV EAX,DWORD PTR SS:[ARG.1]
INC EAX
MOV CL,BYTE PTR DS:[EAX]
TEST CL,CL
JNE SHORT 0040136F ; 跳转到循环开头

伪代码:

text++; // 和前面一样,函数参数的值不能直接修改,要用寄存器辅助
if (cl = *text) {
    goto __loop_begin;
}

其中后面一段和循环开头的判断结合,可以简写为:

cl = *text;
if (!cl) return;
// 一点代码
do {
    // 循环体的一部分
    text++;
    cl = *text;
}
while (cl);

至此我们可以看清加密函数的结构(可以将 j 近似看做 ESI),略微转换一下:

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 的密码轮转起来,那个“循环”可就不是这里的静态循环了。)

附上我的逆向记录的一部分(已经修正),读汇编反求的:

// 加密函数的调用位于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# 版本,已经通过了验证:

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();
}

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

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

分享到 评论