照烟照烟
笔记

gcc和IDA

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
// test.c
#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; // rax
char input[24]; // [rsp+20h] [rbp-20h] BYREF
char *str; // [rsp+38h] [rbp-8h]

_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; // rax
char Buffer[24]; // [rsp+20h] [rbp-20h] BYREF
char *Str1; // [rsp+38h] [rbp-8h]

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; // rax
char Buffer[8]; // [rsp+20h] [rbp-28h] BYREF
char v6; // [rsp+28h] [rbp-20h]

_main();
v3 = __acrt_iob_func(0);
fgets_0(Buffer, 20, v3);
Buffer[strcspn_0(Buffer, "\n")] = 0;
if ( *(_QWORD *)Buffer != 0x796C626D65737361LL || 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
// input_check.c
#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 作用就是同步 rbprsp,建立新栈底。最后的 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,第三、四个进入 r8r9,后续的通过压栈传递。在这段代码中,mov rcx,rax 就是把原本存储在 rax 的参数拷贝到 rcx 中,让 printf 调用。

movzx

对于原指令 movzx eax,[rbp+var_2]eaxrax 的低 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
// string_check.c
#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

字符串存储

  1. 硬编码的文字,比如提示语 “Great!” 和 “Error!”,它们是硬编码数据,直接被 printf 函数使用,存放在只读数据段中。
  2. 栈区数据。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
// loop.c
#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 个参数固定使用 RCXRDXR8R9,第 5 个参数开始,从右向左压入栈。
  • 浮点数传参:前 4 个参数使用 XMM0XMM1XMM2XMM3
  • 影子空间:就算使用这 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
// xor_enc.c
#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))