s0m1ng

二进制学习中

PWN输出机制与泄露接收指南

PWN 泄露输出与接收总结

在 Pwn 实战中,拿到 Shell 的前提通常是完美地“泄露 (Leak)”出内存中的地址(如 Libc 基址、Canary、PIE 基址)。

然而,C 语言的不同输出函数有着截然不同的底层,如果不摸透它们,你的 Pwntools 脚本就会频繁遭遇 EOFErrorstruct.error 或死锁卡死。

一、 C 语言三大输出函数特性对比

在寻找泄露 Gadget 或分析题目原本的输出漏洞时,必须一眼看穿这三个函数的本质区别:

输出函数 遇到 \x00 是否停止 是否自动追加 \n 实战泄露评估 接收核心技巧
puts(addr) 是 (绝对截断) 最常见,但有坑。如果内存中相邻字节是 \x00,它就读不出来了。 接收后必须剔除末尾的 \n,且接收长度不固定。
printf(addr) 是 (绝对截断) 神级武器。支持 %s%p,是格式化字符串漏洞的根源。 %p 泄露时需提防前导 0 被吞;用 %s 泄露同 puts\x00
write(1, addr, len) 否 (无视截断) 最完美的泄露函数!你让它输出多少字节,它就死死输出多少,连 \x00 也会原样打出来。 根据参数 len 定长接收 recv(len),最为稳定。

二、 printf 格式化输出的四大底层陷阱

当我们利用 %p%s 进行盲打或泄露时,常常被眼前的输出欺骗。以下是四大经典误区:

前导零消失术 (%p / %x)

现象:64位地址本应是 16 个十六进制字符(8字节),但 %p 输出往往只有 12 个字符(如 0x7ffff7a00000)甚至更少。

底层原因%p 会把地址当成整数解析,抹杀所有无意义的前导 0

极端情况:如果泄露的是一个空指针(0x0000000000000000),glibc 会直接输出字符串 (nil),而不是 0x0

避坑方案:绝不要用定长 recv(12) 去接低位地址。在 Payload 尾部加上固定分隔符(如 -),使用 recvuntil(b"-") 动态截取。

解引用截断陷阱 (%s)

现象:用 %s 泄露 GOT 表时,有时只能泄露出一两个乱码字节,甚至什么都没有。

底层原因%s 会去读指针指向的内存,一旦在内存中读到 \x00(零字节),它立刻认为字符串结束,停止打印!

实战影响:很多 GOT 表地址中间包含 \x00(尤其是高位全 0 时),导致泄露不完整。必须用 .ljust(8, b'\x00') 在 Python 端强行补齐 8 字节。

Canary 泄露的 \x00 覆盖

现象:Canary 保护机制的最低位固定是 \x00(用来防 puts%s 泄露的)。

泄露手法:必须先用栈溢出刚好覆盖掉那个 \x00(比如写个 a),然后再调用 putsprintf。这样它才会顺着往下把整个 Canary 带出来。接收后,记得把最低位手动改回 \x00

缓冲池死锁 (No setvbuf)

现象:脚本写得很完美,但在本地或远程跑时,终端卡死,什么输出都收不到。

底层原因:题目在 main 函数开头没有写 setvbuf(stdout, 0, 2, 0);。这导致 printf 的输出被缓存在了系统缓冲区里,没有带 \n 换行符就不会刷新到屏幕上。

避坑方案:在 Payload 末尾强制加上 \n,或者使用不需要缓冲的 write 函数。

三、 Pwntools 完美接收泄露地址模板

不要每次都去重新想怎么解包地址,直接粘贴以下三套标准模板

模板一:基于 %p 的通用接收(防前导 0 缺失)

适用场景:格式化字符串漏洞,用 %p 泄露栈或 libc。

核心思路:在 Payload 植入“结束标志符”,动态截取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 发送带有分隔符的 payload
payload = b"%45$p-Okkk"
p.sendline(payload)

# 1. 过滤掉无用的前缀,直到看见 "0x"
p.recvuntil(b"0x")

# 2. 核心:一直读取,直到遇见 "-" 为止。drop=True 表示不要把 "-" 本身包含进来
leaked_str = p.recvuntil(b"-", drop=True)

# 3. 完美转为十六进制整数 (无论它是 12 位还是 6 位)
leaked_addr = int(leaked_str, 16)
print("[+] 完美泄露:", hex(leaked_addr))

模板二:基于 puts 的 64 位接收(防 \n\x00 截断)

适用场景:ROP 链调用 puts(got_addr) 泄露地址。

核心思路:剥离末尾的换行符,并用 \x00 将缺失的高位补齐到完整的 8 字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 假设程序执行了 puts(puts_got)
# puts 输出完地址后,必定会追加一个 \n (0x0a)

# 1. 接收直到换行符,且丢弃换行符
raw_bytes = p.recvuntil(b'\n', drop=True)

# 2. 剥离可能存在的回车符 (应对某些 \r\n 环境)
raw_bytes = raw_bytes.strip()

# 3. 核心:因为 puts 遇到 \x00 就停了,64 位地址通常只有 6 个字节 (如 \x90\xd9\xc2\xf7\xff\x7f)
# 必须在右侧 (高位) 补齐 \x00 直到 8 个字节,否则 u64() 会报错!
leaked_addr = u64(raw_bytes.ljust(8, b'\x00'))
print("[+] puts 泄露地址:", hex(leaked_addr))

模板三:基于 writerecv(定长) 的接收

适用场景:程序使用 write(1, addr, 8) 输出,或者你确信 %p 打印的是 64 位 Libc 地址(必是 12 个有效字符)。

核心思路:不依赖任何分隔符,直接数数。

1
2
3
4
5
6
7
8
# 场景 A:确信 %p 输出的是 0x7f 开头的 libc 地址
p.recvuntil(b"0x")
leaked_addr = int(p.recv(12), 16)

# 场景 B:程序调用了 write 强行打出了完整的 8 字节原始二进制数据
raw_bytes = p.recv(8)
leaked_addr = u64(raw_bytes)

四、排错法则

当你在 u64() 这一步遭遇报错时,立刻按照以下步骤排错:

开天眼:在 Python 脚本开头加上 context.log_level = 'debug'

看长度:观察终端里红色的 [DEBUG] Received 0x... bytes:

对症下药

如果收到了 6 个字节,说明是 \x00 被吃掉了,加上 .ljust(8, b'\x00')

如果收到了 7 个字节,且最后一个字节是 0x0a,说明把 puts 的换行符吃进去了,先 .strip(b'\n') 再补齐。

如果收到了乱码字符串,说明没加 int(..., 16),把十六进制字符串当成了字节流解析

您的支持将鼓励我继续创作!

欢迎关注我的其它发布渠道