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 call 到 sscanf
2 - 这需要将参数(按顺序)传递到 rdi
、rsi
、rdx
、rcx
、r8
、r9
,其余入栈,ax
设置为存储的浮点参数个数在 XMM 寄存器中(此处为零,因此 mov
3)。
如果我们将到目前为止收集的碎片放在一起,我们可以推断出:
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 类型,因为我发现在调用此函数后似乎没有人对 rax
或 eax
感兴趣。
然后:
<+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字节的局部变量,保存rbp
和rbx
因为要用到(而且都是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
的游戏都只是优化器的产物,它避免了 ebx
和 rbp
"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
,因此这可能只是草率优化器在较低级别工作的结果优化级别。
备注
- 实际上它使用
esi
,但结果如描述的那样 - 该地址的前 32 位无论如何都是零,并且设置 64 位寄存器的 32 位部分会将寄存器前 32 位清零少量。它 是 可以做一个 mov r64,imm64
(事实上,它是您实际上可以拥有 imm64 值的少数情况之一),但它是一个巨大的指令,在这里没有任何收获。
-
__isoc99
部分可能是为了消除用于支持各种 C 标准修订的 sscanf
的各种版本之间的歧义,或者避免 conflicts/confusions 与 sscanf
导出libc
的较旧的非 C99 兼容版本; plt
是一个中间蹦床,用于解析从共享库导入的函数。
- 通过一个巨大的 5 字节
mov
将 eax
寄存器清零仍然有点不寻常 - 通常,它是通过更紧凑的 2 字节 xor eax,eax
完成的;我不知道编译器选择这种编码是否有某些特殊原因——也许它更喜欢使用 5 字节 move
并保持对齐 call
而不是使用 2 字节 xor
然后 nop
将 call
对齐到 4 个字节,也许这只是优化器未触及的固定序列。 Edit:鉴于此,启用更高的优化器级别,它变成 xor eax,eax
,我会说它只是一个优化器漏洞(或也许在更高的优化级别,甚至那些固定序列也会通过窥孔优化器)。
我对这次大会上发生的事情有点困惑。我可以看到基本原理,如果不输入六个数字,炸弹就会爆炸并结束程序。逐行检查输入并在六个数字为非负数时进入循环。我迷路了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 call 到 sscanf
2 - 这需要将参数(按顺序)传递到 rdi
、rsi
、rdx
、rcx
、r8
、r9
,其余入栈,ax
设置为存储的浮点参数个数在 XMM 寄存器中(此处为零,因此 mov
3)。
如果我们将到目前为止收集的碎片放在一起,我们可以推断出:
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 类型,因为我发现在调用此函数后似乎没有人对 rax
或 eax
感兴趣。
然后:
<+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字节的局部变量,保存rbp
和rbx
因为要用到(而且都是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
的游戏都只是优化器的产物,它避免了 ebx
和 rbp
"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
.
有趣的是,将优化级别移动到 -O2
,它会将注释 3 中注释的 mov eax,0
更改为 xor eax,eax
,因此这可能只是草率优化器在较低级别工作的结果优化级别。
备注
- 实际上它使用
esi
,但结果如描述的那样 - 该地址的前 32 位无论如何都是零,并且设置 64 位寄存器的 32 位部分会将寄存器前 32 位清零少量。它 是 可以做一个mov r64,imm64
(事实上,它是您实际上可以拥有 imm64 值的少数情况之一),但它是一个巨大的指令,在这里没有任何收获。 -
__isoc99
部分可能是为了消除用于支持各种 C 标准修订的sscanf
的各种版本之间的歧义,或者避免 conflicts/confusions 与sscanf
导出libc
的较旧的非 C99 兼容版本;plt
是一个中间蹦床,用于解析从共享库导入的函数。 - 通过一个巨大的 5 字节
mov
将eax
寄存器清零仍然有点不寻常 - 通常,它是通过更紧凑的 2 字节xor eax,eax
完成的;我不知道编译器选择这种编码是否有某些特殊原因——也许它更喜欢使用 5 字节move
并保持对齐call
而不是使用 2 字节xor
然后nop
将call
对齐到 4 个字节,也许这只是优化器未触及的固定序列。 Edit:鉴于此,启用更高的优化器级别,它变成xor eax,eax
,我会说它只是一个优化器漏洞(或也许在更高的优化级别,甚至那些固定序列也会通过窥孔优化器)。