Files
ARTEX/skills/api-recon/reference.md
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

21 KiB
Raw Permalink Blame History

api-recon — reference manual

Grep recipes, config.json templates, and troubleshooting. All greps run against the js/ directory. For single-line bundles, run js-beautify or sed 's/}/}\n/g' first; a raw grep with a context window is usually enough.

Script notes

Every file in scripts/ is a reference template and must be adapted to the target site before running. Typical changes:

Script Common adjustments
harvest_static.py endpoint regex, webpack/Vite manifest parsing, micro-frontend publicPath, retries/concurrency
runtime_harvest.js neutralize field names and success values, stub matching rules and body structure, routes source, WS recording, waitUntil/routeTimeout/proxy
preload.js loginPathRe, L1 stubs, neutralize.fields, apiPattern, whether to enable L3, recordDetail, observe.*, neutralizeVueRouter
spider_mpa.py --exclude destructive links, cookie, depth/max, same-domain filtering
extract_route_map.py routeMap / routeLink regex, KEY naming pattern
build_perm_tree.py userRouteAuth parsing, ROOTS/PREFIX_PARENT hierarchy heuristics, stub outer field names
config.json single entry point for all the site-specific parameters above

Put adjusted files in the task working directory (e.g. recon/) and note the concrete changes relative to the reference scripts in the report.


A. Reverse-engineering the three gates

A1. Render gate — "how does the app decide you are logged in?"

grep -rhoaE '.{0,40}(isLogin|isAuthenticated|loggedIn|hasLogin|requireAuth)\b.{0,80}' js | head
grep -rhoaE 'function (getUser|getToken|getAuth)[0-9]?\([^)]*\)\{.{0,200}' js | head
grep -rhoaE '(localStorage|sessionStorage)\.getItem\("[^"]+"\)' js | sort -u
grep -rhoaE '(Cookies?|cookie)\.(get|load)\("[^"]+"\)' js | sort -u
grep -rhoaE '\batob\(|JSON\.parse\(|jwt|decode' js | head

Find the chain isLogin = f(getUser()) → getUser = decode(storage.read(KEY)), then pin down the storage key, container (Cookie vs localStorage), and encoding:

Encoding How to forge it in config
Plaintext string / "1" / token "value": "anything-truthy"
JSON.parse(x) "value": "json:{\"id\":1,\"username\":\"admin\"}"
JSON.parse(atob(x)) "value": "b64json:{\"id\":1,\"username\":\"admin\"}"
JWT An unsigned/alg:none JWT, or one signed with the key found in the bundle
Encrypted (SM2/AES/RSA) Find the hardcoded key; forgeable when the render gate only needs a decodable blob; otherwise fall back to static

→ write into cookies / localStorage.

A2. Interceptor gate — "what triggers a redirect to /login?"

grep -rhoaE '.{0,60}(interceptors\.response|axios|request\.use).{0,120}' js | head
grep -rhoaE '.{0,40}(response_code|errcode|errno|\bcode\b|\bret\b|\bstatus\b)\s*[=!]==?\s*[\-0-9]{1,4}.{0,60}' js | head -20
grep -rhoaE '.{0,40}(not logged in|please log in again|login has expired|unauthorized|login expired|authorized|token.{0,10}invalid).{0,40}' js | head
grep -rhoaE '.{0,30}(location\.href|router\.(push|replace)|navigate)\([^)]*login[^)]*\)' js | head

Pin down the field name, the success value (usually 0 or 200), and the failure value that triggers the redirect. Verify with a junk session:

curl -sk -X POST -H 'Cookie: <fakekey>=junk' https://target/api/<protected> -d '{}' -H 'Content-Type: application/json'

→ write into neutralize.fields + neutralize.success.

A3. Content gate — "where do menus/permissions come from?"

grep -rhoaE '"/api[^"]*(permission|perm|role|menu|acl|resource|nav)[^"]*"' js | sort -u
grep -rhoaE '.{0,30}(menus|permissions|menuList|routeList|authList|role_permissions)\b.{0,120}' js | head
grep -rhoaE 'userRouteAuth|getResultTree|routeMap|routeLink|hasPermission|checkAuth' js | head
grep -rhoaE '([A-Z_][A-Z0-9_]*):\{name:"[^"]*",link:"/[^"]+"\}' js | head

Two layers of data (typical enterprise backends):

API Typical payload Consumer
.../role_permissions { permissions: string[], role_type } Route guards, button-level ACL
.../permissions/all tree[{ code, position, children }] Sidebar menu rendering
userRouteAuth in the bundle { CODE: { url, name? } } code → frontend path
routeMap in the bundle { KEY: { name, link } } Alias resolution (webpack o.DASHBOARD)

Read the consumer code to confirm how getResultTree(tree, permissions) filters and which field v-if / hasAuth(code) checks.

Manual forge (small sites): build a permissive payload → stubs.

Full permission-tree restoration (large sites where the sidebar/submodules stay blank): see section I.


B. config.json template

{
  "baseUrl": "https://target/",
  "runtimeMode": "both",
  "chromium": "/usr/bin/chromium",

  "cookies": [
    { "name": "auth", "value": "b64json:{\"id\":1,\"username\":\"admin\",\"role\":\"admin\",\"func\":{},\"permissions\":[\"*\"]}" }
  ],
  "localStorage": { "token": "faketoken", "isLogin": "1" },

  "neutralize": {
    "fields": ["response_code", "code", "errno", "ret", "status"],
    "success": 0,
    "flags": { "success": true, "message": "ok" }
  },
  "forward": true,
  "loginUrlPattern": "/login",
  "apiPattern": "/api/|/rest/|/graphql",

  "mockTier": "L1+L2",
  "recordDetail": true,
  "observe": {
    "storageReads": false,
    "cookieReads": false,
    "xhrHeaders": true
  },
  "neutralizeVueRouter": true,
  "stubs": [
    {
      "match": "permissions/all|/menu|role_permissions",
      "body": {
        "response_code": 0, "code": 0,
        "data": {
          "permissions": ["*"],
          "menus": [
            { "name": "dashboard", "path": "/dashboard", "show": true, "children": [] },
            { "name": "alert", "path": "/alert", "show": true, "children": [] }
          ]
        }
      }
    }
  ],

  "explore": {
    "clickTabs": true,
    "clickTables": true,
    "pushStateFallback": true,
    "maxMenuItems": 50
  },

  "routes": ["/dashboard", "/alert", "/asset", "/device", "/report", "/config", "/system"],
  "waitMs": 1500, "perRouteMs": 900, "headless": true,
  "waitUntil": "domcontentloaded",
  "routeTimeout": 12000,
  "proxy": "",

  "captureResponses": true, "recordWs": true, "respMax": 600
}

Field notes:

  • runtimeMode: depth (Puppeteer), coverage (browser MCP), both
  • cookies[].value prefixes: b64json: → base64(JSON); json: → raw JSON; no prefix → literal
  • forward: true forwards real requests and rewrites the code fields; false means fully offline stubs
  • mockTier: which tiers the preload enables in coverage mode, e.g. L1+L2, L1+L2+L3
  • routes comes from routes.txt; after forging menus the harness appends <a href> automatically
  • captureResponses / recordWs only take effect in depth mode
  • waitUntil: use domcontentloaded for large SPAs to avoid hanging on networkidle2
  • routeTimeout: per-route page.goto timeout (milliseconds)
  • proxy: Puppeteer --proxy-server; you can also set HTTP_PROXY / HTTPS_PROXY

B1. Dual stub template (role_permissions + permissions/all)

"stubs": [
  {
    "match": "role_permissions",
    "body": {
      "response_code": 0,
      "data": {
        "permissions": ["MONITOR", "MONITOR_ALERT", "THREAT", "ASSETS_RISK"],
        "role_type": "SUPER_ADMIN"
      }
    }
  },
  {
    "match": "permissions/all",
    "body": {
      "response_code": 0,
      "data": [
        {
          "code": "MONITOR",
          "position": 1,
          "children": [
            { "code": "MONITOR_ALERT", "position": 1, "children": [] }
          ]
        }
      ]
    }
  }
]

The outer field names (response_code / code / data) must match the A2 interceptor gate; permissions must cover every leaf code in the tree.


C. coverage mode: preload configuration

Edit the CONFIG object at the top of scripts/preload.js, or replace it before CDP injection:

const CONFIG = {
  loginPathRe: /\/(login|signin)(\/|$|\?)/i,
  mockTier: 'L1+L2',
  forward: true,
  recordDetail: true,
  extractUrlsFromResponse: true,
  neutralizeVueRouter: true,
  observe: { storageReads: false, cookieReads: false, xhrHeaders: true },
  neutralize: { fields: ['response_code', 'code'], success: 0 },
  stubs: [ /* same as the config.json stubs */ ],
  apiPattern: /\/(api|apis|v\d+|dev|internal|graphql)\//i,
};

Verify: window.__API_RECON_PRELOAD__ === true and the pathname is stable.

Export the recorded results:

JSON.stringify({
  apis: [...window.__API_RECON_LOG__],
  detail: window.__API_RECON_DETAIL__,
  routes: [...(window.__API_RECON_ROUTES__ || [])],
  observe: window.__API_RECON_OBSERVE__,
}, null, 2)

D. preload / runtime hook capabilities

Browser hook capabilities built into preload (coverage) and runtime_harvest (depth), and their coverage:

Hook capability Value for API discovery Coverage
Hook fetch / XHR.open Record request URL/method ✅ recordDetail + __API_RECON_LOG__
Hook XHR.setRequestHeader Discover headers such as Authorization ✅ observe.xhrHeaders
Hook localStorage/cookie reads Confirm session key names ⚠️ optional observe.storageReads/cookieReads
Vue route capture Fill in frontendRoutes ✅ __API_RECON_ROUTES__ (already-loaded routes)
Vue route-guard neutralization / login-redirect blocking Pry modules open to trigger APIs ✅ neutralizeVueRouter + native redirect neutralization
React route capture Fill in routes ⚠️ static + clicks; no dedicated hook
Navigation blocking (login path) Stay on page for analysis ⚠️ blocks only the login path so business navigation is unaffected
Hook crypto libraries (CryptoJS/SM etc.) Encrypted parameters → plaintext API body ❌ requires manually hooking the crypto function's inputs; write conclusions to config
Anti-debugging bypass Otherwise runtime records no APIs ❌ must be handled manually; static still works

E. Endpoint extraction regexes (when static yields too little)

Loosen extract_endpoints in harvest_static.py, or do it by hand:

grep -rhoaE '"/[a-z][A-Za-z0-9_/\-]{3,}"' js | sort -u
grep -rhoaE '/api/[a-zA-Z0-9_./-]+' js | sort -u

F. Troubleshooting

Symptom Cause → fix
Few static APIs Endpoint dialect mismatch → loosen the regex (section D)
Chunk count ≪ manifest CSS-only or undeployed chunks; 404s already retried
runtime still shows the login page Render gate is wrong → recheck A1: key name, container, encoding, domain
Inside the shell but modules blank Content gate → forge menus (A3); the routes paths may be wrong
Only bootstrap/locale per route Incomplete permission codes → section I permission-tree restoration; check the role_permissions + permissions/all dual stub
Sidebar has items but subpages blank The tree misses intermediate nodes, or codes disagree with userRouteAuth
Every API redirects to login Interceptor gate → confirm neutralize; nested fields need extended walk logic
Zero WS frames Subscribes only after user interaction; lengthen perRouteMs
Empty response bodies Real responses exist only with forward: true
Chromium missing Install chromium or set config.chromium / CHROMIUM
Many mocks but still bounces to login Hooks too late or missing the location.href setter → document-start + preload
Lists all empty L3 empty arrays are normal; keep clicking tabs/settings/details
Mistaking Redux actions for routes Filter internal paths containing get/set/change/clear/toggle/upload
Vue still redirects to login preload is not document-start → change the injection timing; or clear guards manually when neutralizeVueRouter: false
URLs in responses but not in the log Enable extractUrlsFromResponse; or extract manually from __API_RECON_DETAIL__
Unknown Authorization header name Enable observe.xhrHeaders or inspect request headers in DevTools
runtime extremely slow / times out Switch to waitUntil: domcontentloaded; lower routeTimeout; do not use networkidle2
Proxy connection failed Check proxy / env vars; Puppeteer and curl must use the same proxy port

G. Hardened targets

When the server validates sessions step by step (signed cookies that cannot be forged, server-rendered menus that cannot be stubbed), runtime gets stuck at the shell. Expected behavior:

  • Static is enough for endpoint enumeration — module paths are in the code
  • If authorization allows, run the same harness with a real session: forward: true, no neutralize needed, capturing real methods/params/responses

H. Single-task checklist

  1. Confirm the authorized scope
  2. Read scripts/harvest_static.py → adjust for the target → run → review api_static.txt, routes.txt
  3. Phase 1b: path anchor window expansion + binding layer → param_candidates.json (section J)
  4. Reverse-engineer A1/A2/A3 → write a site-specific config.json
  5. Read and adjust runtime_harvest.js / preload.js before executing
  6. runtimeMode=depth: npm install → run the adjusted harvest script
  7. runtimeMode=coverage/both: inject the adjusted preload at document-start → browser MCP dynamic enumeration + parameter trigger matrix
  8. Modules do not render → section I permission-tree restoration → patch stubs → rerun
  9. Multi-sample parameter diff + error-driven inference → params_merged.json
  10. Merge → site_map.json + api_merged.txt, honestly noting coverage, gaps, and script changes

I. Permission-tree restoration (Phase 4 deep dive)

Use when forging a simple menus: [{ path, show: true }] does not work and submodules still do not mount.

I1. Locate the auth module

grep -l 'userRouteAuth' js/*.js
grep -l 'routeMap\|routeLink' js/*.js
grep -rhoaE 'getResultTree|role_permissions|permissions/all' js | head

Record: permission API paths, response field names, consuming chunk file names.

I2. Extract the routeMap

python3 scripts/extract_route_map.py recon/js recon/
# produces recon/route_map.json

If [!] no routeMap pattern found: loosen the regex in extract_route_map.py, or grep by hand:

grep -rhoaE '([A-Z_][A-Z0-9_]*):\{name:"[^"]*",link:"/[^"]+"\}' js | head -20

I3. Build the permission tree + stubs

python3 scripts/build_perm_tree.py recon/js recon/ --config recon/config.json

Script logic:

  1. Parse userRouteAuth={MONITOR:{url:...},...} (including webpack aliases like He=o.DASHBOARD)
  2. Use route_map.json to resolve aliases → real paths
  3. Infer parents from code prefixes (MONITOR_ALERT → MONITOR)
  4. Output permissions_tree.json, permissions_all_stub.json, role_permissions_stub.json
  5. With --config, automatically write config.json stubs and extend routes

Adjust for the target (at the top of the script):

  • DEFAULT_ROOTS: list of top-level module codes
  • DEFAULT_PREFIX_PARENT: PREFIX_ → parent mapping
  • DEFAULT_EXTRA_PARENT: orphan nodes without a prefix relationship

I4. Validate stub consistency

# the number of permissions should be ≈ the number of userRouteAuth entries
wc -l recon/perm_codes_all.txt
# routes should cover every link in route_map
python3 -c "import json; m=json.load(open('recon/route_map.json')); r=set(json.load(open('recon/config.json'))['routes']); print('missing', [v['link'] for v in m.values() if v['link'] not in r])"

I5. Rerun runtime and compare

node recon/runtime_harvest.js recon/config.json
# compare runtime_api.json entry counts before/after forging; check whether module APIs such as /attack and /asset appear
Before forging After forging (success)
The same 3–5 bootstrap calls per route Different routes trigger different module APIs
Only /api/locale/language /api/web/... module endpoints appear
Single-digit routes in routes.txt routes 80–110+ from route_map

I6. If it still fails

  • coverage mode: click the sidebar + tabs; permission gating may only be requested after interaction
  • stub fields: compare the real API (curl + real session) with the stub's nesting
  • extra guards: grep button-level checks such as hasPermission|checkRole|func. and extend role_permissions.permissions
  • static fallback: module API paths are still in api_static.txt, runtime only adds METHOD/body; parameters stay in param_candidates.json + recorded samples

J. Parameter reverse engineering (Phase 1b / 5b / 5c)

A methodology, not a universal script. Regexes for paths; anchor window expansion + UI binding chains + multi-sample diff + error-driven inference for parameters.

J1. Anchor window expansion — find the payload-assembly object from a path

# anchor on a path known from Phase 1
grep -n '"/api/user/list"' js/*.js
grep -rhoaE '.{0,120}("/api[^"]+").{0,200}' js | head
grep -rhoaE '(params|data|body|payload)\s*:\s*\{' js | head
grep -rhoaE '(get|post|put|delete|patch)\([^,]+,\s*\{' js | head

J2. Wrapper layers and transport shapes

# axios / unified request
grep -rhoaE '(axios|request)\.(get|post|put|delete|patch)\(' js | head
grep -rhoaE 'interceptors\.(request|response)' js | head

# GraphQL
grep -rhoaE '(query|mutation)\s+\w+|gql`|graphql\(' js | head
grep -rhoaE '\$[a-zA-Z_]+\s*:\s*(Int|String|Boolean|\[)' js | head

# FormData / multipart
grep -rhoaE 'FormData|\.append\(' js | head

# path parameters
grep -rhoaE 'path:\s*"/[^"]*:[^"]+"' js | head
grep -rhoaE 'useParams|route\.params|\$route\.params' js | head

J3. Validation gate — required / format / enum

grep -rhoaE '(required|message|pattern|enum|validator)\s*:' js | head
grep -rhoaE 'yup\.|zod\.|async-validator|Form\.Item|a-form-item|el-form-item' js | head
grep -rhoaE 'rules\s*:\s*\[|name:\s*["\'][a-zA-Z_]+["\']' js | head
grep -rhoaE 'label.*value|options\s*:\s*\[' js | head

J4. Binding layer — form → API

grep -rhoaE 'onFinish|handleSubmit|getFieldsValue|validateFields' js | head
grep -rhoaE '(pick|omit|transform|dayjs|moment)\(' js | head

Filling the gaps at runtime: DevTools → Network → request → Initiator (call stack), walk up from fetch/send to the payload-assembly function.

J5. Encrypted parameters

grep -rhoaE 'encrypt|decrypt|sign|CryptoJS|sm2|sm3|sm4|RSA|AES' js | head

Do not guess fields from ciphertext — hook the crypto function's inputs and record the plaintext payload before encryption; write conclusions to config.json / param_candidates.json.

J6. Parameter trigger matrix (mandatory in Phase 3)

Record once per operation per module and diff the request body/query:

Operation Watch for
List first screen Pagination defaults
Search keyword, filters
Advanced filter optional fields
Create/edit the full entity
Bulk/export ids[], exportType
Sort/paginate sortField, order

Produces param_samples.json: [{ "path", "method", "action": "search", "body", "query", "headers" }]

J7. Confidence rules

Confidence Condition
High static callsite + ≥2 consistent runtime samples
Medium static only, or just 1 runtime sample
Low inferred from responses/errors, without a second verification
Awaiting trigger field known statically, but the UI/permission path was never exercised

J8. Quick recipes per scenario

Scenario Sequence
REST list page J1 payload object → J6 four diffs → J3 rules
Create/edit form J3 Form name → J4 submit chain → submit at runtime + deliberately leave fields empty to see the 400
GraphQL J2 variables declarations → record variables for each operation at runtime
Encrypted body J5 hook the inputs → the pre-encryption fields are the real params

J9. Mapping to api-recon phases

api-recon parameter recon
Phase 1 static J1 anchor window expansion
Phase 2 A2 interceptor globally injected fields (tenantId, sign)
Phase 3 runtime J6 trigger matrix + param_samples.json
Phase 4 permission tree different modules have different forms → full fields trigger only with sufficient permissions
Phase 5 merge params_merged.json + confidence; never settle required/optional from a single sample

J10. Troubleshooting

Symptom Fix
Field names found statically never appear at runtime Mark "awaiting trigger"; complete the permission tree / click advanced filters / try each option of linked selects
Same path with different body shapes Normal — record one entry per action, do not force-merge schemas
Stub responses are fake but you want the params Look at the outbound request body/headers; do not infer from stub responses
400 reports a nested field Mind the outer wrappers data/bizData/variables
GraphQL shows only the operation name Expand the variables JSON; find $var: Type statically