第十一章:C语言逆向(一):变量与常量、数组、指针、结构体的反汇编识别
各位同学,欢迎来到逆向工程的实战环节。从今天开始,我们要真正进入C语言的反汇编世界。说实话,很多初学者觉得汇编难,其实不是汇编本身难,而是你找不到C代码和汇编指令之间的对应关系。一旦你掌握了这个映射关系,逆向就像看翻译稿一样简单。
这一章,我们聚焦最基础的数据类型:变量、常量、数组、指针和结构体。这些东西在IDA或Ghidra里长什么样?怎么一眼认出它们?我带你走一遍。
11.1 变量与常量:栈上的“临时工”
先看局部变量。C语言里最常见的局部变量,说白了就是放在栈上的临时数据。你写个int a = 10;,编译器会怎么做?
// C源码
void func() {
int a = 10;
int b = 20;
int c = a + b;
}
对应的x86-64汇编(未优化)大概是这样:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 10 ; a = 10
mov DWORD PTR [rbp-8], 20 ; b = 20
mov eax, DWORD PTR [rbp-4]
add eax, DWORD PTR [rbp-8]
mov DWORD PTR [rbp-12], eax ; c = a + b
pop rbp
ret
看到没?局部变量全部通过rbp-偏移量来访问。偏移量是负的,从-4、-8、-12依次往下排。我个人习惯在逆向时,先看函数开头有没有sub rsp, XX,这个XX就是栈帧大小,基本能猜出局部变量数量。
关键识别点:
- 局部变量:
[rbp-XX]或[rsp+XX]形式 - 常量:直接出现在指令中的立即数,如
mov eax, 10 - 全局变量:通过固定地址访问,如
mov eax, [0x404000]
避坑指南:我曾经遇到一个加了-O2优化的函数,局部变量全被优化到寄存器里了,栈上根本看不到。这时候别慌,看寄存器操作就能还原逻辑。优化后的代码,变量可能不存在于内存中。
11.2 数组:连续内存的“排排坐”
数组在内存里就是连续的一块空间。你写int arr[5],编译器会分配20个字节(5*4)。访问arr[i]时,本质是基地址 + i * sizeof(元素)。
// C源码
void array_demo() {
int arr[4] = {1, 2, 3, 4};
arr[2] = 100;
}
反汇编后:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-16], 1 ; arr[0]
mov DWORD PTR [rbp-12], 2 ; arr[1]
mov DWORD PTR [rbp-8], 3 ; arr[2]
mov DWORD PTR [rbp-4], 4 ; arr[3]
mov DWORD PTR [rbp-8], 100 ; arr[2] = 100
pop rbp
ret
你想想看,数组元素在栈上是连续递减的(从高地址到低地址)。arr[0]在rbp-16,arr[1]在rbp-12,每个元素间隔4字节。如果看到这种等间距的栈访问模式,基本可以断定是数组。
对于循环遍历数组的情况,反汇编里会出现一个循环结构,配合基地址+索引*sizeof的寻址方式。比如:
movsxd rcx, dword ptr [rbp-20] ; i
mov eax, dword ptr [rbp+rcx*4-16] ; arr[i]
这种[基址 + 索引 * 比例]的寻址模式,就是数组访问的铁证。
11.3 指针:内存的“导航员”
指针在逆向里是最容易让人迷糊的。其实指针就是个变量,只不过它存的是地址。你写int *p = &a;,反汇编里就是lea rax, [rbp-4],然后把rax存到栈上。
// C源码
void ptr_demo() {
int a = 42;
int *p = &a;
*p = 100;
}
反汇编:
mov DWORD PTR [rbp-4], 42 ; a = 42
lea rax, [rbp-4] ; rax = &a
mov QWORD PTR [rbp-16], rax ; p = &a (存到栈上)
mov rax, QWORD PTR [rbp-16] ; rax = p
mov DWORD PTR [rax], 100 ; *p = 100
注意看:lea指令是取地址,不是取值。如果看到lea rax, [rbp-XX],然后把这个值存到另一个栈位置,那八成就是指针赋值。而mov DWORD PTR [rax], 100这种通过寄存器间接访问内存的,就是解引用。
重要提醒:指针的大小在64位系统上是8字节(QWORD),32位系统上是4字节(DWORD)。逆向时一定要先确认目标程序是32位还是64位,否则指针大小搞错,整个分析都会跑偏。我见过有人把64位的指针当成32位来读,结果地址被截断,分析了一整天发现是位数搞错了。
11.4 结构体:数据的“集装箱”
结构体在内存里就是把多个变量打包在一起。每个成员按声明顺序排列,中间可能有对齐填充。比如:
struct Point {
int x; // 偏移0
int y; // 偏移4
char name; // 偏移8
// 填充3字节到12字节对齐
};
访问结构体成员时,反汇编里会看到基地址 + 固定偏移的模式。比如p->y就是[rbp-8](如果基址是rbp-12的话)。
// C源码
void struct_demo() {
struct Point pt;
pt.x = 10;
pt.y = 20;
pt.name = 'A';
}
反汇编:
mov DWORD PTR [rbp-12], 10 ; pt.x (偏移0)
mov DWORD PTR [rbp-8], 20 ; pt.y (偏移4)
mov BYTE PTR [rbp-4], 65 ; pt.name (偏移8), 'A'=65
看到没?三个成员分别占用了rbp-12、rbp-8、rbp-4。偏移量分别是0、4、8,完全符合结构体定义。如果中间有奇怪的间隔,那就是对齐填充在作怪。
实战技巧:在IDA里,你可以用CreateStruct功能把一段连续内存定义成结构体。我一般先看代码里访问了哪些偏移,然后反推结构体布局。比如看到[rax+0]、[rax+4]、[rax+8],基本可以确定这是一个至少3个成员的结构体。
11.5 知识体系总览
下面这张图总结了C语言基本数据类型在反汇编中的识别要点,建议你保存下来随时对照。
11.6 综合实战:一眼看穿结构体指针数组
最后,我们看一个稍微复杂点的例子,把前面几个知识点串起来。假设有这样一个C结构:
struct Student {
int id; // 0
char grade; // 4
// 填充3字节
long score; // 8
};
void process(struct Student *s) {
s->id = 1001;
s->grade = 'A';
s->score = 95;
}
反汇编后:
process:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi ; 参数s存到栈上
mov rax, QWORD PTR [rbp-8] ; rax = s
mov DWORD PTR [rax], 1001 ; s->id = 1001
mov rax, QWORD PTR [rbp-8] ; rax = s
mov BYTE PTR [rax+4], 65 ; s->grade = 'A'
mov rax, QWORD PTR [rbp-8] ; rax = s
mov QWORD PTR [rax+8], 95 ; s->score = 95
pop rbp
ret
你看,通过偏移量0、4、8,我立刻能还原出结构体有三个成员:4字节的int、1字节的char、8字节的long。中间从偏移5到7是3字节填充。这就是逆向分析结构体的标准流程。
我的个人习惯:在IDA里遇到这种模式,我会直接按Alt+Q打开结构体编辑器,新建一个结构体,把成员按偏移填进去。然后选中代码里的[rax+0],按T键转换成结构体访问。这样代码可读性瞬间提升10倍。
嗯,这一章的内容就到这里。变量、常量、数组、指针、结构体,这些是C语言逆向的基石。你把这些识别模式练熟了,后面遇到函数调用、虚函数、异常处理等高级特性时,才能游刃有余。记住:逆向没有捷径,多读反汇编,多总结模式,你的眼力会越来越毒。
公众号:蓝海资料掘金营,微信deep3321