fix(core): bind numeric type identities and qualify note owners

This commit is contained in:
morluto
2026-10-24 02:46:21 +08:00
parent 1bc27d2378
commit 9b4deb2655
14 changed files with 184 additions and 94 deletions
+14 -8
View File
@@ -88,16 +88,18 @@ def inspect_core(path, cache):
segments, notes, padding, threads, signals = [], [], [], [], []
for segment_index, segment in enumerate(image.iter_segments()):
h = segment.header
backing = h.p_type != "PT_NULL" and h.p_filesz > 0
header_offset = header.e_phoff + segment_index * header.e_phentsize
type_id = int.from_bytes(content[header_offset:header_offset + 4], "little")
backing = type_id != 0 and h.p_filesz > 0
if backing:
location(h.p_offset, h.p_filesz, len(content))
if h.p_type == "PT_LOAD" and h.p_filesz > h.p_memsz:
if type_id == 1 and h.p_filesz > h.p_memsz:
raise CoreFailure("format", "Recorded PT_LOAD file bytes exceed memory size.")
segments.append({"index": segment_index, "type": h.p_type,
"header_location": location(header.e_phoff + segment_index * header.e_phentsize, header.e_phentsize, len(content)),
segments.append({"index": segment_index, "type": h.p_type, "type_id": type_id,
"header_location": location(header_offset, header.e_phentsize, len(content)),
"offset": hex(h.p_offset), "file_size": hex(h.p_filesz), "memory_size": hex(h.p_memsz),
"virtual_address": hex(h.p_vaddr), "physical_address": hex(h.p_paddr), "alignment": hex(h.p_align), "flags": hex(h.p_flags), "file_backing": "file" if backing else "none"})
if h.p_type != "PT_NOTE" or h.p_filesz == 0:
if type_id != 4 or h.p_filesz == 0:
continue
position, end = h.p_offset, h.p_offset + h.p_filesz
header_size = image.structs.Elf_Nhdr.sizeof()
@@ -109,6 +111,7 @@ def inspect_core(path, cache):
padding.append({"segment_index": segment_index, "location": location(position, len(raw), end), "bytes_base64": encoded(raw)})
break
note = struct_parse(image.structs.Elf_Nhdr, image.stream, stream_pos=position)
note_type_id = int.from_bytes(content[position + 8:position + 12], "little")
owner_start = position + header_size
descriptor_start = owner_start + ((note.n_namesz + 3) // 4) * 4
note_end = descriptor_start + ((note.n_descsz + 3) // 4) * 4
@@ -119,7 +122,10 @@ def inspect_core(path, cache):
display = owner.split(b"\0", 1)[0].decode("utf-8", "replace") if owner else None
descriptor = content[descriptor_start:descriptor_start + note.n_descsz]
note_index = len(notes)
notes.append({"index": note_index, "segment_index": segment_index, "type": note.n_type,
# pyelftools selects ET_CORE labels without owner context. Keep
# those labels only for exact CORE owners; other kinds are opaque.
note_type = note.n_type if owner == b"CORE\0" else note_type_id
notes.append({"index": note_index, "segment_index": segment_index, "type": note_type, "type_id": note_type_id,
"location": location(position, note_end - position, end),
"owner_location": location(owner_start, len(owner), end), "owner_bytes_base64": encoded(owner), "owner_display": display,
"descriptor_location": location(descriptor_start, len(descriptor), end), "descriptor_bytes_base64": encoded(descriptor)})
@@ -127,7 +133,7 @@ def inspect_core(path, cache):
# Only the initial Linux CORE ABI is interpreted. Other owners retain exact bytes.
if owner != b"CORE\0":
continue
if note.n_type == "NT_PRSTATUS":
if note_type_id == 1:
if len(descriptor) < ctypes.sizeof(prstatus_type):
raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.")
status = prstatus_type.from_buffer_copy(descriptor)
@@ -136,7 +142,7 @@ def inspect_core(path, cache):
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
elif note.n_type == "NT_SIGINFO":
elif note_type_id == 0x53494749:
if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.")
signal = elf_siginfo_64.from_buffer_copy(descriptor)
+8
View File
@@ -25,8 +25,16 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
are inline through CLI and MCP.
- Original program headers and recorded segment file/memory ranges. Undumped
memory and current executable/library identity remain unknown.
- Program headers and notes retain their original numeric `type_id` separately
from the decoder's `type` label. Source binding and semantic decisions use the
numeric identity; an architecture-specific display name does not establish
support for that segment's payload semantics.
- Every note's original identity, owner/descriptor bytes and source ranges,
including unfamiliar owners and note kinds.
CORE symbolic note labels apply only to the exact `CORE\0` owner. Other
owners keep numeric types, so GNU/type 3 remains opaque instead of acquiring
CORE process-info semantics, and a GNU-owned numeric NT_FILE collision does
not acquire debugger mapping authority.
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
historical PID, signal and lossless uint64 registers bound to source bytes.
Each supported note has exactly one interpretation; each thread contains the
+13
View File
@@ -64,6 +64,19 @@ save("truncated-owner", content)
content = bytearray(original)
content[status.n_offset + image.structs.Elf_Nhdr.sizeof()] = 255
save("opaque-owner", content)
for name, kind in [("gnu-type3", 3), ("gnu-file-collision", 0x46494c45)]:
content = bytearray(original)
note_header(content, status, n_type=kind)
owner_start = status.n_offset + image.structs.Elf_Nhdr.sizeof()
# Preserve the original five-byte name extent and all note alignment.
content[owner_start:owner_start + 5] = b"GNU\0\0"
save(name, content)
for name, kind in [("aarch64-archext", 0x70000000), ("aarch64-unwind", 0x70000001)]:
header = Container(**dict(image.header, e_machine="EM_AARCH64", e_phoff=64, e_phentsize=56,
e_phnum=1, e_shoff=0, e_shnum=0, e_shstrndx=0))
segment = Container(p_type=kind, p_flags=4, p_offset=120, p_vaddr=0, p_paddr=0,
p_filesz=4, p_memsz=4, p_align=4)
save(name, image.structs.Elf_Ehdr.build(header) + image.structs.Elf_Phdr.build(segment) + bytes(4))
content = bytearray(original)
for note in notes:
if note.n_type == "NT_SIGINFO":
+27
View File
@@ -180,6 +180,33 @@ try {
);
assert.equal(opaque.threads.length, 1);
cases++;
for (const [name, kind] of [
["gnu-type3", 3],
["gnu-file-collision", 0x46494c45],
]) {
const value = await basic.inspect(mode, fixtures[name]);
assert.ok(
value.notes.some(
(note) =>
note.owner_display === "GNU" &&
note.type === kind &&
note.type_id === kind,
),
);
assert.equal(value.threads.length, 1);
cases++;
}
for (const [name, kind, label] of [
["aarch64-archext", 0x70000000, "PT_AARCH64_ARCHEXT"],
["aarch64-unwind", 0x70000001, "PT_AARCH64_UNWIND"],
]) {
const value = await basic.inspect(mode, fixtures[name]);
assert.equal(value.architecture, "aarch64-little-endian");
assert.equal(value.segments[0]?.type_id, kind);
assert.equal(value.segments[0]?.type, label);
assert.equal(value.threads.length, 0);
cases++;
}
const noSignals = await basic.inspect(mode, fixtures["unknown-signals"]);
assert.equal(noSignals.signals.length, 0);
assert.equal(noSignals.threads.length, 2);
@@ -195,6 +195,7 @@ it.each([
fixture.segments.push({
index: 0,
type: "PT_LOAD",
type_id: 1,
offset: "0x200",
header_location: { offset: "0x0", bytes: "0x38" },
file_backing: "file",
+1
View File
@@ -208,6 +208,7 @@ it("does not interpret a signal through a mislabeled raw owner ABI", () => {
notes: value.notes.map((note) => ({
...note,
type: "NT_SIGINFO",
type_id: 0x53494749,
owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
})),
signals: [
+43 -22
View File
@@ -19,6 +19,15 @@ const diagnostics = z.strictObject({
// Exact b"CORE\0"; a lossy display label cannot establish the Linux note ABI.
const LINUX_CORE_OWNER_BASE64 = "Q09SRQA=";
/** Source identity controls interpretation; decoder labels are display facts. */
export const isLinuxCoreNote = (
note:
| { readonly owner_bytes_base64: string; readonly type_id: number }
| undefined,
type: number,
): boolean =>
note?.owner_bytes_base64 === LINUX_CORE_OWNER_BASE64 && note.type_id === type;
/** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */
const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
"r15",
@@ -206,6 +215,7 @@ const recordedCrashObjectSchema = z.strictObject({
z.strictObject({
index,
type: z.union([z.string(), index]),
type_id: index.max(0xffffffff),
header_location: range,
offset: hex,
file_size: hex,
@@ -222,6 +232,7 @@ const recordedCrashObjectSchema = z.strictObject({
index,
segment_index: index,
type: z.union([z.string(), index]),
type_id: index.max(0xffffffff),
location: range,
owner_location: range,
owner_bytes_base64: base64,
@@ -298,6 +309,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
))
fail(issue);
for (const [position, segment] of value.segments.entries()) {
if (typeof segment.type === "number" && segment.type !== segment.type_id)
fail("Numeric segment type differs from its source identity.");
if (position !== segment.index || !within(segment.header_location))
fail("Invalid original segment identity or header range.");
if (
@@ -306,20 +319,39 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
)
fail("Recorded segment exceeds artifact bytes.");
if (
segment.type === "PT_LOAD" &&
segment.type_id === 1 &&
BigInt(segment.file_size) > BigInt(segment.memory_size)
)
fail("Recorded PT_LOAD file size exceeds memory size.");
}
for (const [position, note] of value.notes.entries()) {
const segment = value.segments[note.segment_index];
if (position !== note.index || segment?.type !== "PT_NOTE") {
if (position !== note.index || segment?.type_id !== 4) {
fail("Invalid original note identity or segment reference.");
continue;
}
const ownerBytes = Buffer.from(note.owner_bytes_base64, "base64");
const terminator = ownerBytes.indexOf(0);
const ownerDisplay =
ownerBytes.length === 0
? null
: ownerBytes.subarray(0, terminator).toString("utf8");
if (
BigInt(Buffer.byteLength(note.owner_bytes_base64, "base64")) !==
BigInt(note.owner_location.bytes) ||
(ownerBytes.length > 0 && terminator < 0) ||
note.owner_display !== ownerDisplay
)
fail("Note owner display differs from its retained bytes.");
if (typeof note.type === "number" && note.type !== note.type_id)
fail("Numeric note type differs from its source identity.");
if (
note.owner_bytes_base64 !== LINUX_CORE_OWNER_BASE64 &&
note.type !== note.type_id
)
fail(
"Uninterpreted note owners must retain their numeric type identity.",
);
if (
BigInt(ownerBytes.length) !== BigInt(note.owner_location.bytes) ||
BigInt(Buffer.byteLength(note.descriptor_bytes_base64, "base64")) !==
BigInt(note.descriptor_location.bytes)
)
@@ -344,7 +376,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
for (const padding of value.note_padding) {
const segment = value.segments[padding.segment_index];
if (
segment?.type !== "PT_NOTE" ||
segment?.type_id !== 4 ||
!within(
padding.location,
BigInt(segment.offset),
@@ -354,13 +386,11 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
fail("Invalid recorded note padding range.");
}
for (const [kind, rows] of [
["NT_PRSTATUS", value.threads],
["NT_SIGINFO", value.signals],
[1, value.threads],
[0x53494749, value.signals],
] as const) {
const matchingNotes = value.notes.filter(
(note) =>
note.type === kind &&
note.owner_bytes_base64 === LINUX_CORE_OWNER_BASE64,
const matchingNotes = value.notes.filter((note) =>
isLinuxCoreNote(note, kind),
);
if (
rows.length !== matchingNotes.length ||
@@ -374,11 +404,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
const profileRegisters = recordedCoreRegisterSets[value.architecture];
for (const thread of value.threads) {
const note = value.notes[thread.note_index];
if (
note?.type !== "NT_PRSTATUS" ||
note.owner_display !== "CORE" ||
note.owner_bytes_base64 !== LINUX_CORE_OWNER_BASE64
) {
if (note === undefined || !isLinuxCoreNote(note, 1)) {
fail("Thread references a different note kind.");
continue;
}
@@ -404,12 +430,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
fail("Register source exceeds its descriptor.");
}
for (const signal of value.signals) {
if (
value.notes[signal.note_index]?.type !== "NT_SIGINFO" ||
value.notes[signal.note_index]?.owner_display !== "CORE" ||
value.notes[signal.note_index]?.owner_bytes_base64 !==
LINUX_CORE_OWNER_BASE64
)
if (!isLinuxCoreNote(value.notes[signal.note_index], 0x53494749))
fail("Signal references a different note kind.");
const known =
signal.number === 11 && (signal.code === 1 || signal.code === 2);
@@ -1,7 +1,7 @@
type Range = { readonly offset: string; readonly bytes: string };
type Segment = {
readonly index: number;
readonly type: string | number;
readonly type_id: number;
readonly offset: string;
readonly file_size: string;
};
@@ -87,7 +87,7 @@ export function recordedNoteCoverageIssues(
if (start + BigInt(range.bytes) > end) end = start + BigInt(range.bytes);
}
for (const segment of segments) {
if (segment.type !== "PT_NOTE") continue;
if (segment.type_id !== 4) continue;
let cursor = BigInt(segment.offset);
for (const range of (bySegment.get(segment.index) ?? []).sort(compare)) {
if (BigInt(range.offset) !== cursor)
@@ -15,6 +15,15 @@ function mapping(paths: readonly Buffer[]) {
}
describe("core-only NT_FILE authority", () => {
it("keeps GNU-owned colliding note numbers outside CORE mapping authority", () => {
const { report, bytes } = mapping([Buffer.from("../unsafe")]);
expect(validateCoreFileMappingAuthority(report, bytes).ok).toBe(false);
report.notes[0]!.owner_bytes_base64 =
Buffer.from("GNU\0\0").toString("base64");
report.notes[0]!.owner_display = "GNU";
report.notes[0]!.type = 0x46494c45;
expect(validateCoreFileMappingAuthority(report, bytes).ok).toBe(true);
});
it.each([
"/usr/bin/app",
"//usr/lib/libc.so",
@@ -1,5 +1,8 @@
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import type { RecordedCrash } from "../../domain/native/recordedCrash.js";
import {
isLinuxCoreNote,
type RecordedCrash,
} from "../../domain/native/recordedCrash.js";
import { err, ok, type Result } from "../../domain/result.js";
/** Interpret source-bound ELF64 NT_FILE paths once, before GDB can open them. */
@@ -8,7 +11,7 @@ export function validateCoreFileMappingAuthority(
snapshot: Buffer,
): Result<void, AnalysisCapabilityUnavailableError> {
for (const note of report.notes) {
if (note.type !== "NT_FILE") continue;
if (!isLinuxCoreNote(note, 0x46494c45)) continue;
const refuse = (reason: string) => {
const message = `Core mapping note ${note.index} cannot be confined to the owned debugger sysroot: ${reason}.`;
return err(
@@ -0,0 +1,52 @@
import { expect, it } from "vitest";
import { recordedCrashSchema } from "../../domain/native/recordedCrash.js";
import {
recordedCrashFixture,
recordedCrashFixtureBytes,
} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js";
import { validateRecordedCrashStructure } from "./RecordedCrashStructureBindings.js";
it.each([
["aarch64-little-endian", 0x70000000, "PT_AARCH64_ARCHEXT"],
["aarch64-little-endian", 0x70000001, "PT_AARCH64_UNWIND"],
["aarch64-little-endian", 0x70000003, 0x70000003],
["x86_64-little-endian", 0x70000001, 0x70000001],
] as const)(
"binds %s header type %i independently of its decoder label %s",
(architecture, typeId, label) => {
const report = recordedCrashFixture(undefined, architecture);
report.segments[0]!.type_id = typeId;
report.segments[0]!.type = label;
report.notes = [];
report.threads = [];
const bytes = recordedCrashFixtureBytes(report);
const parsed = recordedCrashSchema.parse(report);
expect(() =>
validateRecordedCrashStructure(parsed, bytes, parsed.decoder_diagnostics),
).not.toThrow();
bytes.writeUInt32LE(typeId + 1, 64);
expect(() =>
validateRecordedCrashStructure(parsed, bytes, parsed.decoder_diagnostics),
).toThrow(/original ELF\/note headers/);
},
);
it.each([3, 0x46494c45])(
"retains GNU-owned numeric note %i without CORE interpretation",
(typeId) => {
const report = recordedCrashFixture();
report.notes[0]!.type_id = typeId;
report.notes[0]!.type = typeId;
report.notes[0]!.owner_bytes_base64 =
Buffer.from("GNU\0\0").toString("base64");
report.notes[0]!.owner_display = "GNU";
report.threads = [];
const bytes = recordedCrashFixtureBytes(report);
const parsed = recordedCrashSchema.parse(report);
expect(() =>
validateRecordedCrashStructure(parsed, bytes, parsed.decoder_diagnostics),
).not.toThrow();
report.notes[0]!.type = typeId === 3 ? "NT_PRPSINFO" : "NT_FILE";
expect(recordedCrashSchema.safeParse(report).success).toBe(false);
},
);
@@ -4,50 +4,13 @@ import {
type RecordedCrash,
} from "../../domain/native/recordedCrash.js";
// Exact amd64/core enum projections in the pinned pyelftools 0.33 profile.
const segmentKinds = new Map([
[0, "PT_NULL"],
[1, "PT_LOAD"],
[2, "PT_DYNAMIC"],
[3, "PT_INTERP"],
[4, "PT_NOTE"],
[5, "PT_SHLIB"],
[6, "PT_PHDR"],
[7, "PT_TLS"],
[0x60000000, "PT_LOOS"],
[0x6fffffff, "PT_HIOS"],
[0x6474e550, "PT_GNU_EH_FRAME"],
[0x6474e551, "PT_GNU_STACK"],
[0x6474e552, "PT_GNU_RELRO"],
[0x6474e553, "PT_GNU_PROPERTY"],
]);
const noteKinds = new Map([
[1, "NT_PRSTATUS"],
[2, "NT_FPREGSET"],
[3, "NT_PRPSINFO"],
[4, "NT_TASKSTRUCT"],
[6, "NT_AUXV"],
[0x53494749, "NT_SIGINFO"],
[0x46494c45, "NT_FILE"],
]);
const matchesKind = (
raw: number,
reported: string | number,
kinds: ReadonlyMap<number, string>,
): boolean => {
const known = kinds.get(raw);
return typeof reported === "string"
? reported === known
: known === undefined && reported === raw;
};
const segmentMatches = (
segment: RecordedCrash["segments"][number],
raw: Buffer,
): boolean => {
const type = raw.readUInt32LE(0);
return (
matchesKind(type, segment.type, segmentKinds) &&
type === segment.type_id &&
BigInt(segment.flags) === BigInt(raw.readUInt32LE(4)) &&
segment.file_backing ===
(type === 0 || raw.readBigUInt64LE(32) === 0n ? "none" : "file") &&
@@ -122,19 +85,8 @@ export function validateRecordedCrashStructure(
if (
BigInt(note.owner_location.bytes) !== BigInt(raw.readUInt32LE(0)) ||
BigInt(note.descriptor_location.bytes) !== BigInt(raw.readUInt32LE(4)) ||
!matchesKind(raw.readUInt32LE(8), note.type, noteKinds)
raw.readUInt32LE(8) !== note.type_id
)
fail();
const owner = bytesAt(
BigInt(note.owner_location.offset),
raw.readUInt32LE(0),
);
const terminator = owner.indexOf(0);
const display =
owner.length === 0
? null
: owner.subarray(0, terminator).toString("utf8");
if ((owner.length > 0 && terminator < 0) || note.owner_display !== display)
fail();
}
}
@@ -609,7 +609,7 @@ const alterRecordedCrashReply = (
}));
if (scenario === "wrong-note-kind") {
payload.notes = payload.notes.map((note) =>
note.index === 0 ? { ...note, type: "NT_AUXV" } : note,
note.index === 0 ? { ...note, type: "NT_AUXV", type_id: 6 } : note,
);
payload.threads = [];
}
+6 -9
View File
@@ -42,6 +42,7 @@ export const recordedCrashFixture = (
{
index: 0,
type: "PT_NOTE",
type_id: 4,
header_location: { offset: "0x40", bytes: "0x38" },
offset: "0x80",
file_size: hex(noteBytes),
@@ -58,6 +59,7 @@ export const recordedCrashFixture = (
index: 0,
segment_index: 0,
type: "NT_PRSTATUS",
type_id: 1,
location: { offset: hex(NOTE_START), bytes: hex(noteBytes) },
owner_location: { offset: hex(OWNER_START), bytes: "0x5" },
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
@@ -127,6 +129,7 @@ export const recordedCrashSignalFixture = (
index: 1,
segment_index: 0,
type: "NT_SIGINFO",
type_id: 0x53494749,
location: { offset: hex(signalNoteStart), bytes: hex(signalNoteBytes) },
owner_location: { offset: hex(signalNoteStart + 12), bytes: "0x5" },
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
@@ -170,6 +173,7 @@ export const recordedCrashFileMappingFixture = (
{
...note,
type: "NT_FILE",
type_id: 0x46494c45,
location: { offset: hex(NOTE_START), bytes: hex(bytes) },
descriptor_location: {
offset: hex(DESCRIPTOR_START),
@@ -202,7 +206,7 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
bytes.writeUInt16LE(value.segments.length, 56);
for (const segment of value.segments) {
const start = Number(BigInt(segment.header_location.offset));
bytes.writeUInt32LE(segment.type === "PT_NOTE" ? 4 : 0, start);
bytes.writeUInt32LE(segment.type_id, start);
bytes.writeUInt32LE(Number(BigInt(segment.flags)), start + 4);
for (const [field, offset] of [
[segment.offset, 8],
@@ -221,14 +225,7 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
Number(BigInt(note.descriptor_location.bytes)),
start + 4,
);
bytes.writeUInt32LE(
note.type === "NT_PRSTATUS"
? 1
: note.type === "NT_FILE"
? 0x46494c45
: 0x53494749,
start + 8,
);
bytes.writeUInt32LE(note.type_id, start + 8);
Buffer.from(note.owner_bytes_base64, "base64").copy(
bytes,
Number(BigInt(note.owner_location.offset)),