refactor(macho): centralize segment and section decoding

This commit is contained in:
morluto
2026-10-26 15:40:10 +08:00
parent f4cc6c85ef
commit 981d806924
7 changed files with 306 additions and 60 deletions
@@ -1,3 +1,4 @@
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
import {
MAX_JVM_CLASS_IDENTITY_BYTES,
@@ -504,20 +505,16 @@ const readSegmentSpan = (
body: Uint8Array,
wide: boolean,
): SegmentSpan | undefined => {
if (body.byteLength < (wide ? 72 : 56)) return undefined;
const view = viewOf(body);
const nameBytes = body.subarray(8, 24);
const parsed = readMachoSegmentHeader(body, wide, true);
if (!parsed.ok) return undefined;
const { nameBytes, address: vmaddr, memorySize: vmsize } = parsed.value;
const end = nameBytes.indexOf(0);
return {
name: new TextDecoder("utf-8", { fatal: false }).decode(
end < 0 ? nameBytes : nameBytes.subarray(0, end),
),
vmaddr: wide
? view.getBigUint64(24, true)
: BigInt(view.getUint32(24, true)),
vmsize: wide
? view.getBigUint64(32, true)
: BigInt(view.getUint32(28, true)),
vmaddr,
vmsize,
};
};
@@ -0,0 +1,75 @@
import { expect, it } from "vitest";
import { readMachoSectionHeader } from "./machoSectionHeader.js";
const formats = [
{ wide: false, littleEndian: false, headerSize: 68 },
{ wide: false, littleEndian: true, headerSize: 68 },
{ wide: true, littleEndian: false, headerSize: 80 },
{ wide: true, littleEndian: true, headerSize: 80 },
] as const;
it.each(formats)(
"preserves raw section facts (wide=$wide, littleEndian=$littleEndian)",
({ wide, littleEndian, headerSize }) => {
const backing = Buffer.alloc(headerSize + 19, 0xa5);
const bytes = backing.subarray(7, 7 + headerSize);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const names = Buffer.from([
0x5f, 0x80, 0xff, 0, 0xe9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
]);
names.copy(bytes, 0);
const segmentNames = Buffer.from("__DATA\0trailing!", "latin1");
segmentNames.copy(bytes, 16);
const address = wide ? 0xfedcba9876543210n : 0xfedcba98n;
const size = wide ? 0xffffffffffffffffn : 0xffffffffn;
if (wide) {
view.setBigUint64(32, address, littleEndian);
view.setBigUint64(40, size, littleEndian);
} else {
view.setUint32(32, Number(address), littleEndian);
view.setUint32(36, Number(size), littleEndian);
}
view.setUint32(wide ? 48 : 40, 0xffffffff, littleEndian);
view.setUint32(wide ? 64 : 56, 0x89abcdef, littleEndian);
const expectedSegmentName = Buffer.from(bytes.subarray(16, 32));
const before = Buffer.from(backing);
const parsed = readMachoSectionHeader(
backing.subarray(7),
wide,
littleEndian,
);
expect(backing.equals(before)).toBe(true);
if (!parsed.ok) throw new Error(parsed.error.message);
backing.fill(0);
const { nameBytes, segmentNameBytes, ...facts } = parsed.value;
expect(nameBytes.equals(names)).toBe(true);
expect(segmentNameBytes.equals(expectedSegmentName)).toBe(true);
expect(facts).toEqual({
address,
size,
offset: 0xffffffff,
flags: 0x89abcdef,
});
},
);
it.each(formats)(
"refuses each truncated fixed section record (wide=$wide, littleEndian=$littleEndian)",
({ wide, littleEndian, headerSize }) => {
const backing = Buffer.alloc(headerSize + 7, 0xff);
for (let length = 0; length < headerSize; length++)
expect(
readMachoSectionHeader(
backing.subarray(7, 7 + length),
wide,
littleEndian,
),
).toEqual({
ok: false,
error: {
kind: "malformed",
message: "Mach-O section header is truncated",
},
});
},
);
+39
View File
@@ -0,0 +1,39 @@
import { err, ok, type Result } from "../result.js";
export interface MachoSectionHeader {
readonly nameBytes: Buffer;
readonly segmentNameBytes: Buffer;
readonly address: bigint;
readonly size: bigint;
readonly offset: number;
readonly flags: number;
}
/** Fixed section/section_64 facts; mapping, zero-fill and display policies belong to callers. */
export const readMachoSectionHeader = (
bytes: Uint8Array,
wide: boolean,
littleEndian: boolean,
): Result<
MachoSectionHeader,
{ readonly kind: "malformed"; readonly message: string }
> => {
if (bytes.byteLength < (wide ? 80 : 68))
return err({
kind: "malformed",
message: "Mach-O section header is truncated",
});
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return ok({
nameBytes: Buffer.from(bytes.subarray(0, 16)),
segmentNameBytes: Buffer.from(bytes.subarray(16, 32)),
address: wide
? view.getBigUint64(32, littleEndian)
: BigInt(view.getUint32(32, littleEndian)),
size: wide
? view.getBigUint64(40, littleEndian)
: BigInt(view.getUint32(36, littleEndian)),
offset: view.getUint32(wide ? 48 : 40, littleEndian),
flags: view.getUint32(wide ? 64 : 56, littleEndian),
});
};
@@ -0,0 +1,81 @@
import { expect, it } from "vitest";
import { readMachoSegmentHeader } from "./machoSegmentHeader.js";
const formats = [
{ wide: false, littleEndian: false, headerSize: 56 },
{ wide: false, littleEndian: true, headerSize: 56 },
{ wide: true, littleEndian: false, headerSize: 72 },
{ wide: true, littleEndian: true, headerSize: 72 },
] as const;
it.each(formats)(
"retains exact raw segment facts (wide=$wide, littleEndian=$littleEndian)",
({ wide, littleEndian, headerSize }) => {
const backing = Buffer.alloc(headerSize + 19, 0xa5);
const bytes = backing.subarray(7, 7 + headerSize);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const names = Buffer.from([
0x5f, 0x80, 0xff, 0, 0xe9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11,
]);
names.copy(bytes, 8);
const values = wide
? [
0xfedcba9876543210n,
0x8000000000000001n,
0xffffffffffffffffn,
0x123456789abcdef0n,
]
: [0xfedcba98n, 0x80000001n, 0xffffffffn, 0x12345678n];
values.forEach((value, index) => {
if (wide) view.setBigUint64(24 + index * 8, value, littleEndian);
else view.setUint32(24 + index * 4, Number(value), littleEndian);
});
const protections = wide ? 56 : 40;
[0x80000001, 0xfedcba98, 0xffffffff].forEach((value, index) =>
view.setUint32(protections + index * 4, value, littleEndian),
);
const before = Buffer.from(backing);
// A borrowed window may include following command bytes; only fixed fields belong here.
const parsed = readMachoSegmentHeader(
backing.subarray(7),
wide,
littleEndian,
);
expect(backing.equals(before)).toBe(true);
if (!parsed.ok) throw new Error(parsed.error.message);
backing.fill(0);
const { nameBytes, ...facts } = parsed.value;
expect(nameBytes.equals(names)).toBe(true);
expect(facts).toEqual({
headerSize,
address: values[0],
memorySize: values[1],
fileOffset: values[2],
fileSize: values[3],
maxProtection: 0x80000001,
protection: 0xfedcba98,
sectionCount: 0xffffffff,
});
},
);
it.each(formats)(
"refuses every incomplete fixed segment record (wide=$wide, littleEndian=$littleEndian)",
({ wide, littleEndian, headerSize }) => {
const backing = Buffer.alloc(headerSize + 7, 0xff);
for (let length = 0; length < headerSize; length++)
expect(
readMachoSegmentHeader(
backing.subarray(7, 7 + length),
wide,
littleEndian,
),
).toEqual({
ok: false,
error: {
kind: "malformed",
message: "Mach-O segment header is truncated",
},
});
},
);
+31
View File
@@ -0,0 +1,31 @@
import { err, ok } from "../result.js";
/** Decode a fixed LC_SEGMENT/LC_SEGMENT_64 record; callers own command and mapping admission. */
export const readMachoSegmentHeader = (
bytes: Uint8Array,
wide: boolean,
littleEndian: boolean,
) => {
const headerSize = wide ? 72 : 56;
if (bytes.byteLength < headerSize)
return err({
kind: "malformed" as const,
message: "Mach-O segment header is truncated",
});
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const word = (offset: number): bigint =>
wide
? view.getBigUint64(offset, littleEndian)
: BigInt(view.getUint32(offset, littleEndian));
return ok({
headerSize,
nameBytes: Buffer.from(bytes.subarray(8, 24)),
address: word(24),
memorySize: word(wide ? 32 : 28),
fileOffset: word(wide ? 40 : 32),
fileSize: word(wide ? 48 : 36),
maxProtection: view.getUint32(wide ? 56 : 40, littleEndian),
protection: view.getUint32(wide ? 60 : 44, littleEndian),
sectionCount: view.getUint32(wide ? 64 : 48, littleEndian),
});
};
+29 -12
View File
@@ -1,3 +1,5 @@
import { readMachoSectionHeader } from "../domain/apple/machoSectionHeader.js";
import { readMachoSegmentHeader } from "../domain/apple/machoSegmentHeader.js";
import {
MachoLoadCommandCursor,
readMachoLoadCommandDeclarations,
@@ -302,33 +304,48 @@ const collectSegment = (
{ cursor, size }: LoadCommand,
state: LayoutState,
): void => {
if (size < 72) throw new RangeError("Truncated segment command");
const address = bytes.readBigUInt64LE(cursor + 24),
fileSize = bytes.readBigUInt64LE(cursor + 48),
fileOffset = bytes.readBigUInt64LE(cursor + 40);
const parsed = readMachoSegmentHeader(
bytes.subarray(cursor, cursor + size),
true,
true,
);
if (!parsed.ok) throw new RangeError("Truncated segment command");
const {
address,
fileSize,
fileOffset,
protection,
sectionCount: count,
headerSize,
} = parsed.value;
if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice))
throw new RangeError("Segment file range exceeds target bytes");
state.segments.push({
address,
size: fileSize,
offset: state.slice + Number(fileOffset),
executable: (bytes.readUInt32LE(cursor + 60) & 4) !== 0,
executable: (protection & 4) !== 0,
});
if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address;
const count = bytes.readUInt32LE(cursor + 64);
if (72 + count * 80 > size) throw new RangeError("Truncated section table");
if (headerSize + count * 80 > size)
throw new RangeError("Truncated section table");
for (let section = 0; section < count; section++) {
const position = cursor + 72 + section * 80;
const sectionSize = bytes.readBigUInt64LE(position + 40);
const position = cursor + headerSize + section * 80;
const parsedSection = readMachoSectionHeader(
bytes.subarray(position, position + 80),
true,
true,
);
if (!parsedSection.ok) throw new RangeError("Truncated section table");
const { nameBytes, address, size: sectionSize } = parsedSection.value;
if (sectionSize > BigInt(Number.MAX_SAFE_INTEGER))
throw new RangeError("Section size exceeds exact numeric range");
state.sections.push({
name: bytes
.subarray(position, position + 16)
name: nameBytes
// ASCII decoding masks high bits and can fabricate known metadata names.
.toString("latin1")
.replace(/\0.*$/u, ""),
address: bytes.readBigUInt64LE(position + 32),
address,
size: Number(sectionSize),
});
}
+45 -39
View File
@@ -1,3 +1,8 @@
import {
readMachoSectionHeader,
type MachoSectionHeader,
} from "../../domain/apple/machoSectionHeader.js";
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import {
declaredFileRange,
BinaryFormatFailure,
@@ -49,20 +54,28 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
"malformed",
"Mach-O segment command does not match the header width",
);
const segmentHeader = wide ? 72 : 56;
const sectionWidth = wide ? 80 : 68;
if (size < segmentHeader)
const parsedSegment = readMachoSegmentHeader(
bytes.subarray(offset, offset + size),
wide,
little,
);
if (!parsedSegment.ok)
throw new BinaryFormatFailure(
"malformed",
"Mach-O segment header is truncated",
parsedSegment.error.kind,
parsedSegment.error.message,
);
const address = reader.word(offset + 24, bits);
const memorySize = reader.word(offset + (wide ? 32 : 28), bits);
const fileOffset = reader.word(offset + (wide ? 40 : 32), bits);
const fileSize = reader.word(offset + (wide ? 48 : 36), bits);
const maxProtection = reader.u32(offset + (wide ? 56 : 40));
const protection = reader.u32(offset + (wide ? 60 : 44));
const sectionCount = reader.u32(offset + (wide ? 64 : 48));
const {
headerSize: segmentHeader,
address,
memorySize,
fileOffset,
fileSize,
maxProtection,
protection,
sectionCount,
nameBytes,
} = parsedSegment.value;
const unmapped = memorySize === 0n && protection === 0;
if (
(!unmapped && fileSize > memorySize) ||
@@ -85,9 +98,7 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
unmapped || fileRange === undefined
? undefined
: { ...fileRange, address };
const name = fixedName(
reader.range(offset + 8, 16, "Mach-O segment name"),
);
const name = fixedName(nameBytes);
if (mapping !== undefined && name !== "__PAGEZERO") {
mappings.push(mapping);
loadRegions.push({
@@ -110,18 +121,21 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
});
for (let sectionIndex = 0; sectionIndex < sectionCount; sectionIndex++) {
const section = offset + segmentHeader + sectionIndex * sectionWidth;
const sectionName = fixedName(
reader.range(section, 16, "Mach-O section name"),
const parsedSection = readMachoSectionHeader(
bytes.subarray(section, section + sectionWidth),
wide,
little,
);
if (!parsedSection.ok)
throw new BinaryFormatFailure(
parsedSection.error.kind,
parsedSection.error.message,
);
sections.push(
machoImageSection({
reader,
bits,
wide,
header: section,
name: `${fixedName(reader.range(section + 16, 16, "Mach-O section segment"))},${sectionName}`,
fileBytes: bytes.length,
section: parsedSection.value,
protection,
sectionName,
segmentMapping: mapping ?? null,
segmentUnmapped: unmapped,
unmapped: unmapped || mapping === undefined,
@@ -161,13 +175,9 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
};
const machoImageSection = ({
reader,
bits,
wide,
header,
name,
fileBytes,
section,
protection,
sectionName,
segmentMapping,
segmentUnmapped,
unmapped,
@@ -176,13 +186,9 @@ const machoImageSection = ({
segmentFileOffset,
segmentFileSize,
}: {
readonly reader: BinaryReader;
readonly bits: 32 | 64;
readonly wide: boolean;
readonly header: number;
readonly name: string;
readonly fileBytes: number;
readonly section: MachoSectionHeader;
readonly protection: number;
readonly sectionName: string;
readonly segmentMapping: FileMapping | null;
readonly segmentUnmapped: boolean;
readonly unmapped: boolean;
@@ -191,11 +197,11 @@ const machoImageSection = ({
readonly segmentFileOffset: bigint;
readonly segmentFileSize: bigint;
}): ImageSection => {
const flags = reader.u32(header + (wide ? 64 : 56));
const { flags, address: sectionAddress, size: sectionSize } = section;
const sectionOffset = BigInt(section.offset);
const sectionName = fixedName(section.nameBytes);
const name = `${fixedName(section.segmentNameBytes)},${sectionName}`;
const zeroFill = [1, 0xc, 0x12].includes(flags & 0xff);
const sectionAddress = reader.word(header + 32, bits);
const sectionSize = reader.word(header + (wide ? 40 : 36), bits);
const sectionOffset = BigInt(reader.u32(header + (wide ? 48 : 40)));
const addressDelta = sectionAddress - segmentAddress;
const fileDelta = sectionOffset - segmentFileOffset;
const inSegmentMemory =
@@ -208,7 +214,7 @@ const machoImageSection = ({
sectionSize <= segmentFileSize - fileDelta;
const range = unmapped
? { offset: 0, size: 0, file_backed: false }
: declaredFileRange(reader.bytes.length, sectionOffset, sectionSize);
: declaredFileRange(fileBytes, sectionOffset, sectionSize);
const fileBacked =
range.file_backed && !zeroFill && inSegmentMemory && inSegmentFile;
const readOnly =