跳到主要内容
AZ Tools

Python pickle 检查器

pickle 其实不是数据格式,而是一段给小型栈式虚拟机的程序,pickle.load 会把它跑起来:还没有任何值出现之前,操作码就可以导入任意模块、调用任意可调用对象、构造任意类。所以这个工具从不反序列化。它按 pickletools.dis 的读法逐个操作码地读取字节流,给出偏移、解码后的参数和对栈的影响,并且只还原那些不必进入 Python 就能拼出来的部分。字典、列表、元组、集合、字符串、字节串、任意大小的整数、浮点数、布尔值和 None 原样返回;凡是需要导入才能确定的,一律显示为明确的占位符,而不是猜测。协议版本会报告两个:两者可能不一致。协议 2 及以上以声明版本的 PROTO 操作码开头,协议 0 和 1 什么都不声明,所以工具还会算出文件里的操作码实际要求的最高协议,并在文件自称比实际更旧时给出警告。memo 表显示哪些对象被存入又被重新取回,共享引用和循环引用只有通过这条线索才看得见:一个包含自身的列表,正是取回了在这个列表还在构建时就已存入的键。协议 4 和 5 的字节流带帧,帧表给出每一块的起始位置。最值得先看的是安全发现面板。它会点名每一处在加载时会执行代码的构造:指定导入名称的 GLOBAL 与 STACK_GLOBAL、真正发起调用的 REDUCE、构造类的 INST、OBJ、NEWOBJ 与 NEWOBJ_EX、调用 __setstate__ 的 BUILD、交给加载器自己 persistent_load 钩子的 PERSID,以及名称根本不在文件里的 copyreg 注册表 EXT1/2/4;同时列出加载器会查找的每一个模块与属性,并标出 os.system、subprocess、builtins.eval、posix.system 这类在公开攻击中出现过的名称。完全没有这些操作码的 pickle 是数据,有这些操作码的就是程序,再怎么检查也不会让加载来路不明的 pickle 变得安全。畸形文件会被点名而不是读一半:截断的字节流、STOP 之后剩下的字节(第一个 pickle 后面藏着第二个),以及取用从未存入过的 memo 键,都会被分别报出来。

使用方法

  1. 把 .pkl、.pickle 或任何 pickle 序列化过的文件拖进方框。不上传、不反序列化,只做反汇编。
  2. 先看安全发现面板。“只是数据”表示文件里没有任何能导入或调用的操作码;如果列出了导入名称,就把它当作不可信代码,不要加载。
  3. 对比声明的协议和实际需要的协议。声明协议 2 却用了协议 4 操作码的文件,是手工拼出来的,不是 pickle.dumps 写的。
  4. 浏览操作码表。缩进跟随 MARK 的嵌套,容器因此像一个个块,栈那一列显示每一步之后还有多少个值活着。
  5. 打开 memo 表寻找共享对象:复用次数大于零的键说明同一个对象出现在多处,循环引用也是这样写出来的。

常见问题

在这里打开来路不明的 pickle 安全吗?
安全,因为这里根本不做反序列化。文件是按操作码逐个读取的,读法和 pickletools.dis 完全一样,遇到 GLOBAL 或 REDUCE 时唯一发生的事情就是把模块名和属性名打印出来。不会导入任何东西,也不会调用任何东西,还原止步于仅靠操作码就能构造的值。文件也不会离开你的浏览器:用 File API 读取,在页面内解析。这个工具做不到的是让那个文件事后变安全——如果安全发现面板列出了导入名称,对同一个文件执行 pickle.load 时,这些导入照样会发生。
它明明是序列化格式,为什么能执行代码?
因为要序列化任意对象就得有办法把它重建出来,Python 给出的答案是 __reduce__:对象自己声明"用哪个可调用对象、配上哪些参数,就能把我造回来"。于是字节流里保存的是一个要导入的名称和一次调用,反序列化器把两件事都执行。正是这一套机制让 datetime 对象、numpy 数组和你自己写的任何类都能被 pickle,攻击者用的也是同一套机制,只不过把 datetime.datetime 换成了 os.system。格式本身没有任何东西能区分这两种情况,所以要知道一个文件会做什么,唯一的办法就是读它的操作码。
memo 是做什么的,循环引用又是怎么体现的?
memo 是序列化器记录自己已经写过哪些对象的表。同一个对象再次出现时,第二次开始就变成从 memo 取回:协议 0 用 PUT 和 GET,之后用 BINPUT 和 BINGET,协议 4 起用 MEMOIZE,它不写出键号而是隐式编号。这样才保住了同一性:原本指向同一个列表的两个名字,加载之后仍然指向同一个列表。循环引用是这套机制再往前一步:容器在内容写出之前就已存入 memo,于是内容可以反过来引用它。这就是一个包含自身的列表能被表示出来的原因,也是这里显示引用而不是无限展开的原因。
六个协议版本到底改了什么?
协议 0 是 ASCII 的:整数、浮点数和字符串都写成以换行结尾的文本,所以协议 0 的 pickle 几乎肉眼可读。协议 1 为其中大部分加上了二进制写法。协议 2 加入了用 NEWOBJ 高效构造类,还有一字节元组和真正的布尔值。协议 3 加入了 Python 2 无法表示的 bytes 对象。协议 4 加入了分帧——把字节流切成带长度前缀的块,读取方可以整块取——同时加入 64 位长度、STACK_GLOBAL、原生集合支持和隐式 memo 键。协议 5 加入了带外缓冲区,大数组在字节流旁边走而不是走在里面,这就是 NEXT_BUFFER 后面没有数据的原因。
STOP 之后还有字节,这说明什么?
STOP 结束的是一个 pickle,后面的内容属于接下来的东西。把多个 pickle 写进同一个文件是很常见的做法——对着打开的文件循环调用 pickle.dump,再循环调用 pickle.load 读回来——所以剩余字节往往只是说明这个文件装的是一串记录而不是单个对象。它同时也是把第二段载荷藏在无害的第一段后面的手法,因为只调用一次 load 的程序只看得到第一个对象,从不去看后面。这个工具会反汇编第一个 pickle 并报告还剩多少字节;要检查其余部分,就在 STOP 给出的偏移处把文件切开,单独打开后半段。
能看到 numpy 数组或 pandas DataFrame 的内容吗?
看不到具体数值,因为这类对象是靠调用 numpy 和 pandas 才重建出来的:字节流里写的是numpy.core.multiarray._reconstruct 之类的名称或某个 pandas 类,把一个字节串交给它,数组要等那次调用跑完才存在。你能看到的是文件的轮廓:它会导入哪些名称、携带了哪些原始缓冲区以及它们的大小,通常这已经足以判断一个文件是不是它自称的东西。如果是协议 5 的文件,数组数据甚至可能根本不在文件里:NEXT_BUFFER 意味着载荷是带外传递的,必须由调用方另行提供。

相关工具