
做CTF Web题的同学一定绕不开SSTIServer-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 11。然后发现没反应又开始试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, namename)然后模板文件里通过{{ name }}这种双大括号语法来显示变量。Jinja2引擎会去做两件事解析模板里的语法结构然后把传入的数据填充进去最终生成一个纯HTML字符串返回给浏览器。问题出在什么时候当你的模板文件里存在{{ }}而这个位置的{{ }}中间的内容不是由开发者写死的变量名而是由用户输入拼接进去的字符串时就出问题了。举个例子很多初学者会写出这种代码name request.args.get(name) html h1Hello name /h1 return render_template_string(html)这段代码本身是不能直接SSTI的因为它只是把用户输入插入到HTML字符串里没有让Jinja2去解析{{ }}。但如果开发者图省事直接把用户输入作为模板内容去渲染name request.args.get(name) html h1Hello {{ name }}/h1 return render_template_string(html, namename)这个写法本身也是安全的因为用户输入被当作普通字符串传入Jinja2只会对它转义不会作为模板代码解释。真正危险的写法是name request.args.get(name) template h1Hello 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 完整解题流程从输入框到flag3.1 信息收集先看一眼题目长什么样打开题目页面通常就是一个输入框加一个提交按钮旁边可能有一句话提示是查询信息。我先输入一个普通字符串比如test看页面回显了什么。如果是原样返回说明输入被拼到了模板里如果提示未找到可能中间有查询逻辑如果直接500说明后端报错了。Bugku SSTI 0这道题输入test大概率是原样回显的。这一步的意义在于确认请求参数名字是什么、页面回显的位置在哪、是否有额外的过滤。很多新手喜欢直接上payload连参数名都还没看清结果payload被当成另一个参数名传过去半天不出结果。接着我会看响应头的Server字段和页面源代码的HTML注释。Flask默认的Server头不一定是Werkzeug但页面源码往往会有form action... methodGET这样的表单结构能帮助我确认提交方式是GET还是POST。这道题就是很直接的GET参数传递。3.2 确认注入点7*7就能定生死输入{{7*7}}页面回显49SSTI确认无疑。紧接着再输入{{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 实操现场记录我把这道题的实际操作流程整理成一个速查表方便你对着做步骤输入预期回显意义1testtest确认输入拼接进模板2{{7*7}}49确认存在SSTI3{{7*7}}7777777确认是Jinja24{{ 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(debugTrue)启动之后访问/?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的第一道坎接下来就靠你自己去刷题了。