hunt-spa-api

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

When to use this skill

何时使用此 skill

Trigger when:
  • A target host returns a tiny HTML shell + big
    /static/js/*.js
    or
    /_next/static/*
    bundles (React/Vue/Angular/Next/Svelte SPA)
  • You see a subdomain named
    console
    ,
    app
    ,
    dashboard
    ,
    portal
    ,
    admin
    ,
    panel
    ,
    manage
    ,
    internal
  • Recon surfaces any
    *api*
    ,
    *-api*
    ,
    api.*
    host
  • 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/*.js
    或
    /_next/static/*
    bundles(React/Vue/Angular/Next/Svelte SPA)
  • 发现名为
    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.html
bash
curl -s https://console.target.com/ -o index.html

React/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 '/' '_')"
done
grep -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 '/' '_')"
done

2. Harvest API hosts, routes, and secrets from the bundles

2. 从 bundles 中提取 API hosts、routes 和 secrets

bash
B=bundles/*.js
bash
B=bundles/*.js

Backend 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 -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

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"} pairs
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
**注意:** 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"} pairs

reconstruct /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

undefined
undefined

3. 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
Authorization
header
and compare to the control:
bash
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
Interpret:
  • 401
    /
    "Missing authorization"
    → gated (correct). Move on.
  • 200
    with data
    → unauthenticated data exposure. Finding.
  • 400 "field X is mandatory"
    → the route processed your request and reached business-logic validation without an auth check → auth bypass; supply the field minimally to confirm.
  • 200
    + verbose DB/stack error
    (e.g.
    PROCEDURE db_x.sp_y does not exist
    ) → reached the data layer unauthenticated; also a SQLi-surface signal.
  • Mandatory fields named like
    is_admin
    /
    is_internal
    /
    requested_by
    /
    role_id
    /
    account_type
    → authorization derived from client-supplied parameters — set the privilege flag and you self-elevate. Critical-class.
对于每个发现的 route,在不发送
Authorization
header 的情况下发起请求,并与 control 比较:
bash
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"
    → 已受保护(正确行为)。继续下一个。
  • 带数据的
    200
    → 存在 unauthenticated data exposure。发现项。
  • 400 "field X is mandatory"
    → route 已处理请求并进入 business-logic validation,却没有执行 auth check → auth bypass;最小化补充该字段以确认。
  • 200
    + 详细的 DB/stack error
    (例如
    PROCEDURE db_x.sp_y does not exist
    )→ 已在 unauthenticated 状态下到达 data layer,同时也是 SQLi attack surface 信号。
  • 必填字段名称类似
    is_admin
    /
    is_internal
    /
    requested_by
    /
    role_id
    /
    account_type
    → authorization 由 client-supplied parameters 推导——设置 privilege flag 即可 self-elevate。属于 Critical 级别。

5. Pivot & prove (minimally)

5. 关联验证并最小化举证

  • IDs returned by one endpoint (
    account_id
    ,
    order_id
    ,
    deal_id
    ) are the keys the other endpoints consume — they prove the whole router is reachable, not just one route.
  • Test
    dev-
    /
    beta-
    /
    staging-
    API variants — they frequently have weaker/disabled auth.
  • Check the response headers:
    Access-Control-Allow-Origin: *
    compounds the issue (any web origin reads it from a victim's browser).
  • STOP at minimum-necessary proof. A handful of records (or a
    totalCount
    ) confirms the missing check. Do NOT enumerate the table — see
    redteam-mindset
    data-minimization boundary. The finding is the absent auth, not the data volume.

  • 一个 endpoint 返回的 IDs(
    account_id
    、
    order_id
    、
    deal_id
    )是其他 endpoints 使用的 keys——它们证明整个 router 都可访问,而不只是某一条 route。
  • 测试
    dev-
    /
    beta-
    /
    staging-
    API 变体——它们的 auth 经常更弱,甚至已被禁用。
  • 检查 response headers:
    Access-Control-Allow-Origin: *
    会进一步加剧问题(任意 Web origin 都能从受害者的浏览器中读取响应)。
  • 在完成最小必要举证后立即停止。 少量记录(或一个
    totalCount
    )即可确认缺失的 check。不要枚举整张表——请参阅
    redteam-mindset
    的 data-minimization 边界。发现项是 auth 缺失,而不是数据量。

What "the API behind the SSO login" really means

SSO 登录背后的 API 究竟意味着什么

A common, dangerous architecture:
  • console.target.com
    (the SPA) → login is Entra/Okta/Google OAuth (looks airtight).
  • api.target.com
    (the backend) → some route groups enforce the bearer token, some route groups forgot the middleware.
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.

一种常见且危险的架构:
  • console.target.com
    (SPA)→ login 使用 Entra/Okta/Google OAuth(看似密不透风)。
  • api.target.com
    (backend)→ 部分 route groups 会执行 bearer token,但部分 route groups 忘记了 middleware。
如果 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.
  • "
    /api/v1/foo
    returned 404, so no API here."
    Wrong base or wrong method. Try
    /api/
    ,
    /api/v2/
    , POST not GET, and the exact route strings from the bundle (Express's 404 echoes the path — use it to calibrate).
  • "AIza key found → critical secret." Validate first — most are Maps/analytics keys (
    CONFIGURATION_NOT_FOUND
    on identitytoolkit = not Auth-enabled). Don't over-claim.
  • 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
    /
    signup
    /
    upload
    to "demonstrate" — that's a destructive write needing explicit per-action authorization.

  • “app 需要登录,所以 API 必然受保护。” 不——应直接以 unauthenticated 状态测试 API。这正是此方法的核心。
  • “bundle 经过压缩,看不懂。” 无需阅读 bundle——直接 grep hosts/routes/secrets。只需 5 分钟。
  • “
    /api/v1/foo
    返回 404,所以这里没有 API。”
    base path 错了或 method 错了。尝试
    /api/
    、
    /api/v2/
    、POST 而非 GET,以及 bundle 中的准确 route strings(Express 的 404 会回显 path——可用它来校准)。
  • “发现了 AIza key,所以是 Critical secret。” 先验证——大多数是 Maps/analytics keys(identitytoolkit 返回
    CONFIGURATION_NOT_FOUND
    表示未启用 Auth)。不要夸大结论。
  • 拿到 200 后 dump 整个数据集。 止步于 PoC。(
    redteam-mindset
    。)
  • 将 account-creation / write endpoints 当作“证明”。 Read endpoints 无需创建 state 即可证明 auth gap。绝不要通过 POST
    create
    /
    signup
    /
    upload
    来“演示”——这是破坏性 write,需要针对每个动作获得明确授权。

Related Skills & Chains

相关 Skills 与 Chains

  • hunt-api-misconfig
    — once the API is mapped, run the broader misconfig matrix (method tampering, mass assignment, JWT alg confusion) per route.
  • hunt-idor
    — the
    account_id
    /
    order_id
    pivots feed straight into IDOR testing across discovered routes.
  • hunt-source-leak
    — sourcemaps (
    *.js.map
    ) reconstruct original source for deeper route/secret extraction; same harvesting muscle.
  • hunt-nextjs
    — for Next.js targets, layer the middleware-bypass (
    x-middleware-subrequest
    ) and
    /_next/data
    route tests on top of this.
  • redteam-mindset
    — the data-minimization boundary governs step 5: prove the missing check, don't exfiltrate the table.
  • recon-scope-triage
    — verify the API host actually belongs to the target before testing (don't pop a same-named third party's API).
  • hunt-api-misconfig
    ——API map 完成后,针对每条 route 运行更广泛的 misconfig 测试矩阵(method tampering、mass assignment、JWT alg confusion)。
  • hunt-idor
    ——
    account_id
    /
    order_id
    pivots 可直接用于对已发现 routes 进行 IDOR 测试。
  • hunt-source-leak
    ——通过 sourcemaps(
    *.js.map
    )重建原始 source,以更深入地提取 routes/secrets;使用的是相同的 harvest 能力。
  • hunt-nextjs
    ——对于 Next.js 目标,可在此基础上叠加 middleware-bypass(
    x-middleware-subrequest
    )与
    /_next/data
    route 测试。
  • redteam-mindset
    ——data-minimization 边界支配第 5 步:证明缺失的 check,不要 exfiltrate 整张表。
  • recon-scope-triage
    ——测试前确认 API host 确实属于目标(不要测试只是同名的第三方 API)。