C 汇编循环

C Assembly Loop

我对这次大会上发生的事情有点困惑。我可以看到基本原理,如果不输入六个数字,炸弹就会爆炸并结束程序。逐行检查输入并在六个数字为非负数时进入循环。我迷路了0x0000000000400f29 <+29>: add -0x4(%rbp),%eax

这看起来很简单,但我真的不明白这里添加了什么。是加-4然后和0比较吗?如果相等则跳?

我基本上是在寻找具体的循环说明,以及循环中预计会继续的输入模式。

转储

   0x0000000000400f0c <+0>:     push   %rbp
   0x0000000000400f0d <+1>:     push   %rbx
   0x0000000000400f0e <+2>:     sub    [=10=]x28,%rsp
   0x0000000000400f12 <+6>:     mov    %rsp,%rsi
   0x0000000000400f15 <+9>:     callq  0x40165a <read_six_numbers>
   0x0000000000400f1a <+14>:    cmpl   [=10=]x0,(%rsp)
   0x0000000000400f1e <+18>:    jns    0x400f44 <phase_2+56>
   0x0000000000400f20 <+20>:    callq  0x401624 <explode_bomb>
   0x0000000000400f25 <+25>:    jmp    0x400f44 <phase_2+56>
   0x0000000000400f27 <+27>:    mov    %ebx,%eax
=> 0x0000000000400f29 <+29>:    add    -0x4(%rbp),%eax
   0x0000000000400f2c <+32>:    cmp    %eax,0x0(%rbp)
   0x0000000000400f2f <+35>:    je     0x400f36 <phase_2+42>
   0x0000000000400f31 <+37>:    callq  0x401624 <explode_bomb>
   0x0000000000400f36 <+42>:    add    [=10=]x1,%ebx
   0x0000000000400f39 <+45>:    add    [=10=]x4,%rbp
   0x0000000000400f3d <+49>:    cmp    [=10=]x6,%ebx
   0x0000000000400f40 <+52>:    jne    0x400f27 <phase_2+27>
   0x0000000000400f42 <+54>:    jmp    0x400f50 <phase_2+68>
   0x0000000000400f44 <+56>:    lea    0x4(%rsp),%rbp
   0x0000000000400f49 <+61>:    mov    [=10=]x1,%ebx
   0x0000000000400f4e <+66>:    jmp    0x400f27 <phase_2+27>
   0x0000000000400f50 <+68>:    add    [=10=]x28,%rsp
   0x0000000000400f54 <+72>:    pop    %rbx
   0x0000000000400f55 <+73>:    pop    %rbp
   0x0000000000400f56 <+74>:    retq
End of assembler dump.

read_six_numbers

Dump of assembler code for function read_six_numbers:
=> 0x000000000040165a <+0>:     sub    [=11=]x18,%rsp
   0x000000000040165e <+4>:     mov    %rsi,%rdx
   0x0000000000401661 <+7>:     lea    0x4(%rsi),%rcx
   0x0000000000401665 <+11>:    lea    0x14(%rsi),%rax
   0x0000000000401669 <+15>:    mov    %rax,0x8(%rsp)
   0x000000000040166e <+20>:    lea    0x10(%rsi),%rax
   0x0000000000401672 <+24>:    mov    %rax,(%rsp)
   0x0000000000401676 <+28>:    lea    0xc(%rsi),%r9
   0x000000000040167a <+32>:    lea    0x8(%rsi),%r8
   0x000000000040167e <+36>:    mov    [=11=]x402871,%esi
   0x0000000000401683 <+41>:    mov    [=11=]x0,%eax
   0x0000000000401688 <+46>:    callq  0x400c30 <__isoc99_sscanf@plt>
   0x000000000040168d <+51>:    cmp    [=11=]x5,%eax
   0x0000000000401690 <+54>:    jg     0x401697 <read_six_numbers+61>
   0x0000000000401692 <+56>:    callq  0x401624 <explode_bomb>
   0x0000000000401697 <+61>:    add    [=11=]x18,%rsp
   0x000000000040169b <+65>:    retq
End of assembler dump.

粗略阅读,整个事情归结为:

void read_six_numbers(const char *sz, int numbers[6]) {
    // the format string is inferred from the context,
    // to see its actual value you should look at 0x402871
    if(sscanf(sz, "%d %d %d %d %d %d", &numbers[0], &numbers[1], &numbers[2], &numbers[3], &numbers[4], &numbers[5])<6) explode_bomb();
}

void phase_2(const char *sz) {
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0] < 0) explode_bomb();
    for(int i=1; i!=6; ++i) {
        int a = i + numbers[i-1];
        if(numbers[i]!=a) explode_bomb();
    }
}

可惜我现在在火车上,没有电脑,时间有限,稍后我会补充详细的解释。它来了!

Notice: through this post I'll use the Intel notation for assembly; it's different than what you posted, but, at least IMO, it's way more readable and understandable - besides the terrible taste for sigils in AT&T notation, the reversed operands make zero sense in many instructions, especially arithmetic ones and cmp; also, the syntax for memory addressing is downright unreadable.


read_six_numbers

让我们从简单的开始 - read_six_numbers;从 phase_2

中的代码可以看出
<+6>:     mov    rsi,rsp
<+9>:     call   0x40165a <read_six_numbers>

它在 rsi 中接收一个参数,它是一个指向调用者堆栈中某些内容的指针。常规的 SystemV 调用约定将 rsi 用于 second 参数,而 read_six_numbers 读取 rdi (隐含地,我们稍后会看到)。所以我们可以假设 phase_2 确实在 rdi 中接收到一个参数并将其留在那里,直接传递给 read_six_numbers.

在 "classic" 序言之后为本地人保留堆栈

<+0>:     sub    rsp,0x18

它继续将 "adjusted" 指针值加载到各种寄存器和堆栈中

<+4>:     mov    rdx,rsi
<+7>:     lea    rcx,[rsi+0x4]
<+11>:    lea    rax,[rsi+0x14]
<+15>:    mov    [rsp+0x8],rax
<+20>:    lea    rax,[rsi+0x10]
<+24>:    mov    [rsp],rax
<+28>:    lea    r9,[rsi+0xc]
<+32>:    lea    r8,[rsi+0x8]

如果您按照代码进行操作,您会看到最终结果是

rdx     <- rsi
rcx     <- rsi+4
r8      <- rsi+8
r9      <- rsi+12
[rsp]   <- rsi+16
[rsp+8] <- rsi+20

(不要让 lea 骗了你 - 虽然在 [] 语法中会让你认为它正在访问括号内地址处的内存,但实际上它只是复制该地址到左边的操作数)

然后

<+36>:    mov    esi,0x402871
<+41>:    mov    eax,0x0
<+46>:    call   0x400c30 <__isoc99_sscanf@plt>

rsi 现在设置为某个固定地址 1,考虑到它的地址以及它位于 rsi(所以它将成为下面 sscanf 的第二个参数)。

所以,这是 a regular x86_64 System V ABI variadic callsscanf2 - 这需要将参数(按顺序)传递到 rdirsirdxrcxr8r9,其余入栈,ax设置为存储的浮点参数个数在 XMM 寄存器中(此处为零,因此 mov3)。

如果我们将到目前为止收集的碎片放在一起,我们可以推断出:

  • rdi是一个const char *,是要读取的六个数字的源字符串;
  • rsi 包含指向至少六个 int 的数组的指针(请参阅它加载偏移量为 4 的寄存器 - 又名 sizeof(int)?),这些数组是从rdi;
  • 中的字符串
  • 最有可能的是,如果我们查看 0x402871,我们会看到类似 "%d %d %d %d %d %d" 的内容。

那么,我们可以开始写这个函数的初步定义了:

void read_six_numbers(const char *sz, int numbers[6]) {
    int eax = sscanf(sz, "%d %d %d %d %d %d",
            &numbers[0], &numbers[1], &numbers[2],
            &numbers[3], &numbers[4], &numbers[5]);
    ...
}

请注意,我在这里写 numbers[6] 只是为了提醒我,对于 C 语言,数组参数中的大小被忽略 - 它只是一个常规指针;此外,我将 void 写为 return 类型,因为我发现在调用此函数后似乎没有人对 raxeax 感兴趣。

然后:

<+51>:    cmp    eax,0x5
<+54>:    jg     0x401697 <read_six_numbers+61>
<+56>:    call   0x401624 <explode_bomb>
<+61>:    add    rsp,0x18
<+65>:    ret

此处它只是检查 sscanf 编辑的 return 值是否大于 5 - 即是否成功读取了所有必填字段;如果是,它会跳过对 explode_bomb 的调用。我们可以用更人性化的方式重写它,比如

if(eax<6) explode_bomb();

然后,在 +61 和 +65 处有标准函数尾声(修复堆栈和 return)。

所以,总而言之,我们可以把整个事情写成

void read_six_numbers(const char *sz, int numbers[6]) {
    if(sscanf(sz, "%d %d %d %d %d %d",
            &numbers[0], &numbers[1], &numbers[2],
            &numbers[3], &numbers[4], &numbers[5] < 6) {
        explode_bomb();
    }
}

收工。


phase_2

<+0>:     push   rbp
<+1>:     push   rbx
<+2>:     sub    rsp,0x28

通常的序幕; 40字节的局部变量,保存rbprbx因为要用到(而且都是callee-saved register);请注意,这里 rbp 不是 用作堆栈帧指针,而是用作 "regular" 寄存器。

<+6>:     mov    rsi,rsp
<+9>:     call   0x40165a <read_six_numbers>

调用read_six_numbers,隐式转发pase_2的第一个参数作为第一个参数或rdi中的read_six_numbers(我们收集到的是要解析的字符串), 并将栈顶作为 numbers 参数传递。

请记住,堆栈向下(=>朝向较小的地址)增长,而数组元素向上(=>朝向更大的地址), 因此将 rsp 作为指向第一个数组元素的指针传递意味着以下元素正确地位于刚刚用上面的 sub 分配的堆栈部分中。

从现在开始,记住 rsp 指向 numbers 数组的第一个元素。

<+14>:    cmp    [rsp],0
<+18>:    jns    0x400f44 <phase_2+56>
<+20>:    call  0x401624 <explode_bomb>
<+25>:    jmp    0x400f44 <phase_2+56>

检查第一个数字([rsp] <=> *numbers <=> numbers[0])是否不是负数;如果是这样,请跳过对 explode_bomb.

的调用

(要理解它是如何工作的,请记住 cmp 执行减法而不保存结果,但只保存与之对应的标志,因此 [rsp]-0 是普通的 [rsp],并且jns 表示 jump if not sign 位,所以如果cmp 的结果是非负的)

让我们试着推测一下到目前为止我们拥有的东西:

ret_type? phase_2(const char *sz) {
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    ...
}

让我们暂时跳过 +27 和 +56 之间的部分,继续常规控制流程 - 直接到 +56:

<+56>:    lea    rbp,[rsp+4]
<+61>:    mov    ebx,1
<+66>:    jmp    0x400f27 <phase_2+27>

这里用&numbers[1]加载rbp(记住numbers的每个元素有4个字节大),用1加载ebx,然后,它跳回到+27。

<+27>:    mov    eax,ebx
<+29>:    add    eax,[rbp-4]
<+32>:    cmp    [rbp],eax
<+35>:    je     0x400f36 <phase_2+42>
<+37>:    call   0x401624 <explode_bomb>
<+42>:    add    ebx,1
<+45>:    add    rbp,4
<+49>:    cmp    ebx,6
<+52>:    jne    0x400f27 <phase_2+27>
<+54>:    jmp    0x400f50 <phase_2+68>

如果你快速浏览一下跳跃,你会发现:

  • +56处的小块不再执行;
  • [+27, +56] 之间的较大块,我们在这个小块之后跳转,有条件地重复(参见 +52 处的 jne

这是一个很好的提示,它类似于 for 循环,其中上面的小块是初始化,跳转之前的部分是条件检查和那些 add增量。 ebx,初始化(+61),递增(+42),检查(+49),看起来确实像一个计数器变量。

我们会回到这个问题上;现在,让我们继续循环体:

<+27>:    mov    eax,ebx
<+29>:    add    eax,[rbp-4]
<+32>:    cmp    [rbp],eax
<+35>:    je     0x400f36 <phase_2+42>
<+37>:    call   0x401624 <explode_bomb>

将循环计数器复制到eax,然后将数组元素中的值添加到(-4)之前指向的那个通过 rbp。然后与rbp当前指向的元素进行比较,不匹配则炸弹爆炸。

<+42>:    add    ebx,1
<+45>:    add    rbp,4

循环计数器 (ebx) 递增,rbp 移动到下一个 numbers 元素(递增阶段)

<+49>:    cmp    ebx,6
<+52>:    jne    0x400f27 <phase_2+27>
<+54>:    jmp    0x400f50 <phase_2+68>

如果循环计数器还没有达到 6(numbers 数组中元素的数量),则冲洗并重复,否则跳转到函数的末尾。

<+68>:    add    rsp,0x28
<+72>:    pop    rbx
<+73>:    pop    rbp
<+74>:    ret

常规清理:释放局部变量,恢复损坏的寄存器,return。

让我们总结一下:

void phase_2(const char *sz) {
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    int ebx = 1;
    int *rbp = &numbers[1];
    do {
        int eax = ebx + rbp[-1];
        if(eax != rbp[0]) explode_bomb();
        ++ebx;
        ++rbp;
    } while(ebx!=6);
}

这真的不是 C 程序员的写法; do...while,虽然是汇编的直接翻译,但对于 C 程序员来说是很不自然的(尽管在循环之前检查条件在汇编中编写更自然)。

此外,所有带有 rbp 的游戏都只是优化器的产物,它避免了 ebxrbp "moving on" 从索引重新计算目标地址].总而言之,大概是这样写的:

void phase_2(const char *sz) {
    int numbers[6];
    read_six_numbers(sz, numbers);
    if(numbers[0]<0) explode_bomb();
    for(int i=1; i<6; ++i) {
        int n = i + numbers[i-1];
        if(n != numbers[i]) explode_bomb();
    }
}

...我们到了。

作为最后的检查,我们可以重新运行编译器,如果它是一个众所周知的编译器,它可能会产生相同的程序集。

... 事实上,这是由 gcc 4.9(和其他版本)在 -O1.

生成的 the exact assembly

有趣的是,将优化级别移动到 -O2,它会将注释 3 中注释的 mov eax,0 更改为 xor eax,eax,因此这可能只是草率优化器在较低级别工作的结果优化级别。


备注

  1. 实际上它使用 esi,但结果如描述的那样 - 该地址的前 32 位无论如何都是零,并且设置 64 位寄存器的 32 位部分会将寄存器前 32 位清零少量。它 可以做一个 mov r64,imm64 (事实上,它是您实际上可以拥有 imm64 值的少数情况之一),但它是一个巨大的指令,在这里没有任何收获。
  2. __isoc99 部分可能是为了消除用于支持各种 C 标准修订的 sscanf 的各种版本之间的歧义,或者避免 conflicts/confusions 与 sscanf 导出libc 的较旧的非 C99 兼容版本; plt 是一个中间蹦床,用于解析从共享库导入的函数。
  3. 通过一个巨大的 5 字节 moveax 寄存器清零仍然有点不寻常 - 通常,它是通过更紧凑的 2 字节 xor eax,eax 完成的;我不知道编译器选择这种编码是否有某些特殊原因——也许它更喜欢使用 5 字节 move 并保持对齐 call 而不是使用 2 字节 xor 然后 nopcall 对齐到 4 个字节,也许这只是优化器未触及的固定序列。 Edit:鉴于此,启用更高的优化器级别,它变成 xor eax,eax,我会说它只是一个优化器漏洞(或也许在更高的优化级别,甚至那些固定序列也会通过窥孔优化器)。