hunt-spa-api
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWhen to use this skill
何时使用此 skill
Trigger when:
- A target host returns a tiny HTML shell + big or
/static/js/*.jsbundles (React/Vue/Angular/Next/Svelte SPA)/_next/static/* - You see a subdomain named ,
console,app,dashboard,portal,admin,panel,manageinternal - Recon surfaces any ,
*api*,*-api*hostapi.* - A login page is OAuth/SSO-gated (the frontend auth tells you nothing about whether the API enforces auth)
The core insight: a SPA is a client to a backend API, and it ships the full map of that API — hosts, routes, sometimes keys — to anyone who views source. The login page being protected says nothing about whether the API behind it checks tokens. Auth is frequently enforced on the gateway/login and missing on a route group of the API.
DO NOT skip this because "the app needs login" — that's exactly when this pays off.
触发条件:
- 目标主机返回很小的 HTML shell 和体积较大的 或
/static/js/*.jsbundles(React/Vue/Angular/Next/Svelte SPA)/_next/static/* - 发现名为 、
console、app、dashboard、portal、admin、panel、manage的子域名internal - Recon 中出现任何 、
*api*、*-api*主机api.* - 登录页受 OAuth/SSO 保护(frontend 中的 auth 完全无法说明 API 是否执行 auth)
核心洞察:SPA 是 backend API 的 client,并会把该 API 的完整映射——hosts、routes,有时还有 keys——交付给任何查看 source 的人。 登录页受到保护,并不能说明其背后的 API 是否校验 tokens。Auth 经常仅在 gateway/login 上执行,而 API 的某个 route group 却缺失了它。
不要因为“app 需要登录”而跳过——这恰恰是此方法回报最高的时候。
The play (5 steps)
方法(5 个步骤)
1. Pull the shell + enumerate the bundles
1. 获取 shell 并枚举 bundle
bash
curl -s https://console.target.com/ -o index.htmlbash
curl -s https://console.target.com/ -o index.htmlReact/CRA:
React/CRA:
grep -oE '/static/js/[^"]+.js' index.html
grep -oE '/static/js/[^"]+.js' index.html
Next.js:
Next.js:
grep -oE '/_next/static/[^"]+.js' index.html
grep -oE '/_next/static/[^"]+.js' index.html
generic:
generic:
grep -oiE 'src="[^"]+.js[^"]*"' index.html
Download every bundle (they can be multi-MB — that's fine, it's all route data):
```bash
mkdir bundles
for j in $(grep -oE '/static/js/[^"]+\.js' index.html | sort -u); do
curl -s "https://console.target.com$j" -o "bundles/$(echo "$j"|tr '/' '_')"
donegrep -oiE 'src="[^"]+.js[^"]*"' index.html
下载每个 bundle(可能达到数 MB——没关系,这些全是 route 数据):
```bash
mkdir bundles
for j in $(grep -oE '/static/js/[^"]+\.js' index.html | sort -u); do
curl -s "https://console.target.com$j" -o "bundles/$(echo "$j"|tr '/' '_')"
done2. Harvest API hosts, routes, and secrets from the bundles
2. 从 bundles 中提取 API hosts、routes 和 secrets
bash
B=bundles/*.jsbash
B=bundles/*.jsBackend API hosts (incl. dev/beta/staging variants — often weaker auth)
Backend API hosts (incl. dev/beta/staging variants — often weaker auth)
grep -ohiE 'https://[a-z0-9.-](api|console|backend|service)[a-z0-9.-].target.com[a-z0-9/_-]*' $B | sort -u
grep -ohiE 'https://[a-z0-9.-](api|console|backend|service)[a-z0-9.-].target.com[a-z0-9/_-]*' $B | sort -u
Versioned API base paths
Versioned API base paths
grep -ohiE '/api/v[0-9]+/?' $B | sort -u
grep -ohiE '/api/v[0-9]+/?' $B | sort -u
Route literals — minified bundles store routes as STRING segments, not full URLs.
Route literals — minified bundles store routes as STRING segments, not full URLs.
Grep for quoted "resource/action" strings:
Grep for quoted "resource/action" strings:
grep -ohiE '"[a-z0-9_-]+/[a-z0-9_/-]+"' $B | tr -d '"'
| grep -iE '(login|user|account|order|billing|invoice|payment|deal|report|token|otp|password|reset|admin|profile|auth|upload|export|role|permission|dashboard|wallet|finance|sales)' | sort -u
| grep -iE '(login|user|account|order|billing|invoice|payment|deal|report|token|otp|password|reset|admin|profile|auth|upload|export|role|permission|dashboard|wallet|finance|sales)' | sort -u
grep -ohiE '"[a-z0-9_-]+/[a-z0-9_/-]+"' $B | tr -d '"'
| grep -iE '(login|user|account|order|billing|invoice|payment|deal|report|token|otp|password|reset|admin|profile|auth|upload|export|role|permission|dashboard|wallet|finance|sales)' | sort -u
| grep -iE '(login|user|account|order|billing|invoice|payment|deal|report|token|otp|password|reset|admin|profile|auth|upload|export|role|permission|dashboard|wallet|finance|sales)' | sort -u
Secrets (validate before trusting — most AIza keys are Maps/analytics, not Auth)
Secrets (validate before trusting — most AIza keys are Maps/analytics, not Auth)
grep -ohiE '(AIza[0-9A-Za-z_-]{35}|AKIA[0-9A-Z]{16}|sk_live_[0-9A-Za-z]+|eyJ[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}|apiKey["'"'"']?\s*[:=]\s*["'"'"'][^"'"'"']+)' $B | sort -u
**Note:** minifiers store routes as concatenated string segments (e.g. `"account/payment/list"`), NOT full `/api/v2/...` URLs — so a naive `/api/v*` grep returns nothing. Grep for the **resource-word route strings** and prepend the base yourself.
**Lazy-loaded (async) chunks — don't stop at HTML-referenced bundles.** CRA/webpack SPAs reference only runtime+main+vendor in index.html; numbered async route chunks are named inside main.js's chunk map and loaded at runtime, so grepping only `/static/js/*.js` from the shell truncates route coverage to eager chunks.
```bash
grep -oE '[0-9]+:"[a-f0-9]+"' bundles/*main*.js # {chunkId:"hash"} pairsgrep -ohiE '(AIza[0-9A-Za-z_-]{35}|AKIA[0-9A-Z]{16}|sk_live_[0-9A-Za-z]+|eyJ[A-Za-z0-9_-]{10,}.[A-Za-z0-9_-]{10,}|apiKey["'"']?\s*[:=]\s*["'"'][^"'"']+)' $B | sort -u
**注意:** minifier 会将 routes 存储为拼接后的 string segments(例如 `"account/payment/list"`),而不是完整的 `/api/v2/...` URLs——因此,直接 grep `/api/v*` 可能什么都找不到。应搜索**按资源词筛选的 route strings**,然后自行补上 base path。
**懒加载(async)chunks——不要在 HTML 引用的 bundles 处停下。** CRA/webpack SPA 的 index.html 通常只引用 runtime、main 和 vendor;带编号的 async route chunks 记录在 main.js 的 chunk map 中,并在运行时加载。因此,只 grep shell 中的 `/static/js/*.js` 会将 route 覆盖范围截断为 eager chunks。
```bash
grep -oE '[0-9]+:"[a-f0-9]+"' bundles/*main*.js # {chunkId:"hash"} pairsreconstruct /static/js/<id>.<hash>.chunk.js, download each, re-run route/host/secret harvest
reconstruct /static/js/<id>.<hash>.chunk.js, download each, re-run route/host/secret harvest
asset-manifest.json (if present) lists them all -> hunt-source-leak
asset-manifest.json (if present) lists them all -> hunt-source-leak
undefinedundefined3. Establish a CONTROL — find an endpoint that IS gated
3. 建立一个 CONTROL——找到一个确实受保护的 endpoint
Before declaring anything vulnerable, send an unauthenticated request to an endpoint you expect to be protected, and capture what correct rejection looks like:
bash
curl -s -X POST https://api.target.com/api/users -H 'Content-Type: application/json' -d '{}'在宣告任何漏洞之前,向预期应受保护的 endpoint 发送 unauthenticated request,并观察正确拒绝响应是什么:
bash
curl -s -X POST https://api.target.com/api/users -H 'Content-Type: application/json' -d '{}'secure → {"error":"Missing or invalid authorization header"} or HTTP 401
secure → {"error":"Missing or invalid authorization header"} or HTTP 401
This is your differential. A sibling API (e.g. a second API host, or a different route group on the same host) is the ideal control — same stack, so a different response = real authz gap, not a quirk.这就是差分基线。一个 sibling API(例如第二个 API host,或同一 host 上不同的 route group)是理想的 control——它们使用相同的 stack,因此响应不同意味着真实的 authz gap,而不是偶然差异。4. Test each route family UNAUTHENTICATED, both methods
4. 对每个 route family 进行 UNAUTHENTICATED 测试,并同时测试两种 method
For every discovered route, send it with no header and compare to the control:
Authorizationbash
for r in <routes>; do
curl -s -o /tmp/r -w "[%{http_code}] $r\n" -X POST -H 'Content-Type: application/json' -d '{}' "https://api.target.com/api/v2/$r"
doneInterpret:
- /
401→ gated (correct). Move on."Missing authorization" - with data → unauthenticated data exposure. Finding.
200 - → the route processed your request and reached business-logic validation without an auth check → auth bypass; supply the field minimally to confirm.
400 "field X is mandatory" - + verbose DB/stack error (e.g.
200) → reached the data layer unauthenticated; also a SQLi-surface signal.PROCEDURE db_x.sp_y does not exist - Mandatory fields named like /
is_admin/is_internal/requested_by/role_id→ authorization derived from client-supplied parameters — set the privilege flag and you self-elevate. Critical-class.account_type
对于每个发现的 route,在不发送 header 的情况下发起请求,并与 control 比较:
Authorizationbash
for r in <routes>; do
curl -s -o /tmp/r -w "[%{http_code}] $r\n" -X POST -H 'Content-Type: application/json' -d '{}' "https://api.target.com/api/v2/$r"
done结果判读:
- /
401→ 已受保护(正确行为)。继续下一个。"Missing authorization" - 带数据的 → 存在 unauthenticated data exposure。发现项。
200 - → route 已处理请求并进入 business-logic validation,却没有执行 auth check → auth bypass;最小化补充该字段以确认。
400 "field X is mandatory" - + 详细的 DB/stack error(例如
200)→ 已在 unauthenticated 状态下到达 data layer,同时也是 SQLi attack surface 信号。PROCEDURE db_x.sp_y does not exist - 必填字段名称类似 /
is_admin/is_internal/requested_by/role_id→ authorization 由 client-supplied parameters 推导——设置 privilege flag 即可 self-elevate。属于 Critical 级别。account_type
5. Pivot & prove (minimally)
5. 关联验证并最小化举证
- IDs returned by one endpoint (,
account_id,order_id) are the keys the other endpoints consume — they prove the whole router is reachable, not just one route.deal_id - Test /
dev-/beta-API variants — they frequently have weaker/disabled auth.staging- - Check the response headers: compounds the issue (any web origin reads it from a victim's browser).
Access-Control-Allow-Origin: * - STOP at minimum-necessary proof. A handful of records (or a ) confirms the missing check. Do NOT enumerate the table — see
totalCountdata-minimization boundary. The finding is the absent auth, not the data volume.redteam-mindset
- 一个 endpoint 返回的 IDs(、
account_id、order_id)是其他 endpoints 使用的 keys——它们证明整个 router 都可访问,而不只是某一条 route。deal_id - 测试 /
dev-/beta-API 变体——它们的 auth 经常更弱,甚至已被禁用。staging- - 检查 response headers:会进一步加剧问题(任意 Web origin 都能从受害者的浏览器中读取响应)。
Access-Control-Allow-Origin: * - 在完成最小必要举证后立即停止。 少量记录(或一个 )即可确认缺失的 check。不要枚举整张表——请参阅
totalCount的 data-minimization 边界。发现项是 auth 缺失,而不是数据量。redteam-mindset
What "the API behind the SSO login" really means
SSO 登录背后的 API 究竟意味着什么
A common, dangerous architecture:
- (the SPA) → login is Entra/Okta/Google OAuth (looks airtight).
console.target.com - (the backend) → some route groups enforce the bearer token, some route groups forgot the middleware.
api.target.com
The frontend login is theatre if the API doesn't independently validate the token on every route. Always test the API directly, bare, regardless of how locked-down the login UI is.
一种常见且危险的架构:
- (SPA)→ login 使用 Entra/Okta/Google OAuth(看似密不透风)。
console.target.com - (backend)→ 部分 route groups 会执行 bearer token,但部分 route groups 忘记了 middleware。
api.target.com
如果 API 不会在每条 route 上独立验证 token,那么 frontend login 就只是一层障眼法。无论登录 UI 保护得多严密,始终都要不带凭据直接测试 API。
Anti-patterns
反模式
- "The app requires login, so the API must be protected." No — test the API directly, unauthenticated. The whole point.
- "Minified bundle, can't read it." You don't need to read it — grep it for hosts/routes/secrets. 5 minutes.
- "returned 404, so no API here." Wrong base or wrong method. Try
/api/v1/foo,/api/, POST not GET, and the exact route strings from the bundle (Express's 404 echoes the path — use it to calibrate)./api/v2/ - "AIza key found → critical secret." Validate first — most are Maps/analytics keys (on identitytoolkit = not Auth-enabled). Don't over-claim.
CONFIGURATION_NOT_FOUND - Dumping the whole dataset once you get a 200. Stop at PoC. (.)
redteam-mindset - Account-creation / write endpoints as "proof". Read endpoints prove the auth gap without creating state. Never POST a /
create/signupto "demonstrate" — that's a destructive write needing explicit per-action authorization.upload
- “app 需要登录,所以 API 必然受保护。” 不——应直接以 unauthenticated 状态测试 API。这正是此方法的核心。
- “bundle 经过压缩,看不懂。” 无需阅读 bundle——直接 grep hosts/routes/secrets。只需 5 分钟。
- “返回 404,所以这里没有 API。” base path 错了或 method 错了。尝试
/api/v1/foo、/api/、POST 而非 GET,以及 bundle 中的准确 route strings(Express 的 404 会回显 path——可用它来校准)。/api/v2/ - “发现了 AIza key,所以是 Critical secret。” 先验证——大多数是 Maps/analytics keys(identitytoolkit 返回 表示未启用 Auth)。不要夸大结论。
CONFIGURATION_NOT_FOUND - 拿到 200 后 dump 整个数据集。 止步于 PoC。(。)
redteam-mindset - 将 account-creation / write endpoints 当作“证明”。 Read endpoints 无需创建 state 即可证明 auth gap。绝不要通过 POST /
create/signup来“演示”——这是破坏性 write,需要针对每个动作获得明确授权。upload
Related Skills & Chains
相关 Skills 与 Chains
- — once the API is mapped, run the broader misconfig matrix (method tampering, mass assignment, JWT alg confusion) per route.
hunt-api-misconfig - — the
hunt-idor/account_idpivots feed straight into IDOR testing across discovered routes.order_id - — sourcemaps (
hunt-source-leak) reconstruct original source for deeper route/secret extraction; same harvesting muscle.*.js.map - — for Next.js targets, layer the middleware-bypass (
hunt-nextjs) andx-middleware-subrequestroute tests on top of this./_next/data - — the data-minimization boundary governs step 5: prove the missing check, don't exfiltrate the table.
redteam-mindset - — verify the API host actually belongs to the target before testing (don't pop a same-named third party's API).
recon-scope-triage
- ——API map 完成后,针对每条 route 运行更广泛的 misconfig 测试矩阵(method tampering、mass assignment、JWT alg confusion)。
hunt-api-misconfig - ——
hunt-idor/account_idpivots 可直接用于对已发现 routes 进行 IDOR 测试。order_id - ——通过 sourcemaps(
hunt-source-leak)重建原始 source,以更深入地提取 routes/secrets;使用的是相同的 harvest 能力。*.js.map - ——对于 Next.js 目标,可在此基础上叠加 middleware-bypass(
hunt-nextjs)与x-middleware-subrequestroute 测试。/_next/data - ——data-minimization 边界支配第 5 步:证明缺失的 check,不要 exfiltrate 整张表。
redteam-mindset - ——测试前确认 API host 确实属于目标(不要测试只是同名的第三方 API)。
recon-scope-triage