上篇文章说到,使用objdump -d
反汇编出汇编代码,经过简单整理如下:
mov %rsp,%rbp
sub $0x10,%rsp
lea 0xff2(%rip),%rbx # 2000 <_start+0x1000>
xor %r12,%r12
loop_start:
mov $0x1,%edx
lea -0x8(%rbp),%rsi
mov $0x0,%edi
mov $0x0,%eax
syscall
test %rax,%rax
je loop_break
movzbq -0x8(%rbp),%rax
movzbq 0x3(%rbx,%rax,4),%rdx
mov (%rbx,%rax,4),%rax
mov %r12,%rcx
shl %cl,%rax
or %rax,-0x10(%rbp)
add %rdx,%r12
cmp $0x8,%r12
jb loop_end
mov %r12,%rdx
shr $0x3,%rdx
lea -0x10(%rbp),%rsi
mov $0x1,%edi
mov $0x1,%eax
syscall
mov %r12,%rax
shr $0x3,%rax
movzbq -0x10(%rbp,%rax,1),%rax
mov %rax,-0x10(%rbp)
and $0x7,%r12
loop_end:
jmp loop_start
loop_break:
test %r12d,%r12d
je exit
mov $0x1,%edx
lea -0x10(%rbp),%rsi
mov $0x1,%edi
mov $0x1,%eax
syscall
exit:
xor %edi,%edi
mov $0x3c,%eax
syscall
当时做题时其实没有做简单整理这一步,是现在写题解为了方便大家理解,才加了缩进,把跳转地址也命了名,接下来的任务就是把这段汇编读懂……
读汇编这个东西吧,每条语句的作用都是很明确的,寄存器也不多,大概上网查查就能理解了。
初始化
先看第一段:
mov %rsp,%rbp
sub $0x10,%rsp
lea 0xff2(%rip),%rbx
xor %r12,%r12
第一行mov
指令的意思是“把%rsp
寄存器的值赋给%rbp
寄存器”,第二行sub
指令的意思是“将%rsp
寄存器的值减0x10”,也就是减16。
简单吧~
第三行lea
是一个“取地址”指令,它把%rip + 0xff2
这个地址储存到了%rbx
寄存器里。这是一个相对寻址的操作,其实我们拿到的encoder二进制程序里有一个.rodata
数据段,里面储存了1024个字节的只读数据,这里就是取了这个数据表的地址。这个数据表我们解题的时候肯定是要用到的,在下一章会想办法把它提取出来。
第四行对于没接触过汇编的来说比较有意思,xor
指令是一个进行异或运算的指令,即按位异或,相同为1,不同为0,但是这里两个操作数都是同一个寄存器%r12
。一个数与自身的异或永远为0,这里其实是在对%r12
寄存器清零。
习惯上
%rbp
寄存器中储存的是“栈基址”,%rsp
储存的是“栈顶地址”,如果你了解函数调用的栈操作,那么前两条指令就像在分配16个字节的局部变量!后面我们会看到,这里16个字节其实被后面的代码当作两个64位整数使用,分别是
-0x8(%rbp)
和-0x10(%rbp)
。
对应C代码:
uint64_t v1, v2;
const char *rbx = { /* .rodata中储存的只读数据*/};
uint64_t r12 = 0;
程序大致框架
主要关注汇编中的jmp
、je
、jb
这类跳转指令,它们关系到整个程序的结构。刚接触汇编要注意的是这些跳转指令其实不是单独工作的,往往跳转指令的前一句是一个test
或者cmp
比较指令,作为跳转的条件。
经过简单的分析可以把整个程序大致框架写出来:
// ...
while(1) {
// ...
if (rax)
break;
// ...
if (r12 >= 0x08) {
// ...
}
}
if (r12d) {
// ...
}
exit(0);
系统调用
接下来可以看看汇编中的syscall
指令,顾名思义这是一个系统调用,它也不是单独工作的。
执行syscall
之后会触发一个软中断,会进入内核态,由内核来处理应用程序的请求,系统调用有很多种,光是这个程序里就有三种:read/write/exit,但是系统调用中断只有一个,内核如何知道现在要处理的是什么呢?答案是根据寄存器值。
内核会读取当前%rax
寄存器的值来确定要运行哪个系统调用,这个值称为“系统调用号”。特别地,0x0代表read,0x1代表write,0x3c代表exit。
%rdi
寄存器保存了本次系统调用的第一个参数,同理%rsi
、%rdx
、%rcx
、%r8
和%r9
储存了第二到第6个参数。我们可以看到syscall
指令前三四行往往都是对这些寄存器的一顿操作。
mov $0x1,%edx
lea -0x8(%rbp),%rsi
mov $0x0,%edi
mov $0x0,%eax
syscall
拿这第一个系统调用举例,系统调用号为0
是read
系统调用,三个参数依次为0x0
,%rbp-0x8
和0x1
。也就是:
syscall(SYS_read, 0, &v1, 1);
0
代表标准输入文件stdin,-0x8(%rbp)
前面提到过是第一个变量的地址,最后一个1代表我们想要读1个字节。
Q:前面不是说寄存器是
%rax
吗!这里怎么写的是%eax
呢?A:
%rax
是一个64位寄存器,%eax
寄存器其实是%rax
的低32位,%eax
的低16位叫%ax
,%ax
又可以分为%ah
和%al
,其他同理。具体可以搜索互联网上关于x86和amd64架构寄存器的介绍。
C语言重制
最后我用C语言把encoder重新写了一遍,经过各种调试,成功实现了跟原程序完全一致的行为。
累死个人,拿gdb调试encoder,汇编指令一行一行执行,观察寄存器,看跟C语言版本的区别,调了好久……
#include <stdint.h>
#include <stdio.h>
#include "main.h" // unsigned char table[1040] = {.....}
int main()
{
uint64_t v2 = 0, v1;
uint64_t r12 = 0;
while ((v1 = getchar()) != EOF)
{
char rdx = table[v1 * 4 + 3];
v2 |= ((uint32_t*)table)[v1] << r12;
r12 += rdx;
if (r12 >= 8)
{
fwrite(&v2, 1, r12 >> 3, stdout);
v2 = (int64_t)((char *)&v2)[r12 >> 3];
r12 &= 0b111;
}
}
if ((uint32_t)r12 != 0)
{
fwrite(&v2, 1, 1, stdout);
}
return 0;
}
目前这个版本还保留了许多汇编程序的特点,看起来不像个正常人写的C程序。在下一篇文章中会根据理解稍微调整程序格式,让我们把它读懂!