事情起源于笔者 2019 年的一篇文章:
从零开始python反序列化攻击:pickle原理解析 & 不用reduce的RCE姿势
前言 不经意间就起了这么长一个题目。昨晚用了4h左右的时间看皮ckle源码(主要是_Unpickler部分),发掘出了几种新的漏洞利用方式,目前网上暂未见资料,因此我们决定写一篇文章分享一下。本文详细介绍了pyckle.l...
正在上传…重新上传取消?知乎专栏阮行止上海洛谷网络科技有限公司 讲师
文中提到,注入恶意对象引发 RCE 之后,常常由于返回的反序列化对象不符合业务代码的预期,造成程序 crash。解决方案是使用 POP 指令码(字节码是数字 )把恶意对象弹出,再压一个正常的对象进去,这样 返回的就是普通的对象,以达成无副作用 RCE 的目的。如下图所示:0
pickle.dumps
▲ 先构造恶意对象,再弹出,最后压入正常对象。绿色框内为恶意对象
然后今天收到私信,问 POP 这个指令码有什么用。
这个问题笔者当年确实没有想过。今天翻了一下源码,发现 POP 指令主要是为了防止无限递归构造对象的。下面我们根据 源码进行说明。pickler
0x00 Unpickler 对 POP 的处理方式
首先,我们知道 Unpickler 是一个图灵完备的虚拟机。它的指令编码方式如下:先是一个字节的 op code,然后紧跟操作数。至于操作数的表示方式,也是首先用一个字节表示类型,然后紧跟着操作数。这个虚拟机的语言大体上是一个 LL(1) 型文法,所以 Unpickler 仅通过简单地重复执行「读入一个字节的操作数 - 调用对应 handler」,就可以完成反序列化工作。
Unpickler 每次读入操作数,就查表找到 handler 并调用,handler 会吃掉一些字符,构造一个对象或进行其他操作。具体的 pickle 虚拟机操作码、Unpickler 工作方式可以去我的知乎文章查看。
POP 操作码对应的 op code 字节是 ,注释是 。0
discard topmost stack item
乍一看,这个 POP 指令确实没啥用处。如果我先将一些对象压入栈,再弹出去,那我何不当初就不把这些对象压栈呢?抱着这样的疑问,笔者重新阅读了 Pickler 的源码,看看什么情况下会产生 POP 这个指令。
0x02 Pickler 何时会产生 POP 指令
查找产生 POP 指令的代码,一共有三处,分别是 、 、 方法。save_reduce
save_tuple
save_frozenset
由于 是用于储存 方法,比较特殊,我们先看平凡的 方法在何时产生 POP 指令:reduce
__reduce__
save_tuple
<span style="color:#333333"><span style="color:#444444"><span style="background-color:#f6f6f6"><span style="color:#333333"><strong>def</strong></span> <span style="color:#880000"><strong>save_tuple</strong></span>(<span style="color:#333333"><strong>self</strong></span>, obj):
<span style="color:#333333"><strong>if</strong></span> <span style="color:#333333"><strong>not</strong></span> <span style="color:#bc6060">obj:</span> <span style="color:#888888"># tuple is empty</span>
<span style="color:#333333"><strong>if</strong></span> <span style="color:#333333"><strong>self</strong></span>.<span style="color:#bc6060">bin:</span>
<span style="color:#333333"><strong>self</strong></span>.write(EMPTY_TUPLE)
<span style="color:#bc6060">else:</span>
<span style="color:#333333"><strong>self</strong></span>.write(MARK + TUPLE)
<span style="color:#333333"><strong>return</strong></span>
n = len(obj)
save = <span style="color:#333333"><strong>self</strong></span>.save
memo = <span style="color:#333333"><strong>self</strong></span>.memo
<span style="color:#333333"><strong>if</strong></span> n <= <span style="color:#880000">3</span> <span style="color:#333333"><strong>and</strong></span> <span style="color:#333333"><strong>self</strong></span>.proto >= <span style="color:#880000">2</span>:
<span style="color:#333333"><strong>for</strong></span> element <span style="color:#333333"><strong>in</strong></span> <span style="color:#bc6060">obj:</span>
save(element)
<span style="color:#888888"># Subtle. Same as in the big comment below.</span>
<span style="color:#333333"><strong>if</strong></span> id(obj) <span style="color:#333333"><strong>in</strong></span> <span style="color:#bc6060">memo:</span>
get = <span style="color:#333333"><strong>self</strong></span>.get(memo[id(obj)][<span style="color:#880000">0</span>])
<span style="color:#333333"><strong>self</strong></span>.write(POP * n + get)
<span style="color:#bc6060">else:</span>
<span style="color:#333333"><strong>self</strong></span>.write(_tuplesize2code[n])
<span style="color:#333333"><strong>self</strong></span>.memoize(obj)
<span style="color:#333333"><strong>return</strong></span>
<span style="color:#888888"># proto 0 or proto 1 and tuple isn't empty, or proto > 1 and tuple</span>
<span style="color:#888888"># has more than 3 elements.</span>
write = <span style="color:#333333"><strong>self</strong></span>.write
write(MARK)
<span style="color:#333333"><strong>for</strong></span> element <span style="color:#333333"><strong>in</strong></span> <span style="color:#bc6060">obj:</span>
save(element)
<span style="color:#333333"><strong>if</strong></span> id(obj) <span style="color:#333333"><strong>in</strong></span> <span style="color:#bc6060">memo:</span>
<span style="color:#888888"># Subtle. d was not in memo when we entered save_tuple(), so</span>
<span style="color:#888888"># the process of saving the tuple's elements must have saved</span>
<span style="color:#888888"># the tuple itself: the tuple is recursive. The proper action</span>
<span style="color:#888888"># now is to throw away everything we put on the stack, and</span>
<span style="color:#888888"># simply GET the tuple (it's already constructed). This check</span>
<span style="color:#888888"># could have been done in the "for element" loop instead, but</span>
<span style="color:#888888"># recursive tuples are a rare thing.</span>
get = <span style="color:#333333"><strong>self</strong></span>.get(memo[id(obj)][<span style="color:#880000">0</span>])
<span style="color:#333333"><strong>if</strong></span> <span style="color:#333333"><strong>self</strong></span>.<span style="color:#bc6060">bin:</span>
write(POP_MARK + get)
<span style="color:#bc6060">else:</span> <span style="color:#888888"># proto 0 -- POP_MARK not available</span>
write(POP * (n+<span style="color:#880000">1</span>) + get)
<span style="color:#333333"><strong>return</strong></span>
<span style="color:#888888"># No recursion.</span>
write(TUPLE)
<span style="color:#333333"><strong>self</strong></span>.memoize(obj)</span></span></span>
我们看这一段长注释:
这里有一个细节。当调用 时, 肯定不在 中。save_tuple
id(obj)
memo
(笔者注:如果 在 中,那么 Pickler 会选择生成GET
指令码,让 Unpickler 直接从memo
取出并压栈,而不是选择调用 生成构造 tuple 的指令序列,让 Unpickler 重新构建对象)id(obj)
memo
save_tuple(self, obj)
从而,可以推断出 是在本函数执行的过程中,已经被构造出来,并放进了 的。而现在我们又要构造一次 ,显然是产生了递归。obj
memo
obj
所以,现在应该把当前栈里的东西弹空,并要求 Unpickler 直接引用 中的实例,作为这个元组 反序列化的结果。memo
注释写得非常清楚明了,笔者也刷新了自己对 memo 的认识:?memo 不仅可以帮助 Unpickler 复用对象,还可以用于防无限递归!当检测到对象递归时,Pickler 会通过 POP 放弃自己在栈中生成的中间对象,并提示 Unpickler 采用 memo 中已经构造好的实例。
那么我们很容易构造一个对象,使得 Pickler 产出 POP 指令:
逐行解释一下指令序列。
- 泡菜协议是版本3
- 压入一个整数
1
- 压入 ,注意这是 class 而非 instance,接下来要实例化这个类
__main__.Pointer
- 将栈顶元素存进 (这是一个无用的 PUT)
memo[0]
- 压入一个空的元组
- 以栈顶那个空的 tuple 作为参数,实例化栈内第二个元素(即 类)。完成后,栈里面只剩下一个初始的 对象
Pointer
Pointer
- 把栈顶元素(即这个 对象)存进
Pointer
memo[1]
- 压入一个空的 dict
- 将栈顶元素存进 (这是一个无用的 PUT)
memo[2]
0x03 为什么 Pickler 不优化指令序列
一般情况下,Pickler 会将每个中间对象都存进 memo,所以指令序列中往往存在大量的 PUT,但这大部分的 PUT 都不会被 GET 引用,可以安全地删去( 可以完成这个任务)。另外,上文讨论过,如果产生了 POP 指令,说明肯定有一些压栈步骤是无效的,也应该可以抵消。那么,Pickler 为什么不做这些优化呢?pickletools.optimize()
下面我们分别讨论「删去无用 PUT」和「抵消 POP」这两种优化是否能实现。
在设计上,Pickler 被考虑需要用于序列化非常大的对象(实践上也确实如此,PyTorch、numpy 的导入导出便是采用了 pickle 来序列化动辄几个 GB 的对象)。Pickler 是将生成的指令序列写到一个 buffered file 里面( 是采用 这个 file-like 对象),且边构造边写入。pickle.dumps
io.BytesIO
Pickler 对 file 的使用非常克制,一共只使用到了 这一个方法,连输出缓冲区 都是通过自己实现 来完成。笔者认为,这证明 Pickler 的设计者希望各种各样「可写入字节流的对象」都可以用于用于write
_Framer
在这个基础上,能不能实现「删去无用的 PUT 指令」呢?
其次是性能问题。发现「递归的存在性」是简单的,但是要生成等效的简化指令序列,并不是一个轻松的工作。