@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.
Files changed (26) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/cordis.patch.yml +399 -0
  4. package/package.json +32 -4
  5. package/tools/dsh-plugin-fly-binary/index.js +520 -0
  6. package/tools/dsh-plugin-fly-binary/package.json +23 -0
  7. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/SKILL.md +163 -0
  8. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/mitm_addon_template.py +302 -0
  9. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/code/rules.example.json +52 -0
  10. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/case-ttw.md +157 -0
  11. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/checklist.md +133 -0
  12. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/playbook.md +228 -0
  13. package/tools/dsh-plugin-fly-blackbox/assets/client-blackbox/reference/rules.schema.md +76 -0
  14. package/tools/dsh-plugin-fly-blackbox/index.js +1453 -0
  15. package/tools/dsh-plugin-fly-blackbox/package.json +31 -0
  16. package/tools/dsh-plugin-fly-diag/index.js +772 -0
  17. package/tools/dsh-plugin-fly-diag/package.json +24 -0
  18. package/tools/dsh-plugin-fly-diag/wifi.js +402 -0
  19. package/tools/dsh-plugin-fly-guard/index.js +101 -0
  20. package/tools/dsh-plugin-fly-guard/package.json +26 -0
  21. package/tools/dsh-plugin-fly-pwn/index.js +583 -0
  22. package/tools/dsh-plugin-fly-pwn/package.json +23 -0
  23. package/tools/dsh-plugin-fly-recon/index.js +184 -0
  24. package/tools/dsh-plugin-fly-recon/package.json +23 -0
  25. package/tools/dsh-plugin-fly-reverse/index.js +178 -0
  26. package/tools/dsh-plugin-fly-reverse/package.json +23 -0
@@ -0,0 +1,583 @@
1
+ import { defineTool } from '@deepseek-ai/dsh-tools'
2
+
3
+ /** fly-pwn — local analysis helpers: checksec, cyclic, rop planning hints. */
4
+ export const name = 'fly-pwn'
5
+ export const inject = ['tools', 'shell']
6
+
7
+ function shq(value) {
8
+ return `'${String(value).replace(/'/g, "'\\''")}'`
9
+ }
10
+
11
+ /**
12
+ * Run one confined command through the host `shell` seam.
13
+ *
14
+ * `ShellExecutor` exposes exactly `resolve()` and `execute()` (verified against
15
+ * 0.1.7-rc.2 and 0.2.0-rc.2): `execute()` returns a process handle whose `result`
16
+ * projection carries the run result. An earlier revision of this file awaited a
17
+ * nonexistent `shell.run()`, so every shell-backed tool threw
18
+ * `TypeError: shell.run is not a function` on every call, in every engine
19
+ * version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
20
+ * names a seam method the executor does not have.
21
+ */
22
+ async function runCmd(shell, argv, exec, opts = {}) {
23
+ const result = await (await shell.execute(shell.resolve({
24
+ command: argv.map(shq).join(' '),
25
+ ...opts.timeoutMs !== void 0 ? { timeoutMs: opts.timeoutMs } : {},
26
+ ...opts.stdoutMaxBytes !== void 0 ? { stdoutMaxBytes: opts.stdoutMaxBytes } : {},
27
+ signal: exec.signal,
28
+ }))).result()
29
+ if (result.aborted) {
30
+ const error = new Error('tool call aborted')
31
+ error.name = 'AbortError'
32
+ throw error
33
+ }
34
+ if (result.sandbox?.denied) {
35
+ const error = new Error(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
36
+ error.name = 'SandboxError'
37
+ throw error
38
+ }
39
+ return { stdout: result.stdout.text, stderr: result.stderr.text, exitCode: result.exitCode, command: argv.join(' ') }
40
+ }
41
+
42
+ /**
43
+ * A failed command must never come back as an empty success.
44
+ *
45
+ * Exit 127 (and the shell's "command not found") gets its own wording, because
46
+ * macOS ships no ELF toolchain: `readelf`, `checksec`, `ROPgadget` and `nmap`
47
+ * are simply absent. The old code turned that into "no output" — which read
48
+ * exactly like "nothing found" — and, in `pwn_checksec`, into a fabricated
49
+ * `pie=no` for every binary.
50
+ */
51
+ function requireOk(run, what) {
52
+ if (run.exitCode === 0) return run.stdout
53
+ const detail = run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean).slice(0, 3).join(' | ')
54
+ const missing = run.exitCode === 127 || /command not found/iu.test(run.stderr) ? ' — the command is not installed on this host' : ''
55
+ const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}`}`)
56
+ error.name = 'CommandError'
57
+ throw error
58
+ }
59
+
60
+ /** The 26-letter alphabet every cyclic pattern in this family uses. */
61
+ const CYCLIC_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
62
+
63
+ /**
64
+ * Cyclic pattern for offset discovery: a prefix of the de Bruijn sequence of
65
+ * order 4 over 26 letters, so every 4-byte window occurs exactly once and a
66
+ * needle searched in it names its offset uniquely. The prefix matches the
67
+ * sequence `pwntools.cyclic()` produces, so `cyclic_find()` offsets from a
68
+ * pwntools workflow line up.
69
+ *
70
+ * The previous generator emitted 4-byte counter groups whose windows repeated
71
+ * after ~100 bytes (`aaaa` at offsets 0 and 101; 158 repeats in the first 4096
72
+ * bytes), so an offset past the prologue could not be resolved at all. Asserted
73
+ * in scripts/verify-security-tools.mjs.
74
+ */
75
+ function cyclic(n) {
76
+ const order = 4
77
+ const digits = new Array(order + 1).fill(0)
78
+ const out = []
79
+ const visit = (t, p) => {
80
+ if (out.length >= n) return
81
+ if (t > order) {
82
+ if (order % p === 0) {
83
+ for (let j = 1; j <= p && out.length < n; j++) out.push(CYCLIC_ALPHABET[digits[j]])
84
+ }
85
+ return
86
+ }
87
+ digits[t] = digits[t - p]
88
+ visit(t + 1, p)
89
+ for (let j = digits[t - p] + 1; j < CYCLIC_ALPHABET.length && out.length < n; j++) {
90
+ digits[t] = j
91
+ visit(t + 1, t)
92
+ }
93
+ }
94
+ visit(1, 1)
95
+ return out.join('')
96
+ }
97
+
98
+ // ── BEGIN fly-elf-probe ────────────────────────────────────────────────────
99
+ // Native ELF reader, duplicated byte-for-byte in fly-binary and fly-pwn. The
100
+ // `verify security-tools` gate compares the two copies and fails when they
101
+ // drift: a mis-parse in one copy would silently mislabel mitigations in the
102
+ // other. Duplicated rather than imported because a plugin ships as a tarball
103
+ // (it cannot reach out of its own package) and because this repo never patches
104
+ // upstream. Why it exists: macOS ships neither `readelf` (GNU binutils) nor
105
+ // `checksec`, and a shipped DMG must not require the user to install Homebrew.
106
+ // File bytes are still read through the sandboxed `shell` seam (`od`); only the
107
+ // decoding happens here.
108
+
109
+ const ELF_MAGIC = 0x7f454c46
110
+ const ELF_CLASSES = { 1: 32, 2: 64 }
111
+ const ELF_TYPES = { 0: 'NONE', 1: 'REL', 2: 'EXEC', 3: 'DYN', 4: 'CORE' }
112
+ const ELF_OSABI = { 0: 'UNIX - System V', 3: 'UNIX - GNU/Linux', 6: 'UNIX - Solaris', 9: 'UNIX - FreeBSD', 12: 'UNIX - OpenBSD' }
113
+ const ELF_MACHINES = {
114
+ 2: 'SPARC', 3: 'Intel 80386', 8: 'MIPS', 20: 'PowerPC', 21: 'PowerPC64', 22: 'IBM S/390',
115
+ 40: 'ARM', 42: 'SuperH', 50: 'IA-64', 62: 'Advanced Micro Devices X86-64', 183: 'AArch64',
116
+ 243: 'RISC-V', 258: 'LoongArch',
117
+ }
118
+ const ELF_PTYPES = {
119
+ 0: 'NULL', 1: 'LOAD', 2: 'DYNAMIC', 3: 'INTERP', 4: 'NOTE', 5: 'SHLIB', 6: 'PHDR', 7: 'TLS',
120
+ 0x6474e550: 'GNU_EH_FRAME', 0x6474e551: 'GNU_STACK', 0x6474e552: 'GNU_RELRO', 0x6474e553: 'GNU_PROPERTY',
121
+ }
122
+ const ELF_DTAGS = {
123
+ 0: 'NULL', 1: 'NEEDED', 2: 'PLTRELSZ', 3: 'PLTGOT', 4: 'HASH', 5: 'STRTAB', 6: 'SYMTAB',
124
+ 7: 'RELA', 8: 'RELASZ', 9: 'RELAENT', 10: 'STRSZ', 11: 'SYMENT', 12: 'INIT', 13: 'FINI',
125
+ 14: 'SONAME', 15: 'RPATH', 16: 'SYMBOLIC', 17: 'REL', 18: 'RELSZ', 19: 'RELENT', 20: 'PLTREL',
126
+ 21: 'DEBUG', 22: 'TEXTREL', 23: 'JMPREL', 24: 'BIND_NOW', 25: 'INIT_ARRAY', 26: 'FINI_ARRAY',
127
+ 27: 'INIT_ARRAYSZ', 28: 'FINI_ARRAYSZ', 29: 'RUNPATH', 30: 'FLAGS', 32: 'PREINIT_ARRAY',
128
+ 33: 'PREINIT_ARRAYSZ', 35: 'RELRSZ', 36: 'RELR', 37: 'RELRENT',
129
+ 0x6ffffef5: 'GNU_HASH', 0x6ffffff0: 'VERSYM', 0x6ffffff9: 'RELACOUNT', 0x6ffffffa: 'RELCOUNT',
130
+ 0x6ffffffb: 'FLAGS_1', 0x6ffffffe: 'VERNEED', 0x6fffffff: 'VERNEEDNUM',
131
+ }
132
+
133
+ const ELF_PT_LOAD = 1
134
+ const ELF_PT_DYNAMIC = 2
135
+ const ELF_PT_INTERP = 3
136
+ const ELF_PT_GNU_STACK = 0x6474e551
137
+ const ELF_PT_GNU_RELRO = 0x6474e552
138
+ const ELF_PF_X = 0x1
139
+ const ELF_DT_HASH = 4
140
+ const ELF_DT_STRTAB = 5
141
+ const ELF_DT_SYMTAB = 6
142
+ const ELF_DT_STRSZ = 10
143
+ const ELF_DT_BIND_NOW = 24
144
+ const ELF_DT_FLAGS = 30
145
+ const ELF_DT_FLAGS_1 = 0x6ffffffb
146
+ const ELF_DF_BIND_NOW = 0x8
147
+ const ELF_DF_1_NOW = 0x1
148
+ const ELF_SHT_DYNAMIC = 6
149
+ const ELF_SHN_UNDEF = 0
150
+
151
+ /**
152
+ * Read `length` bytes at `offset` through the shell seam (`od`) as a Buffer.
153
+ *
154
+ * `run` hands back the raw command result (not just stdout) because `od` refuses
155
+ * to start at or past EOF — "cannot skip past end of input", exit 1 — and that is
156
+ * a SHORT READ, not a failure: a truncated file must yield short tables rather
157
+ * than blowing up the whole tool. Every other non-zero exit (missing file,
158
+ * permission) still goes through `requireOk` and stays loud.
159
+ */
160
+ async function elfReadRange(run, path, offset, length) {
161
+ const result = await run(['od', '-An', '-v', '-tx1', '-j', String(offset), '-N', String(length), path], { stdoutMaxBytes: 8388608 })
162
+ if (result.exitCode !== 0 && !/cannot skip|past end of input/iu.test(result.stderr)) requireOk(result, `od -j ${offset}`)
163
+ const hex = String(result.stdout).replace(/[^0-9a-fA-F]/gu, '')
164
+ return Buffer.from(hex.slice(0, hex.length - (hex.length % 2)), 'hex')
165
+ }
166
+
167
+ /**
168
+ * Parse an ELF file's header, program headers, sections and `.dynamic`.
169
+ *
170
+ * Returns null when the file is not ELF at all (the caller then falls through to
171
+ * its Mach-O path). Anything that could not be read stays `null` and is rendered
172
+ * `unknown` by the caller — never guessed. Every table is bounded by the bytes
173
+ * that actually arrived, so a truncated file yields a short table instead of
174
+ * invented rows.
175
+ */
176
+ async function elfProbe(run, path) {
177
+ const head = await elfReadRange(run, path, 0, 64)
178
+ if (head.length < 16 || head.readUInt32BE(0) !== ELF_MAGIC) return null
179
+ const bits = ELF_CLASSES[head[4]]
180
+ const endian = head[5] === 1 ? 'little' : head[5] === 2 ? 'big' : null
181
+ if (!bits || endian === null) return null
182
+ const wide = bits === 64
183
+ const le = endian === 'little'
184
+ const u16 = (b, o) => (o + 2 <= b.length ? (le ? b.readUInt16LE(o) : b.readUInt16BE(o)) : null)
185
+ const u32 = (b, o) => (o + 4 <= b.length ? (le ? b.readUInt32LE(o) : b.readUInt32BE(o)) : null)
186
+ const u64 = (b, o) => (o + 8 <= b.length ? Number(le ? b.readBigUInt64LE(o) : b.readBigUInt64BE(o)) : null)
187
+
188
+ const eType = u16(head, 16)
189
+ const eMachine = u16(head, 18)
190
+ const entry = wide ? u64(head, 24) : u32(head, 24)
191
+ const phoff = wide ? u64(head, 32) : u32(head, 28)
192
+ const shoff = wide ? u64(head, 40) : u32(head, 32)
193
+ const ehsize = wide ? u16(head, 52) : u16(head, 40)
194
+ const phentsize = wide ? u16(head, 54) : u16(head, 42)
195
+ const phnumRaw = wide ? u16(head, 56) : u16(head, 44)
196
+ const shentsize = wide ? u16(head, 58) : u16(head, 46)
197
+ const shnumRaw = wide ? u16(head, 60) : u16(head, 48)
198
+ const shstrndxRaw = wide ? u16(head, 62) : u16(head, 50)
199
+
200
+ // Section header #0 doubles as the escape hatch for the two "too many to fit"
201
+ // encodings (e_shnum 0, e_phnum/shstrndx 0xffff), so read it first.
202
+ const shdr0 = shoff !== null && shoff > 0 && shentsize !== null && shentsize >= (wide ? 64 : 40)
203
+ ? elfParseSection(await elfReadRange(run, path, shoff, shentsize), wide, le)
204
+ : null
205
+ const shnum = shnumRaw === 0 && shdr0 ? shdr0.size : shnumRaw
206
+ const shstrndx = shstrndxRaw === 0xffff && shdr0 ? shdr0.info : shstrndxRaw
207
+ const phnum = phnumRaw === 0xffff && shdr0 ? shdr0.info : phnumRaw
208
+
209
+ const sections = []
210
+ if (shoff !== null && shoff > 0 && shnum !== null && shnum > 0 && shentsize) {
211
+ const table = await elfReadRange(run, path, shoff, shentsize * shnum)
212
+ for (let i = 0; i < shnum; i++) {
213
+ const at = i * shentsize
214
+ if (at + shentsize > table.length) break
215
+ const section = elfParseSection(table.subarray(at, at + shentsize), wide, le)
216
+ if (section) sections.push({ ...section, index: i })
217
+ }
218
+ }
219
+ const phdrs = []
220
+ if (phoff !== null && phoff > 0 && phnum !== null && phnum > 0 && phentsize) {
221
+ const table = await elfReadRange(run, path, phoff, phentsize * phnum)
222
+ for (let i = 0; i < phnum; i++) {
223
+ const at = i * phentsize
224
+ if (at + phentsize > table.length) break
225
+ const seg = elfParseSegment(table.subarray(at, at + phentsize), wide, le)
226
+ if (seg) phdrs.push({ ...seg, index: i })
227
+ }
228
+ }
229
+
230
+ const shstrtab = sections[shstrndx] ?? null
231
+ if (shstrtab && shstrtab.size > 0) {
232
+ const names = await elfReadRange(run, path, shstrtab.offset, Math.min(shstrtab.size, 65536))
233
+ for (const section of sections) {
234
+ section.name = section.nameOffset < names.length ? elfCString(names, section.nameOffset) : ''
235
+ }
236
+ }
237
+
238
+ const loadSegments = phdrs.filter(p => p.type === ELF_PT_LOAD)
239
+ const readAt = (offset, length) => elfReadRange(run, path, offset, length)
240
+ const fileOffsetOfVaddr = (addr) => {
241
+ const seg = loadSegments.find(p => addr >= p.vaddr && addr < p.vaddr + p.memsz)
242
+ return seg ? seg.offset + (addr - seg.vaddr) : null
243
+ }
244
+ const readVaddr = (addr, length) => {
245
+ const offset = fileOffsetOfVaddr(addr)
246
+ return offset === null ? null : readAt(offset, length)
247
+ }
248
+
249
+ // PT_DYNAMIC is the normal source; a stripped file that kept only the section
250
+ // table still works through .dynamic.
251
+ const dynSegment = phdrs.find(p => p.type === ELF_PT_DYNAMIC)
252
+ const dynSection = sections.find(s => s.type === ELF_SHT_DYNAMIC)
253
+ const dynAt = dynSegment ? { offset: dynSegment.offset, size: dynSegment.filesz }
254
+ : dynSection ? { offset: dynSection.offset, size: dynSection.size } : null
255
+ const dynamic = []
256
+ if (dynAt && dynAt.size > 0) {
257
+ const bytes = await readAt(dynAt.offset, Math.min(dynAt.size, 262144))
258
+ const step = wide ? 16 : 8
259
+ for (let at = 0; at + step <= bytes.length; at += step) {
260
+ const tag = wide ? u64(bytes, at) : u32(bytes, at)
261
+ const val = wide ? u64(bytes, at + (wide ? 8 : 4)) : u32(bytes, at + 4)
262
+ if (tag === null || val === null) break
263
+ dynamic.push({ tag, val })
264
+ if (tag === 0) break
265
+ }
266
+ }
267
+ const dynTag = (tag) => dynamic.find(d => d.tag === tag)
268
+
269
+ const strtab = dynTag(ELF_DT_STRTAB)
270
+ const strsz = dynTag(ELF_DT_STRSZ)
271
+ /** Resolve a byte offset inside DT_STRTAB to its string (null if not resolvable). */
272
+ const dynamicString = async (offsetInStrtab) => {
273
+ if (!strtab || strsz === undefined) return null
274
+ if (!Number.isSafeInteger(offsetInStrtab) || offsetInStrtab < 0 || offsetInStrtab >= strsz.val) return null
275
+ const bytes = await readVaddr(strtab.val + offsetInStrtab, 512)
276
+ return bytes ? elfCString(bytes, 0) : null
277
+ }
278
+
279
+ const stack = phdrs.find(p => p.type === ELF_PT_GNU_STACK) ?? null
280
+ const relro = phdrs.find(p => p.type === ELF_PT_GNU_RELRO) ?? null
281
+ const interp = phdrs.find(p => p.type === ELF_PT_INTERP) ?? null
282
+ const flags = dynTag(ELF_DT_FLAGS)?.val ?? 0
283
+ const flags1 = dynTag(ELF_DT_FLAGS_1)?.val ?? 0
284
+
285
+ return {
286
+ path, bits, endian, type: eType, machine: eMachine, osabi: head[7], entry, ehsize,
287
+ phoff, phnum, phentsize, shoff, shnum, shentsize, shstrndx,
288
+ phdrs, sections, dynamic, dynAt,
289
+ stack, relro, interp, loadSegments,
290
+ bindNow: dynamic.some(d => d.tag === ELF_DT_BIND_NOW)
291
+ || (flags & ELF_DF_BIND_NOW) !== 0 || (flags1 & ELF_DF_1_NOW) !== 0,
292
+ dynamicString, readAt, readVaddr, fileOffsetOfVaddr,
293
+ }
294
+ }
295
+
296
+ /** `R`/`W`/`E` triplet with `-` for absent bits (readelf's three-column flags). */
297
+ function elfFlagsText(pFlags) {
298
+ return `${pFlags & 4 ? 'R' : '-'}${pFlags & 2 ? 'W' : '-'}${pFlags & 1 ? 'E' : '-'}`
299
+ }
300
+
301
+ function elfParseSegment(bytes, wide, le) {
302
+ if (!bytes || bytes.length < (wide ? 56 : 32)) return null
303
+ const u16 = (o) => (le ? bytes.readUInt16LE(o) : bytes.readUInt16BE(o))
304
+ const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
305
+ const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
306
+ return wide
307
+ ? { type: u32(0), flags: u32(4), offset: u64(8), vaddr: u64(16), paddr: u64(24), filesz: u64(32), memsz: u64(40), align: u64(48) }
308
+ : { type: u32(0), flags: u32(24), offset: u32(4), vaddr: u32(8), paddr: u32(12), filesz: u32(16), memsz: u32(20), align: u32(28) }
309
+ }
310
+
311
+ function elfParseSection(bytes, wide, le) {
312
+ if (!bytes || bytes.length < (wide ? 64 : 40)) return null
313
+ const u32 = (o) => (le ? bytes.readUInt32LE(o) : bytes.readUInt32BE(o))
314
+ const u64 = (o) => Number(le ? bytes.readBigUInt64LE(o) : bytes.readBigUInt64BE(o))
315
+ return wide
316
+ ? { 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: '' }
317
+ : { 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: '' }
318
+ }
319
+
320
+ function elfCString(bytes, at) {
321
+ let end = at
322
+ while (end < bytes.length && bytes[end] !== 0) end++
323
+ return bytes.subarray(at, end).toString('latin1')
324
+ }
325
+
326
+ /**
327
+ * Dynamic symbol names, used for the canary question ("is `__stack_chk_fail`
328
+ * referenced?"). The symbol count is not stored in ELF; it is recovered from
329
+ * DT_HASH's `nchain`, or by walking DT_GNU_HASH's chains for the highest index.
330
+ * When neither is usable the caller falls back to its documented heuristic.
331
+ */
332
+ async function elfDynamicSymbols(run, elf) {
333
+ const symtab = elf.dynamic.find(d => d.tag === ELF_DT_SYMTAB)
334
+ const strtab = elf.dynamic.find(d => d.tag === ELF_DT_STRTAB)
335
+ if (!symtab || !strtab) return null
336
+ const hash = elf.dynamic.find(d => d.tag === ELF_DT_HASH)
337
+ const gnuHash = elf.dynamic.find(d => d.tag === 0x6ffffef5)
338
+ let count = null
339
+ if (hash) {
340
+ const head = await elf.readVaddr(hash.val, 8)
341
+ if (head && head.length >= 8) count = readU32At(head, 4, elf.endian)
342
+ } else if (gnuHash) {
343
+ const head = await elf.readVaddr(gnuHash.val, 16)
344
+ if (head && head.length >= 16) {
345
+ const nbuckets = readU32At(head, 0, elf.endian)
346
+ const symoffset = readU32At(head, 4, elf.endian)
347
+ const bloomSize = readU32At(head, 8, elf.endian)
348
+ const wordSize = elf.bits === 64 ? 8 : 4
349
+ const bucketsAt = gnuHash.val + 16 + bloomSize * wordSize
350
+ const buckets = await elf.readVaddr(bucketsAt, nbuckets * 4)
351
+ if (buckets && buckets.length >= nbuckets * 4) {
352
+ const chainsAt = bucketsAt + nbuckets * 4
353
+ let max = symoffset
354
+ for (let i = 0; i < nbuckets; i++) {
355
+ let index = readU32At(buckets, i * 4, elf.endian)
356
+ if (index < symoffset) continue
357
+ for (let guard = 0; guard < 65536; guard++) {
358
+ const chain = await elf.readVaddr(chainsAt + (index - symoffset) * 4, 4)
359
+ if (!chain || chain.length < 4) break
360
+ const value = readU32At(chain, 0, elf.endian)
361
+ if ((value & 1) === 1) break
362
+ index++
363
+ }
364
+ if (index + 1 > max) max = index + 1
365
+ }
366
+ count = max
367
+ }
368
+ }
369
+ }
370
+ if (count === null || count <= 0) return null
371
+ const entrySize = elf.bits === 64 ? 24 : 16
372
+ const bytes = await elf.readVaddr(symtab.val, Math.min(count, 65536) * entrySize)
373
+ if (!bytes) return null
374
+ const symbols = []
375
+ for (let i = 0; i < count; i++) {
376
+ const at = i * entrySize
377
+ if (at + entrySize > bytes.length) break
378
+ const nameOffset = readU32At(bytes, at, elf.endian)
379
+ const shndx = elf.bits === 64 ? readU16At(bytes, at + 6, elf.endian) : readU16At(bytes, at + 14, elf.endian)
380
+ const value = elf.bits === 64 ? readU64At(bytes, at + 8, elf.endian) : readU32At(bytes, at + 4, elf.endian)
381
+ const strOffset = strtab.val + nameOffset
382
+ const strBytes = await elf.readVaddr(strOffset, 256)
383
+ symbols.push({ name: strBytes ? elfCString(strBytes, 0) : '', shndx, value })
384
+ }
385
+ return symbols
386
+ }
387
+
388
+ function readU16At(bytes, at, endian) {
389
+ if (at + 2 > bytes.length) return null
390
+ return endian === 'little' ? bytes.readUInt16LE(at) : bytes.readUInt16BE(at)
391
+ }
392
+
393
+ function readU32At(bytes, at, endian) {
394
+ if (at + 4 > bytes.length) return null
395
+ return endian === 'little' ? bytes.readUInt32LE(at) : bytes.readUInt32BE(at)
396
+ }
397
+
398
+ function readU64At(bytes, at, endian) {
399
+ if (at + 8 > bytes.length) return null
400
+ return Number(endian === 'little' ? bytes.readBigUInt64LE(at) : bytes.readBigUInt64BE(at))
401
+ }
402
+ // ── END fly-elf-probe ──────────────────────────────────────────────────────
403
+
404
+ /** Adapter for the shared ELF probe: it wants the raw command result. */
405
+ const rawRun = (shell, exec) => (argv, opts) => runCmd(shell, argv, exec, opts)
406
+
407
+ /**
408
+ * Canary probe of last resort: `strings`. Documented as a heuristic on purpose —
409
+ * it can see a copied `__stack_chk_fail` string in a binary that never calls it.
410
+ */
411
+ async function canaryFromStrings(shell, path, exec, why) {
412
+ const run = await runCmd(shell, ['strings', '-n', '8', path], exec, { stdoutMaxBytes: 1048576 })
413
+ if (run.exitCode !== 0) return [`canary=unknown (neither .dynsym nor strings could be read: ${why})`]
414
+ if (run.stdout.includes('__stack_chk_fail')) return [`canary=present (heuristic: __stack_chk_fail in strings — ${why})`]
415
+ const truncated = run.stdout.length >= 1048576
416
+ return [`canary=${truncated ? 'unknown (strings output truncated)' : 'absent'} (heuristic: __stack_chk_fail in strings — ${why})`]
417
+ }
418
+
419
+ /**
420
+ * Mitigations read out of the ELF itself, used when `checksec` is absent.
421
+ *
422
+ * PIE from e_type + PT_INTERP, NX from PT_GNU_STACK's X bit, RELRO from
423
+ * PT_GNU_RELRO plus DT_BIND_NOW/DF_BIND_NOW, canary from `.dynsym` (whose symbol
424
+ * count has to be recovered from DT_HASH's nchain or DT_GNU_HASH's chains, since
425
+ * ELF does not store it). Anything the file does not state stays `unknown` with
426
+ * the reason attached.
427
+ *
428
+ * The previous fallback is why this exists: it probed `readelf -h` and matched
429
+ * /Type:\s+DYN/, which on a host without readelf failed silently and reported
430
+ * `pie=no` for EVERY binary — a fabricated answer that flips the exploit
431
+ * strategy. Asserted in scripts/verify-security-tools.mjs against ELF32/ELF64,
432
+ * ET_EXEC/ET_DYN (with and without PT_INTERP), DT_HASH and DT_GNU_HASH fixtures.
433
+ */
434
+ async function elfMitigationLines(shell, path, exec, kind) {
435
+ const run = rawRun(shell, exec)
436
+ const elf = await elfProbe(run, path)
437
+ const lines = [
438
+ `path=${path}`,
439
+ 'checksec=not installed (install checksec.sh — brew install checksec — for the vendor verdict)',
440
+ `format=${kind === '' ? 'unknown (file(1) reported nothing)' : kind}`,
441
+ ]
442
+ if (elf === null) {
443
+ lines.push('pie=unknown (not ELF: nothing was parsed)')
444
+ lines.push('note: nothing here is guessed — see rev_* tools for non-ELF formats')
445
+ return lines
446
+ }
447
+ lines.push(`arch=ELF${elf.bits} ${ELF_MACHINES[elf.machine] ?? `machine 0x${Number(elf.machine).toString(16)}`}, ${elf.endian} endian`)
448
+ if (elf.type === 2) lines.push('pie=no (ELF e_type ET_EXEC)')
449
+ else if (elf.type === 3 && elf.interp !== null) lines.push('pie=yes (ET_DYN with PT_INTERP)')
450
+ else if (elf.type === 3) lines.push('pie=unknown (ET_DYN without PT_INTERP — a shared object or a static PIE; the header alone does not say)')
451
+ else lines.push(`pie=unknown (ELF e_type ${ELF_TYPES[elf.type] ?? elf.type})`)
452
+ lines.push(elf.stack === null
453
+ ? 'nx=unknown (no PT_GNU_STACK segment: the file does not state a stack permission)'
454
+ : (elf.stack.flags & ELF_PF_X) !== 0
455
+ ? 'nx=no (PT_GNU_STACK is executable)'
456
+ : 'nx=yes (PT_GNU_STACK without X)')
457
+ lines.push(elf.relro === null
458
+ ? 'relro=no (no PT_GNU_RELRO)'
459
+ : elf.bindNow
460
+ ? 'relro=full (PT_GNU_RELRO + BIND_NOW)'
461
+ : 'relro=partial (PT_GNU_RELRO without BIND_NOW)')
462
+ const symbols = await elfDynamicSymbols(run, elf)
463
+ if (symbols === null) {
464
+ lines.push(...await canaryFromStrings(shell, path, exec, 'the symbol count is not recoverable: no DT_HASH and no DT_GNU_HASH'))
465
+ } else if (symbols.length === 0) {
466
+ lines.push(...await canaryFromStrings(shell, path, exec, '.dynsym parsed but empty'))
467
+ } else {
468
+ lines.push(symbols.some(sym => sym.shndx === ELF_SHN_UNDEF && sym.name.includes('__stack_chk_fail'))
469
+ ? 'canary=present (__stack_chk_fail is an undefined dynamic symbol)'
470
+ : 'canary=absent (no __stack_chk_fail in .dynsym)')
471
+ }
472
+ lines.push('note: read from the file itself (ELF header, program headers, .dynamic, .dynsym); every `unknown` above means the file does not state it — nothing is guessed')
473
+ return lines
474
+ }
475
+
476
+ /** Mach-O counterpart: otool -hv for the header flags, nm -u for the symbols. */
477
+ async function machoMitigationLines(shell, path, exec, kind) {
478
+ const lines = [
479
+ `path=${path}`,
480
+ 'checksec=not installed (install checksec.sh — brew install checksec — for the vendor verdict)',
481
+ `format=${kind}`,
482
+ ]
483
+ const otool = await runCmd(shell, ['otool', '-hv', path], exec)
484
+ if (otool.exitCode === 0) {
485
+ lines.push(`pie=${/\bPIE\b/u.test(otool.stdout) ? 'yes (MH_PIE set in the Mach-O header flags)' : 'no (MH_PIE absent from the Mach-O header flags)'}`)
486
+ lines.push(/\bALLOW_STACK_EXECUTION\b/u.test(otool.stdout)
487
+ ? 'nx=no (MH_ALLOW_STACK_EXECUTION is set in the Mach-O header flags)'
488
+ : 'nx=yes (no MH_ALLOW_STACK_EXECUTION in the Mach-O header flags)')
489
+ } else {
490
+ lines.push('pie=unknown (otool unavailable)', 'nx=unknown (otool unavailable)')
491
+ }
492
+ lines.push('relro=n/a (GNU_RELRO is an ELF concept; Mach-O has no direct equivalent)')
493
+ const nm = await runCmd(shell, ['nm', '-u', path], exec, { stdoutMaxBytes: 4194304 })
494
+ const undefinedSymbols = nm.exitCode === 0
495
+ ? nm.stdout.split('\n').map(line => line.trim()).filter(line => /^_?[A-Za-z_][\w$.@]*$/u.test(line))
496
+ : []
497
+ if (nm.exitCode === 0 && undefinedSymbols.length > 0) {
498
+ lines.push(undefinedSymbols.some(name => name.includes('stack_chk_fail'))
499
+ ? 'canary=present (__stack_chk_fail is among the undefined symbols from nm -u)'
500
+ : 'canary=absent (no __stack_chk_fail among the undefined symbols from nm -u)')
501
+ } else {
502
+ lines.push(...await canaryFromStrings(shell, path, exec, `nm -u listed no undefined symbols (exit ${nm.exitCode})`))
503
+ }
504
+ lines.push('note: read from the Mach-O header flags (otool -hv) and the undefined symbols (nm -u); every `unknown` above means the host could not read it — nothing is guessed')
505
+ return lines
506
+ }
507
+
508
+ /** Dispatch on what the file actually is; `file(1)` is POSIX, unlike checksec. */
509
+ async function mitigationLines(shell, path, exec) {
510
+ const kindRun = await runCmd(shell, ['file', '-b', path], exec)
511
+ if (kindRun.exitCode !== 0) requireOk(kindRun, 'file -b')
512
+ const kind = kindRun.stdout.trim()
513
+ if (kind.startsWith('Mach-O')) return await machoMitigationLines(shell, path, exec, kind)
514
+ if (kind.startsWith('ELF') || kind === '') return await elfMitigationLines(shell, path, exec, kind)
515
+ return [
516
+ `path=${path}`,
517
+ 'checksec=not installed (install checksec.sh — brew install checksec — for the vendor verdict)',
518
+ `format=${kind}`,
519
+ 'pie=unknown (neither ELF nor Mach-O — this tool does not guess for other formats)',
520
+ ]
521
+ }
522
+
523
+ export function apply(ctx) {
524
+ const shell = ctx.shell
525
+
526
+ ctx.tools.register(defineTool({
527
+ name: 'pwn_checksec',
528
+ description: 'Summarize binary mitigations (checksec when installed, else computed from the ELF or Mach-O itself).',
529
+ parameters: {
530
+ path: { type: 'string', required: true },
531
+ },
532
+ output: {
533
+ schema: { type: 'string' },
534
+ render: (_a, v) => [{ type: 'text', text: v }],
535
+ },
536
+ async execute(args, exec) {
537
+ const ck = await runCmd(shell, ['checksec', '--file=' + args.path], exec)
538
+ if (ck.exitCode === 0) return ck.stdout
539
+ return (await mitigationLines(shell, args.path, exec)).join('\n')
540
+ },
541
+ }))
542
+
543
+ ctx.tools.register(defineTool({
544
+ name: 'pwn_cyclic',
545
+ description: 'Generate a cyclic de Bruijn pattern for offset discovery.',
546
+ parameters: {
547
+ length: { type: 'number', required: true, description: 'Pattern length in bytes' },
548
+ },
549
+ output: {
550
+ schema: { type: 'string' },
551
+ render: (_a, v) => [{ type: 'text', text: v }],
552
+ },
553
+ async execute(args) {
554
+ const n = Math.max(1, Math.min(4096, Number(args.length) || 64))
555
+ return cyclic(n)
556
+ },
557
+ }))
558
+
559
+ ctx.tools.register(defineTool({
560
+ name: 'pwn_rop_hint',
561
+ description: 'Produce a ret2libc / ROP planning checklist for a non-PIE NX binary.',
562
+ parameters: {
563
+ binary: { type: 'string', required: true },
564
+ goal: { type: 'string', description: 'e.g. system("/bin/sh") or shell' },
565
+ },
566
+ output: {
567
+ schema: { type: 'string' },
568
+ render: (_a, v) => [{ type: 'text', text: v }],
569
+ },
570
+ async execute(args) {
571
+ return [
572
+ `# ROP plan for ${args.binary}`,
573
+ `goal: ${args.goal ?? 'shell'}`,
574
+ '1. confirm overflow offset via cyclic + crash RIP',
575
+ '2. leak libc via puts@plt(puts@got) and return to main',
576
+ '3. compute libc base, locate system and /bin/sh',
577
+ '4. second payload: pop rdi; ret → binsh → system (align stack with extra ret)',
578
+ '5. prefer local lab targets only; never against unauthorized hosts',
579
+ 'note: this tool is a planning checklist only — it does not inspect the binary; pair it with pwn_checksec, rev_imports and rev_gadget_scan',
580
+ ].join('\n')
581
+ },
582
+ }))
583
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "dsh-plugin-fly-pwn",
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-pwn"
19
+ },
20
+ "publishConfig": {
21
+ "registry": "https://registry.npmjs.org/"
22
+ }
23
+ }