Python pickle序列化的安全风险与替代方案
Python pickle序列化的安全风险与替代方案pickle在反序列化时会执行任意代码。这不是bug而是设计。pickle协议本质上是一个栈虚拟机控制指令pickle opcodes可以创建对象、设置属性、调用函数。opcode的执行过程import pickletoolsimport pickleclass Exploit:def __reduce__(self):import osreturn (os.system, (echo vulnerable,))payload pickle.dumps(Exploit())pickletools.dis(payload)输出显示pickle字节码的各条指令0: \x80 PROTO 52: \x95 FRAME 3711: \x8c SHORT_BINUNICODE os15: \x93 STACK_GLOBAL16: \x8c SHORT_BINUNICODE system24: \x93 STACK_GLOBAL25: \x8c SHORT_BINUNICODE echo vulnerable42: \x85 TUPLE143: \x87 TUPLE344: \x82 BUILD_CLASS45: . STOPSTACK_GLOBAL指令是关键。它从栈上取出两个字符串模块名和函数名通过import和getattr获取任意Python对象。REDUCE指令随后调用这个可调用对象。解析STACK_GLOBAL的C代码static PyObject*load_global(PicklerObject *self) {PyObject *name, *module;name read_unicode(self); // 读取函数名module read_unicode(self); // 读取模块名// 导入模块PyObject *module_obj PyImport_Import(module);// 获取函数PyObject *global PyObject_GetAttr(module_obj, name);self-stack[self-stack_size] global;}STACK_GLOBAL不检查从哪个模块导入什么对象。任何模块中的任何可调用对象都可以在反序列化时被调用。实际的攻击方式比__reduce__更多样。利用R指令REDUCE可以直接调用任意函数import pickleimport pickletools# 手动构造pickle字节码不需要定义任何类payload b\x80\x04\x95\x2e\x00\x00\x00\x00\x00\x00\x00\x8c\x08subprocess\x94\x8c\x03Popen\x93\x94\x8c\x02ls\x85R.pickletools.dis(payload)这个payload直接调用了subprocess.Popen(ls)不需要目标环境中有任何特定的类定义。防范pickle攻击的方式是限制全局对象的访问。pickle的Unpickler.find_class方法可以重写import pickleclass SafeUnpickler(pickle.Unpickler):ALLOWED_MODULES {builtins: {print, range, int, str, list, dict}}def find_class(self, module, name):if module in self.ALLOWED_MODULES and name in self.ALLOWED_MODULES[module]:return super().find_class(module, name)raise pickle.UnpicklingError(f禁止访问 {module}.{name})data pickle.dumps([1, 2, 3])safe SafeUnpickler(io.BytesIO(data)).load() # OKbad_data pickle.dumps(Exploit())try:SafeUnpickler(io.BytesIO(bad_data)).load()except pickle.UnpicklingError:print(攻击被阻止)find_class在反序列化时被调用负责解析模块和名称。重写后只允许白名单内的全局对象。白名单方式的问题是标准库中有大量危险但看起来安全的模块。例如# 看起来安全的builtins但可以做危险操作import builtinsbuiltins.exec(import os; os.system(rm -rf /))# collections模块也可以import collectionscollections.OrderedDict.__builtins__[exec](import os; os.system(ls))所以find_class的阻止并不能完全保证安全。更安全的做法是完全不使用pickle处理不可信数据。替代方案JSON、YAML、MessagePack各有优劣。JSON的局限性import jsondata json.loads([1, 2, 3]) # OK# json不支持复杂类型# json.dumps({1: 2}) # TypeError: keys must be str, int, or floatjson_extend支持自定义编码器class ComplexEncoder(json.JSONEncoder):def default(self, obj):if isinstance(obj, complex):return {__complex__: True, real: obj.real, imag: obj.imag}if isinstance(obj, datetime.datetime):return {__datetime__: True, value: obj.isoformat()}return super().default(obj)def decode_object(dct):if __complex__ in dct:return complex(dct[real], dct[imag])if __datetime__ in dct:return datetime.datetime.fromisoformat(dct[value])return dctdata json.dumps(complex(1, 2), clsComplexEncoder)obj json.loads(data, object_hookdecode_object)print(obj) # (12j)object_hook是一个安全的解码器因为它只处理标准JSON类型不会执行任意代码。YAML的问题比pickle更严重。PyYAML默认支持任意Python对象import yaml# PyYAML默认load会执行任意代码yaml.load(!!python/object/apply:os.system [echo vulnerable])yaml.safe_load只处理YAML的标准类型不处理!!python/object标签yaml.safe_load(!!python/object/apply:os.system [echo vulnerable])# yaml.constructor.ConstructorError这个区别让很多人掉进坑里。看到load明明可用就用了。应该一直用safe_load除非有特殊需求。MessagePack的序列化结果比JSON更紧凑import msgpackdata {name: Alice, scores: [90, 85, 95]}packed msgpack.packb(data)unpacked msgpack.unpackb(packed)print(len(packed)) # 比json.dumps(data)小约30%# 自定义类型import msgpackfrom datetime import datetimedef encode_hook(obj):if isinstance(obj, datetime):return {__datetime__: True, ts: obj.timestamp()}return objdef decode_hook(obj):if __datetime__ in obj:return datetime.fromtimestamp(obj[ts])return objdata {now: datetime.now()}packed msgpack.packb(data, defaultencode_hook)unpacked msgpack.unpackb(packed, object_hookdecode_hook)msgpack本身不执行任意代码但object_hook中如果执行了危险操作同样不安全。protobuf的性能比JSON和pickle都好但需要预定义schemasyntax proto3;message Person {string name 1;int32 age 2;repeated string tags 3;}Google的protobuf库编译这个.proto文件生成Python代码。序列化时按照schema编码不涉及任意函数调用天然安全。protobuf的使用from person_pb2 import Personp Person(nameAlice, age30)data p.SerializeToString()p2 Person()p2.ParseFromString(data)print(p2.name, p2.age)protobuf的缺点是schema管理成本高运行时无法动态添加字段。dill是pickle的扩展版本可以序列化更复杂的对象import dill# 可以序列化lambdafunc lambda x: x * 2serialized dill.dumps(func)restored dill.loads(serialized)print(restored(5)) # 10# 可以序列化整个会话dill.dump_session(session.pkl)# dill.load_session(session.pkl)dill的安全性比pickle还差因为它支持更多的Python对象类型。绝对不能用于反序列化不可信数据。marshal是Python内部的序列化格式用于.pyc文件import marshalcode compile(print(12), , exec)data marshal.dumps(code)# marshal.loads(data)marshal不支持类实例或自定义对象但支持code对象。反序列化第三方marshal数据非常危险因为code对象可以携带任意字节码。pickle协议版本的发展# Python 3.11默认使用协议5import pickleprint(pickle.DEFAULT_PROTOCOL) # 5# 协议5支持out-of-band databuffers []data pickle.dumps(large_array, protocol5, buffer_callbackbuffers.append)reconstructed pickle.loads(data, buffersbuffers)协议5的out-of-band数据允许将大数据缓冲区与pickle流分离避免在pickle流中复制大块字节数据。对于numpy数组和bytearray等类型可以大幅提升序列化性能。为什么Python内部仍然用pickle因为Python的很多标准库功能依赖pickle的灵活性。multiprocessing用pickle传递数据functools.lru_cache用pickle做参数哈希一些数据库驱动用pickle处理复杂类型。完全移除pickle不现实。正确的做法是区分使用场景进程间内部通信可以用pickle任何涉及不可信输入的场景必须用其他序列化方案。