agent-bypass 2.11.7 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ # SPA 前端接口侦察(三道门 / 无凭据进壳 / 参数矩阵 / 权限树)
2
+
3
+ > 一句话:把 SPA 站点的**全部接口、参数结构、权限模型**掏干净。
4
+ > 边界:这里管"接口面侦察";**加密参数还原(sign/加密算法复刻)不在这**——走 SKILL「50-js-reverse」模块。
5
+ > 前提:已授权。所有 grep 针对已下载的 `js/` 目录执行(bundle 单行时先 `sed 's/}/}\n/g'` 切开再 grep)。
6
+
7
+ ---
8
+
9
+ ## 一、两层模型:静态打底,运行时补全
10
+
11
+ | 层 | 来源 | 能拿到 | 拿不到 |
12
+ |---|---|---|---|
13
+ | **静态** | 全量 chunk 下载后 grep | endpoint 全路径、路由草案、字段候选 | HTTP 方法、真实参数结构、运行时拼接的 URL |
14
+ | **运行时** | 活会话 + 代理/fetch hook | 方法 + body + 响应 + 动态 URL + WS/SSE | 没渲染到的页面;**单样本定不了必填/可选** |
15
+
16
+ 顺序:静态先做完(便宜、无痕),运行时补方法与参数。参数结论必须**多样本 diff**,不许拿单样本拍脑袋。
17
+
18
+ ---
19
+
20
+ ## 二、逆向三道门(重点)
21
+
22
+ 拿到一个 SPA 后台,先回答三个问题:**怎么判断已登录 / 什么触发跳登录 / 菜单权限从哪来**。
23
+
24
+ ### 2.1 渲染门——「如何判断已登录?」
25
+
26
+ ```bash
27
+ # 找登录判断函数与存储读取
28
+ grep -rhoaE '.{0,40}(isLogin|isAuthenticated|loggedIn|hasLogin|requireAuth)\b.{0,80}' js/ | head
29
+ grep -rhoaE 'function (getUser|getToken|getAuth)[0-9]?\([^)]*\)\{.{0,200}' js/ | head
30
+ grep -rhoaE '(localStorage|sessionStorage)\.getItem\("[^"]+"\)' js/ | sort -u
31
+ grep -rhoaE '(Cookies?|cookie)\.(get|load)\("[^"]+"\)' js/ | sort -u
32
+ grep -rhoaE '\batob\(|JSON\.parse\(|jwt|decode' js/ | head
33
+ ```
34
+
35
+ 链路通常长这样:`isLogin = f(getUser())` → `getUser = decode(storage.read(键名))`。
36
+ 要确定**三样**:存储键名、容器(Cookie 还是 localStorage)、编码方式。然后按编码给出伪造值:
37
+
38
+ | 编码 | 伪造写法 |
39
+ |---|---|
40
+ | 明文字符串 / `"1"` / token | 任意 truthy 值 |
41
+ | `JSON.parse(x)` | `{"id":1,"username":"admin"}` |
42
+ | `JSON.parse(atob(x))` | base64(上述 JSON) |
43
+ | JWT | 无签名 / `alg:none` JWT;或 bundle 里挖到的密钥自签 |
44
+ | 加密(SM2/AES/RSA) | 找硬编码密钥;渲染门只要求"可解码 blob"时直接 forge,解不开就静态兜底 |
45
+
46
+ 拿到这三样,后面写 mock/hook 配置的 cookies / localStorage 就有底了。
47
+
48
+ ### 2.2 拦截器门——「什么触发跳 /login?」
49
+
50
+ ```bash
51
+ grep -rhoaE '.{0,60}(interceptors\.response|axios|request\.use).{0,120}' js/ | head
52
+ 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
53
+ grep -rhoaE '.{0,40}(未登录|请重新登录|登录已过期|unauthorized|登录失效|token.{0,10}invalid).{0,40}' js/ | head
54
+ grep -rhoaE '.{0,30}(location\.href|router\.(push|replace)|navigate)\([^)]*login[^)]*\)' js/ | head
55
+ ```
56
+
57
+ 要确定:**响应字段名**、**成功值**(大多是 `0` 或 `200`)、**触发跳转的失败值**。
58
+ 验证:拿假会话直接敲受保护接口,看它落哪个分支:
59
+
60
+ ```bash
61
+ curl -sk -X POST -H 'Cookie: <假键>=junk' https://target/api/<受保护> \
62
+ -d '{}' -H 'Content-Type: application/json'
63
+ ```
64
+
65
+ 拿到这三样就能"改旗换码":把拦截器关心的字段值中和掉(代理重写 / 前端 hook / 改包),后端返回什么码都当成功。**这是后面进壳的基础。**
66
+
67
+ ### 2.3 内容门——「菜单/权限从哪来?」
68
+
69
+ ```bash
70
+ grep -rhoaE '"/api[^"]*(permission|perm|role|menu|acl|resource|nav)[^"]*"' js/ | sort -u
71
+ grep -rhoaE '.{0,30}(menus|permissions|menuList|routeList|authList|role_permissions)\b.{0,120}' js/ | head
72
+ grep -rhoaE 'userRouteAuth|getResultTree|routeMap|routeLink|hasPermission|checkAuth' js/ | head
73
+ grep -rhoaE '([A-Z_][A-Z0-9_]*):\{name:"[^"]*",link:"/[^"]+"\}' js/ | head
74
+ ```
75
+
76
+ 常见企业后台是**两层数据**:
77
+
78
+ | 数据 | 典型 payload | 谁消费 |
79
+ |---|---|---|
80
+ | `/role_permissions` 类 | `{ permissions: string[], role_type }` | 路由守卫、按钮级 ACL |
81
+ | `/permissions/all` 类 | `tree[{ code, position, children }]` | 侧栏菜单渲染 |
82
+ | bundle 内 `userRouteAuth` | `{ CODE: { url, name? } }` | code → 前端 path |
83
+ | bundle 内 `routeMap` | `{ KEY: { name, link } }` | 别名解析(webpack `o.DASHBOARD`) |
84
+
85
+ 读消费方代码确认过滤逻辑(`getResultTree(tree, permissions)`、`v-if` / `hasAuth(code)` 查哪个字段)——然后照结构造 permissive payload 喂给 mock。**小站点到这步就能进壳;大站侧栏还是空的话走第 4 节权限树还原。**
86
+
87
+ ---
88
+
89
+ ## 三、无凭据进壳:用 mock 撑开前端
90
+
91
+ **问题**:没账号,SPA 卡在登录页,什么接口都发不出来。
92
+ **思路**:前端只信它自己那几个"登录判断"——**把判断喂饱,让壳先开**。用 2.1/2.2 拿到的键名与成功值,伪造会话 + 把拦截器字段中和,前端就会渲染登录后的壳层、挂载路由、继续发它自己的 XHR/fetch——**你只录 outbound,后端返回什么不重要。**
93
+
94
+ 三层 mock(按需递进):
95
+
96
+ | 层 | 干什么 | 效果 |
97
+ |---|---|---|
98
+ | L1 会话层 | cookies / localStorage 注入伪造会话(按 2.1 的编码表) | 过渲染门 |
99
+ | L2 应答中和 | 代理/hook 把响应里的 `code/errno/status` 字段重写为成功值 | 过拦截器门,请求不再被跳登录吞掉 |
100
+ | L3 内容伪造 | 把 `permissions/all`、`role_permissions`、菜单类接口的响应替换成 permissive payload | 过内容门,侧栏/子模块挂载,触发**更多**模块接口 |
101
+
102
+ **落地手段(不挑工具,拣顺手的)**:
103
+ - 桌面:系统代理(mitmproxy/Charles)重写响应 + 浏览器 devtools 会话注入
104
+ - 纯前端:document-start 注入脚本,hook `fetch`/`XHR` + `localStorage` 读取 + 路由守卫(撑开 Vue 路由守卫 / React 无此概念就靠 L3 数据)
105
+ - 移动端:本地 VPN 代理同套规则
106
+
107
+ **铁律**:
108
+ - 只录 **outbound 请求**(URL/方法/头/body)——空表格、空数据、占位 UI 都是**预期现象**,别为了拿"真实数据"转去做越权/注入(那是下一个阶段的事)
109
+ - 进壳后模块仍空白 → 十有八九是 L3 数据不齐 → 走第 4 节权限树
110
+ - hardened 站点(服务端签名 Cookie、服务端渲染菜单)::build 到此为止——静态照样能出 endpoint 清单,运行时用真实会话再跑同一套
111
+
112
+ ---
113
+
114
+ ## 四、参数逆向(锚点扩窗 / 绑定链 / 触发矩阵)
115
+
116
+ **只有方法论,没有一键脚本。** path 用正则找;参数靠四招:**锚点扩窗 + UI 绑定链 + 多样本 diff + 错误反推**。
117
+
118
+ ### 4.1 锚点扩窗——从 path 找组包对象
119
+
120
+ ```bash
121
+ # 以已知 path 为锚,向两侧扩窗口,看它旁边的对象
122
+ grep -n '"/api/user/list"' js/*.js
123
+ grep -rhoaE '.{0,120}("/api[^"]+").{0,200}' js/ | head
124
+ grep -rhoaE '(params|data|body|payload)\s*:\s*\{' js/ | head
125
+ grep -rhoaE '(get|post|put|delete|patch)\([^,]+,\s*\{' js/ | head
126
+ ```
127
+
128
+ ### 4.2 包装层与传输形态
129
+
130
+ ```bash
131
+ grep -rhoaE '(axios|request)\.(get|post|put|delete|patch)\(' js/ | head
132
+ grep -rhoaE 'FormData|\.append\(' js/ | head
133
+ grep -rhoaE '(query|mutation)\s+\w+|gql\`' js/ | head # GraphQL
134
+ grep -rhoaE 'path:\s*"/[^"]*:[^"]+"' js/ | head # 路径参数
135
+ ```
136
+
137
+ | 形态 | 参数在哪 | 静态优先看 |
138
+ |---|---|---|
139
+ | REST JSON | body + query | path 锚点旁 `(params\|data\|body)\s*:\s*\{` |
140
+ | GraphQL | `variables` | gql 模板、`$page: Int` 声明 |
141
+ | form | urlencoded | `<form>`、`FormData` |
142
+ | 上传 | multipart | `FormData.append` |
143
+ | 路径参数 | `/user/:id` | 路由表 + `useParams` / `$route.params` |
144
+ | 加密体 | 包进 `sign`/`data` | **hook 加密函数入参**(明文在加密前)——算法还原走 50-js-reverse |
145
+
146
+ ### 4.3 绑定层——表单 → API 的追踪链
147
+
148
+ ```
149
+ 表单字段 → onSubmit/handleSubmit → transform → API payload
150
+ ```
151
+
152
+ ```bash
153
+ grep -rhoaE 'onFinish|handleSubmit|getFieldsValue|validateFields' js/ | head
154
+ grep -rhoaE '(pick|omit|transform|dayjs|moment)\(' js/ | head
155
+ grep -rhoaE '(required|pattern|enum|validator)\s*:' js/ | head # 校验门=必填/格式/枚举
156
+ ```
157
+
158
+ 运行时可补:devtools → 请求 → 发起程序(call stack)从 `fetch`/`XHR.send` 往上追组包函数。
159
+
160
+ ### 4.4 参数触发矩阵(必做)
161
+
162
+ 对每个模块**按操作各录一次**,diff 请求体:
163
+
164
+ | 操作 | 关注字段 |
165
+ |---|---|
166
+ | 列表首屏 | 分页默认值 |
167
+ | 搜索 | keyword、filters |
168
+ | 高级筛选 | optional 字段(不点不出现) |
169
+ | 新建/编辑 | 完整 entity 结构 |
170
+ | 批量/导出 | `ids[]`、`exportType` |
171
+ | 排序/翻页 | `sortField`、`order` |
172
+
173
+ ### 4.5 置信度纪律(防止自欺)
174
+
175
+ | 置信度 | 条件 |
176
+ |---|---|
177
+ | 高 | 静态 callsite + 运行时 ≥2 样本一致 |
178
+ | 中 | 仅静态,或仅 1 次运行时 |
179
+ | 低 | 靠响应/报错反推,未验证 |
180
+ | 待触发 | 静态有字段,UI 没跑到(补权限/点高级筛选/联动下拉再录) |
181
+
182
+ **单样本定不了必填/可选**;同 path 不同 body 形状是正常的——按操作分条记录,别硬并 schema。
183
+
184
+ ---
185
+
186
+ ## 五、权限树还原(大站侧栏空白时用)
187
+
188
+ 简单 forge 不灵(侧栏有项、子页空白 / 只出 bootstrap 接口)时,说明权限数据不齐。还原流程:
189
+
190
+ ### 5.1 定位权限模块
191
+
192
+ ```bash
193
+ grep -l 'userRouteAuth' js/*.js
194
+ grep -l 'routeMap\|routeLink' js/*.js
195
+ grep -rhoaE 'getResultTree|role_permissions|permissions/all' js/ | head
196
+ ```
197
+
198
+ 记下:**权限接口 path、响应字段名、消费它的 chunk 文件名**。
199
+
200
+ ### 5.2 提取 routeMap(code ↔ path 映射表)
201
+
202
+ ```bash
203
+ # 别名表通常长这样:{DASHBOARD:{name:"仪表盘",link:"/dashboard"},...}
204
+ grep -rhoaE '([A-Z_][A-Z0-9_]*):\{name:"[^"]*",link:"/[^"]+"\}' js/ | head -20
205
+ ```
206
+
207
+ ### 5.3 拼权限树(关键三步)
208
+
209
+ 1. 解析 `userRouteAuth = {CODE: {url}, ...}` 拿到全部 code(注意 webpack 别名 `He=o.DASHBOARD` 要顺一遍)
210
+ 2. 用 routeMap 把别名 code 换成真实 path
211
+ 3. **按 code 前缀推父子**:`MONITOR_ALERT` → 父 `MONITOR`(做映射表兜住非前缀的孤儿节点)
212
+
213
+ 产出两份 mock 数据:
214
+ - `role_permissions` stub:`{"permissions":[全部 code], "role_type":"SUPER_ADMIN"}`
215
+ - `permissions/all` stub:树形 `[{code, position, children:[...]}]`
216
+
217
+ ### 5.4 校验一致性(别留洞)
218
+
219
+ ```bash
220
+ # permissions 条目数应 ≈ userRouteAuth 条目数
221
+ # routes 应覆盖 routeMap 全部 link —— 缺哪条补哪条
222
+ ```
223
+
224
+ ### 5.5 重跑对比
225
+
226
+ | forge 前 | forge 后(成功) |
227
+ |---|---|
228
+ | 每个路由都是同 3-5 条 bootstrap | 不同路由触发不同模块 API |
229
+ | 只有 locale/i18n | 出现真实业务接口 |
230
+
231
+ 仍失败 → 点击侧栏+Tab(权限 gating 可能在交互后才请求)→ 扩 permissions 覆盖按钮级 `hasPermission|checkRole` → 静态兜底(模块 path 已在静态清单,运行时只是补 METHOD/body)。
232
+
233
+ ---
234
+
235
+ ## 六、静态收割清单(第一刀)
236
+
237
+ ```bash
238
+ # 1) 入口 HTML 找 script/chunk 线索;webpack/Vite 看 manifest
239
+ # 2) 按 manifest 下载全部 lazy chunk 到 js/
240
+ # 3) 批量提取 endpoint
241
+ grep -rhoaE '"/[a-z][A-Za-z0-9_/\-]{3,}"' js/ | sort -u
242
+ grep -rhoaE '/api/[a-zA-Z0-9_./-]+' js/ | sort -u
243
+ # 4) 路由草案
244
+ grep -rhoaE '(path|route|component)\s*:\s*"[^"]+"' js/ | head -40
245
+ ```
246
+
247
+ 产出四件套:`api_static.txt`(接口全集)、`routes.txt`(路由)、`js/`(chunk 仓库)、字段候选清单。
248
+ **先把这步做完再碰运行时**——静态无痕、可反复;运行时只补静态给不了的方法/参数/样本。
249
+
250
+ ---
251
+
252
+ ## 七、完成定义(逐条勾完才准说"做完了")
253
+
254
+ - [ ] 静态收割四件套齐全,`wc -l` 校验过
255
+ - [ ] 三道门都定位到(键名/字段成功值/权限来源),写进 mock 配置
256
+ - [ ] 进壳成功:访问业务路由不再被弹回 `/login`(注意 hash 路由)
257
+ - [ ] 运行时至少跑通"深度"或"点界面"之一
258
+ - [ ] 参数触发矩阵逐操作录完,产出样本集 + 置信度标注
259
+ - [ ] 权限树还原完成(若有模块空白问题)且已重跑验证
260
+ - [ ] 合并产出:接口总表(path/method/参数/样本/置信度)+ 覆盖缺口诚实标注
261
+ - [ ] 产物落盘到任务目录,路径写进报告
262
+
263
+ **硬禁止(踩了就算跑偏)**:
264
+
265
+ | 禁止 | 替代做法 |
266
+ |---|---|
267
+ | 手工逐个 curl chunk / 反复 grep 大 bundle | 先下载全部 chunk,批量脚本提取 |
268
+ | 同一命令失败 ≥2 次还重复 | 换策略:读日志、改正则、查本文档 |
269
+ | 只录 1 个样本就断言必填/可选 | 触发矩阵多样本 diff |
270
+ | 用"拿真实数据"为由做越权/注入 | 停——那是下一阶段,侦察期只录 outbound |
271
+ | 没走完完成定义就声称"全部接口已拿到" | 逐条勾,缺口如实标注 |
272
+ | 大 bundle(>100KB)直接读进上下文 | 用脚本批处理,只把核心片段喂给分析 |
273
+
274
+ ---
275
+
276
+ ## 八、排障速查
277
+
278
+ | 现象 | 处理 |
279
+ |---|---|
280
+ | 静态接口很少 | endpoint 正则放宽;查 manifest 解析 |
281
+ | 进壳后仍跳登录 | 渲染门错了:复查键名/容器/编码/domain |
282
+ | 进壳但模块空白 | 内容门:forge 菜单 + 第 5 节权限树 |
283
+ | 每个接口都跳登录 | 拦截器门:确认 L2 字段中和生效(嵌套字段要递归改) |
284
+ | 响应体全空 | mock 模式下正常——要看参数就去看 **outbound 请求**,别从 stub 响应反推 |
285
+ | WS 帧为 0 | 先交互(点开订阅页),别干等 |
286
+ | Vue 路由还是跳登录 | 注入时机太晚——必须 document-start;或手动清路由守卫 |
287
+ | 400 报嵌套字段错 | 注意外层包装 `data`/`bizData`/`variables` 层级 |
288
+
289
+ ---
290
+
291
+ ## 九、与其它模块的衔接
292
+
293
+ | 环节 | 去哪 |
294
+ |---|---|
295
+ | 加密参数(sign/加密算法复刻、混淆还原) | SKILL「50-js-reverse」模块 |
296
+ | 拿到接口后的注入/越权测试 | SKILL「53-web-injection」+「46-pentest」模块 |
297
+ | 接口泄露的源码审计 | SKILL「54-source-leak」模块 |
298
+ | 目标测绘与源站 | SKILL「56-recon-origin」+ `references/recon-commands.md` |
299
+ | 通用侦察命令集 | `references/recon-commands.md` |
@@ -64,12 +64,12 @@ def from_circl(kw, limit):
64
64
  return []
65
65
 
66
66
 
67
- def from_nvd(kw, limit, retries=2):
67
+ def from_nvd(kw, limit, retries=1):
68
68
  u = ("https://services.nvd.nist.gov/rest/json/cves/2.0?"
69
69
  f"keywordSearch={urllib.parse.quote(kw)}&resultsPerPage={limit}")
70
70
  d = {}
71
71
  for _ in range(retries + 1):
72
- d = get(u, timeout=60)
72
+ d = get(u, timeout=30)
73
73
  if "_error" not in d and "vulnerabilities" in d:
74
74
  break
75
75
  if "_error" in d or "vulnerabilities" not in d:
@@ -137,16 +137,17 @@ def main():
137
137
  if src not in results:
138
138
  continue
139
139
  i += 1
140
+ tgt = sys.stderr if a.json else sys.stdout # JSON 模式诊断走 stderr,防数据错位
140
141
  print(f"\n{'='*60}\n[{i}] {labels[src]} — {a.keyword}\n{'='*60}",
141
- file=sys.stderr)
142
+ file=tgt)
142
143
  rows = results[src]
143
144
  if not rows:
144
- print(" (无结果 / 源不可用)", file=sys.stderr)
145
+ print(" (无结果 / 源不可用)", file=tgt)
145
146
  for c in rows:
146
147
  if a.json:
147
148
  print(json.dumps({"source": src, **c}, ensure_ascii=False))
148
149
  elif src == "github":
149
- print(f"★{c['stars']:<5} {c['repo']}\n {c['desc']}\n {c['url']}")
150
+ print(f"★{c.get('stars', 0):<5} {c.get('repo', '?')}\n {c.get('desc', '')}\n {c.get('url', '')}")
150
151
  else:
151
152
  print(f"{c['cve']} CVSS={c['cvss']} {c['published']}\n {c['summary']}")
152
153
  for r in c["refs"][:2]: