feat(elf): decode Linux core dump process and memory mapping notes (#8830)

* feat(elf): decode Linux core dump process and memory mapping notes

Inspect Linux core dump note tables and decode structured metadata without
requiring external tools or debuggers:
- NT_PRPSINFO (type 3): decode process ID, parent ID, process group,
  session, UID, GID, execution state, filename, and command line arguments
  bounded by the text budget.
- NT_FILE (type 0x46494c45): decode mapped memory ranges (start_address,
  end_address, file_offset_pages) and mapped file paths with page size.
- NT_SIGINFO (type 0x53494749): decode faulting signal number, errno, and code.
- NT_AUXV (type 6): decode auxiliary vector pairs with known AT_* tag names.
- Support both 32-bit and 64-bit, little-endian and big-endian targets.
- Retain unknown core note types gracefully without partial success errors.
- Add comprehensive unit tests, fixtures, acceptance tests, and documentation.

* fix(elf): preserve raw path bytes and enforce auxv termination

* fix(elf): account for path hex text budget and enforce auxv trailing padding

* fix(elf): account for row and scan limits, ABI signal order, and lossless source IDs

* fix(elf): cache auxv padding scans, pre-charge entries, and isolate page-size test
This commit is contained in:
Rishikesh Gowda K K authored and GitHub committed 2026-11-04 11:46:11 +08:00
1 parent c3647ade1e
commit e75d1c741c
7 files changed
+1149 -10

No files matched your search

+7 -1
View File
@@ -38,6 +38,12 @@ were examined; an unavailable section name does not erase those note facts.
For GNU notes, `gnu.build_id_hex`, `gnu.abi_tag`, or `gnu.gold_version` are
filled when the descriptor matches that type. Other owners keep `gnu` null.
For Linux `CORE` notes, `core.prpsinfo`, `core.file`, `core.siginfo`, or
`core.auxv` are decoded for process arguments and status (`NT_PRPSINFO`), mapped
file memory regions and paths (`NT_FILE`), fault signal and code (`NT_SIGINFO`), and
auxiliary vectors (`NT_AUXV`) across 32-bit and 64-bit images. Other owners or unparsed
notes keep `core` null.
`PT_NOTE` and `SHT_NOTE` are examined independently. The same file range may
appear twice when both tables declare it.
@@ -48,7 +54,7 @@ the image never carried notes in another container or overlay.
The selected file is limited to 64 MiB by default (512 MiB maximum). At most
32 note entries are examined by default across every table. Retained note
names, section names, and GNU descriptor text share a scan budget based on
names, section names, GNU descriptor text, and core dump string representations share a scan budget based on
the source bytes plus any decoded section-name table, capped at
`max_file_bytes`. The raw source and decoded table must also fit together
within that ceiling. If admission fails, the error reports the required
@@ -111,7 +111,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_elf_notes"),
kind: "native-provider",
description:
"Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.",
"Inspect one explicit local ELF32/ELF64 image's PT_NOTE program headers and SHT_NOTE sections without executing it or opening a disassembler. Returns note-table file ranges, owners, types, descriptor SHA-256 and hex previews, plus GNU build-id, ABI-tag and gold-version fields and Linux CORE process, mapping, signal, and auxv notes when those descriptors match. A missing table is observed absence of those declarations, not proof the image never carried notes in another container. Reads a stable regular file; malformed ELF headers, truncated notes and exhausted budgets return no partial success. Portable on supported Node hosts; no debug package or catalog is required.",
inputSchema: inspectElfNotesInputSchema,
outputSchema: evidenceResultOf(elfNotesSchema),
examples: [
+66
View File
@@ -17,6 +17,10 @@ export const ELF_NOTE_TYPE_NAMES = [
"gnu_build_id",
"gnu_gold_version",
"gnu_property",
"core_prpsinfo",
"core_file",
"core_siginfo",
"core_auxv",
"unknown",
] as const;
export type ElfNoteTypeName = (typeof ELF_NOTE_TYPE_NAMES)[number];
@@ -64,6 +68,22 @@ export const gnuNoteTypeName = (type: number): ElfNoteTypeName => {
}
};
/** Map CORE n_type when the owner is CORE; other types stay unknown. */
export const coreNoteTypeName = (type: number): ElfNoteTypeName => {
switch (type) {
case 3:
return "core_prpsinfo";
case 6:
return "core_auxv";
case 0x46494c45:
return "core_file";
case 0x53494749:
return "core_siginfo";
default:
return "unknown";
}
};
/** Complete bounded inspection of ELF PT_NOTE and SHT_NOTE tables. */
export const inspectElfNotesInputSchema = z.strictObject({
path: localPathStringSchema.describe(
@@ -95,6 +115,51 @@ const gnuAbiTagSchema = z.strictObject({
subminor: uint,
});
const coreFileMappingSchema = z.strictObject({
start_address: z.string().regex(/^0x[0-9a-f]+$/),
end_address: z.string().regex(/^0x[0-9a-f]+$/),
file_offset_pages: z.string().regex(/^0x[0-9a-f]+$/),
path: z.string(),
path_hex: z.string().regex(/^[0-9a-f]*$/),
});
const coreFileSchema = z.strictObject({
count: uint,
page_size: uint,
mappings: z.array(coreFileMappingSchema),
});
const corePrpsinfoSchema = z.strictObject({
state: uint,
pid: z.number().int(),
ppid: z.number().int(),
pgrp: z.number().int(),
sid: z.number().int(),
uid: uint,
gid: uint,
filename: z.string(),
psargs: z.string(),
});
const coreSiginfoSchema = z.strictObject({
signo: z.number().int(),
code: z.number().int(),
errno: z.number().int(),
});
const coreAuxvEntrySchema = z.strictObject({
type: uint,
type_name: z.string().nullable(),
value: z.string().regex(/^0x[0-9a-f]+$/),
});
export const coreNoteSchema = z.strictObject({
prpsinfo: corePrpsinfoSchema.nullable(),
file: coreFileSchema.nullable(),
siginfo: coreSiginfoSchema.nullable(),
auxv: z.array(coreAuxvEntrySchema).nullable(),
});
const noteEntrySchema = z.strictObject({
index: uint,
table_kind: z.enum(["program", "section"]),
@@ -125,6 +190,7 @@ const noteEntrySchema = z.strictObject({
gold_version: z.string().nullable(),
})
.nullable(),
core: coreNoteSchema.nullable(),
});
/** Observed ELF note tables; absence is an empty list, not proof notes never existed. */
+378
View File
@@ -8,6 +8,10 @@ import { readElfDirectoryFromBuffer } from "../metadata/NativeSectionDirectory.j
import {
compressedElfNoteFixture,
compressedElfNoteSectionFixture,
coreAuxvNote,
coreFileNote,
corePrpsinfoNote,
coreSiginfoNote,
elfNote,
elfNoteFixture,
elfNoteTablePastEnd,
@@ -702,3 +706,377 @@ it.each([
}
},
);
it("decodes Linux core dump notes (NT_PRPSINFO, NT_FILE, NT_SIGINFO, NT_AUXV) for 64-bit little-endian", () => {
const notes = Buffer.concat([
corePrpsinfoNote({
bits: 64,
little: true,
state: 0,
sname: "R",
pid: 4242,
ppid: 1,
pgrp: 4242,
sid: 4242,
uid: 1001,
gid: 1002,
fname: "test-proc",
psargs: "/usr/bin/test-proc --daemon -v",
}),
coreFileNote(
[
{
start: 0x400000n,
end: 0x405000n,
file_ofs: 0n,
path: "/usr/bin/test-proc",
},
{
start: 0x7fff0000n,
end: 0x7fff2000n,
file_ofs: 10n,
path: "/lib/x86_64-linux-gnu/libc.so.6",
},
],
4096,
64,
true,
),
coreSiginfoNote(11, 1, 0, true), // SIGSEGV, SEGV_MAPERR
coreAuxvNote(
[
[6, 4096n], // AT_PAGESZ
[3, 0x400040n], // AT_PHDR
[9, 0x401000n], // AT_ENTRY
[999, 0x1234n], // Unknown auxv tag
],
64,
true,
),
]);
const bytes = elfNoteFixture({
bits: 64,
little: true,
type: 4, // ET_CORE
ownership: "program",
notes,
});
const report = parse(bytes);
expect(report.entries).toHaveLength(4);
// 1. NT_PRPSINFO
expect(report.entries[0]).toMatchObject({
owner: "CORE",
type: 3,
type_name: "core_prpsinfo",
gnu: null,
core: {
prpsinfo: {
state: 0,
pid: 4242,
ppid: 1,
pgrp: 4242,
sid: 4242,
uid: 1001,
gid: 1002,
filename: "test-proc",
psargs: "/usr/bin/test-proc --daemon -v",
},
file: null,
siginfo: null,
auxv: null,
},
});
// 2. NT_FILE
expect(report.entries[1]).toMatchObject({
owner: "CORE",
type: 0x46494c45,
type_name: "core_file",
gnu: null,
core: {
prpsinfo: null,
file: {
count: 2,
page_size: 4096,
mappings: [
{
start_address: "0x400000",
end_address: "0x405000",
file_offset_pages: "0x0",
path: "/usr/bin/test-proc",
path_hex: Buffer.from("/usr/bin/test-proc").toString("hex"),
},
{
start_address: "0x7fff0000",
end_address: "0x7fff2000",
file_offset_pages: "0xa",
path: "/lib/x86_64-linux-gnu/libc.so.6",
path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString(
"hex",
),
},
],
},
siginfo: null,
auxv: null,
},
});
// 3. NT_SIGINFO
expect(report.entries[2]).toMatchObject({
owner: "CORE",
type: 0x53494749,
type_name: "core_siginfo",
gnu: null,
core: {
prpsinfo: null,
file: null,
siginfo: {
signo: 11,
errno: 0,
code: 1,
},
auxv: null,
},
});
// 4. NT_AUXV
expect(report.entries[3]).toMatchObject({
owner: "CORE",
type: 6,
type_name: "core_auxv",
gnu: null,
core: {
prpsinfo: null,
file: null,
siginfo: null,
auxv: [
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
{ type: 3, type_name: "AT_PHDR", value: "0x400040" },
{ type: 9, type_name: "AT_ENTRY", value: "0x401000" },
{ type: 999, type_name: null, value: "0x1234" },
],
},
});
});
it("decodes Linux core dump notes for 32-bit big-endian", () => {
const notes = Buffer.concat([
corePrpsinfoNote({
bits: 32,
little: false,
state: 1,
pid: 123,
ppid: 1,
pgrp: 123,
sid: 123,
uid: 500,
gid: 500,
fname: "proc32",
psargs: "proc32 arg",
}),
coreFileNote(
[
{
start: 0x10000,
end: 0x12000,
file_ofs: 0,
path: "/bin/proc32",
},
],
4096,
32,
false,
),
coreAuxvNote(
[
[6, 4096],
[11, 500],
],
32,
false,
),
]);
const bytes = elfNoteFixture({
bits: 32,
little: false,
type: 4,
ownership: "program",
notes,
});
const report = parse(bytes);
expect(report.entries).toHaveLength(3);
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
state: 1,
pid: 123,
uid: 500,
gid: 500,
filename: "proc32",
psargs: "proc32 arg",
});
expect(report.entries[1]?.core?.file).toMatchObject({
count: 1,
page_size: 4096,
mappings: [
{
start_address: "0x10000",
end_address: "0x12000",
file_offset_pages: "0x0",
path: "/bin/proc32",
path_hex: Buffer.from("/bin/proc32").toString("hex"),
},
],
});
expect(report.entries[2]?.core?.auxv).toEqual([
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
{ type: 11, type_name: "AT_UID", value: "0x1f4" },
]);
});
it("tolerates malformed core notes by returning null without throwing", () => {
const truncatedPrpsinfo = elfNote("CORE", 3, Buffer.alloc(20)); // requires 136 for 64-bit
const truncatedFile = elfNote(
"CORE",
0x46494c45,
Buffer.from("00000000000000010000000000001000", "hex"), // count 1, page_size 4096, but no map entries or filenames
);
const truncatedSiginfo = elfNote("CORE", 0x53494749, Buffer.alloc(4)); // requires 12 bytes
const unterminatedAuxv = elfNote(
"CORE",
6,
Buffer.from("00000000000000060000000000001000", "hex"), // AT_PAGESZ=4096 but lacks AT_NULL terminator
);
const truncatedAuxv = elfNote("CORE", 6, Buffer.alloc(7)); // not multiple of entry size
const notes = Buffer.concat([
truncatedPrpsinfo,
truncatedFile,
truncatedSiginfo,
unterminatedAuxv,
truncatedAuxv,
]);
const bytes = elfNoteFixture({
bits: 64,
ownership: "program",
notes,
});
const report = parse(bytes);
expect(report.entries).toHaveLength(5);
expect(report.entries[0]?.core).toBeNull();
expect(report.entries[0]?.type_name).toBe("core_prpsinfo");
expect(report.entries[1]?.core).toBeNull();
expect(report.entries[1]?.type_name).toBe("core_file");
expect(report.entries[2]?.core).toBeNull();
expect(report.entries[2]?.type_name).toBe("core_siginfo");
expect(report.entries[3]?.core).toBeNull();
expect(report.entries[3]?.type_name).toBe("core_auxv");
expect(report.entries[4]?.core).toBeNull();
expect(report.entries[4]?.type_name).toBe("core_auxv");
});
it("decodes siginfo using target ABI for MIPS machines", () => {
const desc = Buffer.alloc(128);
desc.writeInt32LE(11, 0); // signo = SIGSEGV
desc.writeInt32LE(1, 4); // code = SEGV_MAPERR (offset 4 on MIPS)
desc.writeInt32LE(0, 8); // errno = 0 (offset 8 on MIPS)
const mipsSiginfo = elfNote("CORE", 0x53494749, desc, true);
const bytes = elfNoteFixture({
bits: 32,
machine: 8, // EM_MIPS
ownership: "program",
notes: mipsSiginfo,
});
const report = parse(bytes);
expect(report.entries[0]?.core?.siginfo).toEqual({
signo: 11,
code: 1,
errno: 0,
});
});
it("preserves negative process IDs and signal numbers without rewriting to zero", () => {
const prps = corePrpsinfoNote({
pid: -1,
ppid: -1,
pgrp: -1,
sid: -1,
});
const sig = coreSiginfoNote(-1, -2, -3);
const bytes = elfNoteFixture({
bits: 64,
ownership: "program",
notes: Buffer.concat([prps, sig]),
});
const report = parse(bytes);
expect(report.entries[0]?.core?.prpsinfo).toMatchObject({
pid: -1,
ppid: -1,
pgrp: -1,
sid: -1,
});
expect(report.entries[1]?.core?.siginfo).toMatchObject({
signo: -1,
code: -2,
errno: -3,
});
});
it("rejects 64-bit auxv tags and out-of-range page sizes with core: null without schema error", () => {
const validAuxv = coreAuxvNote([[6, 4096n]], 64, true);
const wideTagAuxv = Buffer.from(validAuxv);
wideTagAuxv.writeBigUInt64LE(0x100000000n, 20);
const validFile = coreFileNote(
[
{
start: 0x400000n,
end: 0x401000n,
file_ofs: 0n,
path: "/bin/app",
},
],
4096,
64,
true,
);
const widePageSizeFile = Buffer.from(validFile);
widePageSizeFile.writeBigUInt64LE(0x100000000n, 28);
const bytes = elfNoteFixture({
bits: 64,
ownership: "program",
notes: Buffer.concat([wideTagAuxv, widePageSizeFile]),
});
const report = parse(bytes);
expect(report.entries[0]?.core).toBeNull();
expect(report.entries[1]?.core).toBeNull();
});
it("does not spend text budget on malformed or discarded NT_FILE paths", () => {
const header = Buffer.alloc(16);
header.writeBigUInt64LE(2n, 0); // count = 2
header.writeBigUInt64LE(4096n, 8); // page_size = 4096
const mappings = Buffer.alloc(48); // 2 mappings * 24 bytes
const paths = Buffer.from(
"/bin/app\0unterminated_path_without_null",
"latin1",
);
const badFileNote = elfNote(
"CORE",
0x46494c45,
Buffer.concat([header, mappings, paths]),
);
const bytes = elfNoteFixture({
bits: 64,
ownership: "program",
notes: badFileNote,
});
const report = parse(bytes);
expect(report.entries[0]?.core).toBeNull();
});
+356 -3
View File
@@ -19,6 +19,7 @@ import type {
InspectElfNotesInput,
} from "../../domain/native/elfNotes.js";
import {
coreNoteTypeName,
elfObjectTypeName,
gnuNoteTypeName,
} from "../../domain/native/elfNotes.js";
@@ -289,6 +290,322 @@ const decodeGnu = (
};
};
const AUXV_TYPE_NAMES: Readonly<Record<number, string>> = {
0: "AT_NULL",
1: "AT_IGNORE",
2: "AT_EXECFD",
3: "AT_PHDR",
4: "AT_PHENT",
5: "AT_PHNUM",
6: "AT_PAGESZ",
7: "AT_BASE",
8: "AT_FLAGS",
9: "AT_ENTRY",
11: "AT_UID",
12: "AT_EUID",
13: "AT_GID",
14: "AT_EGID",
15: "AT_PLATFORM",
16: "AT_HWCAP",
17: "AT_CLKTCK",
23: "AT_SECURE",
25: "AT_RANDOM",
26: "AT_HWCAP2",
31: "AT_EXECFN",
33: "AT_SYSINFO_EHDR",
};
const hexWord = (val: bigint | number): string => `0x${val.toString(16)}`;
const decodeCorePrpsinfo = (
descriptor: Buffer,
little: boolean,
wide: boolean,
text: TextBudget,
): ElfNotes["entries"][number]["core"] => {
const minLen = wide ? 136 : 124;
if (descriptor.length < minLen) return null;
const u8 = (off: number) => descriptor.readUInt8(off);
const u16 = (off: number) =>
little ? descriptor.readUInt16LE(off) : descriptor.readUInt16BE(off);
const u32 = (off: number) =>
little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off);
const i32 = (off: number) =>
little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off);
const state = u8(0);
const uid = wide ? u32(16) : u16(8);
const gid = wide ? u32(20) : u16(10);
const pid = wide ? i32(24) : i32(12);
const ppid = wide ? i32(28) : i32(16);
const pgrp = wide ? i32(32) : i32(20);
const sid = wide ? i32(36) : i32(24);
const fnameOff = wide ? 40 : 28;
const fnameBytes = descriptor.subarray(fnameOff, fnameOff + 16);
const fnameEnd = fnameBytes.indexOf(0);
const fname = (
fnameEnd < 0 ? fnameBytes : fnameBytes.subarray(0, fnameEnd)
).toString("latin1");
chargeText(text, fname.length);
const psargsOff = wide ? 56 : 44;
const psargsBytes = descriptor.subarray(psargsOff, psargsOff + 80);
const psargsEnd = psargsBytes.indexOf(0);
const psargs = (
psargsEnd < 0 ? psargsBytes : psargsBytes.subarray(0, psargsEnd)
).toString("latin1");
chargeText(text, psargs.length);
return {
prpsinfo: {
state,
pid,
ppid,
pgrp,
sid,
uid,
gid,
filename: fname,
psargs,
},
file: null,
siginfo: null,
auxv: null,
};
};
const decodeCoreFile = (
descriptor: Buffer,
little: boolean,
wide: boolean,
text: TextBudget,
): ElfNotes["entries"][number]["core"] => {
const wordSize = wide ? 8 : 4;
const headerSize = wordSize * 2;
if (descriptor.length < headerSize) return null;
const readWord = (off: number): bigint => {
if (wide) {
return little
? descriptor.readBigUInt64LE(off)
: descriptor.readBigUInt64BE(off);
}
const val = little
? descriptor.readUInt32LE(off)
: descriptor.readUInt32BE(off);
return BigInt(val);
};
const countBig = readWord(0);
const pageSizeBig = readWord(wordSize);
if (countBig > 0xffffffffn || pageSizeBig > 0xffffffffn || pageSizeBig === 0n)
return null;
const count = Number(countBig);
const pageSize = Number(pageSizeBig);
const entrySize = wordSize * 3;
const neededBytes = BigInt(headerSize) + countBig * BigInt(entrySize);
if (BigInt(descriptor.length) < neededBytes) return null;
const tableBytes = Number(neededBytes);
const rowBytes = count * (wide ? 54 : 30);
if (rowBytes > text.limit - text.used) {
throw new ArtifactReaderFailure(
"limit",
`ELF note text exceeds the ${String(text.limit)}-byte budget.`,
{ boundary: "memory" },
);
}
const scanKey = `file:${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
const cachedScan = text.scans.get(scanKey);
if (cachedScan === -1) return null;
if (cachedScan === undefined) {
chargeText(text, descriptor.length - tableBytes, "scanned");
}
let strPos = tableBytes;
let totalPathBytes = 0;
for (let i = 0; i < count; i++) {
if (strPos >= descriptor.length) {
text.scans.set(scanKey, -1);
return null;
}
const nullIdx = descriptor.indexOf(0, strPos);
if (nullIdx < 0) {
text.scans.set(scanKey, -1);
return null;
}
totalPathBytes += nullIdx - strPos;
strPos = nullIdx + 1;
}
text.scans.set(scanKey, count);
const pathBytes = totalPathBytes * 3;
chargeText(text, rowBytes + pathBytes);
const mappings: {
start_address: string;
end_address: string;
file_offset_pages: string;
path: string;
path_hex: string;
}[] = new Array(count);
strPos = tableBytes;
for (let i = 0; i < count; i++) {
const entryOff = headerSize + i * entrySize;
const start = readWord(entryOff);
const end = readWord(entryOff + wordSize);
const offsetPages = readWord(entryOff + wordSize * 2);
const nullIdx = descriptor.indexOf(0, strPos);
const pathBuf = descriptor.subarray(strPos, nullIdx);
strPos = nullIdx + 1;
mappings[i] = {
start_address: hexWord(start),
end_address: hexWord(end),
file_offset_pages: hexWord(offsetPages),
path: pathBuf.toString("latin1"),
path_hex: pathBuf.toString("hex"),
};
}
return {
prpsinfo: null,
file: {
count,
page_size: pageSize,
mappings,
},
siginfo: null,
auxv: null,
};
};
const decodeCoreSiginfo = (
descriptor: Buffer,
little: boolean,
machine: number,
): ElfNotes["entries"][number]["core"] => {
if (descriptor.length < 12) return null;
const i32 = (off: number) =>
little ? descriptor.readInt32LE(off) : descriptor.readInt32BE(off);
const isMips = machine === 8 || machine === 10;
const signo = i32(0);
const errno = isMips ? i32(8) : i32(4);
const code = isMips ? i32(4) : i32(8);
return {
prpsinfo: null,
file: null,
siginfo: {
signo,
errno,
code,
},
auxv: null,
};
};
const decodeCoreAuxv = (
descriptor: Buffer,
little: boolean,
wide: boolean,
text: TextBudget,
): ElfNotes["entries"][number]["core"] => {
const wordSize = wide ? 8 : 4;
const entrySize = wordSize * 2;
if (descriptor.length < entrySize || descriptor.length % entrySize !== 0)
return null;
const totalPairs = descriptor.length / entrySize;
const scanKey = `auxv:${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
let nullIndex = text.scans.get(scanKey);
if (nullIndex === -1) return null;
const readWord = (off: number): bigint => {
if (wide) {
return little
? descriptor.readBigUInt64LE(off)
: descriptor.readBigUInt64BE(off);
}
return BigInt(
little ? descriptor.readUInt32LE(off) : descriptor.readUInt32BE(off),
);
};
if (nullIndex === undefined) {
chargeText(text, descriptor.length, "scanned");
let found = false;
for (let i = 0; i < totalPairs; i++) {
const off = i * entrySize;
const aTypeBig = readWord(off);
if (aTypeBig > 0xffffffffn) {
text.scans.set(scanKey, -1);
return null;
}
if (aTypeBig === 0n) {
found = true;
nullIndex = i;
for (let j = off + entrySize; j < descriptor.length; j++) {
if (descriptor[j] !== 0) {
text.scans.set(scanKey, -1);
return null;
}
}
break;
}
}
if (!found || nullIndex === undefined) {
text.scans.set(scanKey, -1);
return null;
}
text.scans.set(scanKey, nullIndex);
}
const rowBytes = nullIndex * (wide ? 38 : 30);
chargeText(text, rowBytes);
const auxv: { type: number; type_name: string | null; value: string }[] =
new Array(nullIndex);
for (let i = 0; i < nullIndex; i++) {
const off = i * entrySize;
const aType = Number(readWord(off));
const aVal = readWord(off + wordSize);
auxv[i] = {
type: aType,
type_name: AUXV_TYPE_NAMES[aType] ?? null,
value: hexWord(aVal),
};
}
return {
prpsinfo: null,
file: null,
siginfo: null,
auxv,
};
};
const decodeCore = (
owner: string,
type: number,
descriptor: Buffer,
little: boolean,
wide: boolean,
machine: number,
text: TextBudget,
): ElfNotes["entries"][number]["core"] => {
if (owner !== "CORE") return null;
if (type === 3) return decodeCorePrpsinfo(descriptor, little, wide, text);
if (type === 0x46494c45)
return decodeCoreFile(descriptor, little, wide, text);
if (type === 0x53494749)
return decodeCoreSiginfo(descriptor, little, machine);
if (type === 6) return decodeCoreAuxv(descriptor, little, wide, text);
return null;
};
/** Decode PT_NOTE and SHT_NOTE tables from one in-memory ELF image. */
export const parseElfNotes = (
bytes: Buffer,
@@ -439,7 +756,17 @@ export const parseElfNotes = (
examined,
text,
))
parsed.push(toEntry(note, "program", index, reader.little, text));
parsed.push(
toEntry(
note,
"program",
index,
reader.little,
wide,
header.machine,
text,
),
);
}
}
if (shnum !== 0) {
@@ -494,7 +821,17 @@ export const parseElfNotes = (
examined,
text,
))
parsed.push(toEntry(note, "section", index, reader.little, text));
parsed.push(
toEntry(
note,
"section",
index,
reader.little,
wide,
header.machine,
text,
),
);
}
}
const absent = tables.length === 0;
@@ -524,9 +861,12 @@ const toEntry = (
tableKind: "program" | "section",
tableIndex: number,
little: boolean,
wide: boolean,
machine: number,
text: TextBudget,
): Omit<ElfNotes["entries"][number], "index"> => {
const gnuOwner = note.owner === "GNU";
const coreOwner = note.owner === "CORE";
const range = `${String(note.descriptor_offset)}:${String(note.descsz)}`;
let digest = text.hashes.get(range);
if (digest === undefined) {
@@ -541,7 +881,11 @@ const toEntry = (
namesz: note.namesz,
descsz: note.descsz,
type: note.type,
type_name: gnuOwner ? gnuNoteTypeName(note.type) : "unknown",
type_name: gnuOwner
? gnuNoteTypeName(note.type)
: coreOwner
? coreNoteTypeName(note.type)
: "unknown",
owner: note.owner,
owner_bytes_hex: note.owner_bytes_hex,
descriptor: {
@@ -554,5 +898,14 @@ const toEntry = (
: null,
},
gnu: decodeGnu(note.owner, note.type, note.descriptor, little, text),
core: decodeCore(
note.owner,
note.type,
note.descriptor,
little,
wide,
machine,
text,
),
};
};
+156 -5
View File
@@ -11,7 +11,7 @@ const notePadding = (declared: number): number => (declared === 8 ? 8 : 4);
const writeWord = (
bytes: Buffer,
offset: number,
value: number,
value: bigint | number,
bits: 32 | 64,
little: boolean,
): void => {
@@ -20,8 +20,8 @@ const writeWord = (
else bytes.writeBigUInt64BE(BigInt(value), offset);
return;
}
if (little) bytes.writeUInt32LE(value, offset);
else bytes.writeUInt32BE(value, offset);
if (little) bytes.writeUInt32LE(Number(value), offset);
else bytes.writeUInt32BE(Number(value), offset);
};
const write16 = (
@@ -40,8 +40,8 @@ const write32 = (
value: number,
little: boolean,
): void => {
if (little) bytes.writeUInt32LE(value, offset);
else bytes.writeUInt32BE(value, offset);
if (little) bytes.writeUInt32LE(Number(value), offset);
else bytes.writeUInt32BE(Number(value), offset);
};
/** Encode one ELF note, padding name/descriptor to the table alignment. */
@@ -402,3 +402,154 @@ export const elfNoteTablePastEnd = (): Buffer => {
writeWord(program, 48, 4, 64, true);
return concat(header, program, note);
};
export interface CorePrpsinfoOptions {
readonly bits?: 32 | 64;
readonly little?: boolean;
readonly state?: number;
readonly sname?: string;
readonly zomb?: number;
readonly nice?: number;
readonly flag?: number;
readonly uid?: number;
readonly gid?: number;
readonly pid?: number;
readonly ppid?: number;
readonly pgrp?: number;
readonly sid?: number;
readonly fname?: string;
readonly psargs?: string;
}
/** Linux CORE NT_PRPSINFO note (type 3). */
export const corePrpsinfoNote = (options: CorePrpsinfoOptions = {}): Buffer => {
const bits = options.bits ?? 64;
const little = options.little ?? true;
const desc = Buffer.alloc(bits === 64 ? 136 : 124);
desc[0] = options.state ?? 0;
desc[1] = (options.sname ?? "R").charCodeAt(0);
desc[2] = options.zomb ?? 0;
desc[3] = options.nice ?? 0;
if (bits === 64) {
if (little) desc.writeBigUInt64LE(BigInt(options.flag ?? 0), 8);
else desc.writeBigUInt64BE(BigInt(options.flag ?? 0), 8);
write32(desc, 16, options.uid ?? 1000, little);
write32(desc, 20, options.gid ?? 1000, little);
if (little) {
desc.writeInt32LE(options.pid ?? 1234, 24);
desc.writeInt32LE(options.ppid ?? 1, 28);
desc.writeInt32LE(options.pgrp ?? 1234, 32);
desc.writeInt32LE(options.sid ?? 1234, 36);
} else {
desc.writeInt32BE(options.pid ?? 1234, 24);
desc.writeInt32BE(options.ppid ?? 1, 28);
desc.writeInt32BE(options.pgrp ?? 1234, 32);
desc.writeInt32BE(options.sid ?? 1234, 36);
}
const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1");
fname.copy(desc, 40, 0, Math.min(fname.length, 16));
const psargs = Buffer.from(
`${options.psargs ?? "./app --flag"}\0`,
"latin1",
);
psargs.copy(desc, 56, 0, Math.min(psargs.length, 80));
} else {
write32(desc, 4, options.flag ?? 0, little);
write16(desc, 8, options.uid ?? 1000, little);
write16(desc, 10, options.gid ?? 1000, little);
if (little) {
desc.writeInt32LE(options.pid ?? 1234, 12);
desc.writeInt32LE(options.ppid ?? 1, 16);
desc.writeInt32LE(options.pgrp ?? 1234, 20);
desc.writeInt32LE(options.sid ?? 1234, 24);
} else {
desc.writeInt32BE(options.pid ?? 1234, 12);
desc.writeInt32BE(options.ppid ?? 1, 16);
desc.writeInt32BE(options.pgrp ?? 1234, 20);
desc.writeInt32BE(options.sid ?? 1234, 24);
}
const fname = Buffer.from(`${options.fname ?? "app"}\0`, "latin1");
fname.copy(desc, 28, 0, Math.min(fname.length, 16));
const psargs = Buffer.from(
`${options.psargs ?? "./app --flag"}\0`,
"latin1",
);
psargs.copy(desc, 44, 0, Math.min(psargs.length, 80));
}
return elfNote("CORE", 3, desc, little);
};
export interface CoreFileMapping {
readonly start: bigint | number;
readonly end: bigint | number;
readonly file_ofs: bigint | number;
readonly path: string;
}
/** Linux CORE NT_FILE note (type 0x46494c45). */
export const coreFileNote = (
mappings: readonly CoreFileMapping[],
pageSize = 4096,
bits: 32 | 64 = 64,
little = true,
): Buffer => {
const wordBytes = bits === 64 ? 8 : 4;
const headerBytes = 2 * wordBytes;
const mapEntryBytes = 3 * wordBytes;
const filenames = Buffer.concat(
mappings.map((m) => Buffer.from(`${m.path}\0`, "latin1")),
);
const desc = Buffer.alloc(
headerBytes + mappings.length * mapEntryBytes + filenames.length,
);
writeWord(desc, 0, mappings.length, bits, little);
writeWord(desc, wordBytes, pageSize, bits, little);
let offset = headerBytes;
for (const m of mappings) {
writeWord(desc, offset, m.start, bits, little);
writeWord(desc, offset + wordBytes, m.end, bits, little);
writeWord(desc, offset + 2 * wordBytes, m.file_ofs, bits, little);
offset += mapEntryBytes;
}
filenames.copy(desc, offset);
return elfNote("CORE", 0x46494c45, desc, little);
};
/** Linux CORE NT_SIGINFO note (type 0x53494749). */
export const coreSiginfoNote = (
signo = 11,
code = 1,
errno = 0,
little = true,
): Buffer => {
const desc = Buffer.alloc(128);
if (little) {
desc.writeInt32LE(signo, 0);
desc.writeInt32LE(errno, 4);
desc.writeInt32LE(code, 8);
} else {
desc.writeInt32BE(signo, 0);
desc.writeInt32BE(errno, 4);
desc.writeInt32BE(code, 8);
}
return elfNote("CORE", 0x53494749, desc, little);
};
/** Linux CORE NT_AUXV note (type 6). */
export const coreAuxvNote = (
entries: readonly [key: number, value: bigint | number][],
bits: 32 | 64 = 64,
little = true,
): Buffer => {
const wordBytes = bits === 64 ? 8 : 4;
const desc = Buffer.alloc((entries.length + 1) * 2 * wordBytes);
let offset = 0;
for (const [key, value] of entries) {
writeWord(desc, offset, key, bits, little);
writeWord(desc, offset + wordBytes, value, bits, little);
offset += 2 * wordBytes;
}
writeWord(desc, offset, 0, bits, little);
writeWord(desc, offset + wordBytes, 0, bits, little);
return elfNote("CORE", 6, desc, little);
};
+185
View File
@@ -0,0 +1,185 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../src/contracts/toolContracts.js";
import {
coreAuxvNote,
coreFileNote,
corePrpsinfoNote,
coreSiginfoNote,
elfNoteFixture,
} from "../../src/native/elf/ElfNotes.fixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
const descriptorHash = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const buildCoreNotes = (): Buffer =>
Buffer.concat([
corePrpsinfoNote({
bits: 64,
little: true,
state: 0,
sname: "R",
pid: 31337,
ppid: 1,
pgrp: 31337,
sid: 31337,
uid: 1000,
gid: 1000,
fname: "target-service",
psargs: "/opt/target-service --worker 1",
}),
coreFileNote(
[
{
start: 0x555555554000n,
end: 0x555555558000n,
file_ofs: 0n,
path: "/opt/target-service",
},
{
start: 0x7ffff7dc0000n,
end: 0x7ffff7de0000n,
file_ofs: 0n,
path: "/lib/x86_64-linux-gnu/libc.so.6",
},
],
4096,
64,
true,
),
coreSiginfoNote(11, 1, 0, true),
coreAuxvNote(
[
[6, 4096n],
[3, 0x555555554040n],
],
64,
true,
),
]);
const assertCoreEntries = (entries: unknown[]): void => {
expect(entries).toHaveLength(4);
expect(entries[0]).toMatchObject({
owner: "CORE",
type: 3,
type_name: "core_prpsinfo",
gnu: null,
core: {
prpsinfo: {
state: 0,
pid: 31337,
ppid: 1,
pgrp: 31337,
sid: 31337,
uid: 1000,
gid: 1000,
filename: "target-service",
psargs: "/opt/target-service --worker 1",
},
},
});
expect(entries[1]).toMatchObject({
owner: "CORE",
type: 0x46494c45,
type_name: "core_file",
gnu: null,
core: {
file: {
count: 2,
page_size: 4096,
mappings: [
{
start_address: "0x555555554000",
end_address: "0x555555558000",
file_offset_pages: "0x0",
path: "/opt/target-service",
path_hex: Buffer.from("/opt/target-service").toString("hex"),
},
{
start_address: "0x7ffff7dc0000",
end_address: "0x7ffff7de0000",
file_offset_pages: "0x0",
path: "/lib/x86_64-linux-gnu/libc.so.6",
path_hex: Buffer.from("/lib/x86_64-linux-gnu/libc.so.6").toString(
"hex",
),
},
],
},
},
});
expect(entries[2]).toMatchObject({
owner: "CORE",
type: 0x53494749,
type_name: "core_siginfo",
gnu: null,
core: {
siginfo: { signo: 11, errno: 0, code: 1 },
},
});
expect(entries[3]).toMatchObject({
owner: "CORE",
type: 6,
type_name: "core_auxv",
gnu: null,
core: {
auxv: [
{ type: 6, type_name: "AT_PAGESZ", value: "0x1000" },
{ type: 3, type_name: "AT_PHDR", value: "0x555555554040" },
],
},
});
};
cliTest(
"decodes Linux core dump notes through CLI and stdio MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-elf-core-notes-");
const path = join(root, "core.elf");
const notes = buildCoreNotes();
const bytes = elfNoteFixture({
bits: 64,
little: true,
type: 4,
ownership: "program",
notes,
});
await writeFile(path, bytes);
const output = await cli.sharedCliRun!({
arguments: ["inspect-elf-notes", path, "--json"],
});
expect(output.exitCode, output.stderr).toBe(0);
const call = await cli.sharedMcpCall!();
const response = await call("inspect_elf_notes", { path });
expect(response.isError, JSON.stringify(response.content)).not.toBe(true);
const schema = toolContract("inspect_elf_notes").outputSchema;
for (const raw of [output.json, response.structuredContent]) {
const evidence = schema.parse(raw);
expect(evidence.subject).toMatchObject({
local_path: path,
digest: { sha256: descriptorHash(bytes) },
});
const report = evidence.normalized_result;
expect(report).toMatchObject({
format: "elf64",
byte_order: "little",
machine: 62,
coverage: {
status: "complete",
examined_tables: 1,
examined_entries: 4,
},
});
assertCoreEntries(report.entries);
}
},
);