diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 5960ef7b51..aaba78de55 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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, }; }; diff --git a/src/domain/apple/machoSectionHeader.test.ts b/src/domain/apple/machoSectionHeader.test.ts new file mode 100644 index 0000000000..3d250c6a64 --- /dev/null +++ b/src/domain/apple/machoSectionHeader.test.ts @@ -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", + }, + }); + }, +); diff --git a/src/domain/apple/machoSectionHeader.ts b/src/domain/apple/machoSectionHeader.ts new file mode 100644 index 0000000000..6069254ef7 --- /dev/null +++ b/src/domain/apple/machoSectionHeader.ts @@ -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), + }); +}; diff --git a/src/domain/apple/machoSegmentHeader.test.ts b/src/domain/apple/machoSegmentHeader.test.ts new file mode 100644 index 0000000000..2efb59dc9c --- /dev/null +++ b/src/domain/apple/machoSegmentHeader.test.ts @@ -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", + }, + }); + }, +); diff --git a/src/domain/apple/machoSegmentHeader.ts b/src/domain/apple/machoSegmentHeader.ts new file mode 100644 index 0000000000..cbe01c36d0 --- /dev/null +++ b/src/domain/apple/machoSegmentHeader.ts @@ -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), + }); +}; diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 317832cf58..6e06305c46 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -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), }); } diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index 9a41228db2..56e5af40d3 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -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 =