@dsh-fly/dsh-plugin-fly-mode 0.0.0-stage → 0.1.2
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 +51 -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,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
|
+
}
|