三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Python Pickle反序列化漏洞:绕过WAF黑名单的五种高级技巧

Python Pickle反序列化漏洞:绕过WAF黑名单的五种高级技巧

1. 项目概述:当Pickle遇上WAF,一场猫鼠游戏的开始

如果你是一名Python开发者,或者对Web安全稍有涉猎,那你肯定听说过“Pickle反序列化漏洞”这个老生常谈却又屡试不爽的安全问题。简单来说,Python的pickle模块就像是一个“魔法打包器”,它能把内存里活生生的Python对象变成一串字节流,方便存储或传输;反过来,它也能把这串字节流“复活”成原来的对象。这个“复活”的过程,就是反序列化。问题就出在这个“复活”机制上——它太信任输入的数据了,会忠实地执行序列化数据里包含的指令,这其中就包括了调用一些危险的函数,比如os.systemeval,从而让攻击者有机会执行任意代码。

正因为如此,稍微有点安全意识的应用都会在接收Pickle数据的地方竖起一道“墙”——Web应用防火墙(WAF)。这道墙的核心策略往往是黑名单过滤:把已知的危险函数名、模块名(比如ossysevalexec__reduce__)统统列入黑名单,一旦在序列化数据里发现这些“敏感词”,就立刻拦截请求。这听起来很完美,对吧?但安全攻防从来都是一场动态的猫鼠游戏。防守方筑起高墙,攻击方就会寻找墙上的裂缝。今天我们要聊的,就是当这道WAF黑名单墙看起来密不透风时,作为攻击方(或者说,作为安全研究员)可以尝试的五种“骚操作”,它们的目标一致:在不触发黑名单关键词检测的前提下,构造出能够实现任意代码执行或敏感信息泄露的Pickle Payload

这篇文章不是教你去做坏事,而是从一个防御者的角度,深入理解攻击者的思维和手段。只有知道墙可能从哪些地方被突破,你才能把墙修得更坚固。我们会从一道经典的CTF题目(DASCTF 2024)出发,拆解其严格的过滤逻辑,然后一步步展示五种不同思路的绕过技巧。这些技巧有的利用Python对象模型的特性,有的借助文件操作“曲线救国”,有的则玩起了指令集的“花活”。无论你是负责代码安全的开发者,还是对Python底层机制充满好奇的学习者,相信这篇近万字的实战剖析都能让你对Pickle反序列化有全新的、更深刻的认识。

2. 靶场搭建与WAF规则深度解析

在开始我们的“骚操作”之前,必须先彻底了解我们要面对的是什么。纸上谈兵永远不如真枪实弹,因此我强烈建议你在一个安全的隔离环境(比如虚拟机或Docker容器)中,搭建一个模拟靶场。这里,我们基于Flask快速构建一个存在Pickle反序列化漏洞的端点,并模拟一个典型的、基于字符串匹配的WAF黑名单。

2.1 模拟漏洞环境搭建

首先,我们创建一个名为vuln_app.py的Flask应用:

from flask import Flask, request, render_template_string import pickle import base64 import sys app = Flask(__name__) # 一个简单的前端页面,用于提交Payload HTML_FORM = ''' <!DOCTYPE html> <html> <head><title>Pickle Test</title></head> <body> <h2>Pickle 反序列化测试接口</h2> <form action="/unpickle" method="POST"> <textarea name="data" rows="10" cols="80" placeholder="请输入Base64编码的Pickle数据..."></textarea><br/><br/> <input type="submit" value="提交"> </form> <hr> <h3>结果:</h3> <pre>{{ result }}</pre> </body> </html> ''' @app.route('/') def index(): return render_template_string(HTML_FORM, result='等待提交...') @app.route('/unpickle', methods=['POST']) def unpickle(): data = request.form.get('data', '') result = "" # 模拟一个非常严格的黑名单WAF BLACKLIST = { 'os', 'sys', 'subprocess', 'commands', 'popen', 'system', 'eval', 'exec', 'compile', 'input', '__import__', 'open', 'file', '__reduce__', '__reduce_ex__', '__setstate__', '__getstate__', 'builtins', '__builtins__', 'globals', 'locals', 'vars', 'getattr', 'setattr', 'delattr', 'apply', 'map', 'filter', '\\', 'class', 'mro', 'bases', 'subclasses', 'import', 'from', 'as', 'pickle', 'marshal', 'shelve', 'cPickle' } try: # 1. Base64解码 pickle_data = base64.b64decode(data) raw_data_str = pickle_data.decode('latin-1') # 转换为字符串进行关键词检查 # 2. 严格的WAF黑名单检查 for forbidden in BLACKLIST: if forbidden in raw_data_str: result = f"WAF拦截!检测到黑名单关键词: '{forbidden}'" return render_template_string(HTML_FORM, result=result) # 3. 额外的危险模块“污染” # 模拟一些题目中常见的操作:将危险模块替换掉,使其在反序列化时不可用 sys.modules['os'] = None sys.modules['sys'] = None sys.modules['subprocess'] = None # 4. 执行反序列化 obj = pickle.loads(pickle_data) result = f"反序列化成功!对象类型: {type(obj)}" if hasattr(obj, '__repr__'): result += f"\n对象内容: {repr(obj)}" except Exception as e: result = f"反序列化失败或处理出错: {str(e)}" return render_template_string(HTML_FORM, result=result) if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000)

运行这个应用 (python vuln_app.py),访问http://127.0.0.1:5000,你就得到了一个带有“严格”WAF的Pickle反序列化测试平台。

2.2 WAF规则逻辑拆解与弱点分析

我们的WAF模拟了现实中可能遇到的一种严格但并非无懈可击的防御策略。我们来逐条分析它的逻辑和潜在弱点:

  1. 关键词字符串匹配:这是最核心的防御。它直接将序列化后的字节流解码成字符串(这里用了latin-1编码以保证兼容性),然后检查其中是否包含黑名单里的任何子字符串。这种方法简单粗暴,但存在几个致命问题:

    • 编码绕过:Pickle协议数据是字节流,直接解码为字符串进行检查,可能会遗漏通过非标准编码或字节操作隐藏的关键词。例如,将关键词拆分成多个部分,或者利用协议指令本身的特性。
    • 指令集特性:Pickle协议有自己的指令集(如GLOBALINSTOBJ等)。攻击者可以不直接出现函数名的字符串,而是通过指令引用模块和函数,这可能会绕过基于纯字符串的检查。
    • 大小写与变形:虽然我们的黑名单是大小写敏感的,但Python的模块和函数名本身是大小写敏感的,所以这一点影响不大。但有些蹩脚的WAF可能只检查小写形式。
  2. 模块污染:在反序列化前,将sys.modules中的危险模块(如ossys)设置为None。这旨在阻止攻击者通过GLOBAL指令直接导入这些模块。这是一个有效的补充防御,但它只影响了通过sys.modules的标准导入路径。攻击者仍然可能通过其他方式获取到这些模块或等价的函数。

  3. 黑名单的“非完备性”:这是所有黑名单机制的根本缺陷。你只能禁止你知道是危险的东西。Python的标准库和内置函数浩如烟海,总有一些“不起眼”的函数或模块组合起来能产生意想不到的效果。我们的黑名单虽然长,但远未覆盖所有可能性。例如,io模块下的文件操作、types模块动态创建函数、甚至一些魔术方法(__getattribute__)的巧妙利用,都可能成为突破口。

实操心得:在真实环境中,WAF的规则可能更复杂,可能结合正则表达式、语法树分析甚至机器学习模型。但基于字符串匹配的黑名单仍然非常常见,尤其是在一些自定义的、轻量级的过滤逻辑中。理解这种防御的局限性,是构思绕过方法的第一步。我们的五种“骚操作”,正是针对这些弱点设计的。

3. 绕过姿势一:利用GLOBAL指令与builtins模块的“间接寻址”

这是最经典,也是理解后续所有高级技巧的基础。当ossys等模块被直接屏蔽时,我们如何获取到执行命令的能力?答案是:builtins

3.1 原理剖析:builtins——Python的“武器库”

builtins模块包含了Python的所有内置函数和异常,比如printlenopen,当然也包括__import__evalexec。最关键的是,builtins模块在Python中具有特殊地位,它是默认的命名空间,很多函数可以直接调用而无需导入。在Pickle反序列化时,我们可以通过GLOBAL指令来引用builtins模块下的函数,即使os模块被污染了。

GLOBAL指令的格式是:c<module>\n<name>\n。例如,cos\nsystem\n表示引用os.system。如果os模块被设置为None,这条指令就会失败。但是,cbuiltins\n__import__\n却可能成功,因为它引用的是builtins.__import__

3.2 攻击链构建:从__import__os.system

我们的目标是执行系统命令。思路如下:

  1. 通过builtins.__import__函数,动态导入os模块(注意,这里导入的是新模块,不受之前sys.modules['os'] = None的影响,因为__import__会尝试重新加载)。
  2. 调用导入的os模块的system方法。

手工构造这样的Pickle字节码非常繁琐且容易出错。因此,安全社区开发了辅助工具,最著名的就是pker(Pickle Exploit Ker)。我们可以用类Python的语法来描述攻击链,然后由pker生成对应的Pickle字节码。

假设我们没有pker,我们也可以手动推导。但为了清晰,我们先展示pker的思路。创建一个exploit.py

# 这是一个概念性代码,用于说明pker的生成逻辑,并非直接运行 # 实际使用需要安装或编写pker工具 import pickle import base64 # 使用pker的“语言”描述攻击链(伪代码) # GLOBAL('builtins', '__import__') -> 得到一个函数对象,我们称它为 import_func # import_func('os') -> 调用这个函数,传入参数'os',得到os模块对象 # 从os模块对象中获取 system 属性 -> 得到 os.system 函数 # 调用 os.system('id') -> 执行命令 # 手工构造的简化版本(不完整,仅示意) # 这非常复杂,涉及到栈操作(PROTO, GLOBAL, TUPLE, REDUCE等) payload = b"""cbuiltins __import__ p0 0(S'os' tRp1 0g1 (S'system' tRp2 0g2 (S'id' tR. """ # 实际上,上面的payload是不完整的,正确的构造需要更精确的指令序列。

由于手工构造极易出错,我们直接给出一个利用pker(假设我们有这个工具)生成的有效Payload,并分析其绕过原理:

cbuiltins getattr p0 0(cbuiltins __import__ S'os' tRp1 0g0 (g1 S'system' tRp2 0g2 (S'id' tR.

绕过分析

  1. 整个Payload中,没有出现ossyseval等被禁用的模块名作为字符串。os是作为参数S'os'传递给__import__的,而参数字符串可能不在WAF的检查范围内(取决于WAF实现,我们的模拟WAF会检查)。
  2. 关键函数__import__getattr都来自builtins模块。builtins本身可能不在黑名单中,或者即使builtins在名单里,我们也可以尝试用其他方式获取这些内置函数(比如通过().__class__.__bases__[0].__subclasses__()遍历,但这种方法通常会产生包含classbases等关键词的字符串,容易被拦截)。

注意事项:这种方法的有效性高度依赖于WAF的实现。如果WAF不仅检查模块/函数名,还深度解析Pickle指令,识别出GLOBAL指令后面的模块名(builtins)和函数名(__import__getattr),那么它仍然可能被拦截。此外,如果builtins也被加入黑名单,此路不通。但现实中,将builtins完全禁用会导致很多正常功能失效,因此较少见。

4. 绕过姿势二:文件操作read/write实现非交互式信息泄露

当直接执行命令的路径被彻底封死时(例如,黑名单包含了所有常见的内置危险函数和builtins),我们就需要转变思路。目标从“执行代码”变为“获取信息”。在很多CTF题目或实际场景中,最终目标是读取服务器上的一个特定文件(如/flag/etc/passwd)。如果应用本身存在文件读取或回显的功能,我们就可以利用它作为“通道”。

4.1 场景复现与攻击链设计

回顾我们引用的DASCTF 2024题目。它的WAF黑名单非常长,但唯独没有过滤readwrite。同时,题目环境允许我们访问Web应用的源代码文件(/app.py)。这就为我们提供了完美的“曲线救国”方案:

攻击逻辑

  1. 打开目标文件:使用open('/flag')打开包含flag的文件。
  2. 读取内容:调用文件对象的read()方法,将flag内容读入内存。
  3. 写入可访问文件:使用open('./app.py', 'w')以写入模式打开Web应用自身的源代码文件(或任何其他前端可访问的文件)。
  4. 写入内容:调用文件对象的write()方法,将flag内容写入这个文件。
  5. 前端回显:通过正常的Web请求访问被篡改的app.py,从而在前端看到flag内容。

这个链条的核心是:利用应用自身的文件读写和访问能力,将敏感数据“搬运”到攻击者可以看见的地方

4.2 Payload构造与pker工具实战

再次强调,手工构造涉及文件操作的复杂Pickle Payload是噩梦。我们使用pker(这里我们假设使用一个类似的工具或库)来生成。pker允许我们用一种更直观的“伪Python”语法来描述操作:

# pker 语法示例 (概念性) getattr = GLOBAL('builtins', 'getattr') open = GLOBAL('builtins', 'open') # 1. 打开flag文件并读取 f_flag = open('/flag') read_func = getattr(f_flag, 'read') flag_content = read_func() # 调用 read() # 2. 打开app.py文件并写入 f_app = open('./app.py', 'w') write_func = getattr(f_app, 'write') write_func(flag_content) # 调用 write() # 3. 返回None或其他无害对象 return None

pker会将上面的逻辑编译成Pickle字节码。关键的绕过点在于:

  • openreadwritegetattr这些函数名本身可能不在黑名单中。
  • 即使open在黑名单里,我们依然可以通过builtins.getattr(builtins, 'open')或者从其他对象中间接获取到它,只要最终Payload字符串里不出现'open'即可。pker生成的字节码中,函数名'open'是作为参数传递给GLOBAL指令的,它可能以S'open'的形式存在。如果WAF检查所有字符串,'open'就会被发现。因此,更高级的绕过需要避免在Payload中出现任何敏感字符串

4.3 无字符串技术:利用数字索引与属性访问

如何避免出现'open''read'这样的字符串?我们可以利用Python对象模型的特性。

思路:在Python中,对象的属性可以通过getattr(obj, name)访问,其中name是字符串。但属性也可以通过对象的__dict__dir()列出,然后通过索引访问。然而,这又回到了需要知道属性名或遍历的问题。

一个更巧妙的思路是利用builtins模块的__getitem__方法。builtins模块有一个__dict__属性,它是一个字典,包含了所有内置函数。我们可以通过数字索引来引用字典的值吗?不行,字典的键是字符串。但是,我们可以先获取builtins.__dict__,然后将其转换为list,这样内置函数就变成了一个有序列表,我们可以通过数字索引来获取它们。

然而,这又引入了list__dict__等新字符串。这条路在严格的字符串过滤下也很难走通。

因此,对于基于字符串匹配的WAF,最有效的绕过往往不是完全消除字符串,而是使用WAF名单之外的、功能等价的字符串。例如,如果WAF禁了open,但没禁file(在Python 2中file是内置函数,Python 3中它在io模块),或许可以尝试。或者,寻找其他具有读写能力的对象和方法。

实操心得:文件操作绕过的精髓在于“信息转移”。它不追求直接RCE(远程代码执行),而是追求数据泄露。在实际渗透测试中,这种思路非常实用。例如,如果你能写入Web目录下的一个文件(比如/tmp/shell.php),并使其可执行,那么结合文件包含漏洞,就可能实现RCE。或者,将敏感信息写入日志文件,然后通过正常的日志查看功能读取。这种“分步走”、“组合拳”的策略,往往能突破单点防御。

5. 绕过姿势三:魔术方法__setstate____getstate__的妙用

Python的Pickle协议在序列化和反序列化对象时,会调用一些特殊的魔术方法(Magic Methods)。除了众所周知的__reduce____setstate____getstate__也扮演着重要角色,并且常常被WAF忽略。

5.1__setstate__的工作原理

当一个对象被反序列化(unpickle)时,如果它的类定义了__setstate__方法,Pickle会调用这个方法,并将序列化时__getstate__返回的状态(或者默认的__dict__)作为参数传递给它。这给了我们一个在反序列化过程中自动执行代码的机会。

import pickle class EvilClass: def __setstate__(self, state): import os os.system('echo "Code executed via __setstate__!"') # 序列化 payload = pickle.dumps(EvilClass()) # 反序列化时,__setstate__会被自动调用 pickle.loads(payload)

绕过优势

  • 隐蔽性:Payload中可能只包含类名EvilClass,而真正的恶意代码藏在类定义里。如果WAF只检查序列化数据流中的字符串,它可能发现不了os.system,因为这个调用发生在类的方法内部,而方法体本身并不直接存在于序列化的字节流中(序列化保存的是对类EvilClass的引用,而不是其源代码)。然而,如果EvilClass是动态定义的,并且其类名或模块路径中包含敏感词,还是可能被拦截。
  • 灵活性:可以在__setstate__里做任何事,包括调用被禁用的模块和函数,因为此时代码执行环境已经跳出了WAF的字符串过滤阶段。

5.2 如何将恶意类“送”进去?

这里有一个关键问题:我们的恶意类EvilClass必须存在于反序列化环境的命名空间中。在CTF题目中,出题人有时会“贴心”地给出一个自定义的、存在漏洞的类。但在更普遍的情况下,我们需要利用Python的对象继承链来动态构造或访问一个已有的、可控的类。

一个常见技巧是利用Python中广泛存在的、可序列化的标准库类,并尝试覆盖其__setstate__方法。但这通常需要利用到__reduce__来返回一个元组,指定一个可调用对象和参数来重建对象,而这又回到了__reduce__被禁的问题。

更可行的方式是,如果环境中存在任何用户可控的、可被序列化的对象,并且我们能控制其__class__属性(这很难),或者能找到一个其__setstate__方法行为可疑的现有类。

例如,一些标准库类(如pickle.Unpicklersocket._socketobject等)的__setstate__可能涉及资源分配或状态恢复,如果我们能构造特定的state参数,或许能触发非预期的行为。但这需要深入的知识和具体的环境分析。

因此,__setstate__绕过更像是一种“机会主义”的技巧。当题目源码中已经定义了一个类,并且这个类会被序列化/反序列化,而它的__setstate__方法实现有缺陷或我们可以控制传入的state时,这个方法就非常强大。如果我们需要从零开始注入一个全新的恶意类,则通常需要配合其他能执行代码的 primitive(如execeval)来动态创建这个类,而这又可能被WAF拦截。

注意事项:使用__setstate__时,要确保类本身可以被安全地序列化和反序列化。另外,__setstate__接收的state参数通常是字典,如果我们可以控制这个字典的内容,也许能进行属性注入攻击,但这属于另一个攻击面(利用__dict__.update(state))。

6. 绕过姿势四:操作码拼接与指令流混淆

前几种方法主要是在“做什么”的层面进行绕过。而操作码混淆则是在“怎么做”的层面进行干扰。Pickle协议本质上是一种基于栈的虚拟机指令集。我们可以直接操作这些底层指令(操作码),来构造WAF难以识别的Payload。

6.1 Pickle指令集简介

Pickle协议定义了一系列单字节的操作码(opcode),例如:

  • c: GLOBAL,推入一个全局对象(模块.名称)
  • (: MARK,在栈上标记一个位置
  • S: STRING,推入一个字符串
  • t: TUPLE,从栈顶弹出多个元素组成元组
  • R: REDUCE,应用可调用对象
  • p: PUT,将栈顶对象存储到memo字典
  • g: GET,从memo字典获取对象
  • .: STOP,停止

我们之前看到的文本格式(如cbuiltins\ngetattr\n)是协议0(文本协议)的表示形式。更高版本的协议(如协议2, 3, 4, 5)使用二进制格式,可读性更差,但原理相同。

6.2 混淆技术实战

WAF的字符串匹配通常针对的是协议0的文本表示或解码后的字符串。我们可以通过以下方式进行混淆:

  1. 使用高版本协议:使用pickle.dumps(obj, protocol=4)生成二进制Payload。二进制数据中,操作码和参数混杂,简单的字符串匹配很难准确提取出builtinssystem这样的关键词,除非WAF也实现了完整的Pickle解析器。直接将二进制数据Base64后提交,可能绕过仅检查文本格式的WAF。

  2. 指令拆分与重组:利用PUT(p)和GET(g)指令。我们可以将一个长字符串或复杂对象拆分成多个部分,分别存储到memo中,然后组合使用。例如,不直接使用字符串'os.system',而是先分别存储'os''system',再组合。这可能会打乱字符串在字节流中的连续出现。

    # 未混淆 S'os.system' # 混淆后 (概念性) S'os' p0 # 存储到memo[0] S'system' p1 # 存储到memo[1] g0 # 取出 'os' g1 # 取出 'system' # 如何组合成 'os.system' 需要其他指令,这里只是示意拆分思路

    实际上,组合需要用到BUILDREDUCE等指令,会引入更多操作码,使得Payload更复杂,字符串特征更分散。

  3. 利用注释和空白:在协议0中,#之后的内容是注释,会被解析器忽略。我们可以在Payload中插入大量无意义的注释和换行,干扰字符串匹配。

    cbuiltins # 这是一段无害的注释,os.system什么的都不在这里 getattr p0 0 # 更多注释...

    如果WAF是简单的逐行或全文字符串搜索,这些注释没有影响。但如果WAF尝试先去除注释再检查,那可能无效。不过,很多简单的WAF不会做这么复杂的预处理。

  4. Unicode与编码技巧:将关键字符串用特殊的Unicode字符(如零宽字符)包裹,或者使用不同的编码(如UTF-16)进行表示,然后确保在Pickle解析时它能被正确解码。这要求对Pickle的字符串解析机制有很深的理解,且成功率不高,因为Pickle协议对字符串的编码有明确规定(通常是ASCII或Latin-1相关的)。

这种方法的局限性:现代WAF和专业的反序列化过滤器(如pickle.Unpickler的子类重写find_class方法)是在解析器层面进行拦截的。它们会真正解析Pickle字节码,在GLOBALINST等指令被解析时,检查即将导入的模块和类名。在这种情况下,无论你怎么混淆操作码流,最终解析出来的模块/函数名都会暴露。因此,操作码混淆主要对抗的是基于字节流或字符串正则匹配的浅层WAF,对于实现了完整解析和钩子函数的安全模块,效果有限。

排查技巧:如果你怀疑WAF是基于字符串匹配的,可以尝试发送一个包含大量随机注释和空白的、无害的Payload(比如只序列化一个数字1),观察是否被拦截。如果被拦截,说明WAF可能在做简单的全文匹配,混淆可能有效。如果无害Payload能通过,但包含builtins的Payload被拦,说明WAF很可能在解析后检查。

7. 绕过姿势五:利用typesfunctools等“白名单”模块进行函数构造

当直接的危险函数和builtins都被封禁时,我们的视野需要扩大到整个Python标准库。有一些模块本身看似无害,但可以用来构造出具有执行代码能力的函数。typesfunctools就是这样的“瑞士军刀”。

7.1 使用types.FunctionType动态创建函数

types.FunctionType可以用于创建一个新的函数对象。你需要提供代码对象(code)、全局命名空间(globals)和一个函数名。如果我们能构造出一个执行任意代码的代码对象,就能创建一个函数并调用它。

如何获取代码对象?可以通过compile内置函数。但compile很可能在黑名单里。另一种方式是,寻找一个现有的、简单的代码对象,然后修改它的字节码?这涉及到底层CPython的code对象结构,极其复杂且不稳定。

一个更现实的路径是:如果我们能执行任意字符串,我们就能用execeval。但这两个也被禁了。那么,有没有其他方式“模拟”出exec的功能?可以考虑types.ModuleType动态创建模块,然后向模块的命名空间里注入代码字符串,再import它?这又回到了需要exec__import__

这条路在完全禁用builtinsexec/eval的环境下非常艰难。它更依赖于环境中已经存在一些我们可控的、能够执行代码的“种子”。

7.2 使用functools.partial进行函数柯里化

functools.partial用于“部分应用”一个函数,即固定函数的一些参数,产生一个新的可调用对象。这个本身不能执行代码,但它可以用于绕过对特定参数形式的检查

例如,假设WAF愚蠢到只检查os.system(‘id’)这样的完整调用字符串。我们可以用partial先绑定函数,再传递参数:

import functools import os cmd_executor = functools.partial(os.system, 'id') # 现在 cmd_executor 是一个可调用对象,调用它就会执行 `os.system('id')`

在Pickle中,我们可以序列化这个cmd_executor对象。Payload里包含的是functools.partialos.system的引用,以及参数'id'。如果WAF只检查os.system(‘id’)这个整体模式,它可能会漏掉这种形式。但这是一种非常脆弱的绕过,实战价值低。

7.3 寻找“替代函数”

核心思路是:寻找那些不在黑名单上,但功能上可以替代危险函数的“白名单”函数。

  • os.popenvsos.system: 如果只禁了systempopen可能还能用,它也能执行命令并返回输出。
  • subprocess.call/run/Popen:os模块被禁,但subprocess模块可能还在。
  • importlib.import_module: 如果__import__被禁,这个函数可能是一个替代品。
  • getattr,setattr,delattr: 这些是属性访问的底层函数,极其强大,常被忽略。我们已经在姿势一中用到了getattr
  • breakpoint(): 在Python 3.7+中,这个内置函数会触发调试器。如果环境允许交互(比如在某些CTF的pwn题中),这可能是一个入口。
  • codecs模块: 可以用于编码解码,有时能配合其他漏洞进行数据泄露。

这本质上是一种“白名单探测”。需要你对Python标准库非常熟悉,并且能快速测试哪些模块和函数是可用的。在CTF中,出题人有时会故意留一些这样的“后门”函数。

实战心得:这种绕过方式没有固定套路,考验的是攻击者的知识广度和对环境的快速适应能力。在真实的漏洞利用中,如果遇到严格过滤,我会习惯性地在交互式Shell里(如果能有的话)快速测试dir(__builtins__),看看哪些内置函数还在;然后尝试导入一些常见的非危险模块(json,re,datetime,itertools等),再深入这些模块的dir(),寻找任何可能用于读写文件、执行代码或导入模块的函数。这个过程就像在迷宫里摸索,需要耐心和创造力。

8. 防御者视角:从黑名单到纵深防御

分析了这么多攻击手法,作为开发者或安全工程师,我们该如何防御?单纯依赖黑名单是注定失败的,因为攻击面太广。我们需要建立一套纵深防御体系。

8.1 根本解决方案:弃用Pickle

最有效、最根本的防御就是不要使用pickle来反序列化不可信数据。对于大多数Web应用,jsonyaml(需注意安全加载)、msgpack等格式是更安全的选择。它们只序列化数据,不序列化代码逻辑。

# 安全替代 import json data = json.dumps(user_object) # 仅序列化数据 user_obj = json.loads(data) # 安全反序列化,不会执行代码

如果因为历史原因或特定需求(如分布式计算框架)必须使用Pickle,请继续往下看。

8.2 增强过滤:使用Unpicklerfind_class白名单

不要自己写字符串过滤。使用pickle.Unpickler类,并重写其find_class方法。这个方法在每次GLOBALINST指令试图加载一个模块/类时被调用。在这里实施白名单策略。

import pickle import io class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): # 只允许反序列化来自安全模块的安全类 ALLOWED = { ('__main__', 'SafeClass'), # 只允许应用内定义的特定类 ('numpy', 'ndarray'), # 如果确实需要,可以按需添加 ('pandas', 'DataFrame'), } if (module, name) in ALLOWED: return super().find_class(module, name) # 拒绝所有其他访问 raise pickle.UnpicklingError(f"Global '{module}.{name}' is forbidden") def safe_loads(data): """安全地反序列化Pickle数据""" return RestrictedUnpickler(io.BytesIO(data)).load() # 使用 try: obj = safe_loads(untrusted_data) except pickle.UnpicklingError as e: print(f"安全拦截: {e}")

这是业界推荐的最佳实践。白名单比黑名单安全得多。你需要仔细评估你的应用真正需要反序列化哪些类,并将它们明确列入白名单。

8.3 环境隔离:沙箱与低权限运行

即使采用了白名单,也不能保证100%安全(白名单内的类也可能存在漏洞)。因此,需要额外的隔离层:

  1. 进程隔离:在一个独立的、低权限的进程或容器中执行反序列化操作。这个进程应该被严格限制(例如,使用seccompAppArmorSELinux限制系统调用,使用chroot限制文件系统访问,使用resource模块限制CPU/内存)。
  2. 系统权限:运行Web服务的用户应该是一个非特权用户(如www-data,nobody),没有sudo权限,对系统关键目录只有读权限。
  3. 网络隔离:反序列化环境不应该有出网权限,防止反弹Shell。

8.4 运行时监控与审计

  1. 日志记录:详细记录所有反序列化操作,包括来源IP、时间、试图加载的模块/类。对异常模式(如频繁尝试加载非常见类)进行告警。
  2. 行为监控:监控反序列化进程的系统调用,特别是execve(执行新程序)、open(打开文件)、connect(网络连接)等。可以使用auditdFalco等工具。
  3. 文件系统监控:对Web目录、临时目录、配置文件等敏感位置设置文件完整性监控(如aide,tripwire),防止被篡改。

8.5 代码审计与依赖管理

  1. 定期审计:使用静态代码分析工具(如Bandit,Semgrep)扫描代码库,查找不安全的pickle.loads调用。
  2. 依赖检查:确保使用的第三方库没有不安全的反序列化点。关注安全公告。
  3. 最小权限原则:即使是白名单里的类,也要确保其实现是安全的,没有危险的副作用或可被利用的魔术方法。

防御Pickle反序列化漏洞是一场持久战。没有银弹,必须结合安全编码、严格过滤、环境隔离和持续监控,才能构建起有效的防线。而作为攻击方(安全研究员)所研究的这些绕过技巧,其最大价值正是帮助防御者看清防线的薄弱之处,从而将其加固。

← 返回列表