追一下小时候的梦,稍微学习一下逆向工程。
找了一些书籍推荐,最终决定从RE4B(安天翻译的版本)开始。
上册基本是一些C语言基础的汇编实现,暂且忽略掉ARM、Thumb和ARM64平台(篇幅过大),专注看一看x86下的AT&T和Intel语法汇编,开一篇文章记载一下学习汇编指令的过程。
[TOC]
1. 环境搭建 由于书中所用的编译器是MSVC2010/2012和GCC4.x,略显过时,特此自己搭了一套GCC 8.1.0(MinGW)和MSVC2019的环境。
安装VS2019 社区版,并勾选使用C++的桌面开发 。
从SourceForge下载MinGW-w64的离线安装包,需要X86和X64两个版本。
安装CLion,以利用其对C语言和AT&T语体汇编语言的有限 支持。
为免去配置VS的头文件和库文件环境变量的麻烦,可以利用VS自带的环境变量批处理,因此编写如下批处理文件以生成各种架构下不同语体的汇编文件:
1 2 3 4 5 6 7 8 call "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvarsamd64_x86.bat" "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.22.27905\bin\Hostx64\x86\cl.exe" main.c /Famain_msvc_86.asmcall "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build\vcvars64.bat" "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.22.27905\bin\Hostx64\x64\cl.exe" main.c /Famain_msvc_64.asm"E:\MinGW\mingw32\bin\gcc.exe" main.c -S -o main_gcc_at_t_86.asm -fno-asynchronous-unwind-tables "E:\MinGW\mingw\bin\gcc.exe" main.c -S -o main_gcc_at_t_64.asm -fno-asynchronous-unwind-tables "E:\MinGW\mingw32\bin\gcc.exe" main.c -S -o main_gcc_intel_86.asm -masm =intel -fno-asynchronous-unwind-tables "E:\MinGW\mingw\bin\gcc.exe" main.c -S -o main_gcc_intel_64.asm -masm =intel -fno-asynchronous-unwind-tables
其中GCC的-fno-asynchronous-unwind-tables参数是为了忽略一些不必要的宏。
最终编译结果有时会与书本上不完全一致,因此本书将作为提纲挈领之用,我将根据书中目录顺序亲手进行汇编,尽力找出不一致的原因,加以拓展学习。
2. 最简函数 C 1 2 3 int f () { return 123 ; }
汇编 GCC AT&T 1 2 3 4 5 6 7 8 .file "main.c" .text .globl _f .def _f; .scl 2; .type 32; .endef _f: movl $123, %eax ret .ident "GCC: (i686-win32-sjlj-rev0, Built by MinGW-W64 project) 8.1.0"
x64的结果几乎一样,除了函数名少了下划线,以及.ident的编译器信息不一样。尽管是x64的汇编,寄存器也只用了EAX。
其中的.file之类的是指示,记录了数据段的开始(.data)、实际程序代码的开始(.text)、字符串常量(书上和某文章说是.string,但实际汇编出来的是.ascii)等。还有一系列.cfi开头的指示,可以记录堆栈信息,在GCC生成汇编指令时可以用-fno-asynchronous-unwind-tables参数忽略。
GCC Intel Intel语体采用了另一个指令:
MSVC x86 MSVC的情况稍复杂一些, 首先它贴心的标记了行号,其次在x86平台生成的汇编语句中出现了函数调用标志,只不过栈顶没有动而已。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ; Listing generated by Microsoft (R) Optimizing Compiler Version 19.22.27905.0 include listing.inc INCLUDELIB LIBCMT INCLUDELIB OLDNAMES PUBLIC f ; Function compile flags: /Odtp _TEXT SEGMENT _f PROC ; File C:\Users\QHS\CLionProjects\untitled1\2.min_function\main.c ; Line 3 push ebp mov ebp, esp ; Line 4 mov eax, 123 ; 0000007bH ; Line 5 pop ebp ret 0 _f ENDP _TEXT ENDS END
MSVC x64 和GCC表现完全一致。
1 2 3 4 5 6 7 f PROC ; File C:\Users\QHS\CLionProjects\untitled1\2.min_function\main.c ; Line 4 mov eax, 123 ; 0000007bH ; Line 5 ret 0 f ENDP
拓展:栈内存 栈内存在内存中自顶向下,添加元素时会使栈底减少。而PUSH就是汇编的入栈指令,其作用是先将栈底减4(32位平台)或8(64位平台),然后将要写入的数写到栈底指向的位置。
例如push ebp等价于:
1 2 sub esp, 4 mov [esp], ebp
POP则是逆向操作,会将内存中的值赋给某寄存器。pop ebp等价于:
1 2 mov ebp, [esp] add esp, 4
拓展: 函数序言和尾声 函数序言:
将EBP寄存器的值压入栈
将ESP寄存器的值赋值给EBP(将函数开始前的栈底保存到EBP,此时ESP和EBP是该函数的局部变量、参数 的基准值 )。
修改ESP,以给函数局部变量分配空间。
1 2 3 push ebp mov ebp, esp sub esp, X
函数尾声:
做函数序言的逆操作
将EBP的值复制到ESP(将ESP的值恢复到函数开始前)
从栈内存中读取EBP的值。
小结
AT&T语体中,MOV采取从左向右赋值的语法,而Intel与C类似,从右向左赋值。其他运算表达式也类似,源和目标相反。
AT&T语体使用圆括号取值,而Intel使用方括号。
AT&T语体会使用%符号标志寄存器,用$符号标志立即数(暂且理解为常量)。
AT&T语体会指定操作数据类型,-q是64位(quad),-l是32位long,-w是16位word,-b是8位byte
函数调用结束后会把返回值放在EAX寄存器,调用者会从该寄存器取值。
某个函数调用开始时,EBP为此时栈顶,ESP随着局部变量声明而变化。函数结束时,将EBP复制到ESP,恢复栈顶为调用开始时的值,再从栈内存中取出原有EBP。
3. Hello World C 1 2 3 4 int main () { printf ("Hello, World!\n" ); return 0 ; }
汇编 GCC x86 AT&T :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 .file "main.c" .text .def ___main; .scl 2; .type 32; .endef .section .rdata,"dr" LC0: .ascii "Hello, World!\0" .text .globl _main .def _main; .scl 2; .type 32; .endef _main: pushl %ebp movl %esp, %ebp andl $-16, %esp subl $16, %esp call ___main movl $LC0, (%esp) call _puts movl $0, %eax leave ret .ident "GCC: (i686-win32-sjlj-rev0, Built by MinGW-W64 project) 8.1.0" .def _puts; .scl 2; .type 32; .endef
andl指令将ESP的值指定为16的整数倍,这是一种x86/x64的编译规范。
subl指令将栈顶向下拉了16字节,本来4字节就够了,但是由于被对齐所以分配了16字节。
接下来,LC0是一个全局变量,相当于const char* LC0[] = "Hello, World!"。在此,该变量的指针地址被复制到ESP寄存器,以便printf函数从ESP寄存器中取值。
该函数比最简函数多一个对printf函数的调用,该函数在GCC中被优化为了puts。
最后将0放入EAX中,表示主函数返回值。
Intel语体的MOV指令略有不同:
1 mov DWORD PTR [esp], OFFSET FLAT:LC0
GCC x64 1 2 3 4 5 6 7 8 9 10 11 12 main: pushq %rbp movq %rsp, %rbp subq $32, %rsp call __main leaq .LC0(%rip), %rcx call puts movl $0, %eax leave ret .ident "GCC: (GNU) 8.1.0" .def puts; .scl 2; .type 32; .endef
LC0的代码没有变化,故省略。
可以看出x64跳过了对齐步骤,而数据类型变成64位导致指令最后的l全部变成了q。
r开头的寄存器是e开头寄存器的扩展,以RAX举例:AL占用第0字节,AH占用第一字节,AX占用0和1字节,EAX占用0、1、2、3字节,而RAX占用0-7这8个字节。
传参的方式也发生了变化,不再使用栈内存,而是直接使用寄存器。与书中不一样的是,此处使用了rcx寄存器而不是edi寄存器进行传参,参数值也多了一个(%rip),查阅资料后得知是指令指针寄存器,暂时不明用途。
LEA命令意为Load Effective Address,暂时可以理解为与MOV等价。
而C编译器为了兼容性会返回32位的0,也就意味着程序结束时EAX为0,RAX不一定为0。
MSVC x86 而MSVC产生的汇编很长,似乎将Windows Kits的stdio.h中printf部分也汇编到了同一个文件。
推测可能是某种优化,但是我也没有开启任何优化选项……
摘取main函数的部分如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 _DATA SEGMENT $SG8163 DB 'Hello, World!', 0aH, 00H _DATA ENDS ; Function compile flags: /Odtp _TEXT SEGMENT _main PROC ; File C:\Users\QHS\CLionProjects\untitled1\3.helloworld\main.c ; Line 3 push ebp mov ebp, esp ; Line 4 push OFFSET $SG8163 call _printf add esp, 4 ; Line 5 xor eax, eax ; Line 6 pop ebp ret 0 _main ENDP _TEXT ENDS
与书中MSVC2010的表现不同,2019用_DATA段取代了CONST段.
与GCC不一样,MSVC使用xor eax eax来置0.
MSVC x64 1 2 3 4 5 6 7 8 9 10 11 12 13 14 main PROC ; File C:\Users\QHS\CLionProjects\untitled1\3.helloworld\main.c ; Line 3 $LN3: sub rsp, 40 ; 00000028H ; Line 4 lea rcx, OFFSET FLAT:$SG7907 call printf ; Line 5 xor eax, eax ; Line 6 add rsp, 40 ; 00000028H ret 0 main ENDP
64位MSVC的行为让我有些费解,为什么会将栈顶减小又增加40字节呢。也没有看到将寄存器内容保存到栈里的语句。
其余和书上基本一致,使用rcx寄存器进行传参。
小结
汇编中的括号类似于C中的取地址操作符。
32位平台用栈传参,64位用寄存器传前几个参数。
64位系统中程序结束时EAX为0不代表RAX为0。
GCC可能会把printf优化为puts,而MSVC会使用XOR对EAX寄存器置0。