gcc
基础用法
把一个 .c 文件编译为可执行文件,使用 gcc main.c,这样可以生成一个名为 a.exe (Windows) / a.out (Linux) 的可执行文件。
常用参数
-E
生成 .i 文件,将包含的头文件,宏定义等展开,但不进行编译、汇编,本质还是 C 代码。使用 gcc 命令 gcc -E main.c -o main.i。
-S
生成 .s 文件,进行预处理和编译,生成的是汇编语言文件,使用 gcc -S main.c -o main.s。指定 -msam=intel / -masm=att 可以切换汇编风格,前者是 Windows 风格(数据从右流向左),后者是 Unix 风格(数据从左流向右),便于汇编分析。
-c
生成 .o 文件,最常用的,用于将源文件编译为目标文件。大项目中一般先把源码编译为 .o 文件,最后统一链接。使用 gcc -c main.c -o main.o。
-o
指定文件名。
-g
保留调试信息。调试信息包括行号的映射,变量名和函数名,结构体联合体的具体定义等。有了调试信息,逆向会很简单,也方便 gdb 调试。
-s
与-g 相反,加上-s 会抹除所有便于调试的信息,包括符号表、函数名等,大大增加逆向难度。
-O0/-O2
指定 -O0 时,编译器并不会进行任何优化,机器码和源代码一一对应。指定 -O2 时,编译器会自动进行优化程序,提高 CPU 的运行效率。但与此同时,gdb 调试会受阻,代码结构也会变得很乱。
-Wall
开启常见警告。在 VScode 写代码时,有些隐藏的错误只能等编译器发现。比如未使用、未初始化的变量,类型不匹配等,好东西呀。
对于一段字符串比较的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #include <stdio.h> #include <string.h> int main () { char *str = "assembly" ; char input[20 ]; fgets(input, sizeof (input), stdin ); input[strcspn (input, "\n" )] = '\0' ; if (!strcmp (str, input)) printf ("Great!" ); else printf ("Error!" ); return 0 ; }
使用命令 gcc test.c -o test.s -S -masm=intel -O0,得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 .file "test.c" .intel_syntax noprefix .text .section .rdata,"dr" .LC0: .ascii "assembly\0" .LC1: .ascii "\12\0" .LC2: .ascii "Great!\0" .LC3: .ascii "Error!\0" .text .globl main .def main; .scl 2; .type 32; .endef .seh_proc main main: push rbp .seh_pushreg rbp mov rbp, rsp .seh_setframe rbp, 0 sub rsp, 64 .seh_stackalloc 64 .seh_endprologue call __main lea rax, .LC0[rip] mov QWORD PTR -8[rbp], rax mov ecx, 0 mov rax, QWORD PTR __imp___acrt_iob_func[rip] call rax mov rdx, rax lea rax, -32[rbp] mov r8, rdx mov edx, 20 mov rcx, rax call fgets lea rdx, .LC1[rip] lea rax, -32[rbp] mov rcx, rax call strcspn mov BYTE PTR -32[rbp+rax], 0 lea rdx, -32[rbp] mov rax, QWORD PTR -8[rbp] mov rcx, rax call strcmp test eax, eax jne .L2 lea rax, .LC2[rip] mov rcx, rax call __mingw_printf jmp .L3 .L2: lea rax, .LC3[rip] mov rcx, rax call __mingw_printf .L3: mov eax, 0 add rsp, 64 pop rbp ret .seh_endproc .def __main; .scl 2; .type 32; .endef .ident "GCC: (Rev8, Built by MSYS2 project) 15.2.0" .def fgets; .scl 2; .type 32; .endef .def strcspn; .scl 2; .type 32; .endef .def strcmp; .scl 2; .type 32; .endef
使用命令 gcc test.c -o test -O0 -g,用 IDA pro 反编译后得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 __int64 __fastcall main () { FILE *v0; char input[24 ]; char *str; _main(); str = "assembly" ; v0 = __acrt_iob_func(0 ); fgets_0(input, 20 , v0); input[strcspn_0(input, "\n" )] = 0 ; if ( !strcmp (str, input) ) _mingw_printf("Great!" ); else _mingw_printf("Error!" ); return 0 ; }
将 -g 换为 -s,反编译后得到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 __int64 sub_140001450 () { FILE *v0; char Buffer[24 ]; char *Str1; sub_1400015C0(); Str1 = "assembly" ; v0 = (FILE *)off_1400090F0(); fgets(Buffer, 20 , v0); Buffer[strcspn (Buffer, "\n" )] = 0 ; if ( !strcmp (Str1, Buffer) ) sub_140002600("Great!" ); else sub_140002600("Error!" ); return 0 ; }
发现 -g 命令下文件大小是 -s 命令下文件大小的 4 倍,看来 gcc 确实做了不少努力啊。不仅如此,如果使用 -s 参数抹掉函数签名,用 IDA 打开后会发现流程图乱糟糟的。这是因为 IDA 识别不到 main 函数了,只能把整个程序的流程图展示出来,但其中包含大量底层的代码,导致看起来很复杂。
指定参数 -O2 的反编译:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int __fastcall main (int argc, const char **argv, const char **envp) { FILE *v3; char Buffer[8 ]; char v6; _main(); v3 = __acrt_iob_func(0 ); fgets_0(Buffer, 20 , v3); Buffer[strcspn_0(Buffer, "\n" )] = 0 ; if ( *(_QWORD *)Buffer != 0x796C626D65737361L L || v6 ) _mingw_printf("Error!" ); else _mingw_printf("Great!" ); return 0 ; }
原本的 !strcmp(str, input) 变成了 if ( *(_QWORD *)Buffer != 0x796C626D65737361LL || v6 ),是因为原字符串 assembly\0 被拆成了 8+1 个字节,优化后的代码先比较一个 QWord(8 字节长字)在小端序的 16 进制下是否相同,再比较末尾的 1 字节。很好优化,使我看不懂 T_T
Debug & Release
Debug(调试版)和 Release(发布版)代表着两种经典的编译配置,比如:
Debug:gcc -g -O0 -Wall -DDEBUG main.c -o main_debug
Release:gcc -O2 -s -DNDEBUG main.c -o main_release
前者是给自己人看的,保留原始代码逻辑,方便 dbg 调试;后者是给用户用的,优化性能和文件大小。
IDA 的常用快捷键和操作
Space
在 IDA 分析完成后,按 Space 可以切换流程图和纯文本汇编。
双击
双击一下 call 或 jmp 后的变量或函数,快速跳转到对应位置。
Esc
退回上一级函数/上一步操作。
G
点击后在弹出的窗口中输入内存地址或函数名,直接跳转。
F5
在汇编窗口按,生成 C 语言伪代码。
Tab
选中一行汇编/C 代码,点击后跳转到 C/汇编的对应位置。
\
隐藏强制类型转换,比如 *(_DWORD *) 等,让反编译更加清爽。
N
对选中的函数/变量重命名。
Y
手动修正某个变量的类型(如 int** 修正为 int*)
: / ;
添加注释。
A / D / C / U
转为字符串/数据/代码/取消定义
X
查看某个变量/函数的交叉引用。
Shift + F12
查看程序中所有字符串的列表。
Alt + T, Ctrl + T
在某个函数/全局中搜索指定的文本,跳转到下一个搜索结果。
File - Close
退出当前反汇编的程序而不退出 IDA。
View - Open subviews - Disassembly
新开一个窗口(如 IDA View-B),也可以这么恢复被叉掉了的窗口。
拖拽 Pseudocode 窗口至屏幕右侧 - Synchronize with - IDA View-A
同步查看 C 代码和原始汇编。
(在 IDA View-A 中) View - Open subviews - Generate pseudocode
独立生成一个反编译窗口(Pseudocode-B),拖拽这个 Pseudocode 窗口至屏幕右侧,再点击其中想要查看的函数,便可以分屏查看父函数和子函数。
Windows - Reset desktop
把所有的窗口恢复到原始状态。
Options - General… - Disassembly - Line prefixes (non-graph)
取消显示纯文本汇编每行前的地址前缀。
简单程序的汇编分析
输入校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include <stdio.h> int main () { char target = 'c' ; char input; printf ("Input: " ); scanf ("%c" , &input); if (target == input) { printf ("Great!" ); return 0 ; } else { printf ("Error!" ); return 1 ; } }
反汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 main proc near ; CODE XREF: __tmainCRTStartup+B4↑p ; DATA XREF: .pdata:0000000140014054↓o var_2 = byte ptr -2 var_1 = byte ptr -1 push rbp mov rbp, rsp sub rsp, 30h call __main mov [rbp+var_1], 63h ; 'c' lea rax, fmt ; "Input: " mov rcx, rax ; fmt call __mingw_printf lea rax, [rbp+var_2] lea rcx, format ; "%c" mov rdx, rax call __mingw_scanf movzx eax, [rbp+var_2] cmp [rbp+var_1], al jnz short loc_1400014A2 lea rax, aGreat ; "Great!" mov rcx, rax ; fmt call __mingw_printf mov eax, 0 jmp short loc_1400014B6 ; --------------------------------------------------------------------------- loc_1400014A2: ; CODE XREF: main+3A↑j lea rax, aError ; "Error!" mov rcx, rax ; fmt call __mingw_printf mov eax, 1 loc_1400014B6: ; CODE XREF: main+50↑j add rsp, 30h pop rbp retn main endp
函数界定
汇编开头的 main proc near 是一条伪指令,声明一个函数的开始。相应地,末尾的 main endp 声明函数的结束,二者共同界定了一个函数的范围。
初始化栈帧
1 2 3 push rbp mov rbp,rsp sub rsp,30h
几乎所有函数开头都会写这三行。由于函数是嵌套调用的,所以在每个函数开头,都要 push rbp。这句代表把上一个函数使用的栈底指针压栈(相当于备份这个指针),在 x64 环境下固定占用 8 字节的栈空间。这 8 字节的栈空间把 rsp 往下撑了,所以后面的 mov rbp,rsp 作用就是同步 rbp 到 rsp,建立新栈底。最后的 sub rsp,30h 就是基于 rsp 这个新栈底,开辟出 48 字节的空间。Windows 单个进程的栈大小默认 1MB,所以栈空间一般用来临时存放没那么大的数据。如果数据大小超出了栈空间上限,就需要用 malloc 函数申请分配堆空间。
以下情况考虑分配堆空间:
数组或结构体很大。
数据需要跨函数使用,长期保留。
内存占用不确定,运行时才知道大小。
lea & mov
lea 是取地址,lea rax,[rbp+var_2] 代表把相对栈底偏移量为 2 字节的数据的内存地址放进 rax 里。
mov 是取内容,mov rax,[rbp+var_2] 是直接把上述数据放进 rax。
在 lea rax,fmt 中,fmt 是提示字符串(Input: )的首地址。lea 命令表示把字符串的地址传递给 rax。因为 printf 函数要接受字符串地址才能打印,所以这一句是为了后面的打印做铺垫。同理,后面的 lea rax,[rbp+var_2] 是把存储输入的变量 input 的地址 给 rax(因为原 scanf("%c", &input) 参数接收的是地址)
传参寄存器
在 x64 Windows 的调用协议中,函数第一个参数进入寄存器 rcx,第二个进入 rdx,第三、四个进入 r8 和 r9,后续的通过压栈传递。在这段代码中,mov rcx,rax 就是把原本存储在 rax 的参数拷贝到 rcx 中,让 printf 调用。
movzx
对于原指令 movzx eax,[rbp+var_2],eax 是 rax 的低 32 位部分,而原数据只占低 8 位。所以这句指令的意思是把这 8 位拷贝到 eax 的低 8 位,其它置 0。
比较与跳转
cmp [rbp+val_1],al 的本质是做减法。前者减去后者,如果得到的不为 0(不相等),后面的 jnz 就会导致跳转到 loc_1400014A2 的失败逻辑,即打印 Error,并设置返回值为 1。如果为 0(相等),jnz 指令就无效,程序会继续执行剩下的逻辑,即打印 Great,设置返回值为 0。但无论是哪种结果,程序都会收尾(即归还栈空间,重新设置栈底等),尽管猜测失败的代码并没有显式跳转到收尾逻辑,但代码依然会从 loc_1400014A2 顺着运行到 loc_1400014B6。
字符串比较
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include <stdio.h> #include <string.h> int main () { char target[] = "hello" ; char input[10 ]; printf ("Input: " ); scanf ("%s" , input); if (!strcmp (target, input)) { printf ("Great!" ); return 0 ; } else { printf ("Error!" ); return 1 ; } }
反汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 main proc near ; CODE XREF: __tmainCRTStartup+B4↑p ; DATA XREF: .pdata:0000000140014054↓o Str2 = byte ptr -10h Str1 = byte ptr -6 var_2 = word ptr -2 push rbp mov rbp, rsp sub rsp, 30h call __main mov dword ptr [rbp+Str1], 6C6C6568h mov [rbp+var_2], 6Fh ; 'o' lea rax, fmt ; "Input: " mov rcx, rax ; fmt call __mingw_printf lea rax, [rbp+Str2] lea rcx, format ; "%s" mov rdx, rax call __mingw_scanf lea rdx, [rbp+Str2] ; Str2 lea rax, [rbp+Str1] mov rcx, rax ; Str1 call strcmp test eax, eax jnz short loc_1400014B6 lea rax, aGreat ; "Great!" mov rcx, rax ; fmt call __mingw_printf mov eax, 0 jmp short loc_1400014CA ; --------------------------------------------------------------------------- loc_1400014B6: ; CODE XREF: main+4E↑j lea rax, aError ; "Error!" mov rcx, rax ; fmt call __mingw_printf mov eax, 1 loc_1400014CA: ; CODE XREF: main+64↑j add rsp, 30h pop rbp retn main endp
字符串存储
硬编码的文字,比如提示语 “Great!” 和 “Error!”,它们是硬编码数据,直接被 printf 函数使用,存放在只读数据段中。
栈区数据。CPU 会把一个字符串拆成若干个由 8 字节 (QWord),4 字节 (DWord),2 字节 (Word) 和 1 字节 (Byte) 的组合,并且宁愿多分配空间,也尽量不增加指令数。
对于 mov dword ptr [rbp+Str1], 6C6C6568h,后面是以 小端序 存储的 “hell”,'o’被单独存起来了。小端序是为了让强制类型转换更高效,比如一个 4 字节的,以小端序存储的 1 (01 00 00 00),转换为 Byte 类型,只需要取走第一个字节就行了。如果是大端序存储,还要加上 3 偏移。
数组遍历
1 2 3 4 5 6 7 8 9 #include <stdio.h> int main () { int arr[] = {1 , 2 , 3 , 4 , 5 , 6 }; for (int i = 0 ; i < sizeof (arr) / sizeof (arr[0 ]); i++) printf ("%d " , arr[i]); return 0 ; }
反汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 main proc near ; CODE XREF: __tmainCRTStartup+B4↑p ; DATA XREF: .pdata:000000014000C054↓o arr = dword ptr -20h i = dword ptr -4 push rbp mov rbp, rsp sub rsp, 40h call __main mov [rbp+arr], 1 mov [rbp+arr+4], 2 mov [rbp+arr+8], 3 mov [rbp+arr+0Ch], 4 mov [rbp+arr+10h], 5 mov [rbp+arr+14h], 6 mov [rbp+i], 0 jmp short loc_1400014AB ; --------------------------------------------------------------------------- loc_140001490: ; CODE XREF: main+61↓j mov eax, [rbp+i] cdqe mov eax, [rbp+rax*4+arr] lea rcx, fmt ; "%d " mov edx, eax call __mingw_printf add [rbp+i], 1 loc_1400014AB: ; CODE XREF: main+3E↑j mov eax, [rbp+i] cmp eax, 5 jbe short loc_140001490 mov eax, 0 add rsp, 40h pop rbp retn main endp
数组访问
对于 mov eax,[rbp+rax*4+arr],这是一句汇编寻址代码。因为数组在内存中是连续的,所以目标地址 = 基址+(索引*元素大小)。
循环实现
程序首先通过 mov [rbp+i],0 给 i 赋初值,接着直接跳到判断环节(loc_1400014AB)。在判断环节,程序通过 cmp 指令比较,再用 jbe 决定是打印数据还是进入收尾逻辑,同时 i 自增,以此实现循环。
函数调用
1 2 3 4 5 6 7 8 9 10 11 12 #include <stdio.h> float avg (int a, int b) { return (a + b) / 2 ; }int main () { int a = 1 ; int b = 2 ; printf ("%f" , avg(a, b)); return 0 ; }
反汇编:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 main proc near ; CODE XREF: __tmainCRTStartup+B4↑p ; DATA XREF: .pdata:000000014000C054↓o ... b = dword ptr -8 a = dword ptr -4 push rbp mov rbp, rsp sub rsp, 30h call __main mov [rbp+a], 1 mov [rbp+b], 2 mov edx, [rbp+b] ; b mov eax, [rbp+a] mov ecx, eax ; a call avg cvtss2sd xmm0, xmm0 movapd xmm1, xmm0 movapd xmm0, xmm1 movq rdx, xmm1 lea rax, fmt ; "%f" movapd xmm1, xmm0 mov rcx, rax ; fmt call __mingw_printf mov eax, 0 add rsp, 30h pop rbp retn main endp
调用规范
整数/指针传参:前 4 个参数固定使用 RCX、RDX、R8、R9,第 5 个参数开始,从右向左压入栈。
浮点数传参:前 4 个参数使用 XMM0、XMM1、XMM2、XMM3。
影子空间:就算使用这 4 个寄存器传参,也需要在栈上为它们预留 32 字节(4*8)的空白空间,方便从寄存器接收返回值。
异或加密
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include <stdio.h> #include <string.h> int main () { printf ("Input: " ); char input[20 ]; scanf ("%s" , input); int i = 0 ; char res[20 ] = {0 }; char pwd[20 ] = {0 , 17 , 16 , 1 , 8 , 4 , 11 , 17 }; while (input[i] != '\0' ) { res[i] = input[i] ^ (97 + i); i++; } if (!memcmp (res, pwd, 8 )) printf ("Great!" ); else printf ("Error!" ); return 0 ; }
Python 解密
1 2 3 4 5 6 cipher = [0 , 17 , 16 , 1 , 8 , 4 , 11 , 17 ] txt = []for i in range (len (cipher)): word = cipher[i] ^ (97 + i) txt.append(word)print ("" .join(chr (x) for x in txt))