fix(macho): unify segment mapping admission

This commit is contained in:
morluto committed 2026-10-27 13:00:33 +08:00
1 parent 0a9d92a54b
commit 8d747c2acd
4 files changed
+126 -19

No files matched your search

+20
View File
@@ -29,3 +29,23 @@ export const readMachoSegmentHeader = (
sectionCount: view.getUint32(wide ? 64 : 48, littleEndian),
});
};
/** Admit VM sizes once for consumers that construct segment mappings. */
export const admitMachoSegmentMapping = (
segment: {
readonly address: bigint;
readonly memorySize: bigint;
readonly fileSize: bigint;
readonly protection: number;
},
bits: 32 | 64,
) => {
const unmapped = segment.memorySize === 0n && segment.protection === 0;
return (!unmapped && segment.fileSize > segment.memorySize) ||
segment.address + segment.memorySize > 1n << BigInt(bits)
? err({
kind: "malformed" as const,
message: "Mach-O segment addresses or sizes are invalid",
})
: ok({ unmapped });
};
+72
View File
@@ -17,6 +17,7 @@ import {
selectMachoSlice,
UnsupportedMachoMetadataFormatError,
} from "./AppleMachoSelection.js";
import { parsePointerFixups } from "./AppleMachoFixups.js";
import { decodeAppleDispatchMetadata } from "./AppleDispatchMetadata.js";
/** macOS 27 system binaries ship CPU_SUBTYPE_ARM64E_X1 (12) with the PAC ABI bit. */
@@ -158,6 +159,77 @@ const mappingImage = (commands: readonly Buffer[]): Buffer => {
};
describe("Mach-O metadata span mapping", () => {
it.each([
["file size larger than VM size", mappingSegment(0x1000n, 0, 128, 64)],
["VM address overflow", mappingSegment((1n << 64n) - 1n, 0, 2)],
])("rejects %s consistently across metadata consumers", (_name, segment) => {
const bytes = mappingImage([segment]);
expect(() => parseMachoLayout(bytes, "arm64")).toThrow(
"Mach-O segment addresses or sizes are invalid",
);
expect(() => readMachoMetadataImage(bytes)).toThrow(
"Mach-O segment addresses or sizes are invalid",
);
});
it.each(["unmapped", "pagezero"])(
"does not expose %s file bytes as metadata pointers",
(kind) => {
const segment = mappingSegment(0x1000n, 0, 128);
if (kind === "unmapped") {
segment.writeBigUInt64LE(0n, 32);
segment.writeUInt32LE(0, 60);
} else segment.write("__PAGEZERO", 8, "ascii");
const bytes = mappingImage([segment]);
const layout = parseMachoLayout(bytes, "arm64");
// Dyld fixup segment ordinals retain the original command order.
expect(layout.segments).toHaveLength(1);
expect(layout.baseAddress).toBe(0n);
expect(() => layout.offset(0x1000n)).toThrow(RangeError);
expect(readMachoMetadataImage(bytes).mappings).toEqual([]);
},
);
it("preserves dyld segment ordinals across PAGEZERO and unmapped segments", () => {
const pagezero = mappingSegment(0n, 0, 0, 4096);
pagezero.write("__PAGEZERO", 8, "ascii");
const unmapped = mappingSegment(0x1000n, 0, 0, 0);
unmapped.writeUInt32LE(0, 60);
const data = mappingSegment(0x2000n, 256, 128);
const opcodes = Buffer.from([
0x40,
...Buffer.from("_local\0"),
0x72,
8,
0x90,
0,
]);
const dyldInfo = Buffer.alloc(48);
dyldInfo.writeUInt32LE(0x80000022, 0);
dyldInfo.writeUInt32LE(48, 4);
dyldInfo.writeUInt32LE(400, 16);
dyldInfo.writeUInt32LE(opcodes.length, 20);
const bytes = mappingImage([pagezero, unmapped, data, dyldInfo]);
opcodes.copy(bytes, 400);
const layout = parseMachoLayout(bytes, "arm64");
expect(layout.offset(0x2008n, 8)).toBe(264);
expect(() => layout.offset(8n, 8)).toThrow(RangeError);
expect(() => layout.offset(0x1000n, 8)).toThrow(RangeError);
const fixups = parsePointerFixups(
bytes,
layout.fixups,
layout.segments,
layout.baseAddress,
);
expect(fixups.issues).toEqual([]);
expect(fixups.decode(0x2008n, 0n)).toMatchObject({
kind: "bind",
symbol: "_local",
library_ordinal: 0,
binding_source: "bind",
});
});
it("rejects a conflicting alias covering only the end of a pointer", () => {
const layout = parseMachoLayout(
mappingImage([
+25 -11
View File
@@ -3,7 +3,10 @@ import {
readMachoDylibCommandFacts,
} from "../domain/apple/machoDylibCommand.js";
import { readMachoSectionHeader } from "../domain/apple/machoSectionHeader.js";
import { readMachoSegmentHeader } from "../domain/apple/machoSegmentHeader.js";
import {
admitMachoSegmentMapping,
readMachoSegmentHeader,
} from "../domain/apple/machoSegmentHeader.js";
import {
MachoLoadCommandCursor,
readMachoLoadCommandDeclarations,
@@ -13,6 +16,7 @@ import {
BinaryFormatFailure,
createFileMappingIndex,
type ZeroFillMapping,
type FileMapping,
} from "./metadata/BinaryContainer.js";
import type { FixupCommands } from "./AppleMachoFixups.js";
@@ -202,6 +206,7 @@ export const parseMachoLayout = (
slice,
sliceEnd,
segments: [],
mappings: [],
zeroFills: [],
sections: [],
dylibs: [],
@@ -226,14 +231,7 @@ export const parseMachoLayout = (
const complete = framing.finish();
if (!complete.ok) throw new RangeError(complete.error);
const { segments, sections, chained, binds, rebases, dylibs } = state;
const mappings = createFileMappingIndex(
segments.map((segment) => ({
address: segment.address,
offset: segment.offset,
size: Number(segment.size),
})),
state.zeroFills,
);
const mappings = createFileMappingIndex(state.mappings, state.zeroFills);
const offset = (address: bigint, size = 1): number => {
try {
return mappings.offset(address, size);
@@ -275,6 +273,7 @@ interface LayoutState {
readonly slice: number;
readonly sliceEnd: number;
readonly segments: Segment[];
readonly mappings: FileMapping[];
readonly zeroFills: ZeroFillMapping[];
readonly sections: Section[];
readonly dylibs: FixupCommands["dylibs"][number][];
@@ -313,20 +312,35 @@ const collectSegment = (
sectionCount: count,
headerSize,
} = parsed.value;
const admitted = admitMachoSegmentMapping(parsed.value, 64);
if (!admitted.ok) throw new RangeError(admitted.error.message);
if (fileOffset + fileSize > BigInt(state.sliceEnd - state.slice))
throw new RangeError("Segment file range exceeds target bytes");
const name = decodeMachoFixedName(nameBytes);
state.segments.push({
address,
size: fileSize,
offset: state.slice + Number(fileOffset),
executable: (protection & 4) !== 0,
});
if (memorySize > fileSize && decodeMachoFixedName(nameBytes) !== "__PAGEZERO")
if (!admitted.value.unmapped && name !== "__PAGEZERO")
state.mappings.push({
address,
size: Number(fileSize),
offset: state.slice + Number(fileOffset),
});
if (memorySize > fileSize && name !== "__PAGEZERO")
state.zeroFills.push({
address: address + fileSize,
size: memorySize - fileSize,
});
if (fileOffset === 0n && fileSize > 0n) state.baseAddress ??= address;
if (
!admitted.value.unmapped &&
name !== "__PAGEZERO" &&
fileOffset === 0n &&
fileSize > 0n
)
state.baseAddress ??= address;
if (headerSize + count * 80 > size)
throw new RangeError("Truncated section table");
for (let section = 0; section < count; section++) {
+9 -8
View File
@@ -3,7 +3,10 @@ import {
readMachoSectionHeader,
type MachoSectionHeader,
} from "../../domain/apple/machoSectionHeader.js";
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import {
admitMachoSegmentMapping,
readMachoSegmentHeader,
} from "../../domain/apple/machoSegmentHeader.js";
import {
declaredFileRange,
BinaryFormatFailure,
@@ -77,15 +80,13 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
sectionCount,
nameBytes,
} = parsedSegment.value;
const unmapped = memorySize === 0n && protection === 0;
if (
(!unmapped && fileSize > memorySize) ||
address + memorySize > 1n << BigInt(bits)
)
const admitted = admitMachoSegmentMapping(parsedSegment.value, bits);
if (!admitted.ok)
throw new BinaryFormatFailure(
"malformed",
"Mach-O segment addresses or sizes are invalid",
admitted.error.kind,
admitted.error.message,
);
const { unmapped } = admitted.value;
if (sectionCount > Math.floor((size - segmentHeader) / sectionWidth))
throw new BinaryFormatFailure(
"malformed",