x86_64 中的除法、模运算和冒泡排序

用高级语言(例如 C)写出来的算法,在查看源代码时会显得非常直白。然而,编译器会把它们在汇编层面转换成一些相当神秘的模式。为了提升我们的逆向工程能力,我们需要学习这些模式,这样才能快速识别它们。这篇博客会列举一些例子,它们都是我今天在解一道 CTF 题时遇到的。

整数之间的有符号除法

先看这段代码:

#include <stdio.h>

int div(int a)
{
    return a / 42;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = div(a);
    printf("%d", a);
    return 0;
}

把它编译一下,然后打开你心爱的 iaito 或 radare2,看看汇编。我们不需要关心 main(),所以直接进入 sym.div,就能看到类似这样的内容:

36: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x30c30c31
0x0000115d      shr      rdx,    0x20
0x00001161      sar      edx,    3
0x00001164      sar      eax,    0x1f
0x00001167      sub      edx,    eax
0x00001169      mov      eax,    edx
0x0000116b      pop      rbp
0x0000116c      ret

这里看不到 idiv 指令。相反,我们能看到 0x00001156 处有一条 imul 指令,后面跟着一条 shr 指令和一条 sar 指令。之后,结果会出现在 edx 寄存器里。

但如果我们换一个数字,会发生什么?试试看。编译下面这段:

#include <stdio.h>

int div(int a)
{
    return a / 6;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = div(a);
    printf("%d", a);
    return 0;
}

我们会得到一段类似的反汇编:

33: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x2aaaaaab
0x0000115d      shr      rdx,    0x20
0x00001161      sar      eax,    0x1f
0x00001164      sub      edx,    eax
0x00001166      mov      eax,    edx
0x00001168      pop      rbp
0x00001169      ret

如果我们使用负数,会发生什么?把 6 换成 -7,我们会得到类似的结果:

36: div (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0xffffffff92492493
0x0000115d      shr      rdx,    0x20
0x00001161      add      edx,    eax
0x00001163      sar      edx,    2
0x00001166      sar      eax,    0x1f
0x00001169      sub      eax,    edx
0x0000116b      pop      rbp
0x0000116c      ret

如果我们不使用常量,会发生什么?编译下面这段:

#include <stdio.h>

int div(int a, int b)
{
    return a / b;
}

int main()
{
    int a, b;
    scanf("%d %d", &a, &b);
    a = div(a, b);
    printf("%d", a);
    return 0;
}

之后,我们终于会看到不同的东西:

19: div (int64_t arg1, signed int64_t arg2);
`- args(rdi, rsi) vars(2:sp[0xc..0x10])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      dword [var_8h],  esi ; arg2
0x00001153      mov      eax,    dword [var_4h]
0x00001156      cdq
0x00001157      idiv     dword [var_8h]
0x0000115a      pop      rbp
0x0000115b      ret

x86_64 CPU 确实支持除法,但代价很高。因此,如果除数已经以常量形式给出,编译器就会在编译时替我们做这种优化。它会根据除数计算出一个魔数乘数,并把除法转换成乘法和位移的组合。有一篇论文对这个算法作了进一步解释

不过,如果这个常量只在运行时才知道,这种优化就帮不上忙了,因此它会直接使用除法指令。

取模运算

现在编译这段:

#include <stdio.h>

int mymod(int a)
{
    return a % 42;
}

int main()
{
    int a;
    scanf("%d", &a);
    a = mymod(a);
    printf("%d", a);
    return 0;
}

我们会得到 mymod() 的反汇编:

45: mymod (int64_t arg1);
`- args(rdi) vars(1:sp[0xc..0xc])
0x00001149      push     rbp
0x0000114a      mov      rbp,    rsp
0x0000114d      mov      dword [var_4h],  edi ; arg1
0x00001150      mov      eax,    dword [var_4h]
0x00001153      movsxd   rdx,    eax
0x00001156      imul     rdx,    rdx,    0x30c30c31
0x0000115d      shr      rdx,    0x20
0x00001161      sar      edx,    3
0x00001164      mov      ecx,    eax
0x00001166      sar      ecx,    0x1f
0x00001169      sub      edx,    ecx
0x0000116b      imul     ecx,    edx,    0x2a
0x0000116e      sub      eax,    ecx
0x00001170      mov      edx,    eax
0x00001172      mov      eax,    edx
0x00001174      pop      rbp
0x00001175      ret

0x000011500x00001161 的指令使用了上面提到的魔数乘数来实现除法。然后它会把被除数复制一份到 ecx,再对它进行 31 位算术右移。如果被除数是非负数,ecx 就是 0;否则,它就是 -1。

这样做是为了从被除数中计算出一个 bias,因为除法总是向零截断。我们用商来计算取模:对于正数,模 = 被除数 - 除数 * 商;对于负数,模 = 被除数 - 除数 * (商 + 1)。

冒泡排序

最后编译这段:

#include <stdio.h>

void bubble(int *arr, int n)
{
    for (int i1 = 0; i1 < n - 1; i1++)
    {
        for (int i2 = 0; i1 + i2 < n - 1; i2++)
        {
            if (arr[i1] > arr[i2])
            {
                int tmp = arr[i1];
                arr[i1] = arr[i2];
                arr[i2] = tmp;
            }
        }
    }
}

int main()
{
    int a[42];
    for (int i1 = 0; i1 < 42; i1++)
        a[i1] = 42 - i1;
    bubble(a, 42);
    for (int i1 = 0; i1 < 42; i1++)
        printf("%d", a[i1]);
    return 0;
}

这次看看 iaito 生成的 sym.bubble 图:

sym.bubble 的图

我们可以看到,0x00001144 处设置了一个计数器。0x00001209 这个块里有一次比较。如果计数器还没有达到阈值,就会继续执行。0x00001150 处又设置了一个计数器。0x0000115c 这个块里会比较两个相邻的数字。如果前者更大,就会在 0x0000118c 这个块里执行交换。

结论

这只是逆向工程过程中可能遇到的几种模式示例。随着我们做得越来越多,我们会积累越来越多的经验,也就能够更快地识别反汇编中的更多模式。

使用 Hugo 构建
主题 StackJimmy 设计