第一次接触Pwn的新手常会问:为什么不能直接学漏洞利用,非要先啃这些晦涩的底层知识?这个问题我五年前刚入门时也困惑过,直到在实战中踩了无数坑才明白——汇编和内存模型就是Pwn领域的"内功心法"。
想象你是个外科医生,如果连人体解剖结构都不清楚,拿着手术刀就敢开膛破肚会是什么后果?Pwn也是如此。当我们分析一个二进制漏洞时,本质上是在和CPU、内存这两个核心部件打交道。寄存器如何传递参数?栈帧如何组织数据?返回地址存放在哪?这些问题的答案都藏在汇编和内存模型里。
去年我带队参加一场线下赛时遇到过典型案例:某道题需要精确计算栈偏移来覆盖返回地址,队里跳过基础直接学利用的新人折腾三小时无果,而系统学习过内存结构的队员五分钟就构造出payload。这种差距不是运气,而是对"栈从高地址向低地址增长"、"局部变量与返回地址的相对位置"等概念的深刻理解。
现代CPU为了加速数据访问,内置了多个高速存储单元——寄存器。x86_64架构下(CTF题目90%采用此环境),有16个通用寄存器,但Pwn中高频使用的集中在以下6个:
| 寄存器 | 位宽 | 主要用途 | Pwn中的特殊意义 |
|---|---|---|---|
| rax | 64位 | 累加器/函数返回值 | 系统调用号存储 |
| rdi | 64位 | 第一个参数 | 函数参数传递关键 |
| rsi | 64位 | 第二个参数 | 常用于指向输入缓冲区 |
| rbp | 64位 | 栈帧基指针 | 定位局部变量和返回地址的锚点 |
| rsp | 64位 | 栈顶指针 | 栈操作的核心参照 |
| rip | 64位 | 指令指针 | 控制流劫持的终极目标 |
关键细节:寄存器名前缀含义
r开头的寄存器是64位(如rax)e开头的寄存器是32位(如eax)- Pwn中重点关注64位寄存器
assembly复制mov rax, 0xdeadbeef ; 将立即数0xdeadbeef存入rax
mov rdi, [rsp+0x20] ; 将rsp+0x20处的内存数据加载到rdi
assembly复制push rbp ; 等价于:
; sub rsp, 8
; mov [rsp], rbp
pop rbx ; 等价于:
; mov rbx, [rsp]
; add rsp, 8
code复制初始状态:
rsp -> 0x7fffffffe000
执行 push rax 后:
rsp -> 0x7fffffffdf8 (0x7fffffffe000 - 8)
[0x7fffffffdf8] = rax的值
执行 pop rbx 后:
rsp -> 0x7fffffffe000
rbx = [0x7fffffffdf8]的原值
c复制// C代码示例
void vuln() {
char buf[16];
gets(buf); // 漏洞点
}
int main() {
vuln(); // 这里对应call指令
return 0; // vuln()返回后执行
}
对应的关键汇编操作:
assembly复制; call vuln 的底层操作:
1. push rip+5 ; 将返回地址(下条指令地址)压栈
2. jmp vuln ; 跳转到vuln函数
; ret 的底层操作:
1. pop rip ; 将栈顶数据弹出到rip
正是这种机制,使得覆盖栈上的返回地址可以实现控制流劫持。去年DEFCON CTF中一道题就是通过精确覆盖返回地址跳转到后门函数。
当一个程序被加载执行时,OS会为其分配虚拟内存空间,这个空间按功能划分为四个主要区域:
代码段(.text)
数据段(.data/.bss)
堆(Heap)
栈(Stack)
每个函数调用都会在栈上创建一个栈帧(Stack Frame),其标准结构如下(以func(a,b)调用为例):
code复制高地址
+------------------+
| 参数b | <-- rbp+0x18
+------------------+
| 参数a | <-- rbp+0x10
+------------------+
| 返回地址 | <-- rbp+0x8
+------------------+
| 保存的rbp | <-- rbp
+------------------+
| 局部变量 | <-- rbp-0x10
低地址
内存排布特点:
这种结构决定了栈溢出漏洞的利用方式:通过覆盖局部变量进而修改返回地址。例如:
c复制void vuln() {
char buf[16]; // rbp-0x10
gets(buf); // 无长度限制的读取
}
当输入超过16字节时,数据将依次覆盖:
准备测试程序(stack.c):
c复制#include <stdio.h>
void target(int secret) {
printf("Flag: CTF{You_Got_It}\n");
}
void vuln() {
char buf[8];
printf("Input: ");
gets(buf); // 危险函数!
printf("You entered: %s\n", buf);
}
int main() {
vuln();
return 0;
}
编译命令(关闭保护机制):
bash复制gcc -g -fno-stack-protector -z execstack -o stack stack.c
启动GDB并设置断点:
bash复制gdb ./stack -q
(gdb) b vuln
(gdb) r
查看函数入口处的栈帧:
bash复制(gdb) disassemble vuln
Dump of assembler code for function vuln:
0x0000555555555155 <+0>: push rbp
0x0000555555555156 <+1>: mov rbp,rsp
0x0000555555555159 <+4>: sub rsp,0x10
...
关键内存观察:
bash复制(gdb) x/8gx $rsp # 查看栈内存(8个8字节)
0x7fffffffdf80: 0x00007fffffffdfa0 0x0000555555555189
0x7fffffffdf90: 0x0000000000000000 0x0000000000000000
0x7fffffffdda0: 0x0000000000000000 0x0000000000000000
0x7fffffffdfb0: 0x0000555555555160 0x00007ffff7df9083
内存解析:
通过输入超长字符串验证溢出:
bash复制(gdb) continue
Input: AAAAAAAAAAAAAAAAAAAAAAAABBBBCCCC
观察崩溃时的寄存器状态:
bash复制Program received signal SIGSEGV, Segmentation fault.
0x43434343 in ?? () # rip被覆盖为'CCCC'的ASCII码
分阶段掌握:
推荐练习方法:
c复制// asm_test.c
int add(int a, int b) {
return a + b;
}
编译查看:
bash复制gcc -S asm_test.c # 生成asm_test.s
工具链配置:
bash复制git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh
checksec:检查程序保护机制cyclic 50:生成测试patternxinfo 0x地址:查看内存属性盲目死记硬背:
忽略调用约定:
混淆内存方向:
理解这些基础知识后,看漏洞利用会有全新视角。比如最简单的ret2text攻击:
构造payload的Python示例:
python复制from pwn import *
payload = b'A'*16 # 填充缓冲区
payload += p64(0xdeadbeef) # 覆盖rbp
payload += p64(0x401152) # 覆盖返回地址为target()
process('./stack').sendline(payload)
这种思维模式正是建立在扎实的汇编和内存知识基础上。我建议在掌握本篇内容后,可以尝试: