refactor: consolidate ELF and CLR record interpretation

Share ELF note headers across note inspection, Flutter build IDs, Node SEA,
and recorded-crash source binding. Decode REL/RELA records once for MIPS
shared-load analysis and IL2CPP linking, removing superseded scalar helpers.

Share the CLR header prefix between desktop detection and managed PE parsing
while preserving their distinct read bounds and admission policies. Cover
those differences and ignored R_NONE fields with production-boundary tests.

Validation: 493 focused tests passed, two existing skips; static checks and
test discovery passed. Public managed/desktop CLI and ELF notes CLI/MCP
workflows exercised; no real external analysis providers were run.
This commit is contained in:
morluto committed 2026-10-26 16:50:08 +08:00
1 parent eb51eb6915
commit a7f50e3386
16 files changed
+382 -37

No files matched your search

+11 -6
View File
@@ -1,3 +1,4 @@
import { readElfRelocationEntry } from "../domain/elfRelocation.js";
import { readElfDynamicEntry } from "../domain/elfDynamicEntry.js";
import { readElfProgramHeader } from "../domain/elfProgramHeader.js";
import type { MipsElfMetadata } from "../domain/binaryTargetTypes.js";
@@ -29,8 +30,6 @@ export const readMipsElfSharedLoad = async (
checkCancelled: () => void,
sections: readonly MipsElfRelocationSection[],
): Promise<Result<SharedLoadFacts, string>> => {
const u32 = (bytes: Buffer, offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const loads: {
offset: number;
address: number;
@@ -219,15 +218,21 @@ export const readMipsElfSharedLoad = async (
if (!chunk.ok) return chunk;
for (let position = 0; position < chunk.value.length; position += 8) {
checkCancelled();
const info = u32(chunk.value, position + 4);
const type = info & 0xff;
const relocationAddress = u32(chunk.value, position);
const parsedEntry = readElfRelocationEntry(
chunk.value.subarray(position, position + 8),
32,
little,
"rel",
);
if (!parsedEntry.ok) return err(parsedEntry.error.message);
const { type, symbolIndex, offset: rawAddress } = parsedEntry.value;
const relocationAddress = Number(rawAddress);
if (type === 3 && mapped(relocationAddress, 4, false) === undefined)
return err(
"MIPS REL32 destination has no unique complete PT_LOAD memory range",
);
types.add(type);
if (type === 3 && info >>> 8 !== 0) symbolRelativeCount += 1;
if (type === 3 && symbolIndex !== 0) symbolRelativeCount += 1;
}
}
return ok({
+5 -4
View File
@@ -16,6 +16,7 @@ import {
peRvaToFileOffset,
} from "../domain/binaryTarget.js";
import { readManagedMetadataLayout } from "../dotnet/ManagedMetadataLayout.js";
import { readManagedCliHeaderPrefix } from "../domain/managed/managedCliHeaderPrefix.js";
import { readAssemblyReferenceName } from "../dotnet/ManagedMetadataInventoryRows.js";
import { ManagedReaderFailure } from "../dotnet/ManagedReaderFailure.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
@@ -285,14 +286,14 @@ const inspectPeFile = async (
"format",
"CLI metadata directory cannot be mapped",
);
const cli = await read(cliOffset, 16);
if (cli.readUInt32LE(0) < 72)
const cli = readManagedCliHeaderPrefix(await read(cliOffset, 16));
if (!cli.ok || cli.value.headerSize < 72)
throw new ArtifactReaderFailure(
"format",
"CLI header declares a truncated runtime header",
);
const metadataBytes = cli.readUInt32LE(12);
const metadataOffset = mapped(cli.readUInt32LE(8), metadataBytes);
const metadataBytes = cli.value.metadata.size;
const metadataOffset = mapped(cli.value.metadata.rva, metadataBytes);
if (metadataOffset === undefined)
throw new ArtifactReaderFailure(
"format",
+5 -2
View File
@@ -23,6 +23,7 @@ import { readMachoLoadCommandTableHeader } from "../../domain/apple/machoLoadCom
import {
elfNoteAlignment,
elfNoteLayout,
readElfNoteHeader,
} from "../../domain/native/elfNoteLayout.js";
import {
NODE_SEA_BLOB_TOO_LARGE,
@@ -504,8 +505,10 @@ const scanElfNoteTable = async (
const header = await readExact(carrier, note.offset + cursor, 12);
if (header === undefined)
return failNoteTable(alreadyFound || found.length > 0);
const namesz = readU32(header, 0, little);
const descsz = readU32(header, 4, little);
const parsedHeader = readElfNoteHeader(header, little);
if (!parsedHeader.ok)
return failNoteTable(alreadyFound || found.length > 0);
const { namesz, descsz } = parsedHeader.value;
if (namesz > MAX_NOTE_NAME_BYTES)
return failNoteTable(alreadyFound || found.length > 0);
if (cursor > note.length - 12 || namesz > note.length - cursor - 12)
+82
View File
@@ -0,0 +1,82 @@
import { expect, it } from "vitest";
import { readElfRelocationEntry } from "./elfRelocation.js";
const formats = [
{ bits: 32, littleEndian: false },
{ bits: 32, littleEndian: true },
{ bits: 64, littleEndian: false },
{ bits: 64, littleEndian: true },
] as const;
it.each(formats)(
"preserves standard ELF$bits REL/RELA fields (littleEndian=$littleEndian)",
({ bits, littleEndian }) => {
const wide = bits === 64;
const wordBytes = wide ? 8 : 4;
const backing = Buffer.alloc(wordBytes * 3 + 19, 0xa5);
const bytes = backing.subarray(7, 7 + wordBytes * 3);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const offset = wide ? 0xffffffffffffffffn : 0xffffffffn;
const symbolIndex = wide ? 0xfedcba98 : 0xabcdef;
const type = wide ? 0x89abcdef : 0x80;
const info = (BigInt(symbolIndex) << (wide ? 32n : 8n)) | BigInt(type);
const addend = wide ? -0x8000000000000000n : -0x80000000n;
if (wide) {
view.setBigUint64(0, offset, littleEndian);
view.setBigUint64(8, info, littleEndian);
view.setBigInt64(16, addend, littleEndian);
} else {
view.setUint32(0, Number(offset), littleEndian);
view.setUint32(4, Number(info), littleEndian);
view.setInt32(8, Number(addend), littleEndian);
}
const before = Buffer.from(backing);
const rel = readElfRelocationEntry(
backing.subarray(7),
bits,
littleEndian,
"rel",
);
const rela = readElfRelocationEntry(
backing.subarray(7),
bits,
littleEndian,
"rela",
);
expect(backing.equals(before)).toBe(true);
backing.fill(0);
expect(rel).toEqual({
ok: true,
value: { offset, type, symbolIndex },
});
expect(rela).toEqual({
ok: true,
value: { offset, type, symbolIndex, addend },
});
},
);
it.each(formats)(
"refuses every incomplete ELF$bits REL/RELA entry (littleEndian=$littleEndian)",
({ bits, littleEndian }) => {
const wordBytes = bits === 64 ? 8 : 4;
const bytes = Buffer.alloc(wordBytes * 3 + 7);
for (let observed = 0; observed < wordBytes * 3; observed++) {
const input = bytes.subarray(7, 7 + observed);
const failure = {
ok: false,
error: {
kind: "malformed",
message: "ELF relocation entry is truncated",
},
};
expect(readElfRelocationEntry(input, bits, littleEndian, "rela")).toEqual(
failure,
);
if (observed < wordBytes * 2)
expect(
readElfRelocationEntry(input, bits, littleEndian, "rel"),
).toEqual(failure);
}
},
);
+61
View File
@@ -0,0 +1,61 @@
import { err, ok, type Result } from "./result.js";
interface ElfRelocationEntry {
readonly offset: bigint;
readonly type: number;
readonly symbolIndex: number;
}
interface ElfRelaEntry extends ElfRelocationEntry {
readonly addend: bigint;
}
interface ElfRelocationFailure {
readonly kind: "malformed";
readonly message: string;
}
export function readElfRelocationEntry(
bytes: Uint8Array,
bits: 32 | 64,
littleEndian: boolean,
encoding: "rel",
): Result<ElfRelocationEntry, ElfRelocationFailure>;
export function readElfRelocationEntry(
bytes: Uint8Array,
bits: 32 | 64,
littleEndian: boolean,
encoding: "rela",
): Result<ElfRelaEntry, ElfRelocationFailure>;
/** Decode standard REL/RELA fields; architecture-specific application remains with consumers. */
export function readElfRelocationEntry(
bytes: Uint8Array,
bits: 32 | 64,
littleEndian: boolean,
encoding: "rel" | "rela",
): Result<ElfRelocationEntry | ElfRelaEntry, ElfRelocationFailure> {
const wordBytes = bits === 64 ? 8 : 4;
if (bytes.byteLength < wordBytes * (encoding === "rela" ? 3 : 2))
return err({
kind: "malformed",
message: "ELF relocation entry is truncated",
});
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const offset =
bits === 64
? view.getBigUint64(0, littleEndian)
: BigInt(view.getUint32(0, littleEndian));
const info =
bits === 64
? view.getBigUint64(wordBytes, littleEndian)
: BigInt(view.getUint32(wordBytes, littleEndian));
const entry = {
offset,
type: Number(info & (bits === 64 ? 0xffffffffn : 0xffn)),
symbolIndex: Number(info >> (bits === 64 ? 32n : 8n)),
};
if (encoding === "rel") return ok(entry);
const addend =
bits === 64
? view.getBigInt64(wordBytes * 2, littleEndian)
: BigInt(view.getInt32(wordBytes * 2, littleEndian));
return ok({ ...entry, addend });
}
@@ -0,0 +1,27 @@
import { expect, it } from "vitest";
import { readManagedCliHeaderPrefix } from "./managedCliHeaderPrefix.js";
it("requires only the fixed prefix and leaves full-header admission to consumers", () => {
const bytes = Buffer.alloc(16);
bytes.writeUInt32LE(0xffffffff, 0);
bytes.writeUInt16LE(0xffff, 4);
bytes.writeUInt16LE(0x8000, 6);
bytes.writeUInt32LE(0x80000000, 8);
bytes.writeUInt32LE(0xffffffff, 12);
expect(readManagedCliHeaderPrefix(bytes)).toEqual({
ok: true,
value: {
headerSize: 0xffffffff,
runtimeMajor: 0xffff,
runtimeMinor: 0x8000,
metadata: { rva: 0x80000000, size: 0xffffffff },
},
});
for (let length = 0; length < 16; length++)
expect(readManagedCliHeaderPrefix(bytes.subarray(0, length))).toMatchObject(
{
ok: false,
error: { kind: "malformed" },
},
);
});
@@ -0,0 +1,19 @@
import { err, ok } from "../result.js";
/** IMAGE_COR20_HEADER's first 16 bytes; consumers own directory/range admission. */
export const readManagedCliHeaderPrefix = (bytes: Buffer) => {
if (bytes.length < 16)
return err({
kind: "malformed" as const,
message: "CLI runtime header prefix is truncated",
});
return ok({
headerSize: bytes.readUInt32LE(0),
runtimeMajor: bytes.readUInt16LE(4),
runtimeMinor: bytes.readUInt16LE(6),
metadata: {
rva: bytes.readUInt32LE(8),
size: bytes.readUInt32LE(12),
},
});
};
+36
View File
@@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { readElfNoteHeader } from "./elfNoteLayout.js";
it.each([false, true])(
"preserves raw note sizes and type without admission (littleEndian=%s)",
(littleEndian) => {
const backing = Buffer.alloc(31, 0xa5);
const bytes = backing.subarray(7, 19);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(0, 0xffffffff, littleEndian);
view.setUint32(4, 0x80000001, littleEndian);
view.setUint32(8, 0xfedcba98, littleEndian);
const before = Buffer.from(backing);
const parsed = readElfNoteHeader(backing.subarray(7), littleEndian);
expect(backing.equals(before)).toBe(true);
backing.fill(0);
expect(parsed).toEqual({
ok: true,
value: { namesz: 0xffffffff, descsz: 0x80000001, type: 0xfedcba98 },
});
},
);
it.each([false, true])(
"refuses incomplete note headers (littleEndian=%s)",
(littleEndian) => {
const bytes = Buffer.alloc(19);
for (let observed = 0; observed < 12; observed++)
expect(
readElfNoteHeader(bytes.subarray(7, 7 + observed), littleEndian),
).toEqual({
ok: false,
error: { kind: "malformed", message: "ELF note header is truncated" },
});
},
);
+17
View File
@@ -1,3 +1,5 @@
import { err, ok } from "../result.js";
/** ELF note padding follows binutils: only a declared alignment of 8 uses 8. */
export const elfNoteAlignment = (declared: number | bigint): number =>
declared === 8 || declared === 8n ? 8 : 4;
@@ -14,3 +16,18 @@ export const elfNoteLayout = (
next: Math.ceil((descriptor + descsz) / alignment) * alignment,
};
};
/** Fixed ELF note words; padding, owner names and descriptor admission belong to consumers. */
export const readElfNoteHeader = (bytes: Uint8Array, littleEndian: boolean) => {
if (bytes.byteLength < 12)
return err({
kind: "malformed" as const,
message: "ELF note header is truncated",
});
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return ok({
namesz: view.getUint32(0, littleEndian),
descsz: view.getUint32(4, littleEndian),
type: view.getUint32(8, littleEndian),
});
};
+14 -4
View File
@@ -8,6 +8,7 @@ import { readPeSectionHeader } from "../domain/peSectionHeader.js";
import type { ManagedParseIssue } from "../domain/managed/managedArtifact.js";
import { readPeCoffHeader } from "../domain/peCoffHeader.js";
import { decodePeMachine } from "../domain/peMachine.js";
import { readManagedCliHeaderPrefix } from "../domain/managed/managedCliHeaderPrefix.js";
import {
managedFailure,
ManagedReaderFailure,
@@ -251,7 +252,17 @@ const readCliHeader = (
size: 72,
scope: "cli.header",
});
const headerSize = bytes.readUInt32LE(offset);
const prefix = readManagedCliHeaderPrefix(
bytes.subarray(offset, offset + 16),
);
if (!prefix.ok)
throw managedFailure(
"invalid-cli-header",
"cli.header",
prefix.error.message,
offset,
);
const { headerSize, runtimeMajor, runtimeMinor, metadata } = prefix.value;
if (headerSize < 72 || headerSize > directory.size)
throw managedFailure(
"invalid-cli-header",
@@ -259,7 +270,6 @@ const readCliHeader = (
"CLI header size is outside its PE data directory",
offset,
);
const metadata = readDirectory(bytes, offset + 8);
if (metadata.rva === 0 || metadata.size === 0)
throw managedFailure(
"invalid-cli-header",
@@ -283,8 +293,8 @@ const readCliHeader = (
return {
headerOffset: offset,
headerSize,
runtimeMajor: bytes.readUInt16LE(offset + 4),
runtimeMinor: bytes.readUInt16LE(offset + 6),
runtimeMajor,
runtimeMinor,
metadata,
flags,
entryPoint: bytes.readUInt32LE(offset + 20),
+4 -3
View File
@@ -6,6 +6,7 @@ import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import {
elfNoteAlignment,
elfNoteLayout,
readElfNoteHeader,
} from "../domain/native/elfNoteLayout.js";
import {
readElfHeader,
@@ -164,9 +165,9 @@ export const readElfBuildIds = async (
return failure("ELF note header is truncated");
break;
}
const namesz = notes.readUInt32LE(at);
const descsz = notes.readUInt32LE(at + 4);
const type = notes.readUInt32LE(at + 8);
const parsedHeader = readElfNoteHeader(notes.subarray(at, at + 12), true);
if (!parsedHeader.ok) return failure(parsedHeader.error.message);
const { namesz, descsz, type } = parsedHeader.value;
const layout = elfNoteLayout(namesz, descsz, range.alignment);
const descriptor = at + layout.descriptor;
const end = descriptor + descsz;
+16 -5
View File
@@ -1,3 +1,4 @@
import { readElfRelocationEntry } from "../domain/elfRelocation.js";
import { readElfDynamicEntry } from "../domain/elfDynamicEntry.js";
import {
BinaryMappingFailure,
@@ -174,11 +175,21 @@ function* applyRelaTable(
if (start === null) throw malformed("RELA table is not file-backed");
for (let at = start; at < start + Number(size); at += 24) {
if ((at - start) % (24 * 4096) === 0) yield;
const info = source.image.readBigUInt64LE(at + 8),
kind = Number(info & 0xffffffffn);
const parsedEntry = readElfRelocationEntry(
source.image.subarray(at, at + 24),
64,
true,
"rela",
);
if (!parsedEntry.ok) throw malformed(parsedEntry.error.message);
const {
type: kind,
offset: destination,
symbolIndex,
addend,
} = parsedEntry.value;
if (kind === 0) continue;
const destination = source.image.readBigUInt64LE(at);
if (kind !== source.relative || info >> 32n !== 0n) {
if (kind !== source.relative || symbolIndex !== 0) {
const width = source.unknownPointerWidths.get(kind);
if (width === undefined)
throw unsupported(`ELF relocation kind ${kind} is not decoded`);
@@ -192,7 +203,7 @@ function* applyRelaTable(
continue;
}
const target = fileOffset(source, destination, 8);
const value = source.image.readBigInt64LE(at + 16) & 0xffffffffffffffffn;
const value = BigInt.asUintN(64, addend);
if (target !== null) output.bytes.writeBigUInt64LE(value, target);
else if (
isZeroFilledSpan(source.mappings, source.zeroFills, destination, 8)
+7 -10
View File
@@ -7,6 +7,7 @@ import { readElfFixedHeader } from "../../domain/elfFixedHeader.js";
import {
elfNoteAlignment as noteAlignment,
elfNoteLayout,
readElfNoteHeader,
} from "../../domain/native/elfNoteLayout.js";
import type {
ElfNotes,
@@ -100,13 +101,6 @@ class ElfReader {
readonly little: boolean,
) {}
u32(offset: number): number {
this.require(offset, 4);
return this.little
? this.bytes.readUInt32LE(offset)
: this.bytes.readUInt32BE(offset);
}
require(offset: number, length: number): void {
if (
!Number.isSafeInteger(offset) ||
@@ -191,9 +185,12 @@ const parseNoteStream = (
{ boundary: "capacity" },
);
const header = offset + at;
const namesz = reader.u32(header);
const descsz = reader.u32(header + 4);
const type = reader.u32(header + 8);
const parsedHeader = readElfNoteHeader(
reader.slice(header, 12),
reader.little,
);
if (!parsedHeader.ok) return fail(parsedHeader.error.message);
const { namesz, descsz, type } = parsedHeader.value;
// Align offsets from the start of this note. Independently rounding
// namesz/descsz disagrees with binutils when alignment is 8 because the
// 12-byte note header is not 8-aligned (GNU property namesz=4 then starts
@@ -1,3 +1,4 @@
import { readElfNoteHeader } from "../../domain/native/elfNoteLayout.js";
import { readElfSectionHeader } from "../../domain/elfSectionHeader.js";
import { readElfProgramHeader } from "../../domain/elfProgramHeader.js";
import { readElfRawTableDeclarations } from "../../domain/elfRawTableDeclarations.js";
@@ -137,10 +138,13 @@ export function validateRecordedCrashStructure(
}
for (const note of report.notes) {
const raw = bytesAt(BigInt(note.location.offset), 12);
const parsedNote = readElfNoteHeader(raw, true);
if (!parsedNote.ok) return fail();
const { namesz, descsz, type } = parsedNote.value;
if (
BigInt(note.owner_location.bytes) !== BigInt(raw.readUInt32LE(0)) ||
BigInt(note.descriptor_location.bytes) !== BigInt(raw.readUInt32LE(4)) ||
raw.readUInt32LE(8) !== note.type_id
BigInt(note.owner_location.bytes) !== BigInt(namesz) ||
BigInt(note.descriptor_location.bytes) !== BigInt(descsz) ||
type !== note.type_id
)
fail();
}
@@ -22,6 +22,7 @@ import {
buildNativePeFixture,
} from "../../../src/dotnet/ManagedPe.fixture.js";
import { readManagedMetadataLayout } from "../../../src/dotnet/ManagedMetadataLayout.js";
import { readManagedPeLayout } from "../../../src/dotnet/ManagedPeReader.js";
import { readAssemblyReference } from "../../../src/dotnet/ManagedMetadataInventoryRows.js";
import {
CPU,
@@ -54,6 +55,59 @@ const put = async (root: string, path: string, bytes: Buffer | string) => {
await writeFile(destination, bytes);
};
it.each(["oversized-header", "prefix-only-section"] as const)(
"keeps desktop CLR prefix admission distinct from full managed admission: %s",
async (mode) => {
const original = buildManagedPeFixture({
references: ["UnityEngine.CoreModule"],
});
expect(readManagedPeLayout(original).cli).not.toBeNull();
let image = Buffer.from(original);
if (mode === "oversized-header") {
// The directory declares 72 bytes; desktop needs only cb >= 72.
image.writeUInt32LE(73, 0x200);
} else {
// Metadata remains in .text; a second section backs only the CLI prefix.
image = Buffer.concat([original, original.subarray(0x200, 0x210)]);
const coff = image.readUInt32LE(0x3c) + 4;
const optional = coff + 20;
const secondSection = optional + image.readUInt16LE(coff + 16) + 40;
image.writeUInt16LE(2, coff + 2);
image.writeUInt32LE(0x900000, optional + 96 + 14 * 8);
image.write(".cli", secondSection, "ascii");
image.writeUInt32LE(16, secondSection + 8);
image.writeUInt32LE(0x900000, secondSection + 12);
image.writeUInt32LE(16, secondSection + 16);
image.writeUInt32LE(original.length, secondSection + 20);
}
expect(readManagedPeLayout(image)).toMatchObject({
cli: null,
cliIssue: {
code:
mode === "oversized-header"
? "invalid-cli-header"
: "invalid-directory",
scope: "cli.header",
},
});
const root = await createTestTempDirectory("rea-desktop-clr-prefix-");
const path = "Game_Data/Managed/Assembly-CSharp.dll";
await put(root, path, image);
const { report } = await inspect(root);
const file = report.files.find((row) => row.path === path);
expect(file?.issues).toEqual([]);
expect(file?.facts).toContainEqual({
kind: "unity-assembly-reference",
offset: expect.any(Number),
value: {
name: "UnityEngine.CoreModule",
token: expect.any(String),
managed_directory: "Game_Data/Managed",
},
});
},
);
it.skipIf(process.platform !== "linux")(
"keeps desktop sibling files with partial coverage for invalid UTF-8 names",
async () => {
@@ -1840,3 +1840,20 @@ it.each([0n, FIXTURE_BINARY_BASE + 0x980n, FIXTURE_BINARY_BASE + 0x9b0n])(
expect(bytes.readBigUInt64LE(0x900)).toBe(raw);
},
);
it.each([183, 62])(
"ignores R_NONE target and addend before relocation application (machine %s)",
(machine) => {
const image = createIl2cppBinaryFixture();
const bytes = createElfFixture(image, {
machine,
unresolvedRelocations: [{ offset: 0x7fffffff, kind: 0 }],
});
const dynamic = Number(bytes.readBigUInt64LE(128));
const relocationBytes = Number(bytes.readBigUInt64LE(dynamic + 24));
const last = 0x1000 + relocationBytes - 24;
bytes.writeBigUInt64LE(0xffffffffffffffffn, last);
bytes.writeBigInt64LE(-0x8000000000000000n, last + 16);
expect(link(bytes)).toEqual(link(createElfFixture(image, { machine })));
},
);