
入门CTF绕不开的经典题型里**模板注入SSTI**绝对算一个。Bugku上这道SSTI题目可以说是很多新手接触服务端模板注入的第一道坎。它名字不起眼但里头的知识点串联得挺完整从模板引擎识别、探测注入点到Python对象继承链的利用再到绕过过滤拿到flag一条龙下来能帮你把SSTI的基础框架搭得非常扎实。这篇文章我不会只贴一套payload就完事而是把这道题拆开揉碎讲清楚每一步“为什么这么干”同时把我实际做题时踩过的坑、排查过的报错也一并记录。不管你是刚看完SSTI概念、准备找题练手的新手还是已经做过几道题、想系统梳理利用链的选手这篇都应该能给你一些参考。1. 先搞清楚SSTI到底在打什么1.1 模板引擎的信任边界被打破了先说个最基础的问题SSTIServer-Side Template Injection中文叫服务端模板注入实际利用的是服务端模板引擎对用户输入处理不当的漏洞。打个比方你就懂了。模板引擎就像一张印刷好的奖状模板上面写着“恭喜[姓名]同学获得[奖项]”。正常情况下你把“张三”填进去它就输出“恭喜张三同学获得三好学生”。但如果这张奖状模板的设计有问题它把“印刷指令”当成“普通文字”一起填进去了那就麻烦了——你填进去的名字如果带有特殊指令比如“同学获得[奖项]然后再打印一张新的奖状”那这台印刷机就会按你的指令多干活。放到Web场景里SSTI就是用户输入的字符串被模板引擎当成了“模板代码”去执行。拿Python生态最常用的Jinja2模板引擎举例render_template_string()函数的参数如果是开发者拼好的字符串那就是正常渲染如果直接把用户输入拼接进去那就成了漏洞点。Bugku这道题就属于这种情况。页面输入框里填的内容被直接传到了模板渲染函数里你输入什么它就尝试渲染什么。所以当你输入{{7*7}}页面返回了49的时候就基本可以确认服务端用的是Jinja2模板引擎而且我们的输入进入了模板上下文。1.2 Bugku这道题的题面与背景这道题在Bugku平台上属于Web题型题面非常简短就是一个你输入ID或者查询信息、页面返回结果的表单没有给出任何源码。它的难点就在于信息少你没法直接看代码只能靠测试去猜后端逻辑。不过Bugku的SSTI题目在设计上有一个特点环境相对干净没有像CTF秀、Polar CTF那种加了大量WAF过滤的变态环境。它更多是想让你先把SSTI最基础的利用链走通先识别模板引擎再找注入点然后通过Python对象继承链构造payload读取文件或执行命令最后拿到flag。这道题零过滤或者基本过滤很少所以非常适合作为入门的第一道SSTI实战题。把这道题吃透了后面再遇到带过滤的题目比如过滤数字、过滤字母、过滤中括号你才知道该往哪个方向去绕。2. 做题前的准备环境与工具链2.1 平台选择与基础工具做这道题你不需要搭本地环境。Bugku的Web题基本都是在线环境直接打开题目链接就能做。唯一的门槛是你可能需要一个稳定的网络环境来访问平台以及浏览器能正常交互。我这里列一下我实际用的工具清单都很基础不涉及任何旁门左道浏览器一个Chrome或Firefox都行Firefox的插件HackBar在新版里有了Firefox里还可以用FoxyProxy配合BurpBurp Suite社区版就够主要用来抓包改包虽然这道题直接浏览器就能做但习惯要养好一个顺手的终端可选如果涉及反弹shell会用得到但拿flag一般用不到反弹curl命令有时候在终端里发请求比浏览器方便尤其是测试特殊字符的时候实际上这道题最简版本只要浏览器就够。我特意强调工具链是因为很多新手一开始就在工具上栽了跟头——Burp没装好抓不了包或者浏览器插件配错导致请求发不出去。这些跟SSTI本身无关但会严重干扰判断。2.2 从“点点点”到“打命令”的全局路线图在开始注入之前我先给一个整体的思路框架。SSTI的完整利用链可以分成四步探测输入特殊表达式确认是否存在模板注入并识别模板引擎类型。找基础表达式确认Jinja2后利用{{}}表达式中支持的Python语法尝试访问基本对象。构造对象链从Jinja2的内置对象如config、self、lipsum出发通过__class__、__base__、__subclasses__等魔术方法往Python根对象object回溯再向下搜索可利用的类。执行/逃逸找到os模块相关的类或者file相关的类实现命令执行或文件读取最终拿到flag。这个顺序非常重要。很多人上来就套网上现成的payload去执行命令结果不行就懵了。原因往往是第二步没确认、第三步的索引没找对。后面我会详细展开每一步的细节。3. 核心利用链详解为什么是__class__和__subclasses__3.1 模板注入的“里应外合”前面说了模板注入是把用户输入当模板代码渲染。Jinja2的模板代码里有一个非常重要的特性在{{ }}表达式内部你可以访问模板上下文中的Python对象并且可以调用对象的属性和方法。Jinja2默认向模板中注入了几个全局对象比如configFlask的配置对象、request当前请求、url_for、lipsum、cycler、joiner等等。也就是说你在模板里写{{ config }}它会直接输出Flask的配置信息。但问题来了光有config还不够它只是暴露了配置项没有直接暴露os.system。我们要做的是从这些内置对象出发一层层挖到Python解释器的底层。Python中每个对象都有一些以双下划线开头和结尾的特殊属性比如__class__返回该对象所属的类。__bases__返回该类直接继承的父类元组。__mro__返回方法解析顺序Method Resolution Order包括该类及其所有祖先类。__subclasses__()返回该类的所有直接子类列表。__init__类的构造函数。__globals__函数对象所在模块的全局命名空间字典里面往往有__builtins__、os等关键东西。这套魔术方法在正常开发里你不会直接去用但在CTF里它们就是我们从模板“越狱”到Python运行时的钥匙。3.2 一步步爬到object再找“带刀”的类我们来看这条经典的利用链{{config.__class__.__init__.__globals__[os].popen(ls).read()}}拆解一下config是Flask的配置对象它的类型是dict的子类。config.__class__得到这个类。类的__init__是类的构造函数一个函数对象。函数的__globals__是函数定义所在模块的全局变量字典。os模块经常被Flask或Jinja2在某个模块里导入所以这个全局字典里往往能找到os。拿到os之后用os.popen(ls).read()执行命令并读输出。这条链不是唯一。另一种更通用的办法是走__subclasses__{{.__class__.__mro__[2].__subclasses__()}}解释一下是空字符串属于str类。str.__mro__返回str类、object类可能还有别的索引[2]一般是object。object是所有类的基类object.__subclasses__()会返回运行时Python进程里所有直接继承object的类列表。拿到这个列表后在里面搜索有__init__.__globals__的类就能找到包含os或__builtins__的类。比如经典的{{.__class__.__mro__[2].__subclasses__()[139].__init__.__globals__[__builtins__][__import__](os).popen(cat flag).read()}}这里的[139]就是某个类的索引具体数字在不同版本、不同框架、不同依赖环境下都不一样。这也是为什么网上抄的payload经常会失效——因为你跑的环境和原作者的环境不一样索引值自然对不上。Bugku这道题的环境相对固定所以你会在网上看到很多带固定索引的payload能直接用。但我不建议你直接抄而是建议你学会了怎么找索引自己去环境里确认一遍。3.3 魔术方法对比速查我把利用链中常遇到的几个属性整理成一张表方便你对照记忆。属性/方法作用典型场景__class__获取实例所属的类从任意对象反推类对象__mro__获取类的继承链顺序元组定位object基类索引[2]通常是它__bases__获取直接父类元组逐级向父类回溯__subclasses__()获取所有直接子类列表从object向下枚举全部类__init__类的初始化函数通过函数对象的__globals__拿全局变量__globals__函数所在模块的全局命名空间搜索os、__builtins__、__import____builtins__内建模块/内建函数字典直接导入os、执行__import____import__动态导入模块导入os、sys等需要注意的坑是__globals__只存在于函数对象上不在类或模块上。所以你要找的是一个类的__init__函数或者某个函数属性然后才能拿到它的__globals__。4. 实战过程Bugku SSTI从探测到拿flag接下来是实操环节。我会按照我实际做题的步骤来记录尽量还原真实的思考过程。4.1 第一步打开题目确认注入点打开Bugku的SSTI题目页面会看到一个输入框一般提示你输入查询内容。我第一件事不是急着输入payload而是先随便输入一个字符串比如hello看看页面怎么显示。输入hello页面回显可能就是hello或者“查询结果hello”之类的。这一步的意义在于看清输入内容是否原样回显以及回显的位置在哪里。如果输入hello页面返回的还是hello那说明输入被包在某个模板变量里渲染了。接下来输入{{7*7}}。如果页面返回49注入成立。如果返回{{7*7}}原样说明可能是其他类型的注入或者模板引擎不是Jinja2。Bugku这道题输入{{7*7}}之后页面上明确出现了49这就坐实了SSTI。4.2 第二步确认内置对象走两种路线确认是Jinja2后我先测一下内置对象是否可用{{config}}如果页面回显了配置类的信息比如Config {ENV: production, ...}说明config对象能直接被访问。有了config就可以走短链{{config.__class__.__init__.__globals__}}如果这一步能把全局变量字典打出来你就能直接在输出的内容里搜索os、builtins等关键词。Bugku的这道题在这一步通常会出来一大堆全局变量里面就躺着os和__builtins__。如果config不可用或者你想走更通用的路线那就用{{.__class__.__mro__[2].__subclasses__()}}把类列表打出来。这一步的输出会很长我建议直接用view-source查看页面源码或者复制到本地文本编辑器里搜索。两种路线各有优劣。短链快速直接但依赖config存在且config.__init__.__globals__的环境较固定长链通用性强几乎所有Jinja2环境都适用但需要找索引自动化搜索可能更累一点。4.3 第三步读取文件还是执行命令拿到os模块之后下一步有两个目标一个是文件读取比如cat flag一个是命令执行比如ls。一般来说命令执行覆盖文件读取所以我会优先尝试命令执行。常用的两个端点os.popen(命令).read()执行命令并捕获输出。os.system(命令)执行命令但返回状态码不直接输出内容需要结合其他手段把输出带出来。CTF中通常用popen。用config短链构造的命令执行payload{{config.__class__.__init__.__globals__[os].popen(ls).read()}}如果ls能列目录就把flag文件找出来再cat flag或cat flag.txt。这里有一个细节如果命令输出里包含特殊字符或者空格在URL里直接传可能有问题。我建议用Burp的Repeater或者curl在终端里测试避免浏览器编码导致payload被改坏。Bugku这道题执行ls之后目录里会有一个类似flag的文件然后用cat flag就能把flag打出来。4.4 第四步如果索引不对怎么办通用搜索法刚才说了config短链在Bugku这道题里能用。但换一道题、换一个环境就不一定了。所以我在这里补一个通用的搜索方法虽然笨但非常稳。把{{.__class__.__mro__[2].__subclasses__()}}的输出复制到一个文本文件里观察每个类前面的索引编号。然后写一个简单的脚本或用Burp的搜索功能在类列表字符串里搜索os、popen、Warning、catch_warnings等关键词。catch_warnings是个经典目标类因为它在很多Python环境下都会加载os模块。它的利用链是{{.__class__.__mro__[2].__subclasses__()[索引]().__init__.__globals__[os].popen(ls).read()}}注意这里catch_warnings要在索引后加()实例化然后才能访问__init__再拿__globals__。很多新手卡在这一步缺了括号报错。如果你不想手动翻列表也可以直接用一些知名payload构造工具比如在线的SSTI payload生成器它们能自动在__subclasses__()列表里搜索含os或__builtins__的类并生成payload。不过我个人建议至少手动操作一遍不然出了问题你都不知道从哪排查。4.5 这道题的完整payload记录我把我实际用的payload完整记录在这里方便你直接复现如果环境未变的话。但我还是要强调环境变了索引和模块可能就换了payload直接抄大概率失效核心是理解链路自己动态调整。第一步探测{{7*7}}第二步确认config可用{{config}}第三步读取目录{{config.__class__.__init__.__globals__[os].popen(ls).read()}}如果ls返回了flag文件则读取flag{{config.__class__.__init__.__globals__[os].popen(cat flag).read()}}如果flag是flag.txt之类的就把文件名补全。如果你走__subclasses__()路线需要先确定索引。我这里的参考索引是枚举出来的不同环境不同所以不写死数字。跑通之后把flag提交到Bugku的flag框里这道题就算结束了。5. 绕过过滤如果题目加了限制怎么办Bugku原题基本是无过滤的。但既然你的热词里出现了“ssti 过滤字母数字”“ssti 过滤字母”那我觉得有必要把常见的过滤场景一起聊了。毕竟题目是死的思路是活的学会绕过才是硬实力。5.1 数字被过滤的绕过思路有些题目会过滤数字字符这时__mro__[2]、__subclasses__()[139]这类带数字的payload就直接失效了。绕过方法是不用字面数字而是通过运算或者其他方式生成数字。比如Jinja2的表达式支持True等于1False等于0所以用TrueTrue代表2。用TrueTrueTrue代表3。用(True|int)强制转成1。用[]的长度来计数[].__len__()等于1()代表取长度|length也行。更绝的是用变量计数{% set a[] %}{% set ba.__len__() %}{{ b }}这样你就能用b代表数字1用bb代表2以此类推。虽然繁琐但在数字被过滤的死环境下通信是靠的。还有利用chr()函数把数字转成字符再拼接成关键字符串的办法。这招要求__builtins__可用否则chr都调用不了。5.2 关键字被过滤的绕过思路过滤常见的关键字有__class__、__globals__、os、popen、system、cat等。遇到这种题思路是拆解和组装。一个常用技巧是利用Jinja2的字符串拼接{{config.__class__}} 被过滤的话可以拆成 {{config[__class__]}}双引号或者单引号内字符串通过拼接绕过来。还有用request对象传参{{config.__class__.__init__.__globals__[os].popen(request.args.cmd).read()}}然后在URL参数里传?cmdcat flag。这样popen和cat flag都是在URL参数里不经过过滤器的检测。还可以用|attr()过滤器动态获取属性{{config|attr(__class__)|attr(__init__)|attr(__globals__)}}这样连__class__这种带下划线的字符串都可以通过attr的字符串参数动态获取等于给过滤器绕了一大圈。__getitem__也可以替代属性访问比如config[__class__]等同于config.__class__还能配合字符串拼接变量使用。5.3 引号被过滤的绕过思路过滤单双引号更狠。没有引号就没法直接传字符串参数。这时可以利用request对象获取URL里的参数作为字符串{{config.__class__.__init__.__globals__[request.args.a]}} ?apopenrequest.args.a把URL里的参数值动态变成字符串绕过了引号过滤。还有利用dict的键值生成字符串的办法{{config.__class__.__init__.__globals__[dict(os1).keys()|list|first]}}这段代码的意思是构造一个字典{os:1}取出它的键os一个不含引号写法的字符串然后用它作为字典的键去__globals__里找。这个方法很常用值得记住。5.4 过滤WAF写死到变态的情况如果题目把所有常见payload全部拦截那就要考虑“读源码找过滤规则”这步了。很多平台虽然加了过滤但过滤规则看得见。如果能把源码读出来就能针对性地构造绕过。读源码的基础依赖是Python的文件读取。比如利用open函数{{config.__class__.__init__.__globals__[__builtins__].open(app.py).read()}}如果open也被过滤可以尝试用io模块{{config.__class__.__init__.__globals__[__builtins__].__import__(io).open(app.py).read()}}能把app.py的源码读出来过滤规则的代码就一目了然了剩下的绕过就是体力活。6. 常见问题与排查技巧实录我在带新手做这道题的时候碰到的报错和卡壳点翻来覆去就那么几个。这里整理成一份速查表你直接对照自己的报错来找答案。现象可能原因排查思路输入{{7*7}}页面原样输出模板引擎不是Jinja2或者输入被转义尝试${7*7}、% 7*7 %探测其他引擎输入{{config}}没有回显模板中未暴露config或者引擎是Tornado/Django改用{{self.__init__.__globals__}}或其他内置对象__subclasses__()索引越界环境类列表长度不够或不含目标类枚举索引列表搜索os/warning等关键词访问__globals__报错当前对象不是函数函数才有__globals__检查是否椭圆?记得调用()__init__之前的对象要实例化popen未找到命令os模块被改名或未导入改用__builtins__.__import__(os)动态导入执行命令但看不到输出使用了os.system而非os.popen换成os.popen(cmd).read()输出被截断输出过长页面截断或者特殊字符HTML转义用view-source看源码或分段输出Payload在URL里被编码破坏浏览器或Burp对特殊字符自动URL编码在Burp Repeater或终端curl中构造原始请求这里我再分享一个通用的“侦探技巧”当你完全不知道当前环境中哪些模块可用时优先执行一个最简单的“内省”命令把当前对象的所有属性打出来{{config.__dict__}}如果没有__dict__就试{{config|list}}。把这些输出和你已知环境的输出做对比很多线索一下就有了。还有一个小习惯每次构造一个新的payload之前先在一个最小payload上验收环境。比如说你准备跑{{config.__class__.__init__.__globals__[os].popen(ls).read()}}先分段测{{config}}是否回显。{{config.__class__}}是否能显示类名。{{config.__class__.__init__}}是否有函数对象。{{config.__class__.__init__.__globals__}}是否出字典。在字典中人工确认os存在再走下一步。每一步都验证通过再合成完整payload。这样做的好处是一旦出错你能一眼定位是哪一步出了问题而不是对着一个两千字符的payload干瞪眼。7. 防御视角怎么修这个洞虽然我们站在做题的角度但有个防御的视角会让你的理解更完整。现实中如果开发者写了下面这种代码SSTI就诞生了from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, ) return render_template_string(Hello name)问题出在render_template_string收到的参数是拼接了用户输入后的完整字符串而name里的{{}}会被当成模板表达式执行。修复方式有好几种不要将用户输入直接拼接到模板字符串里。如果只是输出用户名字应该用render_template_string(Hello {{ name }}, namename)把输入作为参数传进去。对用户输入进行白名单校验比如只允许字母数字。使用沙箱来渲染用户可控内容限制模板对内置对象的访问Jinja2的sandboxed环境虽然沙箱也不是百分百安全。关闭错误回显很多SSTI利用依赖于模板报错信息里泄露的对象信息在产品环境里调试信息一定不能开。升级依赖Flask/Jinja2新版本会修复一些已知的绕过路径虽然SSTI的根因始终是人写烂的代码。我自己在写小工具的时候踩过一次坑图省事直接把一个“模板预览”的功能用render_template_string实现结果用户提交的模板里包含{{ config }}就能把Flask的SECRET_KEY打出来。那之后我深刻地意识到SSTI的本质是代码注入它跟那些把用户输入直接拼进eval()的漏洞是同一类问题只不过入口在模板引擎这层。所以永远不要信任模板引擎的默认渲染能力默认它就是有漏洞的。8. 一些做题之外的体会Bugku这道题本身不难但它是一个很好的“承上启下”的例题。做完它你应该可以顺藤摸瓜去尝试Flask SSTI相关的其他题比如vulhub上的Flask SSTI漏洞环境或者一些CTF平台上有字母数字过滤的进阶题。你甚至可以自己搭一个本地环境用Flask写一个存在漏洞的小页面把config链路、__subclasses__链路、attr过滤器绕过、request传参绕过全部练习一遍。我个人在反复练习SSTI后最大的体会是与其背payload不如背“语法能力”。如果你能熟练掌握Python在表达式层面的各种语法列表推导、字典构造、切片、过滤器、|attr、|join、|map、|list等那么面对过滤你能组合出来的绕过路径会非常多。最后再补充一个小技巧供你平时练习时用测试SSTI时可以优先用“无副作用的探测表达式”来确认引擎类型。比如{{7*7}}返回49{{7*7}}返回7777777Jinja2会把字符串重复7次而${7*7}返回${7*7}说明不是Freemarker。这类“不执行命令、只做计算”的探测方式在任何题目里都不会触发告警也比一上来就执行ls要隐蔽得多。Bugku这道SSTI应该能让你把“模板引擎把用户输入当代码执行”这一句话变成一种切实的体感。题目做完了链路走通了剩下的就交给你在其他题目里去沉淀成经验了。