@dsh-fly/dsh-plugin-fly-mode 0.1.9 → 0.1.11
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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-fly/dsh-plugin-fly-mode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Fly 模式作为一个可安装的包:**两个 preset** —— Fly 安全模式(preset-fly,8 个安全工具内联 + smart)与 Fly 标准模式(preset-fly-standard,上游 standard 的行表 + smart,无安全工具);依赖单独发布的 dsh-plugin-fly-smart。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -146,6 +146,9 @@ tail -f .fly-blackbox/capture/all.log
|
|
|
146
146
|
| `bx_ca` | CA 信任状态 + **要人跑的命令文本** + `security verify-cert` 事后证据 | 不受门控(永不提权) |
|
|
147
147
|
| `bx_flows` | 离线读 dump:`hosts` / `list` / `show` / `grep`(版本闸 + 拒绝截断) | 不受门控(只读) |
|
|
148
148
|
| `bx_diff` | 改前/改后字段级 diff,显式区分**缺失** / **假值** / **类型变化** | 不受门控(只读) |
|
|
149
|
+
| `bx_cdp_targets` | **进程内通道第一件**:列 `127.0.0.1:<port>` 上可附加的 target(page/worker/iframe)。只走回环、端口只认参数、控制通道地址不打印;整件在 `FLY_ALLOW_INTERCEPT` 门后 |
|
|
150
|
+
| `bx_cdp_scripts` | 附加上去收 `Debugger.scriptParsed`:**已解析**脚本清单(会回放已解析的;**不返回源码**;窗口一结束就断开) |
|
|
151
|
+
| `bx_cdp_coverage` | 精确覆盖率:采一段窗口 → `takePreciseCoverage` → 逐脚本"已执行字节"(区间**求并集**;拿不到快照就**大声失败**,不给半份) |
|
|
149
152
|
|
|
150
153
|
规则文件固定 `<workspace>/.fly-blackbox/rules.json`,由**你自己用文件工具写**,`bx_proxy start` 只读并校验;
|
|
151
154
|
schema 与"显式 off、无静默默认值"的纪律见 `reference/rules.schema.md`。
|
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* · **知识层**:一个 bundled skill(`assets/client-blackbox/`),按需加载。agent 读到的是
|
|
8
8
|
* 心智模型(三源一闸 S1/S2/S3/G)、六条判据 T1–T6、五阶段带 Gate 流程与可复用脚本模板。
|
|
9
9
|
* 知识本身不产生副作用,所以不受授权闸门管。
|
|
10
|
-
* ·
|
|
10
|
+
* · **执行层**:十个工具,走宿主 `shell` 接缝(最近三件 `bx_cdp_targets` / `bx_cdp_scripts` /
|
|
11
|
+
* `bx_cdp_coverage` 是**进程内通道**:CDP 调试端口上的 target 清单、已解析脚本清单、精确覆盖率 ——
|
|
12
|
+
* 线缆层看不见"进程里显示了什么、哪段 JS 改的它",它们补这一半;只连回环、整件在授权门后)。它只管**可观测性与判定**(起代理、判管道、
|
|
11
13
|
* 读抓包、比对前后),改写路径(请求/响应注入)必须显式授权。
|
|
12
14
|
*
|
|
13
15
|
* 三条纪律写死在实现里,因为每一条都对应一次实测事故:
|
|
@@ -433,6 +435,286 @@ function workspaceRoot(exec) {
|
|
|
433
435
|
* 读一行文本,走接缝。用于**用户数据**(dump、规则文件)—— 这样沙箱拒绝会以
|
|
434
436
|
* `SandboxError` 报出。允许 fs 的两处例外(自带资产 / 自身状态文件)见 §3.1。
|
|
435
437
|
*/
|
|
438
|
+
// ── CDP(进程内通道)───────────────────────────────────────────────────────
|
|
439
|
+
//
|
|
440
|
+
// 为什么加在 blackbox 而不是新开一个包(裁决 2026-10-15):线缆层(mitm 抓包)看得见
|
|
441
|
+
// "谁跟谁说了什么",看不见"进程里到底显示了什么、哪段 JS 改的它";Electron / Chromium 内核
|
|
442
|
+
// 宿主这两层都开着,所以 CDP 是同一个使命的另一条通道。
|
|
443
|
+
//
|
|
444
|
+
// 两条结构性纪律:
|
|
445
|
+
// · **只走回环**:URL 由端口拼出来,参数表里**没有 host** ⇒ 结构上不可能指向别的主机;
|
|
446
|
+
// · **端口只认参数**:状态文件里的 `port` 是 mitm **代理**端口,拿它当调试端口是类别错误。
|
|
447
|
+
|
|
448
|
+
/** 一次列多少条 target(超出的报 omitted,不静默丢)。 */
|
|
449
|
+
const CDP_TARGETS_SHOWN = 50
|
|
450
|
+
|
|
451
|
+
/** CDP 调试端口:只认显式参数。 */
|
|
452
|
+
function requireCdpPort(value) {
|
|
453
|
+
const port = Number(value)
|
|
454
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
455
|
+
const error = new Error(
|
|
456
|
+
`CDP 端口必须是 1..65535 的整数(收到 ${JSON.stringify(value)})。`
|
|
457
|
+
+ '这一项**只认参数**:.fly-blackbox 状态里的 port 是 mitm 代理端口,不是调试端口。',
|
|
458
|
+
)
|
|
459
|
+
error.name = 'ArgumentError'
|
|
460
|
+
throw error
|
|
461
|
+
}
|
|
462
|
+
return port
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 只走回环:URL 由端口拼出来(没有 host 参数,所以不可能指向别处)。 */
|
|
466
|
+
function cdpListUrl(port) {
|
|
467
|
+
return `http://127.0.0.1:${port}/json/list`
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 连不上时的**大声**文案:是什么 + 为什么只可能是这几种 + 下一步(怎么起目标)。 */
|
|
471
|
+
function cdpUnreachableError(port, run) {
|
|
472
|
+
const detail = (run.stderr ?? '').trim().split('\n').filter(Boolean)[0] ?? ''
|
|
473
|
+
const error = new Error([
|
|
474
|
+
`连不上 CDP 调试端口 127.0.0.1:${port}:${detail === '' ? `curl 退出码 ${run.exitCode}` : detail}`,
|
|
475
|
+
'—— 这一项**只连回环**,所以只剩三种可能:那个进程没在监听这个端口 / 端口号给错了 / 目标没带调试开关起来。',
|
|
476
|
+
'怎么起一个带调试端口的目标(端口号自己换一个没人用的):',
|
|
477
|
+
` · Chromium 内核浏览器:open -a "Google Chrome" --args --remote-debugging-port=${port} --user-data-dir=/tmp/cdp-${port}`,
|
|
478
|
+
` · Electron 应用:<App 可执行文件> --remote-debugging-port=${port}`,
|
|
479
|
+
'顺带一句:调试端口开着就等于把那个界面完全交出去(能读 DOM、也能改它),用完关掉那个实例。',
|
|
480
|
+
].join('\n'))
|
|
481
|
+
error.name = 'CommandError'
|
|
482
|
+
return error
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** `/json/list` 的返回体:不是 JSON / 不是数组,都要说清"是那端口不对,不是没有 target"。 */
|
|
486
|
+
function parseCdpTargets(text, port) {
|
|
487
|
+
let parsed
|
|
488
|
+
try {
|
|
489
|
+
parsed = JSON.parse(text)
|
|
490
|
+
} catch (error) {
|
|
491
|
+
throw new Error(
|
|
492
|
+
`127.0.0.1:${port}/json/list 返回的不是 JSON —— 这**不是**"没有 target",`
|
|
493
|
+
+ `是那个端口上不是调试接口(${error.message})。返回体头 120 字:${JSON.stringify(text.slice(0, 120))}`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(parsed)) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
`127.0.0.1:${port}/json/list 的返回不是数组(CDP 的约定是数组)—— 实际是 ${typeof parsed}:`
|
|
499
|
+
+ '这**不是**"没有 target",是那个端口上不是调试接口。',
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
return parsed.filter(item => item !== null && typeof item === 'object' && !Array.isArray(item))
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** 取一次 target 清单(走接缝的 curl,所以有审计、沙箱拒绝会照常浮出)。 */
|
|
506
|
+
async function fetchCdpTargets(shell, exec, port) {
|
|
507
|
+
const run = await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
508
|
+
if (run.exitCode !== 0) return { ok: false, run }
|
|
509
|
+
return { ok: true, targets: parseCdpTargets(run.stdout, port) }
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** 从清单里挑一个**可附加**的 target:给了 id 就按 id,否则优先第一个 page。 */
|
|
513
|
+
function pickCdpTarget(targets, wanted, port) {
|
|
514
|
+
const attachable = targets.filter(item => typeof item.webSocketDebuggerUrl === 'string' && item.webSocketDebuggerUrl !== '')
|
|
515
|
+
const wantedId = wanted === undefined || wanted === null ? '' : String(wanted).trim()
|
|
516
|
+
if (wantedId !== '') {
|
|
517
|
+
const found = attachable.find(item => item.id === wantedId)
|
|
518
|
+
if (found === undefined) {
|
|
519
|
+
throw new Error(`127.0.0.1:${port} 上没有 id=${wantedId} 的可附加 target(可附加的有:${attachable.map(item => `${item.id}(${item.type})`).join(', ') || '无'})`)
|
|
520
|
+
}
|
|
521
|
+
return found
|
|
522
|
+
}
|
|
523
|
+
const page = attachable.find(item => item.type === 'page')
|
|
524
|
+
if (page !== undefined) return page
|
|
525
|
+
if (attachable.length > 0) return attachable[0]
|
|
526
|
+
throw new Error(`127.0.0.1:${port} 上没有可附加的 target(清单里 ${targets.length} 条,但都没有 webSocketDebuggerUrl)—— 这不等于"这个进程没有页面",只是没有可附加的调试目标。`)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 采样窗口:夹在合理区间里(给 0 或负值等于"什么都不收",那就不是测量了)。 */
|
|
530
|
+
function clampWindow(value, fallback, lo, hi) {
|
|
531
|
+
if (value === undefined || value === null) return fallback
|
|
532
|
+
const window = Number(value)
|
|
533
|
+
if (!Number.isFinite(window)) return fallback
|
|
534
|
+
return Math.min(hi, Math.max(lo, Math.round(window)))
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* 把一组 offset 区间**求并集**:V8 的函数区间会嵌套,直接相加会重复计数。
|
|
539
|
+
* (这是 bx_cdp_coverage 的口径;尺子反过来也专抓"直接相加"那种错。)
|
|
540
|
+
*/
|
|
541
|
+
function mergeRanges(ranges) {
|
|
542
|
+
const sorted = ranges.slice().sort((a, b) => a.startOffset - b.startOffset)
|
|
543
|
+
const merged = []
|
|
544
|
+
for (const range of sorted) {
|
|
545
|
+
const last = merged.at(-1)
|
|
546
|
+
if (last !== undefined && range.startOffset <= last.endOffset) {
|
|
547
|
+
last.endOffset = Math.max(last.endOffset, range.endOffset)
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
merged.push({ startOffset: range.startOffset, endOffset: range.endOffset })
|
|
551
|
+
}
|
|
552
|
+
return merged
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** `Profiler.takePreciseCoverage` 的返回 → 逐脚本"跑过多少"(区间求并集后统计)。 */
|
|
556
|
+
function summarizeCoverage(scripts) {
|
|
557
|
+
const rows = []
|
|
558
|
+
let usedTotal = 0
|
|
559
|
+
let sizeTotal = 0
|
|
560
|
+
for (const script of scripts) {
|
|
561
|
+
const functions = Array.isArray(script.functions) ? script.functions : []
|
|
562
|
+
const rangesOf = entry => (Array.isArray(entry.ranges) ? entry.ranges : [])
|
|
563
|
+
const executed = functions.flatMap(entry => rangesOf(entry)
|
|
564
|
+
.filter(range => (range.count ?? 0) > 0)
|
|
565
|
+
.map(range => ({ startOffset: range.startOffset, endOffset: range.endOffset })))
|
|
566
|
+
const ends = functions.flatMap(entry => rangesOf(entry).map(range => range.endOffset))
|
|
567
|
+
const size = ends.length === 0 ? 0 : Math.max(...ends)
|
|
568
|
+
const used = mergeRanges(executed).reduce((sum, range) => sum + (range.endOffset - range.startOffset), 0)
|
|
569
|
+
usedTotal += used
|
|
570
|
+
sizeTotal += size
|
|
571
|
+
rows.push({
|
|
572
|
+
scriptId: script.scriptId,
|
|
573
|
+
url: typeof script.url === 'string' && script.url !== '' ? script.url : '(inline/eval)',
|
|
574
|
+
functions: functions.length,
|
|
575
|
+
executedFunctions: functions.filter(entry => rangesOf(entry).some(range => (range.count ?? 0) > 0)).length,
|
|
576
|
+
usedBytes: used,
|
|
577
|
+
sizeBytes: size,
|
|
578
|
+
})
|
|
579
|
+
}
|
|
580
|
+
return { rows, usedTotal, sizeTotal }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** 取 target → 挑 target → 连上去跑一段会话;连不上就给两条信号合起来的判词。 */
|
|
584
|
+
async function cdpAttach(transport, shell, exec, { port, target, commands, finish, windowMs }) {
|
|
585
|
+
const fetched = await fetchCdpTargets(shell, exec, port)
|
|
586
|
+
if (!fetched.ok) throw cdpUnreachableError(port, fetched.run)
|
|
587
|
+
const picked = pickCdpTarget(fetched.targets, target, port)
|
|
588
|
+
try {
|
|
589
|
+
const session = await transport.session(picked.webSocketDebuggerUrl, { commands, finish, windowMs })
|
|
590
|
+
return { session, picked }
|
|
591
|
+
} catch (error) {
|
|
592
|
+
throw new Error(await cdpUnreachableDetail(shell, exec, port, error, { httpKnownOk: true }))
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** 真传输:Node 全局 WebSocket(22+,零依赖)。 */
|
|
597
|
+
function realCdpTransport() {
|
|
598
|
+
return {
|
|
599
|
+
/**
|
|
600
|
+
* 连上去 → 发 commands → 收 windowMs 事件 → 发 finish → 等它们的响应 → 关掉。
|
|
601
|
+
*
|
|
602
|
+
* 实测的三种失败**在客户端长得一模一样**(error.message 为空、close code 1006、没有 reason),
|
|
603
|
+
* 所以这里只如实记下 closeCode/reason,**不猜**是哪一种失败 —— 分辨交给调用方(它会另跑一次
|
|
604
|
+
* /json/list 探针)。
|
|
605
|
+
*/
|
|
606
|
+
session(wsUrl, { commands = [], finish = [], windowMs = 1200, finishTimeoutMs = 3000 } = {}) {
|
|
607
|
+
return new Promise((resolve, reject) => {
|
|
608
|
+
let ws
|
|
609
|
+
try {
|
|
610
|
+
ws = new WebSocket(wsUrl)
|
|
611
|
+
} catch (error) {
|
|
612
|
+
reject(error)
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
const events = []
|
|
616
|
+
const results = {}
|
|
617
|
+
const wanted = new Map()
|
|
618
|
+
let opened = false
|
|
619
|
+
let settled = false
|
|
620
|
+
let windowTimer
|
|
621
|
+
let finishTimer
|
|
622
|
+
const settle = (payload, error) => {
|
|
623
|
+
if (settled) return
|
|
624
|
+
settled = true
|
|
625
|
+
clearTimeout(windowTimer)
|
|
626
|
+
clearTimeout(finishTimer)
|
|
627
|
+
try { ws.close() } catch { /* 关不掉就算了,下面已经把结果交出去了 */ }
|
|
628
|
+
if (error !== undefined) reject(error)
|
|
629
|
+
else resolve(payload)
|
|
630
|
+
}
|
|
631
|
+
const send = (spec, id) => {
|
|
632
|
+
wanted.set(id, spec.key ?? spec.method)
|
|
633
|
+
ws.send(JSON.stringify({ id, method: spec.method, ...spec.params === undefined ? {} : { params: spec.params } }))
|
|
634
|
+
}
|
|
635
|
+
ws.onopen = () => {
|
|
636
|
+
opened = true
|
|
637
|
+
let id = 0
|
|
638
|
+
for (const spec of commands) { id += 1; send(spec, id) }
|
|
639
|
+
windowTimer = setTimeout(() => {
|
|
640
|
+
let pending = 0
|
|
641
|
+
for (const spec of finish) { id += 1; pending += 1; send(spec, id) }
|
|
642
|
+
if (pending === 0) { settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' }); return }
|
|
643
|
+
finishTimer = setTimeout(() => settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' }), finishTimeoutMs)
|
|
644
|
+
}, windowMs)
|
|
645
|
+
}
|
|
646
|
+
ws.onmessage = (message) => {
|
|
647
|
+
let parsed
|
|
648
|
+
try {
|
|
649
|
+
parsed = JSON.parse(String(message.data))
|
|
650
|
+
} catch {
|
|
651
|
+
return // 不认识的帧就当没看见(不假装解析过)
|
|
652
|
+
}
|
|
653
|
+
if (typeof parsed.method === 'string') {
|
|
654
|
+
events.push(parsed)
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
if (typeof parsed.id === 'number' && wanted.has(parsed.id)) {
|
|
658
|
+
results[wanted.get(parsed.id)] = parsed.error === undefined ? { result: parsed.result } : { error: parsed.error }
|
|
659
|
+
if (finishTimer !== undefined && [...wanted.values()].every(key => Object.hasOwn(results, key))) {
|
|
660
|
+
settle({ events, results, closedEarly: false, closeCode: null, closeReason: '' })
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
ws.onerror = () => {
|
|
665
|
+
// 实测:message 是空串 ⇒ 这里什么都不记,别编原因(closeCode 由 onclose 给)
|
|
666
|
+
}
|
|
667
|
+
ws.onclose = (event) => {
|
|
668
|
+
const closeCode = event.code
|
|
669
|
+
const closeReason = event.reason ?? ''
|
|
670
|
+
if (opened) {
|
|
671
|
+
settle({ events, results, closedEarly: true, closeCode, closeReason })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
const error = new Error(`WebSocket 没连上 ${wsUrl}(close code ${closeCode}${closeReason === '' ? '' : ` · ${closeReason}`})`)
|
|
675
|
+
error.name = 'CdpOpenError'
|
|
676
|
+
error.closeCode = closeCode
|
|
677
|
+
error.closeReason = closeReason
|
|
678
|
+
settle(undefined, error)
|
|
679
|
+
}
|
|
680
|
+
})
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 连不上时把两种信号合起来判:WebSocket 侧(只有 closeCode)+ /json/list 探针(HTTP 侧通不通)。
|
|
687
|
+
* **不猜**是哪一种 —— 实测三种失败在客户端同形(见 realCdpTransport 的注释)。
|
|
688
|
+
*/
|
|
689
|
+
async function cdpUnreachableDetail(shell, exec, port, error, { httpKnownOk = false } = {}) {
|
|
690
|
+
// 注意别拿"假的探针结果"当哨兵:一开始这里用 `{ exitCode: 1 }` 表示"HTTP 侧已知是活的",
|
|
691
|
+
// 于是它和自己的失败分支**同形**,被读成"两条路都不通"(测试当场抓到)。现在用**独立布尔**。
|
|
692
|
+
const probe = httpKnownOk
|
|
693
|
+
? undefined
|
|
694
|
+
: await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
695
|
+
const httpOk = httpKnownOk || probe.exitCode === 0
|
|
696
|
+
const wsShape = `WebSocket 侧:close code ${error?.closeCode ?? '(无)'}${(error?.closeReason ?? '') === '' ? '、reason 空' : `、reason ${error.closeReason}`}`
|
|
697
|
+
if (!httpOk) {
|
|
698
|
+
const detail = (probe.stderr ?? '').trim().split('\n').filter(Boolean)[0] ?? ''
|
|
699
|
+
return [
|
|
700
|
+
`连不上 CDP 调试端口 127.0.0.1:${port} —— HTTP 探针(/json/list)也失败:${detail === '' ? `curl 退出码 ${probe.exitCode}` : detail}`,
|
|
701
|
+
`${wsShape}。两条路都不通 ⇒ 最可能是那个进程没在监听这个端口。`,
|
|
702
|
+
'怎么起一个带调试端口的目标(端口号自己换一个没人用的):',
|
|
703
|
+
` · Chromium 内核浏览器:open -a "Google Chrome" --args --remote-debugging-port=${port} --user-data-dir=/tmp/cdp-${port}`,
|
|
704
|
+
` · Electron 应用:<App 可执行文件> --remote-debugging-port=${port}`,
|
|
705
|
+
].join('\n')
|
|
706
|
+
}
|
|
707
|
+
return [
|
|
708
|
+
`127.0.0.1:${port} 的 HTTP 侧是**活的**(/json/list 正常返回),但 WebSocket 握手没成 —— ${wsShape}`,
|
|
709
|
+
'(实测:Node 自带的 WebSocket 把"没人听""握手被拒""握上就断"报成**同一个形状** —— error.message 为空、close 1006;'
|
|
710
|
+
+ '所以这里不猜是哪一种,只给两条分辨路。)',
|
|
711
|
+
'常见原因:Chrome 会拒绝 Host 头不是 localhost/IP 的握手;那个调试端口已被另一个工具占着(同一时间只允许一个客户端);目标刚退出。',
|
|
712
|
+
'另外:如果你要的是"看页面里有什么、跑了哪些代码",用同样在这个包里的 bx_cdp_targets 先看一眼清单是否还在。',
|
|
713
|
+
].join('\n')
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
|
|
436
718
|
async function readTextFileVia(shell, exec, path, what, opts = {}) {
|
|
437
719
|
const run = await runCmd(shell, ['base64', '-i', path], exec, { stdoutMaxBytes: 64 * 1024 * 1024 })
|
|
438
720
|
if (missingTool(run)) throw new Error(`${what} 读不到:${path} —— 本机没有 base64(不应发生)`)
|
|
@@ -1191,6 +1473,11 @@ const GATE_TABLE = {
|
|
|
1191
1473
|
bx_har: { actionless: true },
|
|
1192
1474
|
// 没有 action 参数,只读两份 JSON。
|
|
1193
1475
|
bx_diff: { actionless: true },
|
|
1476
|
+
// 进程内通道:**整件受控**(照 bx_doctor 的先例)—— 附加调试接口会读走那个进程的实时状态,
|
|
1477
|
+
// 后续的 Debugger/Profiler 还会在目标里打开仪表,属于"主动探测"那一档。
|
|
1478
|
+
bx_cdp_targets: { readOnly: [] },
|
|
1479
|
+
bx_cdp_scripts: { readOnly: [] },
|
|
1480
|
+
bx_cdp_coverage: { readOnly: [] },
|
|
1194
1481
|
}
|
|
1195
1482
|
|
|
1196
1483
|
function gateReason(tool, action) {
|
|
@@ -1895,4 +2182,183 @@ export function apply(ctx) {
|
|
|
1895
2182
|
return [`${lines.length} 处差异:`, ...lines.map(line => ` ${line}`)].join('\n')
|
|
1896
2183
|
},
|
|
1897
2184
|
}))
|
|
2185
|
+
// 传输是可注入的(`ctx.flyCdpTransport`)—— 桩就钉在这里:真接缝是 Node 全局 WebSocket,
|
|
2186
|
+
// 而实测它把"没人听 / 握手被拒 / 握上就断"报成**同一个形状**,所以桩必须复刻那个形状。
|
|
2187
|
+
const cdpTransport = () => (typeof ctx.flyCdpTransport?.session === 'function' ? ctx.flyCdpTransport : realCdpTransport())
|
|
2188
|
+
|
|
2189
|
+
// ── bx_cdp_targets(进程内通道第一件)───────────────────────
|
|
2190
|
+
ctx.tools.register(defineTool({
|
|
2191
|
+
name: 'bx_cdp_targets',
|
|
2192
|
+
description: 'List debuggable targets (pages/workers/iframes) in a Chromium/Electron process started with --remote-debugging-port, via GET 127.0.0.1:<port>/json/list. Loopback only; needs FLY_ALLOW_INTERCEPT=1.',
|
|
2193
|
+
parameters: {
|
|
2194
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2195
|
+
},
|
|
2196
|
+
output: stringOutput,
|
|
2197
|
+
async execute(args, exec) {
|
|
2198
|
+
const port = requireCdpPort(args.port)
|
|
2199
|
+
const run = await runCmd(shell, ['curl', '-sS', '-m', '8', cdpListUrl(port)], exec, { timeoutMs: 15_000 })
|
|
2200
|
+
if (run.exitCode !== 0) throw cdpUnreachableError(port, run)
|
|
2201
|
+
const targets = parseCdpTargets(run.stdout, port)
|
|
2202
|
+
const shown = targets.slice(0, CDP_TARGETS_SHOWN)
|
|
2203
|
+
return JSON.stringify({
|
|
2204
|
+
tool: 'bx_cdp_targets',
|
|
2205
|
+
target: `127.0.0.1:${port}`,
|
|
2206
|
+
operation: `GET ${cdpListUrl(port)}(只走回环;端口只认参数)`,
|
|
2207
|
+
observed: {
|
|
2208
|
+
count: targets.length,
|
|
2209
|
+
shown: shown.length,
|
|
2210
|
+
omitted: targets.length - shown.length,
|
|
2211
|
+
targets: shown.map(item => ({
|
|
2212
|
+
id: item.id,
|
|
2213
|
+
type: item.type,
|
|
2214
|
+
title: item.title,
|
|
2215
|
+
url: item.url,
|
|
2216
|
+
attachable: typeof item.webSocketDebuggerUrl === 'string' && item.webSocketDebuggerUrl !== '',
|
|
2217
|
+
})),
|
|
2218
|
+
},
|
|
2219
|
+
confidence: 'observed',
|
|
2220
|
+
limitations: [
|
|
2221
|
+
'这是**某一刻**的清单:target 会随页面开关、进程进退而变(attach 之后还会变)',
|
|
2222
|
+
'只列**开了调试端口的那一个进程** —— 本机别的 Electron / 浏览器没开端口就完全看不见,'
|
|
2223
|
+
+ '这**不是**"本机没有这类应用"',
|
|
2224
|
+
'只走回环(参数表里没有 host),所以它**不能**用来连远端设备 —— 那是另一件事',
|
|
2225
|
+
'`webSocketDebuggerUrl`(真正的控制通道)**故意不打印**,只给 attachable 真假;'
|
|
2226
|
+
+ '拿到它就能完全接管那个页面,需要附加上去的动作在后续工具里、同样在这道门后面',
|
|
2227
|
+
'`/json/list` 的字段随 Chromium 版本变(本工具只挑 id/type/title/url 四列 + attachable),'
|
|
2228
|
+
+ '少一列就是少一列,不当作失败',
|
|
2229
|
+
],
|
|
2230
|
+
}, null, 2)
|
|
2231
|
+
},
|
|
2232
|
+
}))
|
|
2233
|
+
// ── bx_cdp_scripts(进程内通道第二件)───────────────────────
|
|
2234
|
+
ctx.tools.register(defineTool({
|
|
2235
|
+
name: 'bx_cdp_scripts',
|
|
2236
|
+
description: 'Attach to a CDP target on a loopback debug port and inventory its parsed scripts (Debugger.enable + Debugger.scriptParsed over a short window). Replays already-parsed scripts; source text is NOT returned. Needs FLY_ALLOW_INTERCEPT=1.',
|
|
2237
|
+
parameters: {
|
|
2238
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2239
|
+
target: { type: 'string', description: 'target id from bx_cdp_targets; omit = first page target' },
|
|
2240
|
+
windowMs: { type: 'number', description: 'how long to collect scriptParsed (default 1200, clamped 200..5000)' },
|
|
2241
|
+
},
|
|
2242
|
+
output: stringOutput,
|
|
2243
|
+
async execute(args, exec) {
|
|
2244
|
+
const port = requireCdpPort(args.port)
|
|
2245
|
+
const windowMs = clampWindow(args.windowMs, 1200, 200, 5000)
|
|
2246
|
+
const { session, picked } = await cdpAttach(cdpTransport(), shell, exec, {
|
|
2247
|
+
port, target: args.target, windowMs, commands: [{ method: 'Debugger.enable' }],
|
|
2248
|
+
})
|
|
2249
|
+
const enable = session.results['Debugger.enable']
|
|
2250
|
+
if (enable?.error !== undefined) {
|
|
2251
|
+
throw new Error(`Debugger.enable 被目标拒绝(${enable.error.code ?? '?'} ${enable.error.message ?? ''})—— 这个 target 不允许附加调试域,换一个 target 或换一个实例。`)
|
|
2252
|
+
}
|
|
2253
|
+
const scripts = session.events.filter(event => event.method === 'Debugger.scriptParsed').map(event => event.params ?? {})
|
|
2254
|
+
const shown = scripts.slice(0, CDP_TARGETS_SHOWN)
|
|
2255
|
+
return JSON.stringify({
|
|
2256
|
+
tool: 'bx_cdp_scripts',
|
|
2257
|
+
target: `127.0.0.1:${port} · ${picked.id}(${picked.type})`,
|
|
2258
|
+
operation: `WebSocket 附加 → Debugger.enable → 收 ${windowMs}ms 的 Debugger.scriptParsed`,
|
|
2259
|
+
observed: {
|
|
2260
|
+
count: scripts.length,
|
|
2261
|
+
shown: shown.length,
|
|
2262
|
+
omitted: scripts.length - shown.length,
|
|
2263
|
+
closedEarly: session.closedEarly,
|
|
2264
|
+
closeCode: session.closeCode,
|
|
2265
|
+
scripts: shown.map(item => ({
|
|
2266
|
+
scriptId: item.scriptId,
|
|
2267
|
+
url: typeof item.url === 'string' && item.url !== '' ? item.url : '(inline/eval)',
|
|
2268
|
+
length: item.length,
|
|
2269
|
+
isModule: item.isModule,
|
|
2270
|
+
sourceMap: typeof item.sourceMapURL === 'string' && item.sourceMapURL !== '',
|
|
2271
|
+
hasSourceURL: item.hasSourceURL,
|
|
2272
|
+
})),
|
|
2273
|
+
},
|
|
2274
|
+
confidence: 'observed',
|
|
2275
|
+
limitations: [
|
|
2276
|
+
'这是**已经解析过**的脚本清单:`Debugger.enable` 会**回放**已解析的(实测挂上去立刻收到 7 条),'
|
|
2277
|
+
+ '它不是"将要解析什么"的预测',
|
|
2278
|
+
'看不见**还没解析的懒加载代码**(窗口内没加载就不出现)、**已被 GC 的脚本**、以及**别的 target**'
|
|
2279
|
+
+ '(worker / iframe 在清单里是独立条目,要各自附加)',
|
|
2280
|
+
'`url` 显示 `(inline/eval)` = 那段脚本没有 URL(内联或 eval);`length` 是**源文本字符数**,不是字节数',
|
|
2281
|
+
'**不返回源码**(要源码得再发 `Debugger.getScriptSource`,那是另一件事、另一个工具)',
|
|
2282
|
+
`只收了一个窗口(${windowMs}ms),窗口一结束就**断开连接** —— CDP 的域随连接走,断开即释放,目标上不留仪表`,
|
|
2283
|
+
session.closedEarly
|
|
2284
|
+
? `⚠️ 连接在窗口内断了(close code ${session.closeCode}):上面这份是**窗口内收到的部分**,不是完整清单`
|
|
2285
|
+
: '窗口内连接保持正常(没有提前断开)',
|
|
2286
|
+
],
|
|
2287
|
+
}, null, 2)
|
|
2288
|
+
},
|
|
2289
|
+
}))
|
|
2290
|
+
|
|
2291
|
+
// ── bx_cdp_coverage(进程内通道第三件)───────────────────────
|
|
2292
|
+
ctx.tools.register(defineTool({
|
|
2293
|
+
name: 'bx_cdp_coverage',
|
|
2294
|
+
description: 'Attach to a CDP target on a loopback debug port, measure precise JS coverage (Profiler.startPreciseCoverage + takePreciseCoverage) over a short window, and report executed-byte ratios per script. Fails loudly when the snapshot cannot be taken. Needs FLY_ALLOW_INTERCEPT=1.',
|
|
2295
|
+
parameters: {
|
|
2296
|
+
port: { type: 'number', required: true, description: 'CDP debug port on 127.0.0.1, e.g. 9222' },
|
|
2297
|
+
target: { type: 'string', description: 'target id from bx_cdp_targets; omit = first page target' },
|
|
2298
|
+
windowMs: { type: 'number', description: 'sampling window before the snapshot (default 1500, clamped 300..10000)' },
|
|
2299
|
+
},
|
|
2300
|
+
output: stringOutput,
|
|
2301
|
+
async execute(args, exec) {
|
|
2302
|
+
const port = requireCdpPort(args.port)
|
|
2303
|
+
const windowMs = clampWindow(args.windowMs, 1500, 300, 10000)
|
|
2304
|
+
const { session, picked } = await cdpAttach(cdpTransport(), shell, exec, {
|
|
2305
|
+
port,
|
|
2306
|
+
target: args.target,
|
|
2307
|
+
windowMs,
|
|
2308
|
+
commands: [
|
|
2309
|
+
{ method: 'Profiler.enable' },
|
|
2310
|
+
{ method: 'Profiler.startPreciseCoverage', params: { callCount: true, detailed: false }, key: 'start' },
|
|
2311
|
+
],
|
|
2312
|
+
finish: [{ method: 'Profiler.takePreciseCoverage', key: 'take' }],
|
|
2313
|
+
})
|
|
2314
|
+
const took = session.results.take
|
|
2315
|
+
if (took === undefined) {
|
|
2316
|
+
throw new Error(session.closedEarly
|
|
2317
|
+
? `采样窗口里连接断了(close code ${session.closeCode})—— **拿不到快照**,所以不给"部分覆盖率":半份快照会让人把"没跑过"读成"跑过了"。`
|
|
2318
|
+
: '发了 Profiler.takePreciseCoverage 但没等到响应 —— 目标可能卡住了(这一次没有覆盖率可报)。')
|
|
2319
|
+
}
|
|
2320
|
+
if (took.error !== undefined) {
|
|
2321
|
+
throw new Error(`Profiler.takePreciseCoverage 被目标拒绝(${took.error.code ?? '?'} ${took.error.message ?? ''})—— 目标可能不允许精确覆盖率采样。`)
|
|
2322
|
+
}
|
|
2323
|
+
const started = session.results.start
|
|
2324
|
+
if (started?.error !== undefined) {
|
|
2325
|
+
throw new Error(`Profiler.startPreciseCoverage 被目标拒绝(${started.error.code ?? '?'} ${started.error.message ?? ''})—— 这一步没成功,后面的数就不是覆盖率。`)
|
|
2326
|
+
}
|
|
2327
|
+
const summary = summarizeCoverage(Array.isArray(took.result?.result) ? took.result.result : [])
|
|
2328
|
+
const ranked = summary.rows.slice().sort((a, b) => b.sizeBytes - a.sizeBytes)
|
|
2329
|
+
const shown = ranked.slice(0, CDP_TARGETS_SHOWN)
|
|
2330
|
+
const percent = summary.sizeTotal === 0 ? null : Math.round(1000 * summary.usedTotal / summary.sizeTotal) / 10
|
|
2331
|
+
return JSON.stringify({
|
|
2332
|
+
tool: 'bx_cdp_coverage',
|
|
2333
|
+
target: `127.0.0.1:${port} · ${picked.id}(${picked.type})`,
|
|
2334
|
+
operation: `WebSocket 附加 → Profiler.enable + startPreciseCoverage → 采 ${windowMs}ms → takePreciseCoverage`,
|
|
2335
|
+
observed: {
|
|
2336
|
+
windowMs,
|
|
2337
|
+
scripts: summary.rows.length,
|
|
2338
|
+
shown: shown.length,
|
|
2339
|
+
omitted: ranked.length - shown.length,
|
|
2340
|
+
executedBytes: summary.usedTotal,
|
|
2341
|
+
totalBytes: summary.sizeTotal,
|
|
2342
|
+
executedPercent: percent,
|
|
2343
|
+
rows: shown.map(row => ({
|
|
2344
|
+
url: row.url,
|
|
2345
|
+
functions: row.functions,
|
|
2346
|
+
executedFunctions: row.executedFunctions,
|
|
2347
|
+
executedBytes: row.usedBytes,
|
|
2348
|
+
sizeBytes: row.sizeBytes,
|
|
2349
|
+
})),
|
|
2350
|
+
},
|
|
2351
|
+
confidence: 'observed',
|
|
2352
|
+
limitations: [
|
|
2353
|
+
`只反映**这个 ${windowMs}ms 窗口内**跑过的代码 —— 再采一次会变;它不是"静态可达性",`
|
|
2354
|
+
+ '更不能读成"这段代码永远不会跑"',
|
|
2355
|
+
'**没加载的脚本根本不出现**(覆盖率只报看得见的脚本)—— 清单里没有 ≠ 这个应用没有那段代码',
|
|
2356
|
+
'`detailed: false` ⇒ **函数粒度**(不是块粒度 / 不是逐语句)',
|
|
2357
|
+
'区间**求了并集**再相加(V8 的函数区间会嵌套,直接相加会重复计数)—— 这是本工具的口径',
|
|
2358
|
+
'采样期间目标带着 V8 的仪表跑,会略微变慢;窗口一结束就**断开连接**(域随连接走,断开即释放)',
|
|
2359
|
+
'`执行比例` 是**字节口径**(已执行区间 / 脚本最大偏移),不是函数比例',
|
|
2360
|
+
],
|
|
2361
|
+
}, null, 2)
|
|
2362
|
+
},
|
|
2363
|
+
}))
|
|
1898
2364
|
}
|
|
@@ -96,6 +96,126 @@ function requireOk(run, what) {
|
|
|
96
96
|
* match `/\bret\b/` — so a textbook `pop rdi; ret` came back as "no gadgets
|
|
97
97
|
* found" (asserted in scripts/verify-security-tools.mjs).
|
|
98
98
|
*/
|
|
99
|
+
// ── Ghidra headless(2026-10-16 实测:本机可用,但有三个坑)──────────────────────
|
|
100
|
+
//
|
|
101
|
+
// 坑一(用户踩过):`/usr/libexec/java_home` 默认指向
|
|
102
|
+
// `/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home` —— 那是 **x86_64 的
|
|
103
|
+
// Java 8**,arm64 上直接 `Bad CPU type in executable`。必须显式指 Homebrew 的 arm64 JDK:
|
|
104
|
+
// `JAVA_HOME=/opt/homebrew/opt/openjdk@26`(实测 26.0.1;openjdk@11 备选)。
|
|
105
|
+
// 坑二(本次实测):analyzeHeadless 的**项目目录必须已存在**(否则
|
|
106
|
+
// `ERROR Abort due to Headless analyzer error: Directory not found: …`)—— 它不替你建。
|
|
107
|
+
// 坑三(本次实测):`os/mac_arm_64/decompile does not exist` ⇒ **反编译的原生件没编译**,
|
|
108
|
+
// 这条能力要先跑 `support/buildNatives`(要 C++ 工具链)才有;在此之前只有 import/分析/函数表。
|
|
109
|
+
|
|
110
|
+
/** arm64 macOS 上唯一实测可用的 JDK(doctor 会把这条写进结果)。 */
|
|
111
|
+
const GHIDRA_JAVA_HOME = '/opt/homebrew/opt/openjdk@26'
|
|
112
|
+
|
|
113
|
+
/** `-postScript` 里那个脚本:把函数表打成 `FLYFN ` 前缀的 JSON 行(脚本在 scratch 里现写)。 */
|
|
114
|
+
function flyFunctionsScript() {
|
|
115
|
+
return [
|
|
116
|
+
'// Generated by fly-reverse (rev_ghidra) — prints one `FLYFN <json>` line per function.',
|
|
117
|
+
'import ghidra.app.script.GhidraScript;',
|
|
118
|
+
'import ghidra.program.model.listing.*;',
|
|
119
|
+
'import java.io.*;',
|
|
120
|
+
'public class FlyFunctions extends GhidraScript {',
|
|
121
|
+
' @Override public void run() throws Exception {',
|
|
122
|
+
' int limit = 200;',
|
|
123
|
+
' if (getScriptArgs().length > 0) limit = Integer.parseInt(getScriptArgs()[0]);',
|
|
124
|
+
' PrintWriter out = new PrintWriter(System.out);',
|
|
125
|
+
' FunctionIterator it = currentProgram.getFunctionManager().getFunctions(true);',
|
|
126
|
+
' int n = 0;',
|
|
127
|
+
' while (it.hasNext() && n < limit) {',
|
|
128
|
+
' Function fn = it.next();',
|
|
129
|
+
' char q = 34;',
|
|
130
|
+
' String json = "{" + q + "name" + q + ":" + q + fn.getName().replace(String.valueOf(q), "") + q',
|
|
131
|
+
' + "," + q + "entry" + q + ":" + q + "0x" + Long.toHexString(fn.getEntryPoint().getOffset()) + q',
|
|
132
|
+
' + "," + q + "size" + q + ":" + fn.getBody().getNumAddresses()',
|
|
133
|
+
' + "," + q + "thunk" + q + ":" + fn.isThunk()',
|
|
134
|
+
' + "," + q + "external" + q + ":" + fn.isExternal() + "}";',
|
|
135
|
+
' out.println("FLYFN " + json);',
|
|
136
|
+
' n += 1;',
|
|
137
|
+
' }',
|
|
138
|
+
' out.println("FLYFN-END " + n);',
|
|
139
|
+
' out.flush();',
|
|
140
|
+
' }',
|
|
141
|
+
'}',
|
|
142
|
+
].join('\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 经接缝写一份文件(base64 中转 ⇒ 不引号化内容、也**不靠 stdin**:runCmd 没有 stdin,cat 会挂死)。 */
|
|
146
|
+
async function writeViaBase64(shell, exec, path, content) {
|
|
147
|
+
const encoded = Buffer.from(content, 'utf8').toString('base64')
|
|
148
|
+
return runCmd(shell, ['sh', '-c', 'printf %s ' + shq(encoded) + ' | base64 -d > ' + shq(path)], exec, { timeoutMs: 15_000 })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 找 analyzeHeadless:显式 GHIDRA_HOME 优先,否则候选目录(找不到就空串)。 */
|
|
152
|
+
/** 反编译用的 postScript(要 `os/<平台>/decompile` 原生件;没有就由 ghidraFix 给修法)。 */
|
|
153
|
+
function flyDecompileScript() {
|
|
154
|
+
return [
|
|
155
|
+
'// Generated by fly-reverse (rev_ghidra decompile) — prints `FLYFN-NAME` + pseudocode blocks.',
|
|
156
|
+
'import ghidra.app.script.GhidraScript;',
|
|
157
|
+
'import ghidra.app.decompiler.*;',
|
|
158
|
+
'import ghidra.program.model.listing.*;',
|
|
159
|
+
'import java.io.*;',
|
|
160
|
+
'public class FlyDecompile extends GhidraScript {',
|
|
161
|
+
' @Override public void run() throws Exception {',
|
|
162
|
+
' String want = getScriptArgs().length > 0 ? getScriptArgs()[0] : "";',
|
|
163
|
+
' DecompInterface ifc = new DecompInterface();',
|
|
164
|
+
' ifc.openProgram(currentProgram);',
|
|
165
|
+
' FunctionIterator it = currentProgram.getFunctionManager().getFunctions(true);',
|
|
166
|
+
' PrintWriter out = new PrintWriter(System.out);',
|
|
167
|
+
' int n = 0;',
|
|
168
|
+
' while (it.hasNext() && n < 20) {',
|
|
169
|
+
' Function fn = it.next();',
|
|
170
|
+
' if (!want.isEmpty() && !fn.getName().equals(want) && !want.equals(fn.getEntryPoint().toString())) continue;',
|
|
171
|
+
' DecompileResults res = ifc.decompileFunction(fn, 60, monitor);',
|
|
172
|
+
' out.println("FLYFN-NAME " + fn.getName());',
|
|
173
|
+
' out.println("FLYCODE-START");',
|
|
174
|
+
' out.println(res.decompileCompleted() ? res.getDecompiledFunction().getC() : "/* decompile failed for this function */");',
|
|
175
|
+
' out.println("FLYCODE-END");',
|
|
176
|
+
' n += 1;',
|
|
177
|
+
' }',
|
|
178
|
+
' out.flush();',
|
|
179
|
+
' ifc.dispose();',
|
|
180
|
+
' }',
|
|
181
|
+
'}',
|
|
182
|
+
].join('\n')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
function ghidraHeadless(shell, exec) {
|
|
187
|
+
return runCmd(shell, ['sh', '-c', 'if [ -n "$GHIDRA_HOME" ]; then echo "$GHIDRA_HOME/support/analyzeHeadless"; else ls -d "$HOME"/tools/ghidra_*/support/analyzeHeadless 2>/dev/null | sort | tail -1; fi'], exec, { timeoutMs: 10_000 })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** JDK 探针直接跑 javaHome 下的 java —— **别**写 `JAVA_HOME=x "$JAVA_HOME/bin/java"`:
|
|
191
|
+
* POSIX 里同一命令的变量展开发生在赋值生效**之前**,`$JAVA_HOME` 会取到旧值(= 那个 x86_64
|
|
192
|
+
* Java 8)⇒ doctor 会把"能用"报成"不能用"(两处口径不一致,测试当场抓到)。 */
|
|
193
|
+
/** 两类"跑不了"都要给**可复制的修法**(不是"工具缺失"四个字)。 */
|
|
194
|
+
function ghidraFix(text) {
|
|
195
|
+
if (/Bad CPU type|Unable to prompt user for JDK path/u.test(text)) {
|
|
196
|
+
return [
|
|
197
|
+
'Ghidra 起不来:/usr/libexec/java_home 默认指向 /Library/Internet Plug-Ins/JavaAppletPlugin.plugin',
|
|
198
|
+
'(那是 **x86_64 的 Java 8**,arm64 上直接 Bad CPU type in executable)。',
|
|
199
|
+
'修法(arm64 macOS,实测可用):',
|
|
200
|
+
' export JAVA_HOME=/opt/homebrew/opt/openjdk@26',
|
|
201
|
+
' ~/tools/ghidra_12.1.4_PUBLIC/support/analyzeHeadless …',
|
|
202
|
+
].join('\n')
|
|
203
|
+
}
|
|
204
|
+
if (/decompile does not exist|Building Native Components/u.test(text)) {
|
|
205
|
+
return [
|
|
206
|
+
'Ghidra 的反编译原生件还没编译(os/mac_arm_64/decompile 不存在)——',
|
|
207
|
+
'这只影响**反编译**,import / 分析 / 函数表照旧能用。',
|
|
208
|
+
'修法(要 C++ 工具链,一次性):',
|
|
209
|
+
' cd ~/tools/ghidra_12.1.4_PUBLIC/support/gradle/ && gradle buildNatives # 或自带 wrapper(需联网):./gradlew buildNatives',
|
|
210
|
+
' # 编完在 <模块>/build/os/<平台>/ 下,**要装进** <模块>/os/<平台>/(release 布局里 Ghidra 找的是后者):',
|
|
211
|
+
' cp Ghidra/Features/Decompiler/build/os/mac_*/decompile Ghidra/Features/Decompiler/os/mac_*/',
|
|
212
|
+
].join('\n')
|
|
213
|
+
}
|
|
214
|
+
return ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
99
219
|
function scanGadgets(disasm, filter) {
|
|
100
220
|
const instruction = /^\s*([0-9a-f]+):\s+(?:[0-9a-f]{2} )+\s*([a-z][a-z0-9.]*)\s*(.*)$/u
|
|
101
221
|
const wanted = filter === undefined ? null : filter.toLowerCase().split(/\s+/u).filter(Boolean)
|
|
@@ -289,4 +409,155 @@ export function apply(ctx) {
|
|
|
289
409
|
}, null, 2)
|
|
290
410
|
},
|
|
291
411
|
}))
|
|
412
|
+
// ── rev_ghidra(Ghidra headless:doctor / functions)───────────────
|
|
413
|
+
ctx.tools.register(defineTool({
|
|
414
|
+
name: 'rev_ghidra',
|
|
415
|
+
description: 'Run Ghidra headless (analyzeHeadless) on a binary. doctor = probe the local install (JAVA_HOME trap, native decompiler); functions = import + analyse and list functions as JSON lines. arm64 macOS needs JAVA_HOME=/opt/homebrew/opt/openjdk@26.',
|
|
416
|
+
parameters: {
|
|
417
|
+
action: { type: 'string', required: true, description: 'doctor | functions' },
|
|
418
|
+
binary: { type: 'string', description: 'binary to analyse (required for functions)' },
|
|
419
|
+
limit: { type: 'number', description: 'max functions to list (default 200, clamped 1..2000)' },
|
|
420
|
+
},
|
|
421
|
+
output: {
|
|
422
|
+
schema: { type: 'string' },
|
|
423
|
+
render: (_a, v) => [{ type: 'text', text: v }],
|
|
424
|
+
},
|
|
425
|
+
async execute(args, exec) {
|
|
426
|
+
const action = String(args.action ?? '').trim()
|
|
427
|
+
if (action !== 'doctor' && action !== 'functions' && action !== 'decompile') {
|
|
428
|
+
const error = new Error('rev_ghidra 的 action 只认 doctor / functions / decompile(收到 ' + JSON.stringify(args.action) + ')')
|
|
429
|
+
error.name = 'ArgumentError'
|
|
430
|
+
throw error
|
|
431
|
+
}
|
|
432
|
+
const found = await ghidraHeadless(shell, exec)
|
|
433
|
+
const headless = found.stdout.trim().split('\n').filter(Boolean).at(-1) ?? ''
|
|
434
|
+
if (headless === '') {
|
|
435
|
+
const error = new Error([
|
|
436
|
+
'rev_ghidra:找不到 analyzeHeadless —— 这台机器上 Ghidra 要么没装,要么没装在 ~/tools/ghidra_*/ 下。',
|
|
437
|
+
'两条路:设 GHIDRA_HOME=<你的 Ghidra 目录>(我只找 $GHIDRA_HOME/support/analyzeHeadless 与 ~/tools/ghidra_*/support/analyzeHeadless),',
|
|
438
|
+
'或者装一个:https://ghidra-sre.org/ (下载 zip 解压即可,不需要安装器)。',
|
|
439
|
+
].join('\n'))
|
|
440
|
+
error.name = 'ToolError'
|
|
441
|
+
throw error
|
|
442
|
+
}
|
|
443
|
+
const javaHome = typeof process.env.GHIDRA_JAVA_HOME === 'string' && process.env.GHIDRA_JAVA_HOME.trim() !== ''
|
|
444
|
+
? process.env.GHIDRA_JAVA_HOME.trim()
|
|
445
|
+
: GHIDRA_JAVA_HOME
|
|
446
|
+
const work = (process.env.TMPDIR || '/tmp').replace(/\/$/u, '') + '/fly-reverse-ghidra'
|
|
447
|
+
// 注意:`analyzeHeadless` 是**文件**,`<文件>/../…` 在 POSIX 里解析不了(ENOTDIR)——
|
|
448
|
+
// 第一版探针就是这么把"装好了"读成"不在"的(手跑同一条命令复现)。要先 `$(dirname …)`。
|
|
449
|
+
// 平台目录名是 `mac_arm_64`(**有下划线**):拿 `uname -m` 的 `arm64` 直接拼 `mac_${P}` 会得到
|
|
450
|
+
// `mac_arm64` ⇒ test -e 恒假,而 ls 明明看得见那个文件 —— 两个读数矛盾就是尺子拼错了。
|
|
451
|
+
const nativeProbe = headless === '' ? null : await runCmd(shell, ['sh', '-c', 'P=$(uname -m | sed s/arm64/arm_64/); D=$(dirname "' + headless + '"); test -e "$D/../Ghidra/Features/Decompiler/os/mac_${P}/decompile" || test -e "$D/../../os/mac_${P}/decompile"'], exec, { timeoutMs: 10_000 })
|
|
452
|
+
const javaRun = await runCmd(shell, ['sh', '-c', shq(javaHome + '/bin/java') + ' -version'], exec, { timeoutMs: 15_000 })
|
|
453
|
+
if (action === 'doctor') {
|
|
454
|
+
const probes = []
|
|
455
|
+
probes.push({ what: 'analyzeHeadless', ok: headless !== '', detail: headless === '' ? '不在 ~/tools/ghidra_*/support/ 下(装了的话设 GHIDRA_HOME)' : headless })
|
|
456
|
+
probes.push({ what: 'arm64 JDK', ok: javaRun.exitCode === 0, detail: javaRun.stdout.trim().split('\n')[0] ?? javaRun.stderr.trim().split('\n')[0] })
|
|
457
|
+
probes.push({ what: '反编译原生件 os/mac_arm_64/decompile', ok: nativeProbe !== null && nativeProbe.exitCode === 0, detail: nativeProbe !== null && nativeProbe.exitCode === 0 ? '在' : '不在 —— 反编译不可用,函数表照旧(修法见 limitations)' })
|
|
458
|
+
return JSON.stringify({
|
|
459
|
+
tool: 'rev_ghidra',
|
|
460
|
+
target: headless === '' ? '(未找到 analyzeHeadless)' : headless,
|
|
461
|
+
operation: '三个探针:analyzeHeadless 在不在 / arm64 JDK 能不能跑 / 反编译原生件在不在',
|
|
462
|
+
observed: { probes, usable: { functions: headless !== '' && javaRun.exitCode === 0, decompile: nativeProbe !== null && nativeProbe.exitCode === 0 } },
|
|
463
|
+
confidence: 'observed',
|
|
464
|
+
limitations: [
|
|
465
|
+
'arm64 macOS 上**必须**显式设 JAVA_HOME=/opt/homebrew/opt/openjdk@26:'
|
|
466
|
+
+ '/usr/libexec/java_home 默认指向 JavaAppletPlugin(x86_64 Java 8),arm64 直接 Bad CPU type',
|
|
467
|
+
'analyzeHeadless 的**项目目录必须已存在**(它不替你建)—— 实测出来的形状',
|
|
468
|
+
'反编译原生件缺失 ≠ "这个包没有反编译":那是装机器的事,修法在下一条',
|
|
469
|
+
'修法(反编译,官方 GettingStarted.md §Building Native Components):cd ~/tools/ghidra_12.1.4_PUBLIC/support/gradle/ && gradle buildNatives # 或自带 wrapper(需联网):./gradlew buildNatives',
|
|
470
|
+
],
|
|
471
|
+
}, null, 2)
|
|
472
|
+
}
|
|
473
|
+
if (action === 'decompile') {
|
|
474
|
+
if (typeof args.binary !== 'string' || args.binary.trim() === '') {
|
|
475
|
+
const error = new Error('rev_ghidra decompile 需要 binary 参数(要反编译哪个文件)')
|
|
476
|
+
error.name = 'ArgumentError'
|
|
477
|
+
throw error
|
|
478
|
+
}
|
|
479
|
+
const decBinary = safePath(args.binary.trim())
|
|
480
|
+
const want = typeof args.function === 'string' && args.function.trim() !== '' ? args.function.trim() : ''
|
|
481
|
+
await runCmd(shell, ['mkdir', '-p', work], exec, { timeoutMs: 10_000 })
|
|
482
|
+
await writeViaBase64(shell, exec, work + '/FlyDecompile.java', flyDecompileScript())
|
|
483
|
+
const run = await runCmd(shell, ['sh', '-c', [
|
|
484
|
+
'JAVA_HOME=' + javaHome + ' ' + shq(headless) + ' ' + shq(work) + ' flyproj',
|
|
485
|
+
'-import ' + shq(decBinary) + ' -deleteProject',
|
|
486
|
+
'-scriptPath ' + shq(work) + ' -postScript FlyDecompile ' + shq(want),
|
|
487
|
+
].join(' ')], exec, { timeoutMs: 240_000, stdoutMaxBytes: 8 * 1024 * 1024 })
|
|
488
|
+
const text = (run.stdout ?? '') + (run.stderr ?? '')
|
|
489
|
+
if (run.exitCode !== 0) {
|
|
490
|
+
const fix = ghidraFix(text)
|
|
491
|
+
throw new Error('rev_ghidra decompile 跑失败(exit ' + run.exitCode + '):'
|
|
492
|
+
+ text.trim().split('\n').filter(Boolean).slice(0, 3).join(' | ')
|
|
493
|
+
+ (fix === '' ? '' : '\n\n' + fix))
|
|
494
|
+
}
|
|
495
|
+
const out = run.stdout
|
|
496
|
+
if (!/FLYCODE-END/u.test(out)) {
|
|
497
|
+
throw new Error('rev_ghidra decompile:一行伪代码都没产出(退出码却是 0)—— 脚本多半没编译过或原生反编译器没装,'
|
|
498
|
+
+ 'Ghidra 对两种情况都可能只打日志不报错;跑 rev_ghidra doctor 看反编译原生件在不在。')
|
|
499
|
+
}
|
|
500
|
+
const blocks = []
|
|
501
|
+
const pattern = /FLYFN-NAME (\S+)\r?\nFLYCODE-START\r?\n([\s\S]*?)\r?\nFLYCODE-END/gu
|
|
502
|
+
for (const match of out.matchAll(pattern)) blocks.push({ name: match[1], code: match[2] })
|
|
503
|
+
return JSON.stringify({
|
|
504
|
+
tool: 'rev_ghidra',
|
|
505
|
+
target: decBinary + (want === '' ? '' : ' · ' + want),
|
|
506
|
+
operation: 'analyzeHeadless import + 分析 → FlyDecompile postScript(DecompInterface)',
|
|
507
|
+
observed: { functions: blocks.length, blocks: blocks.slice(0, 20) },
|
|
508
|
+
confidence: 'observed',
|
|
509
|
+
limitations: [
|
|
510
|
+
'这是 Ghidra 反编译器的**伪代码**(C 风格):不是源码、不是汇编,变量名多是 Ghidra 起的(FUN_xxx 等)',
|
|
511
|
+
'每个函数最多等 60 秒反编译(DecompInterface 的超时);超时的会标注 decompile failed',
|
|
512
|
+
'需要 os/<平台>/decompile 原生件 —— 没装时 Ghidra 只在日志里说,这里把它抬成大声报错 + 修法',
|
|
513
|
+
'同一函数的伪代码会随 Ghidra 版本/分析深度变化,别当稳定接口用',
|
|
514
|
+
],
|
|
515
|
+
}, null, 2)
|
|
516
|
+
}
|
|
517
|
+
if (typeof args.binary !== 'string' || args.binary.trim() === '') {
|
|
518
|
+
const error = new Error('rev_ghidra functions 需要 binary 参数(要分析哪个文件)')
|
|
519
|
+
error.name = 'ArgumentError'
|
|
520
|
+
throw error
|
|
521
|
+
}
|
|
522
|
+
const binary = safePath(args.binary.trim())
|
|
523
|
+
const limit = Math.min(2000, Math.max(1, Math.round(Number(args.limit ?? 200) || 200)))
|
|
524
|
+
await runCmd(shell, ['mkdir', '-p', work], exec, { timeoutMs: 10_000 })
|
|
525
|
+
await writeViaBase64(shell, exec, work + '/FlyFunctions.java', flyFunctionsScript())
|
|
526
|
+
const run = await runCmd(shell, ['sh', '-c', [
|
|
527
|
+
'JAVA_HOME=' + javaHome + ' ' + shq(headless) + ' ' + shq(work) + ' flyproj',
|
|
528
|
+
'-import ' + shq(binary) + ' -deleteProject',
|
|
529
|
+
'-scriptPath ' + shq(work) + ' -postScript FlyFunctions ' + limit,
|
|
530
|
+
].join(' ')], exec, { timeoutMs: 240_000, stdoutMaxBytes: 4 * 1024 * 1024 })
|
|
531
|
+
const text = (run.stdout ?? '') + (run.stderr ?? '')
|
|
532
|
+
if (run.exitCode !== 0) {
|
|
533
|
+
const fix = ghidraFix(text)
|
|
534
|
+
throw new Error('rev_ghidra functions 跑失败(exit ' + run.exitCode + '):'
|
|
535
|
+
+ text.trim().split('\n').filter(Boolean).slice(0, 3).join(' | ')
|
|
536
|
+
+ (fix === '' ? '' : '\n\n' + fix))
|
|
537
|
+
}
|
|
538
|
+
const rows = run.stdout.split('\n').filter(line => line.startsWith('FLYFN {')).map(line => JSON.parse(line.slice(6)))
|
|
539
|
+
const total = Number(/FLYFN-END (\d+)/u.exec(run.stdout)?.[1] ?? rows.length)
|
|
540
|
+
if (!/FLYFN-END/u.test(run.stdout)) {
|
|
541
|
+
// Ghidra 对**编译不过**的 postScript 是 exit 0 + 一行都不打(实测)—— 这里必须大声,
|
|
542
|
+
// 不许把"脚本没跑"读成"这个二进制没有函数"。
|
|
543
|
+
throw new Error('rev_ghidra functions:FlyFunctions 一行都没产出(退出码却是 0)—— 脚本多半没编译过。'
|
|
544
|
+
+ 'Ghidra 对编译失败的 postScript 是静默跳过的;请看命令日志里的 javac/脚本错误,'
|
|
545
|
+
+ '或照审计里的命令原样跑一遍看它说什么。')
|
|
546
|
+
}
|
|
547
|
+
return JSON.stringify({
|
|
548
|
+
tool: 'rev_ghidra',
|
|
549
|
+
target: binary,
|
|
550
|
+
operation: 'analyzeHeadless import + 分析 → FlyFunctions postScript(最多 ' + limit + ' 条)',
|
|
551
|
+
observed: { count: rows.length, listed: rows.length, total, functions: rows.slice(0, limit) },
|
|
552
|
+
confidence: 'observed',
|
|
553
|
+
limitations: [
|
|
554
|
+
'这是 Ghidra 的**函数表**(按地址顺序、最多 limit 条):不是全部符号,也不是反编译输出',
|
|
555
|
+
'thunk / external 两个标志原样给出(Ghidra 的判断),没有再做二次解释',
|
|
556
|
+
'整个二进制**会被完整分析一遍**(headless 的语义),大文件会慢 —— 请给 limit 或等',
|
|
557
|
+
'项目目录与脚本写在 scratch(TMPDIR/fly-reverse-ghidra/)里,-deleteProject 用完即删,不碰仓库',
|
|
558
|
+
'反编译(函数体的伪代码)还没接:原生件缺失时 Ghidra 自己会报,修法见 rev_ghidra doctor',
|
|
559
|
+
],
|
|
560
|
+
}, null, 2)
|
|
561
|
+
},
|
|
562
|
+
}))
|
|
292
563
|
}
|