@dsh-fly/dsh-plugin-fly-mode 0.1.7 → 0.1.9

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.
@@ -12,6 +12,17 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
12
12
  * Command execution goes through the host `shell` seam so the sandbox confines
13
13
  * it (and the FLY_ALLOW_RECON gate is an ADDITIONAL authorization layer on top
14
14
  * of the sandbox, not a replacement for it).
15
+ *
16
+ * **失败面的口径是"大声"**(Q5):
17
+ * · 没有 `nmap`(macOS 常态)⇒ 用系统自带的 `nc` 扫,并在结果里标明"这是 nc 扫的",且**明说
18
+ * `no-response` 分不清 closed 与 filtered**(要区分必须换 nmap);端口数超过内置回退的 64 上限时
19
+ * **一个都不扫**并说清上限与出路,绝不扫一半交差;
20
+ * · 目标 URL **先校验再用**(`requireHttpUrl`:只允许 http/https)—— 2026-10-13 补:此前这条只有
21
+ * `diag_net_throughput` 有,`recon_http_fingerprint` 会放行 `file://`,而 curl 能把本地文件读出来
22
+ * 回显进工具结果;Q5 那次我在本注释里先写了这条保护,**当时它并不成立**,现在它成立了;
23
+ * · `curl` 失败 ⇒ `requireOk` 抛 `CommandError`,不给"空指纹";
24
+ * · `recon_http_fingerprint` 只读响应头与 `<title>`:**不执行 JS** —— 由 JS 搭出来的界面在这把尺子里
25
+ * 等于不存在(AGENTS.md 里那台摄像头的负结论就是这么来的),这一点写进了它的 `limitations`。
15
26
  */
16
27
  export const name = 'fly-recon'
17
28
  export const inject = ['tools', 'shell']
@@ -58,11 +69,60 @@ async function runCmd(shell, argv, exec, opts = {}) {
58
69
  * tools must say so instead of reporting an empty scan, because an empty scan
59
70
  * reads as "target closed".
60
71
  */
72
+ /**
73
+ * 只接受主机名或 IP 字面量。2026-10-13 补:此前 `recon_ports` 的 `host` **没有**这道校验,
74
+ * 而 diag_* 三个工具都有 —— 于是 `-oN=/tmp/x` 这种"以 - 开头的目标"会被 nmap 当成**选项**
75
+ * (写出文件)。同族性质必须在**每个**工具上都有。
76
+ */
77
+ function requireHost(value) {
78
+ const host = String(value ?? '').trim()
79
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]*[A-Za-z0-9])?$/u.test(host)) {
80
+ const error = new Error(
81
+ `拒绝目标 ${JSON.stringify(host)}:只接受主机名或 IP 字面量 ——`
82
+ + ' 不许有空格或 shell 元字符(; | & $ ` 引号 括号),也不许以 "-" 开头'
83
+ + '(会被当成命令参数:nmap、nc 会把 `-oN=file` 之类的选项当真)。',
84
+ )
85
+ error.name = 'ArgumentError'
86
+ throw error
87
+ }
88
+ return host
89
+ }
90
+
91
+ /** 只允许 http/https:`file:` 会让 curl 把本地文件读出来回显进工具结果(2026-10-13 发现并补上)。 */
92
+ const ALLOWED_URL_SCHEMES = ['http:', 'https:']
93
+ function requireHttpUrl(value) {
94
+ const text = String(value ?? '').trim()
95
+ let parsed
96
+ try {
97
+ parsed = new URL(text)
98
+ } catch {
99
+ const error = new Error(`不是合法的 URL:${text}`)
100
+ error.name = 'ArgumentError'
101
+ throw error
102
+ }
103
+ if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
104
+ const error = new Error(`拒绝 ${parsed.protocol} 目标:只允许 http/https(file: 会读本地文件)`)
105
+ error.name = 'ArgumentError'
106
+ throw error
107
+ }
108
+ return parsed.href
109
+ }
110
+
61
111
  function requireOk(run, what) {
62
112
  if (run.exitCode === 0) return run.stdout
63
- const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
64
- const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
65
- const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
113
+ // 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
114
+ // 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
115
+ // 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
116
+ const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
117
+ const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
118
+ const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
119
+ const missing = notInstalled ? ' — the command is not installed on this host' : ''
120
+ // 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
121
+ // 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
122
+ const next = notInstalled
123
+ ? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
124
+ : 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
125
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
66
126
  error.name = 'CommandError'
67
127
  throw error
68
128
  }
@@ -128,38 +188,59 @@ export function apply(ctx) {
128
188
  render: (_a, v) => [{ type: 'text', text: v }],
129
189
  },
130
190
  async execute(args, exec) {
191
+ // 先校验再构造命令(同族性质:diag_* 三个工具早就有这道门)。
192
+ const host = requireHost(args.host)
193
+ // 证据形状(规划 §5.1):逐分支迁移 —— 本轮只把 nc 回退分支包上。
194
+ const envelope = (observed, limitations) => JSON.stringify({
195
+ tool: 'recon_ports',
196
+ target: args.host,
197
+ operation: 'TCP connect scan of the requested ports (authorized lab targets only)',
198
+ observed,
199
+ confidence: 'observed',
200
+ limitations,
201
+ }, null, 2)
131
202
  const requested = args.ports === undefined || String(args.ports).trim() === '' ? '22,80,443,445,3389,8080' : String(args.ports)
132
203
  const expanded = expandPorts(requested)
133
204
  if (expanded === null) {
134
- return `unparsable port spec ${JSON.stringify(requested)} — use a comma list with optional ranges, e.g. 22,80,8000-8010`
205
+ return envelope({ requested, dump: `unparsable port spec ${JSON.stringify(requested)} — use a comma list with optional ranges, e.g. 22,80,8000-8010` }, ['端口规格不合法 ⇒ 一个端口都没扫:observed.dump 里是解析失败的原因与可照抄的写法'])
206
+ }
207
+ const nmap = await runCmd(shell, ['nmap', '-Pn', '--open', '-p', expanded.join(','), host], exec, { timeoutMs: 60_000 })
208
+ if (nmap.exitCode === 0) {
209
+ return envelope({ method: 'nmap', dump: nmap.stdout }, [
210
+ 'nmap 的表格原样返回,本工具不改写它;本轮参数只带 -Pn --open(没有版本探测)',
211
+ 'open 只说明 TCP 连得上,不说明服务身份或版本',
212
+ ])
135
213
  }
136
- const nmap = await runCmd(shell, ['nmap', '-Pn', '--open', '-p', expanded.join(','), args.host], exec, { timeoutMs: 60_000 })
137
- if (nmap.exitCode === 0) return nmap.stdout
138
214
  if (!portMissing(nmap)) return requireOk(nmap, 'nmap')
139
215
  // `nmap` is not installed by default on macOS. Instead of failing outright
140
216
  // (the previous behaviour) or returning an empty scan that reads as "target
141
217
  // closed", scan with `nc`, which ships with the base system. The result is
142
218
  // labelled: nc cannot tell closed from filtered, and does no fingerprinting.
143
219
  if (expanded.length > 64) {
144
- return [
220
+ return envelope({ method: 'nc', scanned: 0, cap: 64, requested: expanded.length, dump: [
145
221
  `nmap is not installed on this host: the built-in fallback scans with the system nc and is capped at 64 ports per call, but ${expanded.length} were requested`,
146
222
  'narrow the range, or install nmap for a full scan (no 64-port cap, service and version detection)',
147
- ].join('\n')
223
+ ].join('\n') }, [
224
+ '请求超过内置回退的 64 端口上限 ⇒ **一个端口都没扫**(不是"扫了、没有开着的端口")',
225
+ ])
148
226
  }
149
227
  const script = expanded
150
- .map((port) => `if nc -z -G 1 -w 1 ${shq(args.host)} ${port} >/dev/null 2>&1; then echo '${port}/tcp open'; else echo '${port}/tcp no-response'; fi`)
228
+ .map((port) => `if nc -z -G 1 -w 1 ${shq(host)} ${port} >/dev/null 2>&1; then echo '${port}/tcp open'; else echo '${port}/tcp no-response'; fi`)
151
229
  .join('; ')
152
230
  const scan = await runCmd(shell, ['/bin/sh', '-c', script], exec, { timeoutMs: 120_000 })
153
231
  if (scan.exitCode !== 0 && scan.stdout.trim() === '') return requireOk(scan, 'nc -z')
154
232
  const rows = scan.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
155
- return [
233
+ return envelope({ method: 'nc', scanned: expanded.length, dump: [
156
234
  'nmap=not installed (scanned with the system nc: one TCP connect per port, no service or version detection)',
157
- `host=${args.host}`,
235
+ `host=${host}`,
158
236
  `ports=${expanded.length}`,
159
237
  ...rows,
160
238
  `open=${rows.filter((l) => l.endsWith('/tcp open')).length}`,
161
239
  'note: `no-response` means closed or filtered — nc cannot tell those two apart; install nmap for a real scan',
162
- ].join('\n')
240
+ ].join('\n') }, [
241
+ 'nmap 不在本机:这是系统 nc 的回退结果(每端口一次 TCP connect,无服务/版本探测)',
242
+ '**nc 分不清 closed 与 filtered**:no-response 两种都可能 —— 要区分必须换 nmap',
243
+ ])
163
244
  },
164
245
  }))
165
246
 
@@ -174,11 +255,31 @@ export function apply(ctx) {
174
255
  render: (_a, v) => [{ type: 'text', text: v }],
175
256
  },
176
257
  async execute(args, exec) {
177
- const headers = requireOk(await runCmd(shell, ['curl', '-sI', '-m', '8', args.url], exec), `curl -sI ${args.url}`)
178
- const body = await runCmd(shell, ['curl', '-s', '-m', '8', args.url], exec)
179
- if (body.exitCode !== 0) return `${headers.trim()}\nTITLE: unknown (body fetch failed, exit ${body.exitCode})`
258
+ // 证据形状(规划 §5.1):两支都产出信封。
259
+ const envelope = (observed, limitations) => JSON.stringify({
260
+ tool: 'recon_http_fingerprint',
261
+ target: args.url,
262
+ operation: 'one-shot HTTP fingerprint: response headers + <title> only',
263
+ observed,
264
+ confidence: 'observed',
265
+ limitations,
266
+ }, null, 2)
267
+ // 先校验再构造命令:non-http 目标在**命令构造之前**就被拒(不是事后清洗)。
268
+ const url = requireHttpUrl(args.url)
269
+ const headers = requireOk(await runCmd(shell, ['curl', '-sI', '-m', '8', url], exec), `curl -sI ${url}`)
270
+ const body = await runCmd(shell, ['curl', '-s', '-m', '8', url], exec)
271
+ if (body.exitCode !== 0) {
272
+ return envelope({ headers: headers.trim(), title: null, bodyFetched: false, dump: `${headers.trim()}\nTITLE: unknown (body fetch failed, exit ${body.exitCode})` }, [
273
+ 'body 取不到 ⇒ 只有响应头;title 是**未知**,不是「这个页面没有标题」',
274
+ 'curl -sI 不跟随重定向:看到的是**跳转前那一跳**的响应头',
275
+ ])
276
+ }
180
277
  const title = /<title[^>]*>([^<]*)<\/title>/iu.exec(body.stdout)?.[1]?.trim() ?? ''
181
- return `${headers.trim()}\nTITLE: ${title === '' ? '(no <title> element in the body)' : title}`
278
+ return envelope({ headers: headers.trim(), title, bodyFetched: true, dump: `${headers.trim()}\nTITLE: ${title === '' ? '(no <title> element in the body)' : title}` }, [
279
+ '只读响应头与第一个 <title>:**不执行 JS** —— 由 JS 搭出来的界面(登录框、动态面板、JS 表格)在这把尺子里等于不存在',
280
+ '要判 JS 渲染出来的东西,得用真浏览器(仓库有 playwright-core),并断「页面实际请求了什么、状态码是什么」',
281
+ '标题取的是第一个 <title>;SPA 或没有 title 的页面会给 (no <title> element in the body)',
282
+ ])
182
283
  },
183
284
  }))
184
285
  }
@@ -18,7 +18,28 @@ function shq(value) {
18
18
  * `TypeError: shell.run is not a function` on every call, in every engine
19
19
  * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
20
20
  * names a seam method the executor does not have.
21
+ *
22
+ * **失败面的口径是"大声"**(Q5):
23
+ * · `objdump -T` 读不出动态符号表(Mach-O 常态)⇒ 换 `nm -u` 这条**回退**,并把"这是回退路径、
24
+ * nm 只报未定义的名字、不说是从哪个库来的"写进结果的 `limitations`;
25
+ * · **两把尺子都没有** ⇒ 明说"没有可读的未定义符号",并附上两条命令的 exit 与 stderr,
26
+ * 绝不说成"这个程序不依赖任何外部符号"(静态链接/已 strip 正是这个形状);
27
+ * · `ROPgadget` 不在 ⇒ 用内置扫描器(只认 pop/leave + ret 的简单序列),并在结果里写明
28
+ * "**没找到 ≠ 没有**";`objdump` 缺失时 `requireOk` 抛错,不给空反汇编。
21
29
  */
30
+ /**
31
+ * 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
32
+ *
33
+ * 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
34
+ * `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
35
+ * `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回(`bin_sections { path: '--version' }`
36
+ * 真发生过)。加 `./` 之后它就是一个普通相对路径:不存在就正常报错,存在就是那个文件。
37
+ */
38
+ function safePath(value) {
39
+ const text = String(value ?? '')
40
+ return text.startsWith('-') ? './' + text : text
41
+ }
42
+
22
43
  async function runCmd(shell, argv, exec, opts = {}) {
23
44
  const result = await (await shell.execute(shell.resolve({
24
45
  command: argv.map(shq).join(' '),
@@ -49,9 +70,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
49
70
  */
50
71
  function requireOk(run, what) {
51
72
  if (run.exitCode === 0) return run.stdout
52
- const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
53
- const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
54
- const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
73
+ // 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
74
+ // 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
75
+ // 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
76
+ const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
77
+ const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
78
+ const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
79
+ const missing = notInstalled ? ' — the command is not installed on this host' : ''
80
+ // 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
81
+ // 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
82
+ const next = notInstalled
83
+ ? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
84
+ : 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
85
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
55
86
  error.name = 'CommandError'
56
87
  throw error
57
88
  }
@@ -103,33 +134,67 @@ export function apply(ctx) {
103
134
  render: (_a, v) => [{ type: 'text', text: v }],
104
135
  },
105
136
  async execute(args, exec) {
106
- const kindRun = await runCmd(shell, ['file', '-b', args.path], exec)
137
+ const path = safePath(args.path)
138
+ const kindRun = await runCmd(shell, ['file', '-b', path], exec)
107
139
  const kind = kindRun.exitCode === 0 ? kindRun.stdout.trim() : ''
108
- const dynamic = await runCmd(shell, ['objdump', '-T', args.path], exec)
140
+ const dynamic = await runCmd(shell, ['objdump', '-T', path], exec)
109
141
  const dynamicLines = dynamic.exitCode === 0
110
142
  ? dynamic.stdout.split('\n').filter((l) => l.includes('DF') && l.includes('*UND*')).map((l) => l.trim())
111
143
  : []
112
- if (dynamicLines.length > 0) return dynamicLines.slice(0, 300).join('\n')
144
+ if (dynamicLines.length > 0) {
145
+ return JSON.stringify({
146
+ tool: 'rev_imports',
147
+ target: args.path,
148
+ operation: 'list undefined (imported) symbols: objdump -T (DF *UND*)',
149
+ observed: { method: 'objdump', shown: Math.min(dynamicLines.length, 300), dump: dynamicLines.slice(0, 300).join('\n') },
150
+ confidence: 'observed',
151
+ limitations: [
152
+ '只列**动态**符号表(.dynsym)里的未定义项:静态链接进来的、或本地定义的符号不在里面',
153
+ dynamicLines.length > 300 ? '只给前 300 条(要全量就自己按符号名筛)' : '这次没有截断(未定义符号 ≤ 300)',
154
+ ],
155
+ }, null, 2)
156
+ }
113
157
  // `objdump -T` exits 0 with a bare table header for Mach-O, and a stripped
114
158
  // ELF may have no dynamic symbol table at all. `nm -u` is the portable
115
159
  // second opinion (it ships with macOS and the Xcode CLT).
116
- const nm = await runCmd(shell, ['nm', '-u', args.path], exec, { stdoutMaxBytes: 4194304 })
160
+ const nm = await runCmd(shell, ['nm', '-u', path], exec, { stdoutMaxBytes: 4194304 })
117
161
  const undefinedSymbols = nm.exitCode === 0
118
162
  ? nm.stdout.split('\n').map((l) => l.trim()).filter((l) => /^_?[A-Za-z_][\w$.@]*$/u.test(l))
119
163
  : []
120
164
  if (undefinedSymbols.length > 0) {
121
- return [
165
+ return JSON.stringify({
166
+ tool: 'rev_imports',
167
+ target: args.path,
168
+ operation: 'list undefined (imported) symbols: nm -u (objdump -T had no dynamic table)',
169
+ observed: { method: 'nm -u', format: kind === '' ? null : kind, shown: Math.min(undefinedSymbols.length, 300), dump: [
122
170
  `${kind === '' ? 'format unknown' : kind}: no ELF dynamic symbol table to read, so these are the undefined symbols from nm -u (${undefinedSymbols.length}):`,
123
171
  ...undefinedSymbols.slice(0, 300),
124
- ].join('\n')
172
+ ].join('\n') },
173
+ confidence: 'observed',
174
+ limitations: [
175
+ '走的是 nm -u 这条**回退**路径:objdump -T 在这个文件上没有动态符号表(Mach-O 常见)',
176
+ 'nm -u 只报"未定义"的名字,不告诉你是从哪个库来的(要库映射得看 otool -L / readelf -d)',
177
+ ],
178
+ }, null, 2)
125
179
  }
126
- return [
180
+ return JSON.stringify({
181
+ tool: 'rev_imports',
182
+ target: args.path,
183
+ operation: 'list undefined (imported) symbols: both objdump -T and nm -u came back empty',
184
+ observed: { method: null, format: kind === '' ? null : kind, shown: 0, dump: [
127
185
  `no undefined symbols for ${args.path}: objdump -T (exit ${dynamic.exitCode}) and nm -u (exit ${nm.exitCode}) both came back empty`,
128
186
  `format: ${kind === '' ? 'unknown (file(1) reported nothing)' : kind}`,
129
187
  `nm -u stderr: ${nm.stderr.trim().slice(0, 200) || '(empty)'}`,
130
188
  `objdump -T output follows (${dynamic.stdout.trim().length} chars), so the emptiness is inspectable:`,
131
189
  dynamic.stdout.trim().slice(0, 2000),
132
- ].join('\n')
190
+ ].join('\n') },
191
+ confidence: 'observed',
192
+ limitations: [
193
+ '**两把尺子都没给出符号**:这句话的前提是 objdump -T 与 nm -u **都跑过了**(两条 exit 与 nm 的 stderr 就在 dump 里)',
194
+ '它说的是「这份文件里没有可读的未定义符号」,不是「这个程序不依赖任何外部符号」—— 静态链接或已 strip 的二进制正是这个形状',
195
+ '要继续追依赖,先换个体:bin_file 看格式、bin_readelf 看动态段(.dynamic / NEEDED)',
196
+ ],
197
+ }, null, 2)
133
198
  },
134
199
  }))
135
200
 
@@ -146,10 +211,23 @@ export function apply(ctx) {
146
211
  render: (_a, v) => [{ type: 'text', text: v }],
147
212
  },
148
213
  async execute(args, exec) {
149
- const argv = ['-d', args.path]
214
+ const path = safePath(args.path)
215
+ const argv = ['-d', path]
150
216
  if (args.start && args.stop) argv.splice(1, 0, `--start-address=${args.start}`, `--stop-address=${args.stop}`)
151
217
  const stdout = requireOk(await runCmd(shell, ['objdump', ...argv], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
152
- return stdout.slice(0, 12000)
218
+ // 证据形状(规划 §5.1):信封 JSON,objdump 输出原样进 observed.dump。
219
+ return JSON.stringify({
220
+ tool: 'rev_disasm',
221
+ target: args.path,
222
+ operation: 'objdump -d' + (args.start && args.stop ? ` --start-address=${args.start} --stop-address=${args.stop}` : ' (whole file)'),
223
+ observed: { window: args.start && args.stop ? { start: args.start, stop: args.stop } : null, truncated: stdout.length > 12000, dump: stdout.slice(0, 12000) },
224
+ confidence: 'observed',
225
+ limitations: [
226
+ stdout.length > 12000 ? '只给前 12000 字符(truncated=true)—— 要看后面请给 start/stop 缩小窗口' : '这次没有截断(输出 ≤ 12000 字符)',
227
+ '反汇编是**线性扫描**的结果:数据段夹在代码里时,那些字节也会被当成指令打出来(用 start/stop 框住函数能少踩这个)',
228
+ '不看控制流、不做函数边界识别(要那些得换真正的反编译器)',
229
+ ],
230
+ }, null, 2)
153
231
  },
154
232
  }))
155
233
 
@@ -165,14 +243,50 @@ export function apply(ctx) {
165
243
  render: (_a, v) => [{ type: 'text', text: v }],
166
244
  },
167
245
  async execute(args, exec) {
168
- const argv = ['--binary', args.path, '--only', 'pop|ret|leave']
246
+ const path = safePath(args.path)
247
+ const argv = ['--binary', path, '--only', 'pop|ret|leave']
169
248
  if (args.filter) argv.push('--filter', args.filter)
170
249
  const rop = await runCmd(shell, ['ROPgadget', ...argv], exec)
171
- if (rop.exitCode === 0) return rop.stdout.slice(0, 8000)
172
- const disasm = requireOk(await runCmd(shell, ['objdump', '-d', args.path], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
250
+ if (rop.exitCode === 0) {
251
+ return JSON.stringify({
252
+ tool: 'rev_gadget_scan',
253
+ target: args.path,
254
+ operation: 'ROPgadget --only pop|ret|leave' + (args.filter ? ` --filter ${args.filter}` : ''),
255
+ observed: { method: 'ROPgadget', shown: null, truncated: rop.stdout.length > 8000, dump: rop.stdout.slice(0, 8000) },
256
+ confidence: 'observed',
257
+ limitations: [
258
+ 'ROPgadget 的输出原样返回(本工具只做截断:>8000 字符时 truncated=true)',
259
+ 'ROPgadget 自己会做指令边界识别与去重,但**只扫 --only pop|ret|leave 这几类**(要别的指令得改本工具)',
260
+ ],
261
+ }, null, 2)
262
+ }
263
+ const disasm = requireOk(await runCmd(shell, ['objdump', '-d', path], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
173
264
  const gadgets = scanGadgets(disasm, args.filter)
174
- if (gadgets.length > 0) return gadgets.slice(0, 80).join('\n')
175
- return `no pop/leave/ret gadgets found in ${args.path} (install ROPgadget for a deeper scan)`
265
+ if (gadgets.length > 0) {
266
+ return JSON.stringify({
267
+ tool: 'rev_gadget_scan',
268
+ target: args.path,
269
+ operation: 'built-in scanner over objdump -d (ROPgadget not installed)' + (args.filter ? ` filtered by ${args.filter}` : ''),
270
+ observed: { method: 'builtin', shown: Math.min(gadgets.length, 80), truncated: gadgets.length > 80, dump: gadgets.slice(0, 80).join('\n') },
271
+ confidence: 'observed',
272
+ limitations: [
273
+ '走的是**内置扫描器**(本机没有 ROPgadget):它只在 objdump 的反汇编里找 pop/leave + 紧跟的 ret 这类**简单序列**',
274
+ '不做指令边界校验、不做 gadget 去重:地址可能重叠,长链 gadget(多指令 + 中间跳转)它也不会给',
275
+ gadgets.length > 80 ? '只给前 80 条(observed.truncated=true)' : '这次没有截断(≤ 80 条)',
276
+ ],
277
+ }, null, 2)
278
+ }
279
+ return JSON.stringify({
280
+ tool: 'rev_gadget_scan',
281
+ target: args.path,
282
+ operation: 'built-in scanner over objdump -d: nothing matched',
283
+ observed: { method: 'builtin', shown: 0, dump: `no pop/leave/ret gadgets found in ${args.path} (install ROPgadget for a deeper scan)` },
284
+ confidence: 'observed',
285
+ limitations: [
286
+ '**没找到 ≠ 没有**:这条走的是内置扫描器(只认 pop/leave + ret 的简单序列),装 ROPgadget 后同一条命令可能给出几十条',
287
+ '也不排除这个文件根本没有可执行段(比如被测的是数据文件)—— 先用 bin_file 看它是什么',
288
+ ],
289
+ }, null, 2)
176
290
  },
177
291
  }))
178
292
  }