Pwn 方向栈溢出与 ROP 链

面向 CTF 竞赛与自建靶场的栈溢出与 ROP 入门:从 System V 调用约定与栈帧布局讲起,串起 checksec 保护机制读法、溢出偏移测量、ret2text 与 ret2shellcode、ret2libc 的 libc 版本匹配、ROP gadget 链与 ret2csu、GOT 覆写、格式化字符串任意写、Canary 泄露与绕过,并逐节给出编译期与运行期的检测防御手段。

引言

栈溢出是内存破坏类漏洞的鼻祖,也是 CTF Pwn 方向的第一课。它的原理朴素到一句话就能说完:程序把用户输入写进一个固定长度的栈上缓冲区时没有做边界检查,输入超出长度就会覆盖相邻的栈数据,最终覆盖到函数保存的返回地址,从而在函数返回时劫持控制流。1988 年的 Morris 蠕虫用的就是这个思路,三十多年过去,原理没变,变的是围绕它的攻防对抗。

工程上真正的难点不在「溢出」本身,而在于现代 Linux 发行版默认打开的一整套保护机制。Ubuntu 22.04 之后的默认编译产物通常同时具备 NX、PIE、Canary、Full RELRO 与 ASLR,直接往返回地址写一个固定地址几乎必然段错误。于是 Pwn 的核心能力从「覆盖返回地址」变成了「在保护约束下构造一条完整的利用链」:先找到信息泄露点,从泄露的地址反推基址,再用 ROP gadget 拼出可用的调用序列,最后落到 system("/bin/sh") 或 execve 这类能读取 flag 的动作上。

本文按「基础 → 演进 → 组合」的顺序组织。第 1 到第 3 节打地基:栈帧布局、System V 调用约定、保护机制与 checksec 读法、溢出偏移的测量方法。第 4 到第 6 节沿着历史演进讲利用手法:从最简的 ret2text、ret2shellcode,到需要信息泄露的 ret2libc,再到能对抗 NX 与 ASLR 的 ROP 链。第 7、8 节讲 GOT 覆写与格式化字符串这两类常见组合拳,第 9 节收束到 Canary 绕过与防御体系。

需要先明确边界:以下所有内容都面向 CTF 题目与自建靶场,代码示例是自写的易受攻击 demo 与教学最小复现,用于理解内存破坏原理与缓解机制的设计意图。任何真实生产系统都不在讨论范围内,实战中请以授权的渗透测试与代码审计流程为准,防御视角可参考本站企业安全方向的实践。

目录

  1. 栈布局与函数调用约定
  2. 保护机制总览与 checksec 读法
  3. 缓冲区溢出的成因与偏移测量
  4. ret2text 与 ret2shellcode
  5. ret2libc 与 libc 版本匹配
  6. ROP 原理与 gadget 链构造
  7. GOT/PLT 与延迟绑定下的 GOT 覆写
  8. 格式化字符串漏洞
  9. Canary 绕过与检测防御体系

1. 栈布局与函数调用约定

x86-64 Linux 遵循 System V AMD64 ABI:整数与指针参数按顺序放进 rdi、rsi、rdx、rcx、r8、r9,超过六个的部分从右往左压栈;返回值放在 rax。浮点参数走 xmm0 到 xmm7。而 32 位 cdecl 约定里参数全部走栈,从右往左压入,由调用方负责清理栈——这个差异直接决定了 32 位 ROP 与 64 位 ROP 的写法完全不同。

一个典型的函数序言与尾声如下,理解这两段是理解溢出的前提:

push   rbp              ; 保存调用者的 rbp
mov    rbp, rsp         ; 建立当前栈帧
sub    rsp, 0x40        ; 为局部变量开辟 64 字节
; ... 函数体,局部缓冲区就在 rbp-0x40 附近 ...
leave                   ; 等价于 mov rsp, rbp; pop rbp
ret                     ; 从栈顶弹出返回地址到 rip

关键点在于 ret 弹出的地址紧挨在缓冲区之后(中间通常隔着 saved rbp)。这意味着只要溢出长度足够,就能精确控制 rip。栈是向下增长的,缓冲区起始地址低于 rbp,因此「从缓冲区开头到返回地址」的距离就是需要填充的偏移量。

把内存按地址从高到低画出来,溢出的方向就一目了然:

高地址
+------------------+  <- 调用者的栈帧
| ...              |
+------------------+
| 参数 7、8 ...    |  <- System V 下第 7 个及以后的参数
+------------------+
| 返回地址 (rip)   |  <- ret 从这里弹出,溢出攻击的最终目标
+------------------+
| saved rbp        |  <- leave 时被 pop 回去
+------------------+
| Canary (可选)    |  <- -fstack-protector 插入,低字节 \x00
+------------------+
| 局部缓冲区 buf[] |  <- read() 写入的起点,向高地址溢出
+------------------+
| ... 其他局部变量 |
+------------------+
低地址                <- rsp 指向这里

从图中可以看出两点:其一,缓冲区与返回地址之间隔着 Canary 和 saved rbp,任何「顺序写」都要跨过它们;其二,栈上还散落着函数指针、结构体、长度字段等,若溢出只覆盖到这些「不经过校验」的目标,就能绕开 Canary,这也是第 9 节要讲的思路。

维度x86 (cdecl)x86-64 (System V)
参数传递全部压栈,右到左rdi/rsi/rdx/rcx/r8/r9
栈宽度4 字节8 字节
返回地址覆盖 4 字节覆盖 8 字节
ROP 常用寄存器无万能 gadgetpop rdi; ret 是万能钥匙
栈对齐要求4 字节16 字节(见第 6 节)

检测与防御:栈布局本身不是漏洞,但它是理解一切栈类漏洞的坐标系。工程上通过 -fstack-protector-strong 在局部数组与返回地址之间插入 Canary,任何顺序溢出都会在 ret 前触发 __stack_chk_fail,把「静默劫持」变成「明确崩溃并记录」。

2. 保护机制总览与 checksec 读法

拿到一道 Pwn 题的第一步永远是 checksec,它输出的五个字段决定了解题路线。用 pwntools 自带的实现即可:

checksec --file=./chall
# 输出示例(教学靶场常见形态)
# Arch:     amd64-64-little
# RELRO:    Partial RELRO
# Stack:    No canary found
# NX:       NX enabled
# PIE:      No PIE (0x400000)

五项保护各自阻断什么、又该怎么绕,是 Pwn 的路线图:

保护阻断的利用典型绕过思路
NX / DEP栈上直接执行 shellcodeROP、ret2libc、mprotect 改权限
Canary顺序溢出覆盖返回地址泄露 Canary、改写不经过 Canary 的指针
ASLR固定地址跳转信息泄露算基址、部分覆盖低 12 位
PIE代码段固定地址泄露代码地址、ret2plt 先泄露再返回
RELRO覆写 GOT 表Partial 档下 GOT 可写,Full 档需换思路

RELRO 分四档要特别记住:No RELRO 时 .got.plt 完全可写;Partial RELRO(GCC 默认)把 .got 设为只读但 .got.plt 仍可写;Full RELRO 把整个 GOT 在启动时解析完并设为只读,彻底堵死 GOT 覆写。很多题目的难度差异就来自这一个开关。

检测与防御:这五项保护本身就是防御手段,检查方法也很直接——用 readelf -lW ./chall | grep GNU_RELRO 看 RELRO 段、readelf -d ./chall | grep BIND_NOW 看是否立即绑定、file 与 readelf -h 看 PIE。生产环境还应配合内核参数 kernel.randomize_va_space=2 确保 ASLR 全开。

3. 缓冲区溢出的成因与偏移测量

先看一个自写的最小可复现 demo,它模拟 CTF 题目里最常见的交互形态:

/* vuln_demo.c —— 仅用于本地靶场教学,禁止用于生产 */
#include <stdio.h>
#include <unistd.h>

void win(void) {
    puts("simulated flag read");
}

int main(void) {
    char buf[64];
    read(0, buf, 256);   /* 边界检查缺失:可读 256 字节进 64 字节缓冲 */
    puts("done");
    return 0;
}

偏移量指的是「缓冲区起始地址到 saved rbp 或返回地址的字节数」。最可靠的测量方式是 pwntools 的 cyclic 模式串:喂入一段无重复子串的 pattern,崩溃时看 rip 里是哪八个字节,反查即可。

python3 -c "from pwn import *; print(cyclic(200))" > /tmp/pat
gdb -q ./vuln_demo -ex 'run < /tmp/pat' -ex 'info registers rip'
# 假设 rip = 0x6161616b6161616a
python3 -c "from pwn import *; print(cyclic_find(0x6161616b6161616a))"
# 输出 72 —— 即覆盖 72 字节后到达返回地址

对 64 位程序,若 saved rbp 是 8 字节,那么「偏移到返回地址」通常等于「缓冲区大小 + 8」。但编译器会做 16 字节对齐填充,实际值必须以实测为准,不能靠算。

崩溃现场的信息量往往比想象中大。除了 rip,还要看 rsp 相对缓冲区的位置、rbp 是否被 pattern 覆盖、以及 backtrace 停在哪一层:

Program received signal SIGSEGV, Segmentation fault.
0x00000000004011a8 in main ()
(gdb) x/4gx $rsp
0x7fffffffe0a8: 0x6161616b6161616a  0x6161616d6161616c
(gdb) info frame
rip = 0x4011a8 in main; saved rip = 0x6161616b6161616a

若 saved rip 报 Cannot access memory at address ...,说明覆盖已经深入栈帧之外,payload 长度超了;若 saved rip 就是 pattern 值,则偏移测量成功,下一步才是构造跳转目标。

检测与防御:这类缺陷的本质是 read/gets/strcpy/sprintf 等无边界 API 被误用。静态检测靠 -Wall -Wextra、clang-tidy 的 bugprone-unsafe-functions 与 Coverity;动态检测靠 AddressSanitizer(-fsanitize=address)与 fuzzing(AFL++、libFuzzer)在 CI 里做持续验证。安全编码规范应优先用 fgets、read 配长度、snprintf。

4. ret2text 与 ret2shellcode

ret2text 是最简单的形态:程序里本来就存在一段「读 flag」或「执行 shell」的代码(比如上面 demo 里的 win 函数),溢出的目标只是把返回地址改成它的地址。前提是 PIE 关闭或已知代码基址。

ret2shellcode 则要求栈可执行(NX 关闭)。在 32 位、NX disabled 的老题目里,把 shellcode 放进缓冲区,再把返回地址指向缓冲区首地址即可:

# 教学片段:32 位 NX 关闭的靶场场景,需先泄露/已知栈地址
payload  = asm(shellcraft.i386.linux.sh())   # 23 字节左右的 execve("/bin/sh")
payload  = payload.ljust(offset, b"A")       # 填充到返回地址
payload += p32(buf_addr)                     # 跳回缓冲区首地址

现代题目里这两条路基本都被堵死:Ubuntu 从 2016 年起默认开启 NX,PIE 也默认开启,栈地址在 ASLR 下每次运行都变。所以 ret2text 如今只在「PIE 关闭 + 有后门函数」的签到题里出现,ret2shellcode 则几乎只存在于教学靶场。

检测与防御:NX 由 -z noexecstack 与内核的 READ_IMPLIES_EXEC 行为共同保证,检查 readelf -lW ./chall | grep GNU_STACK 应显示 RW(无 E)。开发时若确实需要 JIT,应显式 mprotect(PROT_EXEC) 到最小范围,而不是全局关 NX。

5. ret2libc 与 libc 版本匹配

当 NX 开启、栈不可执行时,思路转向「复用已有的可执行代码」。libc 里天然存在 system 函数和 /bin/sh 字符串,只要知道 libc 的加载基址,就能把它们串起来。问题在于 ASLR 让 libc 每次加载地址不同,所以必须先泄露一个 libc 函数的运行时地址,再反推基址。

泄露的常用手段是 puts 或 write:把某个已解析过的 GOT 项(如 puts@got)作为参数调用 puts,输出它的实际地址。

# 教学片段:64 位 ret2libc 两阶段(泄露 + 二次溢出)
pop_rdi = 0x4007c3                      # pop rdi; ret(ROPgadget 找到)
payload  = b"A" * offset
payload += p64(pop_rdi) + p64(elf.got["puts"]) + p64(elf.plt["puts"])
payload += p64(elf.symbols["main"])     # 返回 main 再来一轮
io.sendline(payload)
leak = u64(io.recvline().strip().ljust(8, b"\x00"))
libc_base = leak - libc.symbols["puts"] # 基址 = 泄露值 - 符号偏移

libc.symbols["puts"] 中的偏移来自具体 libc 文件。libc 版本匹配是 ret2libc 最大的坑:泄露地址的低 12 位(页内偏移)是不受 ASLR 影响的,可以用它去 libc-database 或 libcsearcher 反查版本;常见对照如下:

libc 版本典型发行版关键差异
2.23Ubuntu 16.04无 tcache,system 偏移固定
2.27Ubuntu 18.04引入 tcache,堆题难度下降
2.31Ubuntu 20.04tcache key 校验、__free_hook 仍可用
2.35Ubuntu 22.04移除 __malloc_hook/__free_hook,改用 FSOP

反查 libc 版本的流程通常是:拿到泄露地址后取低 12 位(例如 0x7f...d0 这类页内偏移),把它连同其他已知符号偏移一起喂给数据库:

# 教学流程:用泄露的末位地址反查 libc 版本
./libc-database/find puts 0x840 system 0x52290
# 或用 python 包 libcsearcher / LibcSearcher
python3 -c "from libcsearcher import LibcSearcher; \
  l = LibcSearcher('puts', 0x7ffff7a5a840); print(l)"
# 找到版本后下载对应 libc,用 patchelf 让本地程序链接它
patchelf --set-interpreter ./ld-2.31.so --replace-needed libc.so.6 ./libc-2.31.so ./chall

patchelf 这一步很关键:只有让本地程序的 libc 与远程完全一致,libc.symbols["system"] 这类偏移才可靠。若不想改二进制,也可以用 LD_PRELOAD=./libc-2.31.so ./chall 在运行时替换。

检测与防御:泄露类漏洞多来自「把指针当数据打印」(printf("%s", ptr) 误用、格式串可控)。防御上,ASLR 只能提高门槛不能根治,真正的兜底是控制流完整性(CET 的 IBT/Shadow Stack)、以及把敏感服务放进最小权限沙箱,让拿到 shell 也无法读取 flag。

6. ROP 原理与 gadget 链构造

ROP(Return-Oriented Programming)的核心思想是:在已存在的代码里找一串以 ret 结尾的短指令序列(gadget),用精心排列的栈数据把它们串起来,相当于用「返回指令」当跳转、用「栈」当程序计数器。最万能的 gadget 是 pop rdi; ret——它能把栈上的下一个值送进 rdi,正好用来给 system 传参。

找 gadget 用 ROPgadget 或 ropper:

ROPgadget --binary ./chall | grep "pop rdi ; ret"
ROPgadget --binary ./libc.so.6 | grep "pop rsi ; pop r15 ; ret"
one_gadget ./libc.so.6     # 直接找 execve("/bin/sh") 的单条跳转

用 pwntools 的 ROP 类可以自动组装,它会处理 gadget 搜索与参数排布:

from pwn import *
elf, libc = ELF("./chall"), ELF("./libc.so.6")
io = process("./chall")
rop = ROP(elf)
rop.call("puts", [elf.got["puts"]])   # 泄露
rop.call(elf.symbols["main"])         # 回到 main
io.sendline(b"A" * offset + rop.chain())

两个高频坑必须提前知道。其一是 ret2csu:64 位程序里若找不到能控制 rdx 的 gadget,可以借助 __libc_csu_init 尾部那段通用 gadget 来设置 rdi/rsi/rdx,代价是链路更长、约束更多。其二是栈对齐:system 内部会执行 movaps,要求 rsp 16 字节对齐,否则在 glibc 2.27+ 上会段错误;解法是在 gadget 链前面多插一个单独的 ret,把栈「挪」8 字节。

检测与防御:ROP 之所以可行,是因为代码段里存在大量 ret 结尾的片段且控制流可被栈数据改写。缓解手段包括 CET Shadow Stack(返回地址存一份硬件保护的副本,ret 时校验)、-fcf-protection=full 编译选项,以及针对性的 gadget 抑制(如 LLVM 的 ROP mitigations)。

__libc_csu_init 里的通用 gadget 值得单独展开,因为它是 64 位静态链接或小体积程序里唯一的「参数设置器」:

; 典型的 __libc_csu_init 尾部(教学示例,地址随编译变化)
pop  rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
mov  rdx, r14
mov  rsi, r13
mov  edi, r12d
call qword ptr [r15 + rbx*8]

利用时先用第一段 gadget 把 r12(目标函数参数)、r13(rsi)、r14(rdx)、r15(函数指针表基址)、rbx(表内偏移)填好,再用第二段 gadget 完成调用。它一次能设置三个参数,代价是需要额外控制 rbx 与 rbp,且必须能在内存里找到一个「存着目标函数地址、且偏移可控」的表。glibc 2.34 之后 __libc_csu_init 被移除,这条路随之失效,新题目更多依赖 libc 内部 gadget。

7. GOT/PLT 与延迟绑定下的 GOT 覆写

理解 GOT/PLT 是理解 ret2libc 与 GOT 覆写的前提。ELF 采用延迟绑定:第一次调用某个库函数时,控制流经 func@plt 跳到 func@got 指向的地址——此时它指向 PLT 里的一小段解析桩,动态链接器解析出真实地址后写回 GOT,之后每次调用就直接从 GOT 跳过去。

这条机制带来两个可利用点。第一,泄露:调用 puts(puts@got) 能打印出解析后的真实地址,因为 GOT 里已经存着它。第二,覆写:在 Partial RELRO(No RELRO 更佳)下 .got.plt 可写,如果能通过任意写(比如格式化字符串、UAF 写指针)把某个函数(如 free、atoi、printf)的 GOT 项改成 system 的地址,那么后续对这个函数的调用就等价于调用 system。

调用路径(延迟绑定前后):
  第一次:  call free@plt -> jmp *free@got -> PLT 解析桩 -> 动态链接器 -> 真实 free
  写回后:  free@got = &free
  再调用:  call free@plt -> jmp *free@got -> 真实 free
  覆写后:  free@got = &system  =>  call free@plt 实际调用 system

实际解题里常把「泄露 libc 基址」和「覆写 GOT」组合使用:先泄露 puts 地址算出 system 的运行时地址,再把 free@got 覆写成 system,最后触发 free(chunk) 且 chunk 内容是 /bin/sh,即可拿到 shell。这套组合是中级 Pwn 题的标准套路。

检测与防御:Full RELRO 通过启动时全部解析并 mprotect 只读来根治 GOT 覆写,编译选项为 -Wl,-z,relro,-z,now。此外,-Wl,-z,noexecstack 与 PIE 配合可进一步提高利用成本;生产环境还可启用 glibc 的 LD_BIND_NOW=1。

8. 格式化字符串漏洞

printf 家族在格式串可控时会变成「任意读 + 任意写」原语。%p 会按顺序从参数寄存器与栈上取值打印,%n 则把「已输出字符数」写回对应参数的地址。在 64 位下前六个 %p 读寄存器(rsi、rdx、rcx、r8、r9),第七个开始读栈。

定位偏移最直接的办法是喂一串 %p 观察输出:

./vuln_demo <<< '%p.%p.%p.%p.%p.%p.%p.%p.%p'
# 输出中若出现 0x41414141 之类,说明第 N 个 %p 落在了我们可控的输入上

确定偏移 k 后,就能用 %k$p 定点读取,或用 %k$n 定点写:

# 教学片段:把 target 变量的值改成任意数(靶场演示 %n 的语义)
payload  = p64(target_addr)              # 地址放在开头,供 %n 取用
payload += b"%<value>c%<k>$n"            # 输出 value 个字符后写入

%n 的家族还包括 %hn(写 2 字节)与 %hhn(写 1 字节),实际利用中常用 hhn 逐字节写以规避大数输出的问题。格式化字符串的可怕之处在于它不需要溢出:只要格式串可控,就能在完全不触发 Canary 的情况下完成任意写,因此常与 GOT 覆写组合使用。

常用的格式说明符与其在利用中的角色对照如下:

说明符作用利用价值
%p / %x按指针/十六进制打印参数泄露栈与寄存器内容、定位偏移
%s把参数当地址读字符串任意地址读(地址可控时)
%n把已输出字符数写回参数地址任意 4 字节写
%hn / %hhn写 2 字节 / 1 字节逐字节写,规避大数输出
%<n>c输出 n 个填充字符控制 %n 写入的具体数值
%<k>$...直接取第 k 个参数跳过前导偏移,定位可控数据

写 4 字节整数(如一个函数地址)需要输出上亿字符,实际不可行,所以真实利用总是拆成 4 次 %hhn 逐字节写,再用 %<k>$hhn 的偏移数组(把 4 个目标地址连续放在 payload 开头)一次性完成。

检测与防御:编译期用 -Wformat -Wformat-security 把 printf(str) 报成警告,-D_FORTIFY_SOURCE=2 在运行时拦截非常量格式串中的 %n(配合 -Werror=format-security 效果更好)。运行时可用 glibc 的 %n 限制与 seccomp 过滤;代码审计时把所有 printf/scanf/syslog 家族的第一个参数不是字面量的调用点都列为高危。

9. Canary 绕过与检测防御体系

Canary 是在栈上、局部缓冲区与返回地址之间插入的一个随机值,低字节固定为 \x00(防止被 strcpy 之类的字符串函数整体带走)。函数返回前会比对它是否被改动,不一致就调用 __stack_chk_fail 终止进程。

绕过思路主要有三类。第一是泄露:若程序存在「读取缓冲区并原样输出」的功能,且缓冲区正好在 Canary 前,那么读到溢出位置时就能把 Canary 打印出来;由于低字节是 \x00,实际只能读到高 7 字节,需要自己补一个 \x00。第二是改写不经过 Canary 的指针:比如溢出覆盖的是函数指针、GOT 项或某个结构体里的回调地址,返回时根本不会做 Canary 校验。第三是爆破:在 fork 型服务里(如经典 pwn 题),父进程的 Canary 会被子进程继承,可逐字节爆破,最多 256 次尝试一个字节。

防御体系应当分层建设,单一措施都不够:

层级手段作用
编译期-fstack-protector-strong对含数组的函数插 Canary
编译期-D_FORTIFY_SOURCE=2 -O2把不安全 API 换成带检查版本
编译期-fcf-protection=full启用 CET IBT/Shadow Stack
链接期-Wl,-z,relro,-z,now -Wl,-z,noexecstackFull RELRO + NX
运行期ASLR + randomize_va_space=2提高地址猜测成本
运行期seccomp / 最小权限沙箱降低拿到 shell 后的影响面
研发流程ASan + libFuzzer + CI 门禁在上线前发现内存破坏

对 CTF 选手来说,还有一套「非绕过」的思路同样重要:把利用目标降到最低。题目环境往往用 seccomp 限制可用系统调用,只允许 read/write/open/openat 而禁用 execve,此时拿到 shell 也执行不了任何东西,正确解法是用 ROP 直接拼出 open("/flag") + read + write(1, buf, n) 的调用序列(俗称 ORW)。这类题目考的是对 gadget 链的精细编排,也提醒我们:沙箱的作用不是阻止控制流劫持,而是让劫持之后无事可做。

需要清醒认识 ASLR 的局限:它只是概率防御,32 位下熵不足、fork 型服务可被暴力枚举、信息泄露一旦发生就完全失效。真正把栈溢出从「可利用」变成「不可利用」的,是 CET 这类硬件级控制流完整性,以及从语言层面消除内存破坏(Rust/Go)。CTF 里练的是「在保护下找突破口」,工程里做的则是「把突破口全部堵死」,两者互为镜像。

权衡取舍

同一道题往往有多种解法,选择取决于保护组合与题目给的条件。下面这张表是实战中的决策参考:

场景首选手法理由代价
PIE 关 + 有后门ret2text一条 payload 直接跳现代题几乎绝迹
NX 关 + 栈地址已知ret2shellcode最直观需要泄露栈地址
NX 开 + 有 puts 泄露点ret2libc通用、稳定依赖 libc 版本匹配
无泄露但 gadget 丰富纯 ROP + 部分覆盖绕 PIE 低 12 位链路长、易崩
格式串可控格式化字符串任意写不需要溢出、不碰 Canary偏移定位耗时
Partial RELRO + 任意写GOT 覆写一次写换来稳定控制Full RELRO 下失效
fork 型服务 + Canary逐字节爆破无需泄露点需多次尝试、可能超时
seccomp 禁用 execveORW 链沙箱下唯一可行需精确编排 open/read/write
无泄露且仅一次输入ret2dlresolve不需要泄露地址构造复杂、依赖 ELF 结构

还有一类容易被忽略的取舍是**「一次打穿 vs 分阶段」**。分阶段(先泄露、再二次溢出)的稳定性远高于一次打穿,因为每一阶段的地址都是确定值;代价是需要程序能回到一个可复用的入口(通常靠返回 main 或某个循环点),并且交互逻辑要能撑过两轮。若程序只给一次输入机会,就只能走一次打穿的路线,此时必须接受「地址全猜」带来的不确定性,或者改用 ret2dlresolve 这类能在无泄露条件下解析符号的技术。

核心权衡是「确定性 vs 成本」:信息泄露能带来确定性但需要额外漏洞;爆破成本高但不需要额外原语;ROP 通用但链路脆弱。经验法则是优先找泄露点,因为一次成功的泄露能让后续所有地址计算变成确定值,把整条链的稳定性提升一个数量级。

常见坑清单

  1. 偏移算错:用 bufsize + 8 硬算而不实测,编译器对齐填充会让实际偏移差 8 到 16 字节;一律用 cyclic_find 实测。
  2. libc 版本不匹配:本地跑通、远程打不通,多半是远程 libc 与本地不同;先泄露低 12 位去 libc-database 反查,再换用对应 libc。
  3. 栈未对齐导致 movaps 崩溃:链里执行 system 时段错误,位置却在函数内部;在 gadget 链前多插一个 ret 补齐 16 字节对齐。
  4. \x00 截断 payload:read/sendline 混淆、字符串函数遇 \x00 截断;64 位地址天然含 \x00,必须用 send/sendafter 而非 sendline。
  5. GOT 表不可写:Full RELRO 下 GOT 覆写必然失败;先 checksec 确认档位,改用 FSOP 或 __free_hook(2.35 前)。
  6. Canary 泄露少了一个字节:Canary 低字节是 \x00,泄露出来的只有 7 字节;拼回去时记得手动补 \x00。
  7. PIE 下直接写绝对地址:地址每次运行都变;必须先用泄露点拿到基址,或利用只覆盖低字节的部分覆盖技巧。
  8. puts 泄露读到换行就停:puts 遇 \x00 截断,泄露的地址若含 \x00 会读不全;改用 write(1, addr, 8) 精确读 8 字节。
  9. 本地 /bin/sh 拿到却读不到 flag:远程题目常是 xinetd 起服务、stdin 非 tty;用 cat flag 而非交互式 shell,或改用 execve("/bin/sh", 0, 0)。
  10. 调试与利用环境不一致:gdb 默认关闭 ASLR、pwntools process 与远程环境不同;用 set disable-randomization off 并在同版本容器里复现。

小结

栈溢出与 ROP 是 Pwn 的地基,它的知识结构其实是一条清晰的因果链:调用约定决定了栈帧形状,栈帧形状决定了溢出能覆盖什么,保护机制决定了哪些覆盖被禁止,信息泄露决定了能否绕过保护,ROP 决定了在 NX 之下如何复用已有代码。把这条链上的每一环都吃透,再去看堆利用、格式化字符串、内核利用,都是在同一套内存模型上换场景。

学习方法上,建议在自建靶场里把每个手法都亲手跑通一遍:先用 checksec 读保护,用 cyclic 测偏移,用 ROPgadget 找 gadget,用 pwntools 写脚本,用 gdb.attach 单步验证。每一次段错误都是一次对栈布局的重新确认,这种「写脚本 → 崩 → 调试 → 改」的循环是 Pwn 唯一有效的训练方式。工具链与调试技巧可参考 CTF 工具链与攻击视角下的防御 ,逆向侧的基础可先补 Reverse 方向静态分析与反编译 。

下一步建议按两条线推进:横向读 Pwn 方向堆利用基础 ,看 glibc 分配器如何把「溢出」换成「结构体操纵」;纵向读本站企业安全方向的内容,理解同样的内存破坏漏洞在生产系统里如何被检测、被缓解、被隔离。CTF 教你「怎么打进去」,工程安全教你「怎么让它打不进来」,两条线合起来才是完整的内存安全认知。入门路径与方向选择可回到 CTF 竞赛全景与学习路径 复习。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「网络安全攻防」更多文章

  1. 流量分析与协议逆向
  2. 椭圆曲线与格攻击
  3. Windows 提权与 AD 内网渗透