fix(native): bind core segment names to their architecture

Validate recorded-core program-header projections against the selected pyelftools architecture table after binding e_machine to the snapshot. Preserve numeric unknowns and reject names borrowed from another machine or inconsistent header bytes.

Add source-binding malformed controls and authored auxiliary-header carriers to the existing real CLI/MCP verification lane. Document pinned parser semantics separately from payload interpretation and real crash coverage.

Fixes #6538
This commit is contained in:
morluto
2026-10-24 04:54:47 +08:00
parent 1081f81484
commit 462b73ac9b
5 changed files with 162 additions and 6 deletions
+13 -2
View File
@@ -25,6 +25,11 @@ 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.
Header type names follow the pinned parser's machine-specific projection:
`PT_AARCH64_ARCHEXT` and `PT_AARCH64_UNWIND` denote `0x70000000` and
`0x70000001` only for AArch64. The same codes on x86-64 remain numeric, as
does AArch64's `0x70000002` in pyelftools 0.33. Accepting these exact header
identities does not decode architecture compatibility, unwind or MTE payloads.
- Every note's original identity, owner/descriptor bytes and source ranges,
including unfamiliar owners and note kinds.
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
@@ -105,8 +110,14 @@ GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test
program; subsequent inspection only reads the resulting core. Real acceptance
checks recorded threads, high registers, missing/malformed notes, opaque owners,
CLI/MCP parity, optional debugger context and historical PID collision with an
owned live sentinel. An observed Node thread group must launch the exact ownership
`ps` inventory before it can read that live process's `stat` or `status` for native
owned live sentinel. Authored AArch64 auxiliary-header and numeric-unknown
carriers also pass through the configured decoder and both public adapters;
they are metadata fixtures, rather than captured AArch64 crashes. Their expected
names follow [pyelftools 0.33's enums](https://github.com/eliben/pyelftools/blob/v0.33/elftools/elf/enums.py#L431-L435)
and [machine-specific selection](https://github.com/eliben/pyelftools/blob/v0.33/elftools/elf/structs.py#L179-L184).
The [AArch64 ELF ABI](https://github.com/ARM-software/abi-aa/blob/2025Q1/aaelf64/aaelf64.rst#program-header)
defines their raw codes. An observed Node thread group must launch the exact
ownership `ps` inventory before it can read that live process's `stat` or `status` for native
identity/UID facts. Only traced `CLONE_THREAD` members share this allowance;
exec-replaced threads, ordinary children, debugger and decoder processes cannot
use it. The purpose of stat/status reads within the same Node thread group
+21
View File
@@ -5,6 +5,7 @@ import json
from pathlib import Path
import sys
from elftools.elf.elffile import ELFFile
from elftools.elf.structs import ELFStructs
from elftools.construct import Container
from pwnlib.elf.datatypes import elf_prstatus_amd64
@@ -31,6 +32,26 @@ def note_header(content, note, **changes):
content[note.n_offset:note.n_offset + len(encoded)] = encoded
# Authored metadata carriers exercise actual parser projection through public
# inspection, independently of the host that produced the captured amd64 core.
for name, machine, kind in [
("aarch64-archext", "EM_AARCH64", "PT_AARCH64_ARCHEXT"),
("aarch64-unwind", "EM_AARCH64", "PT_AARCH64_UNWIND"),
("aarch64-mte-opaque", "EM_AARCH64", 0x70000002),
("amd64-processor-opaque", "EM_X86_64", 0x70000001),
]:
structs = ELFStructs(little_endian=True, elfclass=64)
structs.create_basic_structs()
structs.create_advanced_structs("ET_CORE", machine, image.header.e_ident.EI_OSABI)
header_bytes, entry_bytes = structs.Elf_Ehdr.sizeof(), structs.Elf_Phdr.sizeof()
header = Container(**dict(image.header, e_machine=machine, e_entry=0, e_ehsize=header_bytes,
e_phoff=header_bytes, e_phentsize=entry_bytes, e_phnum=1,
e_shoff=0, e_shentsize=64, e_shnum=0, e_shstrndx=0, e_flags=0))
segment = Container(p_type=kind, p_flags=4, p_offset=header_bytes + entry_bytes,
p_vaddr=0, p_paddr=0, p_filesz=4, p_memsz=4, p_align=4)
save(name, structs.Elf_Ehdr.build(header) + structs.Elf_Phdr.build(segment) + bytes(4))
save("truncated-header", original[:32])
for name, osabi, version in [("linux-osabi", 3, 0), ("solaris-osabi", 6, 0), ("freebsd-osabi", 9, 0), ("unsupported-abi-version", 0, 1)]:
content = bytearray(original)
+16
View File
@@ -189,6 +189,22 @@ try {
assert.equal(noNotes.threads.length, 0);
assert.equal(noNotes.note_interpretation_completeness, "unknown");
cases++;
for (const [name, architecture, type] of [
["aarch64-archext", "aarch64-little-endian", "PT_AARCH64_ARCHEXT"],
["aarch64-unwind", "aarch64-little-endian", "PT_AARCH64_UNWIND"],
["aarch64-mte-opaque", "aarch64-little-endian", 0x70000002],
["amd64-processor-opaque", "x86_64-little-endian", 0x70000001],
]) {
const auxiliary = await basic.inspect(mode, fixtures[name]);
assert.equal(auxiliary.architecture, architecture);
assert.equal(auxiliary.segments.length, 1);
assert.equal(auxiliary.segments[0].type, type);
assert.equal(auxiliary.notes.length, 0);
assert.equal(auxiliary.threads.length, 0);
assert.equal(auxiliary.note_interpretation_completeness, "unknown");
assert.equal(auxiliary.live_process_identity, "unknown");
cases++;
}
const negative = await basic.inspect(mode, fixtures["negative-pid"]);
assert.ok(negative.threads.some((thread) => thread.historical_pid === -2));
cases++;
@@ -2,10 +2,12 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
import {
RECORDED_LINUX_CORE_PROFILES,
type RecordedCrash,
type RecordedCrashArchitecture,
} from "../../domain/native/recordedCrash.js";
// Exact amd64/core enum projections in the pinned pyelftools 0.33 profile.
const segmentKinds = new Map([
// Base plus machine-specific projections from pinned pyelftools 0.33. A name
// establishes the original p_type only in the architecture that emits it.
const baseSegmentKinds = new Map([
[0, "PT_NULL"],
[1, "PT_LOAD"],
[2, "PT_DYNAMIC"],
@@ -21,6 +23,16 @@ const segmentKinds = new Map([
[0x6474e552, "PT_GNU_RELRO"],
[0x6474e553, "PT_GNU_PROPERTY"],
]);
const segmentKinds: Readonly<
Record<RecordedCrashArchitecture, ReadonlyMap<number, string>>
> = {
"x86_64-little-endian": baseSegmentKinds,
"aarch64-little-endian": new Map([
...baseSegmentKinds,
[0x70000000, "PT_AARCH64_ARCHEXT"],
[0x70000001, "PT_AARCH64_UNWIND"],
]),
};
const noteKinds = new Map([
[1, "NT_PRSTATUS"],
[2, "NT_FPREGSET"],
@@ -44,10 +56,11 @@ const matchesKind = (
const segmentMatches = (
segment: RecordedCrash["segments"][number],
raw: Buffer,
kinds: ReadonlyMap<number, string>,
): boolean => {
const type = raw.readUInt32LE(0);
return (
matchesKind(type, segment.type, segmentKinds) &&
matchesKind(type, segment.type, kinds) &&
BigInt(segment.flags) === BigInt(raw.readUInt32LE(4)) &&
segment.file_backing ===
(type === 0 || raw.readBigUInt64LE(32) === 0n ? "none" : "file") &&
@@ -115,7 +128,8 @@ export function validateRecordedCrashStructure(
)
fail();
const raw = bytesAt(offset, entryBytes);
if (!segmentMatches(segment, raw)) fail();
if (!segmentMatches(segment, raw, segmentKinds[report.architecture]))
fail();
}
for (const note of report.notes) {
const raw = bytesAt(BigInt(note.location.offset), 12);
@@ -10,8 +10,102 @@ import { validateRecordedCrashSources } from "../../../src/native/pwntools/Recor
import {
recordedCrashSchema,
RECORDED_LINUX_CORE_PROFILES,
type RecordedCrashArchitecture,
} from "../../../src/domain/native/recordedCrash.js";
/** Header-only metadata carrier; no thread, unwind, or MTE payload is decoded. */
const auxiliaryHeader = (
architecture: RecordedCrashArchitecture,
rawType: number,
reportedType: string | number,
) => {
const report = recordedCrashFixture(undefined, architecture);
report.segments = report.segments.map((segment) => ({
...segment,
type: reportedType,
file_size: "0x4",
memory_size: "0x4",
}));
report.notes = [];
report.threads = [];
const snapshot = recordedCrashFixtureBytes(report);
snapshot.writeUInt32LE(rawType, 64);
return { report: recordedCrashSchema.parse(report), snapshot };
};
it.each([
["aarch64-little-endian", 0x70000000, "PT_AARCH64_ARCHEXT"],
["aarch64-little-endian", 0x70000001, "PT_AARCH64_UNWIND"],
// The ABI names MTE, but pinned pyelftools still reports its numeric type.
["aarch64-little-endian", 0x70000002, 0x70000002],
["aarch64-little-endian", 0x70000003, 0x70000003],
["x86_64-little-endian", 0x70000000, 0x70000000],
["x86_64-little-endian", 0x70000001, 0x70000001],
["aarch64-little-endian", 0x6474e551, "PT_GNU_STACK"],
["x86_64-little-endian", 0x6474e551, "PT_GNU_STACK"],
] as const)(
"binds %s program type %s to its exact parser projection %s",
(architecture, rawType, reportedType) => {
const { report, snapshot } = auxiliaryHeader(
architecture,
rawType,
reportedType,
);
const original = Buffer.from(snapshot);
expect(() =>
validateRecordedCrashStructure(
report,
snapshot,
report.decoder_diagnostics,
),
).not.toThrow();
expect(snapshot).toEqual(original);
expect(report.note_interpretation_completeness).toBe("unknown");
expect(report.live_process_identity).toBe("unknown");
},
);
it.each([
["aarch64-little-endian", 0x70000000, "PT_AARCH64_UNWIND"],
["aarch64-little-endian", 0x70000001, "PT_AARCH64_ARCHEXT"],
["x86_64-little-endian", 0x70000000, "PT_AARCH64_ARCHEXT"],
["x86_64-little-endian", 0x70000001, "PT_AARCH64_UNWIND"],
["aarch64-little-endian", 0x70000001, 0x70000001],
["aarch64-little-endian", 0x70000002, "PT_AARCH64_MEMTAG_MTE"],
] as const)(
"rejects %s program type %s reported as %s outside its pinned projection",
(architecture, rawType, reportedType) => {
const { report, snapshot } = auxiliaryHeader(
architecture,
rawType,
reportedType,
);
expect(() =>
validateRecordedCrashStructure(
report,
snapshot,
report.decoder_diagnostics,
),
).toThrow(/Reported core structure differs/);
},
);
it("still binds the remaining AArch64 auxiliary header fields to their source", () => {
const { report, snapshot } = auxiliaryHeader(
"aarch64-little-endian",
0x70000001,
"PT_AARCH64_UNWIND",
);
snapshot.writeUInt32LE(4, 64 + 4);
expect(() =>
validateRecordedCrashStructure(
report,
snapshot,
report.decoder_diagnostics,
),
).toThrow(/Reported core structure differs/);
});
it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)(
"admits a %s core whose header carries that profile's e_machine",
(architecture) => {