@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +79 -2
- package/cordis.patch.yml +399 -0
- package/package.json +32 -4
- package/tools/dsh-plugin-fly-binary/index.js +520 -0
- package/tools/dsh-plugin-fly-binary/package.json +23 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
- package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
- package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
- package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
- package/tools/dsh-plugin-fly-diag/index.js +772 -0
- package/tools/dsh-plugin-fly-diag/package.json +24 -0
- package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
- package/tools/dsh-plugin-fly-guard/index.js +101 -0
- package/tools/dsh-plugin-fly-guard/package.json +26 -0
- package/tools/dsh-plugin-fly-pwn/index.js +583 -0
- package/tools/dsh-plugin-fly-pwn/package.json +23 -0
- package/tools/dsh-plugin-fly-recon/index.js +184 -0
- package/tools/dsh-plugin-fly-recon/package.json +23 -0
- package/tools/dsh-plugin-fly-reverse/index.js +178 -0
- package/tools/dsh-plugin-fly-reverse/package.json +23 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* fly-binary — ELF / strings / sections toolkit for DSH Fly.
|
|
5
|
+
* Registers model-facing analysis tools on ctx.tools. All command execution
|
|
6
|
+
* goes through the host `shell` capability seam so the sandbox confines it;
|
|
7
|
+
* a tool never shells out via node:child_process directly.
|
|
8
|
+
*/
|
|
9
|
+
export const name = 'fly-binary'
|
|
10
|
+
export const inject = ['tools', 'shell']
|
|
11
|
+
|
|
12
|
+
function shq(value) {
|
|
13
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run one confined command through the host `shell` seam.
|
|
18
|
+
*
|
|
19
|
+
* `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
|
|
20
|
+
* 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
|
|
21
|
+
* projection carries the run result. An earlier revision of this file awaited a
|
|
22
|
+
* nonexistent `shell.run()`, so every shell-backed tool threw
|
|
23
|
+
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
24
|
+
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
25
|
+
* names a seam method the executor does not have.
|
|
26
|
+
*/
|
|
27
|
+
async function runCmd(shell, argv, exec, opts = {}) {
|
|
28
|
+
const result = await (await shell.execute(shell.resolve({
|
|
29
|
+
command: argv.map(shq).join(' '),
|
|
30
|
+
...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
|
|
31
|
+
...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
|
|
32
|
+
signal: exec.signal,
|
|
33
|
+
}))).result()
|
|
34
|
+
if (result.aborted) {
|
|
35
|
+
const error = new Error('tool call aborted')
|
|
36
|
+
error.name = 'AbortError'
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
if (result.sandbox?.denied) {
|
|
40
|
+
const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
|
|
41
|
+
error.name = 'SandboxError'
|
|
42
|
+
throw error
|
|
43
|
+
}
|
|
44
|
+
return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A failed command must never come back as an empty success.
|
|
49
|
+
*
|
|
50
|
+
* Exit 127 (and the shell's "command not found") gets its own wording, because
|
|
51
|
+
* macOS ships no ELF toolchain: `readelf`, `checksec`, `ROPgadget` and `nmap`
|
|
52
|
+
* are simply absent. The old code turned that into "no output", which read
|
|
53
|
+
* exactly like "nothing found".
|
|
54
|
+
*/
|
|
55
|
+
function requireOk(run, what) {
|
|
56
|
+
if (run.exitCode === 0) return run.stdout
|
|
57
|
+
const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
|
|
58
|
+
const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
|
|
59
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
|
|
60
|
+
error.name = 'CommandError'
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── BEGIN fly-elf-probe ────────────────────────────────────────────────────
|
|
65
|
+
// Native ELF reader, duplicated byte-for-byte in fly-binary and fly-pwn. The
|
|
66
|
+
// `verify security-tools` gate compares the two copies and fails when they
|
|
67
|
+
// drift: a mis-parse in one copy would silently mislabel mitigations in the
|
|
68
|
+
// other. Duplicated rather than imported because a plugin ships as a tarball
|
|
69
|
+
// (it cannot reach out of its own package) and because this repo never patches
|
|
70
|
+
// upstream. Why it exists: macOS ships neither `readelf` (GNU binutils) nor
|
|
71
|
+
// `checksec`, and a shipped DMG must not require the user to install Homebrew.
|
|
72
|
+
// File bytes are still read through the sandboxed `shell` seam (`od`); only the
|
|
73
|
+
// decoding happens here.
|
|
74
|
+
|
|
75
|
+
const ELF_MAGIC = 0x7f454c46
|
|
76
|
+
const ELF_CLASSES = { 1: 32, 2: 64 }
|
|
77
|
+
const ELF_TYPES = { 0: 'NONE', 1: 'REL', 2: 'EXEC', 3: 'DYN', 4: 'CORE' }
|
|
78
|
+
const ELF_OSABI = { 0: 'UNIX - System V', 3: 'UNIX - GNU/Linux', 6: 'UNIX - Solaris', 9: 'UNIX - FreeBSD', 12: 'UNIX - OpenBSD' }
|
|
79
|
+
const ELF_MACHINES = {
|
|
80
|
+
2: 'SPARC', 3: 'Intel 80386', 8: 'MIPS', 20: 'PowerPC', 21: 'PowerPC64', 22: 'IBM S/390',
|
|
81
|
+
40: 'ARM', 42: 'SuperH', 50: 'IA-64', 62: 'Advanced Micro Devices X86-64', 183: 'AArch64',
|
|
82
|
+
243: 'RISC-V', 258: 'LoongArch',
|
|
83
|
+
}
|
|
84
|
+
const ELF_PTYPES = {
|
|
85
|
+
0: 'NULL', 1: 'LOAD', 2: 'DYNAMIC', 3: 'INTERP', 4: 'NOTE', 5: 'SHLIB', 6: 'PHDR', 7: 'TLS',
|
|
86
|
+
0x6474e550: 'GNU_EH_FRAME', 0x6474e551: 'GNU_STACK', 0x6474e552: 'GNU_RELRO', 0x6474e553: 'GNU_PROPERTY',
|
|
87
|
+
}
|
|
88
|
+
const ELF_DTAGS = {
|
|
89
|
+
0: 'NULL', 1: 'NEEDED', 2: 'PLTRELSZ', 3: 'PLTGOT', 4: 'HASH', 5: 'STRTAB', 6: 'SYMTAB',
|
|
90
|
+
7: 'RELA', 8: 'RELASZ', 9: 'RELAENT', 10: 'STRSZ', 11: 'SYMENT', 12: 'INIT', 13: 'FINI',
|
|
91
|
+
14: 'SONAME', 15: 'RPATH', 16: 'SYMBOLIC', 17: 'REL', 18: 'RELSZ', 19: 'RELENT', 20: 'PLTREL',
|
|
92
|
+
21: 'DEBUG', 22: 'TEXTREL', 23: 'JMPREL', 24: 'BIND_NOW', 25: 'INIT_ARRAY', 26: 'FINI_ARRAY',
|
|
93
|
+
27: 'INIT_ARRAYSZ', 28: 'FINI_ARRAYSZ', 29: 'RUNPATH', 30: 'FLAGS', 32: 'PREINIT_ARRAY',
|
|
94
|
+
33: 'PREINIT_ARRAYSZ', 35: 'RELRSZ', 36: 'RELR', 37: 'RELRENT',
|
|
95
|
+
0x6ffffef5: 'GNU_HASH', 0x6ffffff0: 'VERSYM', 0x6ffffff9: 'RELACOUNT', 0x6ffffffa: 'RELCOUNT',
|
|
96
|
+
0x6ffffffb: 'FLAGS_1', 0x6ffffffe: 'VERNEED', 0x6fffffff: 'VERNEEDNUM',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ELF_PT_LOAD = 1
|
|
100
|
+
const ELF_PT_DYNAMIC = 2
|
|
101
|
+
const ELF_PT_INTERP = 3
|
|
102
|
+
const ELF_PT_GNU_STACK = 0x6474e551
|
|
103
|
+
const ELF_PT_GNU_RELRO = 0x6474e552
|
|
104
|
+
const ELF_PF_X = 0x1
|
|
105
|
+
const ELF_DT_HASH = 4
|
|
106
|
+
const ELF_DT_STRTAB = 5
|
|
107
|
+
const ELF_DT_SYMTAB = 6
|
|
108
|
+
const ELF_DT_STRSZ = 10
|
|
109
|
+
const ELF_DT_BIND_NOW = 24
|
|
110
|
+
const ELF_DT_FLAGS = 30
|
|
111
|
+
const ELF_DT_FLAGS_1 = 0x6ffffffb
|
|
112
|
+
const ELF_DF_BIND_NOW = 0x8
|
|
113
|
+
const ELF_DF_1_NOW = 0x1
|
|
114
|
+
const ELF_SHT_DYNAMIC = 6
|
|
115
|
+
const ELF_SHN_UNDEF = 0
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Read `length` bytes at `offset` through the shell seam (`od`) as a Buffer.
|
|
119
|
+
*
|
|
120
|
+
* `run` hands back the raw command result (not just stdout) because `od` refuses
|
|
121
|
+
* to start at or past EOF — "cannot skip past end of input", exit 1 — and that is
|
|
122
|
+
* a SHORT READ, not a failure: a truncated file must yield short tables rather
|
|
123
|
+
* than blowing up the whole tool. Every other non-zero exit (missing file,
|
|
124
|
+
* permission) still goes through `requireOk` and stays loud.
|
|
125
|
+
*/
|
|
126
|
+
async function elfReadRange(run, path, offset, length) {
|
|
127
|
+
const result = await run(['od', '-An', '-v', '-tx1', '-j', String(offset), '-N', String(length), path], { stdoutMaxBytes: 8388608 })
|
|
128
|
+
if (result.exitCode !== 0 && !/cannot skip|past end of input/iu.test(result.stderr)) requireOk(result, `od -j ${offset}`)
|
|
129
|
+
const hex = String(result.stdout).replace(/[^0-9a-fA-F]/gu, '')
|
|
130
|
+
return Buffer.from(hex.slice(0, hex.length - (hex.length % 2)), 'hex')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Parse an ELF file's header, program headers, sections and `.dynamic`.
|
|
135
|
+
*
|
|
136
|
+
* Returns null when the file is not ELF at all (the caller then falls through to
|
|
137
|
+
* its Mach-O path). Anything that could not be read stays `null` and is rendered
|
|
138
|
+
* `unknown` by the caller — never guessed. Every table is bounded by the bytes
|
|
139
|
+
* that actually arrived, so a truncated file yields a short table instead of
|
|
140
|
+
* invented rows.
|
|
141
|
+
*/
|
|
142
|
+
async function elfProbe(run, path) {
|
|
143
|
+
const head = await elfReadRange(run, path, 0, 64)
|
|
144
|
+
if (head.length < 16 || head.readUInt32BE(0) !== ELF_MAGIC) return null
|
|
145
|
+
const bits = ELF_CLASSES[head[4]]
|
|
146
|
+
const endian = head[5] === 1 ? 'little' : head[5] === 2 ? 'big' : null
|
|
147
|
+
if (!bits || endian === null) return null
|
|
148
|
+
const wide = bits === 64
|
|
149
|
+
const le = endian === 'little'
|
|
150
|
+
const u16 = (b, o) => (o + 2 <= b.length ? (le ? b.readUInt16LE(o) : b.readUInt16BE(o)) : null)
|
|
151
|
+
const u32 = (b, o) => (o + 4 <= b.length ? (le ? b.readUInt32LE(o) : b.readUInt32BE(o)) : null)
|
|
152
|
+
const u64 = (b, o) => (o + 8 <= b.length ? Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o)) : null)
|
|
153
|
+
|
|
154
|
+
const eType = u16(head, 16)
|
|
155
|
+
const eMachine = u16(head, 18)
|
|
156
|
+
const entry = wide ? u64(head, 24) : u32(head, 24)
|
|
157
|
+
const phoff = wide ? u64(head, 32) : u32(head, 28)
|
|
158
|
+
const shoff = wide ? u64(head, 40) : u32(head, 32)
|
|
159
|
+
const ehsize = wide ? u16(head, 52) : u16(head, 40)
|
|
160
|
+
const phentsize = wide ? u16(head, 54) : u16(head, 42)
|
|
161
|
+
const phnumRaw = wide ? u16(head, 56) : u16(head, 44)
|
|
162
|
+
const shentsize = wide ? u16(head, 58) : u16(head, 46)
|
|
163
|
+
const shnumRaw = wide ? u16(head, 60) : u16(head, 48)
|
|
164
|
+
const shstrndxRaw = wide ? u16(head, 62) : u16(head, 50)
|
|
165
|
+
|
|
166
|
+
// Section header #0 doubles as the escape hatch for the two "too many to fit"
|
|
167
|
+
// encodings (e_shnum 0, e_phnum/shstrndx 0xffff), so read it first.
|
|
168
|
+
const shdr0 = shoff !== null && shoff > 0 && shentsize !== null && shentsize >= (wide ? 64 : 40)
|
|
169
|
+
? elfParseSection(await elfReadRange(run, path, shoff, shentsize), wide, le)
|
|
170
|
+
: null
|
|
171
|
+
const shnum = shnumRaw === 0 && shdr0 ? shdr0.size : shnumRaw
|
|
172
|
+
const shstrndx = shstrndxRaw === 0xffff && shdr0 ? shdr0.info : shstrndxRaw
|
|
173
|
+
const phnum = phnumRaw === 0xffff && shdr0 ? shdr0.info : phnumRaw
|
|
174
|
+
|
|
175
|
+
const sections = []
|
|
176
|
+
if (shoff !== null && shoff > 0 && shnum !== null && shnum > 0 && shentsize) {
|
|
177
|
+
const table = await elfReadRange(run, path, shoff, shentsize * shnum)
|
|
178
|
+
for (let i = 0; i < shnum; i++) {
|
|
179
|
+
const at = i * shentsize
|
|
180
|
+
if (at + shentsize > table.length) break
|
|
181
|
+
const section = elfParseSection(table.subarray(at, at + shentsize), wide, le)
|
|
182
|
+
if (section) sections.push({ ...section, index: i })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const phdrs = []
|
|
186
|
+
if (phoff !== null && phoff > 0 && phnum !== null && phnum > 0 && phentsize) {
|
|
187
|
+
const table = await elfReadRange(run, path, phoff, phentsize * phnum)
|
|
188
|
+
for (let i = 0; i < phnum; i++) {
|
|
189
|
+
const at = i * phentsize
|
|
190
|
+
if (at + phentsize > table.length) break
|
|
191
|
+
const seg = elfParseSegment(table.subarray(at, at + phentsize), wide, le)
|
|
192
|
+
if (seg) phdrs.push({ ...seg, index: i })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const shstrtab = sections[shstrndx] ?? null
|
|
197
|
+
if (shstrtab && shstrtab.size > 0) {
|
|
198
|
+
const names = await elfReadRange(run, path, shstrtab.offset, Math.min(shstrtab.size, 65536))
|
|
199
|
+
for (const section of sections) {
|
|
200
|
+
section.name = section.nameOffset < names.length ? elfCString(names, section.nameOffset) : ''
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const loadSegments = phdrs.filter(p => p.type === ELF_PT_LOAD)
|
|
205
|
+
const readAt = (offset, length) => elfReadRange(run, path, offset, length)
|
|
206
|
+
const fileOffsetOfVaddr = (addr) => {
|
|
207
|
+
const seg = loadSegments.find(p => addr >= p.vaddr && addr < p.vaddr + p.memsz)
|
|
208
|
+
return seg ? seg.offset + (addr - seg.vaddr) : null
|
|
209
|
+
}
|
|
210
|
+
const readVaddr = (addr, length) => {
|
|
211
|
+
const offset = fileOffsetOfVaddr(addr)
|
|
212
|
+
return offset === null ? null : readAt(offset, length)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// PT_DYNAMIC is the normal source; a stripped file that kept only the section
|
|
216
|
+
// table still works through .dynamic.
|
|
217
|
+
const dynSegment = phdrs.find(p => p.type === ELF_PT_DYNAMIC)
|
|
218
|
+
const dynSection = sections.find(s => s.type === ELF_SHT_DYNAMIC)
|
|
219
|
+
const dynAt = dynSegment ? { offset: dynSegment.offset, size: dynSegment.filesz }
|
|
220
|
+
: dynSection ? { offset: dynSection.offset, size: dynSection.size } : null
|
|
221
|
+
const dynamic = []
|
|
222
|
+
if (dynAt && dynAt.size > 0) {
|
|
223
|
+
const bytes = await readAt(dynAt.offset, Math.min(dynAt.size, 262144))
|
|
224
|
+
const step = wide ? 16 : 8
|
|
225
|
+
for (let at = 0; at + step <= bytes.length; at += step) {
|
|
226
|
+
const tag = wide ? u64(bytes, at) : u32(bytes, at)
|
|
227
|
+
const val = wide ? u64(bytes, at + (wide ? 8 : 4)) : u32(bytes, at + 4)
|
|
228
|
+
if (tag === null || val === null) break
|
|
229
|
+
dynamic.push({ tag, val })
|
|
230
|
+
if (tag === 0) break
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const dynTag = (tag) => dynamic.find(d => d.tag === tag)
|
|
234
|
+
|
|
235
|
+
const strtab = dynTag(ELF_DT_STRTAB)
|
|
236
|
+
const strsz = dynTag(ELF_DT_STRSZ)
|
|
237
|
+
/** Resolve a byte offset inside DT_STRTAB to its string (null if not resolvable). */
|
|
238
|
+
const dynamicString = async (offsetInStrtab) => {
|
|
239
|
+
if (!strtab || strsz === undefined) return null
|
|
240
|
+
if (!Number.isSafeInteger(offsetInStrtab) || offsetInStrtab < 0 || offsetInStrtab >= strsz.val) return null
|
|
241
|
+
const bytes = await readVaddr(strtab.val + offsetInStrtab, 512)
|
|
242
|
+
return bytes ? elfCString(bytes, 0) : null
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const stack = phdrs.find(p => p.type === ELF_PT_GNU_STACK) ?? null
|
|
246
|
+
const relro = phdrs.find(p => p.type === ELF_PT_GNU_RELRO) ?? null
|
|
247
|
+
const interp = phdrs.find(p => p.type === ELF_PT_INTERP) ?? null
|
|
248
|
+
const flags = dynTag(ELF_DT_FLAGS)?.val ?? 0
|
|
249
|
+
const flags1 = dynTag(ELF_DT_FLAGS_1)?.val ?? 0
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
path, bits, endian, type: eType, machine: eMachine, osabi: head[7], entry, ehsize,
|
|
253
|
+
phoff, phnum, phentsize, shoff, shnum, shentsize, shstrndx,
|
|
254
|
+
phdrs, sections, dynamic, dynAt,
|
|
255
|
+
stack, relro, interp, loadSegments,
|
|
256
|
+
bindNow: dynamic.some(d => d.tag === ELF_DT_BIND_NOW)
|
|
257
|
+
|| (flags & ELF_DF_BIND_NOW) !== 0 || (flags1 & ELF_DF_1_NOW) !== 0,
|
|
258
|
+
dynamicString, readAt, readVaddr, fileOffsetOfVaddr,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** `R`/`W`/`E` triplet with `-` for absent bits (readelf's three-column flags). */
|
|
263
|
+
function elfFlagsText(pFlags) {
|
|
264
|
+
return `${pFlags & 4 ? 'R' : '-'}${pFlags & 2 ? 'W' : '-'}${pFlags & 1 ? 'E' : '-'}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function elfParseSegment(bytes, wide, le) {
|
|
268
|
+
if (!bytes || bytes.length < (wide ? 56 : 32)) return null
|
|
269
|
+
const u16 = (o) => (le ? bytes.readUInt16LE(o) : bytes.readUInt16BE(o))
|
|
270
|
+
const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
|
|
271
|
+
const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
|
|
272
|
+
return wide
|
|
273
|
+
? { type: u32(0), flags: u32(4), offset: u64(8), vaddr: u64(16), paddr: u64(24), filesz: u64(32), memsz: u64(40), align: u64(48) }
|
|
274
|
+
: { type: u32(0), flags: u32(24), offset: u32(4), vaddr: u32(8), paddr: u32(12), filesz: u32(16), memsz: u32(20), align: u32(28) }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function elfParseSection(bytes, wide, le) {
|
|
278
|
+
if (!bytes || bytes.length < (wide ? 64 : 40)) return null
|
|
279
|
+
const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
|
|
280
|
+
const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
|
|
281
|
+
return wide
|
|
282
|
+
? { nameOffset: u32(0), type: u32(4), flags: u64(8), addr: u64(16), offset: u64(24), size: u64(32), link: u32(40), info: u32(44), align: u64(48), entsize: u64(56), name: '' }
|
|
283
|
+
: { nameOffset: u32(0), type: u32(4), flags: u32(8), addr: u32(12), offset: u32(16), size: u32(20), link: u32(24), info: u32(28), align: u32(32), entsize: u32(36), name: '' }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function elfCString(bytes, at) {
|
|
287
|
+
let end = at
|
|
288
|
+
while (end < bytes.length && bytes[end] !== 0) end++
|
|
289
|
+
return bytes.subarray(at, end).toString('latin1')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Dynamic symbol names, used for the canary question ("is `__stack_chk_fail`
|
|
294
|
+
* referenced?"). The symbol count is not stored in ELF; it is recovered from
|
|
295
|
+
* DT_HASH's `nchain`, or by walking DT_GNU_HASH's chains for the highest index.
|
|
296
|
+
* When neither is usable the caller falls back to its documented heuristic.
|
|
297
|
+
*/
|
|
298
|
+
async function elfDynamicSymbols(run, elf) {
|
|
299
|
+
const symtab = elf.dynamic.find(d => d.tag === ELF_DT_SYMTAB)
|
|
300
|
+
const strtab = elf.dynamic.find(d => d.tag === ELF_DT_STRTAB)
|
|
301
|
+
if (!symtab || !strtab) return null
|
|
302
|
+
const hash = elf.dynamic.find(d => d.tag === ELF_DT_HASH)
|
|
303
|
+
const gnuHash = elf.dynamic.find(d => d.tag === 0x6ffffef5)
|
|
304
|
+
let count = null
|
|
305
|
+
if (hash) {
|
|
306
|
+
const head = await elf.readVaddr(hash.val, 8)
|
|
307
|
+
if (head && head.length >= 8) count = readU32At(head, 4, elf.endian)
|
|
308
|
+
} else if (gnuHash) {
|
|
309
|
+
const head = await elf.readVaddr(gnuHash.val, 16)
|
|
310
|
+
if (head && head.length >= 16) {
|
|
311
|
+
const nbuckets = readU32At(head, 0, elf.endian)
|
|
312
|
+
const symoffset = readU32At(head, 4, elf.endian)
|
|
313
|
+
const bloomSize = readU32At(head, 8, elf.endian)
|
|
314
|
+
const wordSize = elf.bits === 64 ? 8 : 4
|
|
315
|
+
const bucketsAt = gnuHash.val + 16 + bloomSize * wordSize
|
|
316
|
+
const buckets = await elf.readVaddr(bucketsAt, nbuckets * 4)
|
|
317
|
+
if (buckets && buckets.length >= nbuckets * 4) {
|
|
318
|
+
const chainsAt = bucketsAt + nbuckets * 4
|
|
319
|
+
let max = symoffset
|
|
320
|
+
for (let i = 0; i < nbuckets; i++) {
|
|
321
|
+
let index = readU32At(buckets, i * 4, elf.endian)
|
|
322
|
+
if (index < symoffset) continue
|
|
323
|
+
for (let guard = 0; guard < 65536; guard++) {
|
|
324
|
+
const chain = await elf.readVaddr(chainsAt + (index - symoffset) * 4, 4)
|
|
325
|
+
if (!chain || chain.length < 4) break
|
|
326
|
+
const value = readU32At(chain, 0, elf.endian)
|
|
327
|
+
if ((value & 1) === 1) break
|
|
328
|
+
index++
|
|
329
|
+
}
|
|
330
|
+
if (index + 1 > max) max = index + 1
|
|
331
|
+
}
|
|
332
|
+
count = max
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (count === null || count <= 0) return null
|
|
337
|
+
const entrySize = elf.bits === 64 ? 24 : 16
|
|
338
|
+
const bytes = await elf.readVaddr(symtab.val, Math.min(count, 65536) * entrySize)
|
|
339
|
+
if (!bytes) return null
|
|
340
|
+
const symbols = []
|
|
341
|
+
for (let i = 0; i < count; i++) {
|
|
342
|
+
const at = i * entrySize
|
|
343
|
+
if (at + entrySize > bytes.length) break
|
|
344
|
+
const nameOffset = readU32At(bytes, at, elf.endian)
|
|
345
|
+
const shndx = elf.bits === 64 ? readU16At(bytes, at + 6, elf.endian) : readU16At(bytes, at + 14, elf.endian)
|
|
346
|
+
const value = elf.bits === 64 ? readU64At(bytes, at + 8, elf.endian) : readU32At(bytes, at + 4, elf.endian)
|
|
347
|
+
const strOffset = strtab.val + nameOffset
|
|
348
|
+
const strBytes = await elf.readVaddr(strOffset, 256)
|
|
349
|
+
symbols.push({ name: strBytes ? elfCString(strBytes, 0) : '', shndx, value })
|
|
350
|
+
}
|
|
351
|
+
return symbols
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function readU16At(bytes, at, endian) {
|
|
355
|
+
if (at + 2 > bytes.length) return null
|
|
356
|
+
return endian === 'little' ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function readU32At(bytes, at, endian) {
|
|
360
|
+
if (at + 4 > bytes.length) return null
|
|
361
|
+
return endian === 'little' ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function readU64At(bytes, at, endian) {
|
|
365
|
+
if (at + 8 > bytes.length) return null
|
|
366
|
+
return Number(endian === 'little' ? bytes.readBigUInt64LE(at) : bytes.readBigUInt64BE(at))
|
|
367
|
+
}
|
|
368
|
+
// ── END fly-elf-probe ──────────────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
/** Adapter for the shared ELF probe: it wants the raw command result. */
|
|
371
|
+
const rawRun = (shell, exec) => (argv, opts) => runCmd(shell, argv, exec, opts)
|
|
372
|
+
|
|
373
|
+
const elfHex = (value, width = 6) => `0x${Number(value).toString(16).padStart(width, '0')}`
|
|
374
|
+
const elfSay = (value) => (value === null || value === undefined ? 'unknown' : value)
|
|
375
|
+
|
|
376
|
+
function renderElfHeader(elf) {
|
|
377
|
+
return [
|
|
378
|
+
'ELF Header (parsed natively — this host has no readelf):',
|
|
379
|
+
` File: ${elf.path}`,
|
|
380
|
+
` Class: ${elf.bits === 64 ? 'ELF64' : 'ELF32'}`,
|
|
381
|
+
` Data: 2's complement, ${elf.endian} endian`,
|
|
382
|
+
` OS/ABI: ${ELF_OSABI[elf.osabi] ?? `0x${Number(elf.osabi).toString(16)}`}`,
|
|
383
|
+
` Type: ${ELF_TYPES[elf.type] ?? `0x${Number(elf.type).toString(16)}`}`,
|
|
384
|
+
` Machine: ${ELF_MACHINES[elf.machine] ?? `0x${Number(elf.machine).toString(16)}`}`,
|
|
385
|
+
` Entry point: ${elfSay(elf.entry === null ? null : elfHex(elf.entry))}`,
|
|
386
|
+
` Program headers: ${elfSay(elf.phnum)} x ${elfSay(elf.phentsize)} bytes at ${elfSay(elf.phoff === null ? null : elfHex(elf.phoff))}`,
|
|
387
|
+
` Section headers: ${elfSay(elf.shnum)} x ${elfSay(elf.shentsize)} bytes at ${elfSay(elf.shoff === null ? null : elfHex(elf.shoff))} (shstrndx ${elfSay(elf.shstrndx)})`,
|
|
388
|
+
elf.interp === null ? ' PT_INTERP: none' : ' PT_INTERP: present (dynamically linked executable)',
|
|
389
|
+
].join('\n')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderElfSegments(elf) {
|
|
393
|
+
if (elf.phdrs.length === 0) {
|
|
394
|
+
return `no program headers could be read from ${elf.path} (e_phnum=${elfSay(elf.phnum)}) — a relocatable object, or a file whose header is unreadable`
|
|
395
|
+
}
|
|
396
|
+
const rows = elf.phdrs.map(ph => ` ${(ELF_PTYPES[ph.type] ?? `0x${ph.type.toString(16)}`).padEnd(14)} ${elfHex(ph.offset, 8)} ${elfHex(ph.vaddr, 8)} ${elfHex(ph.filesz, 8)} ${elfHex(ph.memsz, 8)} ${elfFlagsText(ph.flags)}`)
|
|
397
|
+
return [
|
|
398
|
+
`Program Headers (${elf.phdrs.length}) — parsed natively:`,
|
|
399
|
+
' Type Offset VirtAddr FileSiz MemSiz Flg',
|
|
400
|
+
...rows,
|
|
401
|
+
].join('\n')
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function renderElfDynamic(elf) {
|
|
405
|
+
if (elf.dynamic.length === 0) {
|
|
406
|
+
return `no PT_DYNAMIC segment and no .dynamic section in ${elf.path} — statically linked, so it has no run-time linker entries`
|
|
407
|
+
}
|
|
408
|
+
const lines = [`Dynamic section (${elf.dynamic.length} entries) — parsed natively:`]
|
|
409
|
+
for (const entry of elf.dynamic) {
|
|
410
|
+
const name = ELF_DTAGS[entry.tag] ?? `0x${entry.tag.toString(16)}`
|
|
411
|
+
let value = elfHex(entry.val, 8)
|
|
412
|
+
if (entry.tag === 1 || entry.tag === 14 || entry.tag === 15 || entry.tag === 29) {
|
|
413
|
+
const text = await elf.dynamicString(entry.val)
|
|
414
|
+
value = text === null ? `${elfHex(entry.val, 8)} (string not resolvable)` : `"${text}"`
|
|
415
|
+
}
|
|
416
|
+
lines.push(` ${elfHex(entry.tag, 8)} (${name}) ${value}`)
|
|
417
|
+
}
|
|
418
|
+
return lines.join('\n')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function apply(ctx) {
|
|
422
|
+
const shell = ctx.shell
|
|
423
|
+
|
|
424
|
+
ctx.tools.register(defineTool({
|
|
425
|
+
name: 'bin_file',
|
|
426
|
+
description: 'Identify a binary file (file(1) + size + basic ELF sanity).',
|
|
427
|
+
parameters: {
|
|
428
|
+
path: { type: 'string', required: true, description: 'Absolute path to the binary' },
|
|
429
|
+
},
|
|
430
|
+
output: {
|
|
431
|
+
schema: { type: 'string' },
|
|
432
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
433
|
+
},
|
|
434
|
+
async execute(args, exec) {
|
|
435
|
+
const wc = await runCmd(shell, ['wc', '-c', args.path], exec)
|
|
436
|
+
const size = requireOk(wc, 'wc -c').trim().split(/\s+/)[0] ?? '0'
|
|
437
|
+
const identify = await runCmd(shell, ['file', '-b', args.path], exec)
|
|
438
|
+
return `size=${size}\n${requireOk(identify, 'file -b').trim()}`
|
|
439
|
+
},
|
|
440
|
+
}))
|
|
441
|
+
|
|
442
|
+
ctx.tools.register(defineTool({
|
|
443
|
+
name: 'bin_strings',
|
|
444
|
+
description: 'Extract printable strings from a binary, optionally filtered.',
|
|
445
|
+
parameters: {
|
|
446
|
+
path: { type: 'string', required: true },
|
|
447
|
+
min: { type: 'number', description: 'Minimum string length (default 4)' },
|
|
448
|
+
grep: { type: 'string', description: 'Substring filter' },
|
|
449
|
+
},
|
|
450
|
+
output: {
|
|
451
|
+
schema: { type: 'string' },
|
|
452
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
453
|
+
},
|
|
454
|
+
async execute(args, exec) {
|
|
455
|
+
const min = String(args.min ?? 4)
|
|
456
|
+
const run = await runCmd(shell, ['strings', '-n', min, args.path], exec, { stdoutMaxBytes: 1048576 })
|
|
457
|
+
let lines = requireOk(run, `strings -n ${min}`).split('\n').filter(Boolean)
|
|
458
|
+
if (args.grep) {
|
|
459
|
+
const g = args.grep.toLowerCase()
|
|
460
|
+
lines = lines.filter((l) => l.toLowerCase().includes(g))
|
|
461
|
+
}
|
|
462
|
+
if (lines.length === 0) {
|
|
463
|
+
return args.grep === undefined
|
|
464
|
+
? `no printable strings of length ${min} or more in ${args.path}`
|
|
465
|
+
: `no printable strings matching ${JSON.stringify(args.grep)} (min length ${min}) in ${args.path}`
|
|
466
|
+
}
|
|
467
|
+
return lines.slice(0, 400).join('\n')
|
|
468
|
+
},
|
|
469
|
+
}))
|
|
470
|
+
|
|
471
|
+
ctx.tools.register(defineTool({
|
|
472
|
+
name: 'bin_readelf',
|
|
473
|
+
description: 'Dump ELF headers / program headers / dynamic section (readelf when installed, else parsed natively).',
|
|
474
|
+
parameters: {
|
|
475
|
+
path: { type: 'string', required: true },
|
|
476
|
+
mode: { type: 'string', description: 'headers | segments | dynamic' },
|
|
477
|
+
},
|
|
478
|
+
output: {
|
|
479
|
+
schema: { type: 'string' },
|
|
480
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
481
|
+
},
|
|
482
|
+
async execute(args, exec) {
|
|
483
|
+
const mode = args.mode === 'segments' || args.mode === 'dynamic' ? args.mode : 'headers'
|
|
484
|
+
const flag = mode === 'segments' ? '-l' : mode === 'dynamic' ? '-d' : '-h'
|
|
485
|
+
// GNU binutils is not part of macOS. When readelf is absent (or refuses the
|
|
486
|
+
// file) the ELF is parsed here instead: the previous revision threw
|
|
487
|
+
// "command not found" on every Mac, which made four of the five binary
|
|
488
|
+
// tools useless on the platform this app ships for.
|
|
489
|
+
const real = await runCmd(shell, ['readelf', flag, args.path], exec)
|
|
490
|
+
if (real.exitCode === 0 && real.stdout.trim() !== '') return real.stdout
|
|
491
|
+
const elf = await elfProbe(rawRun(shell, exec), args.path)
|
|
492
|
+
if (elf === null) {
|
|
493
|
+
const kind = requireOk(await runCmd(shell, ['file', '-b', args.path], exec), 'file -b').trim()
|
|
494
|
+
return [
|
|
495
|
+
`readelf ${flag} is unavailable on this host (${real.exitCode === 127 ? 'not installed' : `exit ${real.exitCode}`})`,
|
|
496
|
+
`${args.path} is not ELF, so a native ELF reader has nothing to report either — file(1) says: ${kind}`,
|
|
497
|
+
'for Mach-O use rev_disasm / rev_imports (objdump, otool and nm ship with macOS)',
|
|
498
|
+
].join('\n')
|
|
499
|
+
}
|
|
500
|
+
if (mode === 'segments') return renderElfSegments(elf)
|
|
501
|
+
if (mode === 'dynamic') return await renderElfDynamic(elf)
|
|
502
|
+
return renderElfHeader(elf)
|
|
503
|
+
},
|
|
504
|
+
}))
|
|
505
|
+
|
|
506
|
+
ctx.tools.register(defineTool({
|
|
507
|
+
name: 'bin_sections',
|
|
508
|
+
description: 'List ELF section headers (objdump -h).',
|
|
509
|
+
parameters: {
|
|
510
|
+
path: { type: 'string', required: true },
|
|
511
|
+
},
|
|
512
|
+
output: {
|
|
513
|
+
schema: { type: 'string' },
|
|
514
|
+
render: (_args, value) => [{ type: 'text', text: value }],
|
|
515
|
+
},
|
|
516
|
+
async execute(args, exec) {
|
|
517
|
+
return requireOk(await runCmd(shell, ['objdump', '-h', args.path], exec), 'objdump -h')
|
|
518
|
+
},
|
|
519
|
+
}))
|
|
520
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-plugin-fly-binary",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js"
|
|
8
|
+
],
|
|
9
|
+
"peerDependencies": {
|
|
10
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
11
|
+
"@deepseek-ai/dsh-shell": "^0.2.0-rc.2",
|
|
12
|
+
"@deepseek-ai/dsh-tools": "^0.2.0-rc.2"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ethanchen669/dsh-fly.git",
|
|
18
|
+
"directory": "packages/dsh-plugin-fly-binary"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
}
|
|
23
|
+
}
|