题解 · Encoder · 汇编

上篇文章说到,使用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;

程序大致框架

主要关注汇编中的jmpjejb这类跳转指令,它们关系到整个程序的结构。刚接触汇编要注意的是这些跳转指令其实不是单独工作的,往往跳转指令的前一句是一个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

拿这第一个系统调用举例,系统调用号为0read系统调用,三个参数依次为0x0%rbp-0x80x1。也就是:

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程序。在下一篇文章中会根据理解稍微调整程序格式,让我们把它读懂!