From 3c736dfe119663b229d831fa8254600c01ce58a5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:50:13 +0800 Subject: [PATCH] fix(targets): reject ambiguous native image selections (#9933) fix(targets): refuse ambiguous slice and file mappings --- src/domain/binaryTarget.ts | 58 ++++++---- src/domain/virtualFileMapping.ts | 101 ++++++++++++++++++ src/go/GoBinaryImage.ts | 6 +- src/go/GoBuildInfoSearch.ts | 2 +- src/go/GoStringLiterals.test.ts | 2 +- src/go/GoStringLiterals.ts | 2 +- src/il2cpp/ElfRelocations.ts | 10 +- src/native/AppleMachoSelection.ts | 6 +- src/native/metadata/BinaryContainer.test.ts | 6 +- src/native/metadata/BinaryContainer.ts | 84 +++------------ src/native/metadata/ElfImage.ts | 6 +- src/native/metadata/ElfPointerRelocations.ts | 6 +- src/native/metadata/MachoImage.ts | 6 +- src/native/metadata/PeImage.ts | 6 +- .../boundary/filesystem/binaryTarget.test.ts | 83 +++++++++++++- 15 files changed, 268 insertions(+), 116 deletions(-) create mode 100644 src/domain/virtualFileMapping.ts diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts index b601960af5..a72c46261d 100644 --- a/src/domain/binaryTarget.ts +++ b/src/domain/binaryTarget.ts @@ -1,5 +1,9 @@ +import { + resolveMappedFileOffset, + type FileMapping, + type ZeroFillMapping, +} from "./virtualFileMapping.js"; import type { - BinaryArchitecture, ElfFamilyMetadata, HeaderArchitecture, MipsElfMetadata, @@ -271,9 +275,14 @@ const parseFatMachO = ( // family declared more than once, such as arm64 and arm64e, has no // provider-neutral choice and is not selected. const preferred = fatHostArchitecture(host); - const hostSlice = declarations.find( + const hostSlices = declarations.filter( ({ architecture }) => architecture === preferred, ); + if (hostSlices.length > 1) + return err( + `FAT binary declares several ${preferred} slices; no slice was selected`, + ); + const hostSlice = hostSlices[0]; const unambiguous = declarations.filter( ({ architecture }) => declarations.filter((other) => other.architecture === architecture) @@ -604,28 +613,37 @@ export const peRvaToFileOffset = ( sizeOfHeaders: number, size = 1, ): number | undefined => { - if (rva < sizeOfHeaders && size <= sizeOfHeaders - rva) return rva; - for (let i = 0; i < numberOfSections; i++) { - const offset = i * 40; + const mappings: FileMapping[] = [ + { address: 0n, offset: 0, size: sizeOfHeaders }, + ]; + const zeroFills: ZeroFillMapping[] = []; + for (let index = 0; index < numberOfSections; index += 1) { + const offset = index * 40; const parsed = readPeSectionHeader( sectionTable.subarray(offset, offset + 40), ); - if (!parsed.ok) break; - const { - virtual_address: virtualAddress, - raw_size: sizeOfRawData, - offset: pointerToRawData, - } = parsed.value; - if ( - pointerToRawData > 0 && - rva >= virtualAddress && - rva - virtualAddress < sizeOfRawData && - size <= sizeOfRawData - (rva - virtualAddress) - ) { - return pointerToRawData + (rva - virtualAddress); - } + if (!parsed.ok) return undefined; + const section = parsed.value; + const address = BigInt(section.virtual_address); + if (section.raw_size > 0) + mappings.push({ + address, + offset: section.offset, + size: section.raw_size, + }); + if (section.virtual_size > section.raw_size) + zeroFills.push({ + address: address + BigInt(section.raw_size), + size: BigInt(section.virtual_size - section.raw_size), + }); } - return undefined; + const mapped = resolveMappedFileOffset( + mappings, + BigInt(rva), + size, + zeroFills, + ); + return mapped.ok ? mapped.value : undefined; }; /** CodeView RVA admission uses the same bounded prefix as target file reads. */ diff --git a/src/domain/virtualFileMapping.ts b/src/domain/virtualFileMapping.ts new file mode 100644 index 0000000000..9211071b28 --- /dev/null +++ b/src/domain/virtualFileMapping.ts @@ -0,0 +1,101 @@ +import { err, ok, type Result } from "./result.js"; + +/** One file-backed virtual range; addresses stay exact until mapped to file offsets. */ +export interface FileMapping { + readonly address: bigint; + readonly offset: number; + readonly size: number; +} + +/** Declared zero-initialized virtual memory with no corresponding file bytes. */ +export interface ZeroFillMapping { + readonly address: bigint; + readonly size: bigint; +} + +/** Why a declared virtual span does not establish one contiguous file span. */ +export interface VirtualFileMappingFailure { + readonly reason: "invalid-size" | "unmapped" | "ambiguous"; + readonly message: string; +} + +/** Map a physically contiguous virtual span across consistent adjacent ranges. */ +export const resolveMappedFileOffset = ( + mappings: readonly FileMapping[], + address: bigint, + size: number, + zeroFills: readonly ZeroFillMapping[] = [], +): Result => { + if (!Number.isSafeInteger(size) || size < 0) + return err({ + reason: "invalid-size", + message: "Virtual span size is invalid", + }); + const end = address + BigInt(size); + let found: number | undefined; + const covered: { start: bigint; end: bigint }[] = []; + let whollyCovered = size === 0; + for (const mapping of mappings) { + const mappingEnd = mapping.address + BigInt(mapping.size); + const start = address > mapping.address ? address : mapping.address; + const overlapEnd = end < mappingEnd ? end : mappingEnd; + if ( + size === 0 + ? address < mapping.address || address > mappingEnd + : start >= overlapEnd + ) + continue; + const origin = mapping.offset + Number(address - mapping.address); + if (found !== undefined && found !== origin) + return err({ + reason: "ambiguous", + message: "Virtual span has ambiguous or noncontiguous file mappings", + }); + found = origin; + if (start === address && overlapEnd === end) whollyCovered = true; + else if (!whollyCovered) covered.push({ start, end: overlapEnd }); + } + if (found === undefined) + return err({ + reason: "unmapped", + message: "Virtual address or string range is not file mapped", + }); + if (!whollyCovered) { + covered.sort((left, right) => + left.start < right.start ? -1 : left.start > right.start ? 1 : 0, + ); + let cursor = address; + for (const span of covered) { + if (span.start > cursor) + return err({ + reason: "unmapped", + message: "Virtual address or string range is not file mapped", + }); + if (span.end > cursor) cursor = span.end; + } + if (cursor < end) + return err({ + reason: "unmapped", + message: "Virtual address or string range is not file mapped", + }); + } + for (const zeroFill of zeroFills) { + if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size)) + return err({ + reason: "ambiguous", + message: "Virtual address has ambiguous file and zero-fill mappings", + }); + } + return ok(found); +}; + +const spansOverlap = ( + address: bigint, + size: bigint, + otherAddress: bigint, + otherSize: bigint, +): boolean => + size > 0n && + otherSize > 0n && + address < otherAddress + otherSize && + otherAddress < address + size; diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 3ecc5a5615..a38c2f21b4 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../domain/virtualFileMapping.js"; import { BinaryFormatFailure, BinaryResourceFailure, @@ -6,8 +10,6 @@ import { imageFileMappings, isZeroFilledSpan, type BinaryContainer, - type FileMapping, - type ZeroFillMapping, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; import { diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts index 8f02bedf56..435eb710ff 100644 --- a/src/go/GoBuildInfoSearch.ts +++ b/src/go/GoBuildInfoSearch.ts @@ -1,9 +1,9 @@ +import type { FileMapping } from "../domain/virtualFileMapping.js"; import { BinaryFormatFailure, BinaryReader, mappedFileOffset, type BinaryContainer, - type FileMapping, } from "../native/metadata/BinaryContainer.js"; export interface GoBuildInfoSearchRange extends FileMapping { diff --git a/src/go/GoStringLiterals.test.ts b/src/go/GoStringLiterals.test.ts index 4daecbc989..f59895536c 100644 --- a/src/go/GoStringLiterals.test.ts +++ b/src/go/GoStringLiterals.test.ts @@ -1,8 +1,8 @@ +import type { FileMapping } from "../domain/virtualFileMapping.js"; import { expect, it } from "vitest"; import { GO_ANALYSIS_BUDGET_DEFAULTS } from "../domain/go/goBinary.js"; import type { BinaryContainer, - FileMapping, ImageSection, LoadRegion, } from "../native/metadata/BinaryContainer.js"; diff --git a/src/go/GoStringLiterals.ts b/src/go/GoStringLiterals.ts index 91e2afb4b1..0130355185 100644 --- a/src/go/GoStringLiterals.ts +++ b/src/go/GoStringLiterals.ts @@ -1,3 +1,4 @@ +import type { FileMapping } from "../domain/virtualFileMapping.js"; import { isUtf8 } from "node:buffer"; import { GO_ANALYSIS_BUDGET_DEFAULTS, @@ -11,7 +12,6 @@ import { createFileMappingIndex, imageFileMappings, type BinaryContainer, - type FileMapping, type FileMappingIndex, type LoadRegion, } from "../native/metadata/BinaryContainer.js"; diff --git a/src/il2cpp/ElfRelocations.ts b/src/il2cpp/ElfRelocations.ts index 461d115061..5659360d51 100644 --- a/src/il2cpp/ElfRelocations.ts +++ b/src/il2cpp/ElfRelocations.ts @@ -1,12 +1,12 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../domain/virtualFileMapping.js"; import { readElfRelocationWritesSteps, ElfPointerRelocationFailure, } from "../native/metadata/ElfPointerRelocations.js"; -import type { - BinaryContainer, - FileMapping, - ZeroFillMapping, -} from "../native/metadata/BinaryContainer.js"; +import type { BinaryContainer } from "../native/metadata/BinaryContainer.js"; import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js"; /** Materialize the shared loader facts only for IL2CPP's existing private reader. */ diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 08f3169cbf..4e8672b08e 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../domain/virtualFileMapping.js"; import { machoDependencyCommand, readMachoDylibCommandFacts, @@ -15,8 +19,6 @@ import { createHash } from "node:crypto"; import { BinaryFormatFailure, createFileMappingIndex, - type ZeroFillMapping, - type FileMapping, } from "./metadata/BinaryContainer.js"; import type { FixupCommands } from "./AppleMachoFixups.js"; diff --git a/src/native/metadata/BinaryContainer.test.ts b/src/native/metadata/BinaryContainer.test.ts index c76edc82e0..e0a017c539 100644 --- a/src/native/metadata/BinaryContainer.test.ts +++ b/src/native/metadata/BinaryContainer.test.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import { readBinaryContainer } from "./readBinaryContainer.js"; import { expect, it } from "vitest"; import { @@ -8,8 +12,6 @@ import { mappedFileOffset, readMappedCString, type BinaryContainer, - type FileMapping, - type ZeroFillMapping, } from "./BinaryContainer.js"; it("distinguishes a mapped string read allowance from malformed bytes", () => { diff --git a/src/native/metadata/BinaryContainer.ts b/src/native/metadata/BinaryContainer.ts index 8477045916..dab69c5fa1 100644 --- a/src/native/metadata/BinaryContainer.ts +++ b/src/native/metadata/BinaryContainer.ts @@ -1,3 +1,8 @@ +import { + resolveMappedFileOffset, + type FileMapping, + type ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import type { PeDataDirectory } from "../../domain/peOptionalHeader.js"; /** Distinguish corrupt recognized binaries from containers outside parser coverage. */ @@ -45,19 +50,6 @@ export class BinaryStringReadLimitFailure extends BinaryFormatFailure { } } -/** One file-backed virtual range; addresses stay exact until mapped to file offsets. */ -export interface FileMapping { - readonly address: bigint; - readonly offset: number; - readonly size: number; -} - -/** Declared zero-initialized virtual memory with no corresponding file bytes. */ -export interface ZeroFillMapping { - readonly address: bigint; - readonly size: bigint; -} - /** Native loader region facts; language-specific fallback selection belongs to consumers. */ export interface LoadRegion { readonly mapping: FileMapping; @@ -264,71 +256,19 @@ export const declaredFileRange = ( }; }; -/** Map a physically contiguous virtual span across consistent adjacent ranges. */ +/** Translate the domain mapping result into native parser failure classes. */ export const mappedFileOffset = ( mappings: readonly FileMapping[], address: bigint, size: number, zeroFills: readonly ZeroFillMapping[] = [], ): number => { - if (!Number.isSafeInteger(size) || size < 0) - throw new BinaryFormatFailure("malformed", "Virtual span size is invalid"); - const end = address + BigInt(size); - let found: number | undefined; - const covered: { start: bigint; end: bigint }[] = []; - let whollyCovered = size === 0; - for (const mapping of mappings) { - const mappingEnd = mapping.address + BigInt(mapping.size); - const start = address > mapping.address ? address : mapping.address; - const overlapEnd = end < mappingEnd ? end : mappingEnd; - if ( - size === 0 - ? address < mapping.address || address > mappingEnd - : start >= overlapEnd - ) - continue; - const origin = mapping.offset + Number(address - mapping.address); - if (found !== undefined && found !== origin) - throw new BinaryMappingFailure( - "ambiguous", - "Virtual span has ambiguous or noncontiguous file mappings", - ); - found = origin; - if (start === address && overlapEnd === end) whollyCovered = true; - else if (!whollyCovered) covered.push({ start, end: overlapEnd }); - } - if (found === undefined) - throw new BinaryMappingFailure( - "unmapped", - "Virtual address or string range is not file mapped", - ); - if (!whollyCovered) { - covered.sort((left, right) => - left.start < right.start ? -1 : left.start > right.start ? 1 : 0, - ); - let cursor = address; - for (const span of covered) { - if (span.start > cursor) - throw new BinaryMappingFailure( - "unmapped", - "Virtual address or string range is not file mapped", - ); - if (span.end > cursor) cursor = span.end; - } - if (cursor < end) - throw new BinaryMappingFailure( - "unmapped", - "Virtual address or string range is not file mapped", - ); - } - for (const zeroFill of zeroFills) { - if (spansOverlap(address, BigInt(size), zeroFill.address, zeroFill.size)) - throw new BinaryMappingFailure( - "ambiguous", - "Virtual address has ambiguous file and zero-fill mappings", - ); - } - return found; + const mapped = resolveMappedFileOffset(mappings, address, size, zeroFills); + if (mapped.ok) return mapped.value; + const { reason, message } = mapped.error; + if (reason === "invalid-size") + throw new BinaryFormatFailure("malformed", message); + throw new BinaryMappingFailure(reason, message); }; /** Index of the last sorted value at or below `value`, or -1. */ diff --git a/src/native/metadata/ElfImage.ts b/src/native/metadata/ElfImage.ts index cfa49a1bb5..41b6e3dbd7 100644 --- a/src/native/metadata/ElfImage.ts +++ b/src/native/metadata/ElfImage.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import { admitElfLoadSegment } from "../../domain/elfProgramHeader.js"; import { declaredFileRange, @@ -5,10 +9,8 @@ import { BinaryReader, type BinaryContainer, type ElfDynamicSegment, - type FileMapping, type ImageSection, type LoadRegion, - type ZeroFillMapping, } from "./BinaryContainer.js"; import { elfArchitecture } from "../../domain/elfFixedHeader.js"; import { readElfDirectoryFromBuffer } from "./NativeSectionDirectory.js"; diff --git a/src/native/metadata/ElfPointerRelocations.ts b/src/native/metadata/ElfPointerRelocations.ts index 281e276380..fff7eb7dcc 100644 --- a/src/native/metadata/ElfPointerRelocations.ts +++ b/src/native/metadata/ElfPointerRelocations.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import { readElfDynamicEntry } from "../../domain/elfDynamicEntry.js"; import { readElfRelocationEntry } from "../../domain/elfRelocation.js"; import { @@ -5,8 +9,6 @@ import { createFileMappingIndex, isZeroFilledSpan, type ElfDynamicSegment, - type FileMapping, - type ZeroFillMapping, } from "./BinaryContainer.js"; /** Shared loader interpretation; language adapters choose their public errors. */ diff --git a/src/native/metadata/MachoImage.ts b/src/native/metadata/MachoImage.ts index 2b352afbd4..933777aab6 100644 --- a/src/native/metadata/MachoImage.ts +++ b/src/native/metadata/MachoImage.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import { decodeMachoFixedName } from "../AppleMachoNames.js"; import { isMachoZeroFillSection, @@ -13,10 +17,8 @@ import { BinaryFormatFailure, BinaryReader, type BinaryContainer, - type FileMapping, type ImageSection, type LoadRegion, - type ZeroFillMapping, } from "./BinaryContainer.js"; import { MachoLoadCommandCursor, diff --git a/src/native/metadata/PeImage.ts b/src/native/metadata/PeImage.ts index 34885ee8fe..1441829ac1 100644 --- a/src/native/metadata/PeImage.ts +++ b/src/native/metadata/PeImage.ts @@ -1,3 +1,7 @@ +import type { + FileMapping, + ZeroFillMapping, +} from "../../domain/virtualFileMapping.js"; import { readPeCoffHeader } from "../../domain/peCoffHeader.js"; import { readPeOptionalHeader } from "../../domain/peMachine.js"; import { readPeSectionHeader } from "../../domain/peSectionHeader.js"; @@ -10,9 +14,7 @@ import { BinaryFormatFailure, BinaryReader, type BinaryContainer, - type FileMapping, type ImageSection, - type ZeroFillMapping, } from "./BinaryContainer.js"; const peHeaderOffset = (reader: BinaryReader): number => { diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 9859c46541..6e64402213 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -178,6 +178,24 @@ it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])( }, ); +it("refuses ambiguous host subtypes even when a foreign family is selectable", async () => { + const directory = await createTestTempDirectory("rea-ambiguous-host-fat-"); + const path = join(directory, "image"); + const bytes = fat([0x0100000c, 0x0100000c, 0x01000007]); + bytes.writeUInt32BE(2, 8 + 20 + 4); + bytes.writeUInt32BE(2, bytes.readUInt32BE(8 + 20 + 8) + 8); + await writeFile(path, bytes); + expect( + await parseBinaryTarget(path, { hostArchitecture: "arm64" }), + ).toMatchObject({ + ok: false, + error: { + _tag: "BinaryTargetError", + reason: expect.stringContaining("several arm64 slices"), + }, + }); +}); + it("reads a sparse FAT slice header beyond the bounded prefix", async () => { const directory = await createTestTempDirectory("rea-sparse-fat-target-"); const path = join(directory, "image"); @@ -217,11 +235,15 @@ it("reads the complete bounded 128-entry FAT64 table beyond the prefix", async ( const table = Buffer.alloc(tableEnd); table.writeUInt32BE(0xcafebabf, 0); table.writeUInt32BE(entryCount, 4); - const thinHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12); for (let index = 0; index < entryCount; index += 1) { + const cpuType = index === 0 ? CPU.arm64.type : 0x100 + index; + const thinHeader = Buffer.from( + machoImage({ cpu: CPU.arm64 }).subarray(0, 12), + ); + thinHeader.writeUInt32LE(cpuType, 4); const entry = 8 + index * 32; const sliceOffset = tableEnd + index * sliceSize; - table.writeUInt32BE(CPU.arm64.type, entry); + table.writeUInt32BE(cpuType, entry); table.writeUInt32BE(CPU.arm64.subtype, entry + 4); table.writeBigUInt64BE(BigInt(sliceOffset), entry + 8); table.writeBigUInt64BE(BigInt(sliceSize), entry + 16); @@ -1720,6 +1742,63 @@ it.each([ }, ); +it.each([ + { + label: "identical file aliases", + virtualAddress: 0x1000, + rawOffset: 512, + rawSize: 512, + expected: "first.pdb", + }, + { + label: "conflicting file aliases", + virtualAddress: 0x1000, + rawOffset: 768, + rawSize: 512, + expected: undefined, + }, + { + label: "independent sections", + virtualAddress: 0x2000, + rawOffset: 768, + rawSize: 512, + expected: "first.pdb", + }, + { + label: "zero-fill aliases", + virtualAddress: 0x1000, + rawOffset: 0, + rawSize: 0, + expected: undefined, + }, +])( + "preserves CodeView mapping certainty for $label", + async ({ virtualAddress, rawOffset, rawSize, expected }) => { + const directory = await createTestTempDirectory("rea-codeview-mapping-"); + const path = join(directory, "symbols.exe"); + const bytes = Buffer.alloc(1280); + pe(0x8664, 64, 0x0002, false, { pdbPath: "first.pdb" }).copy(bytes); + bytes.writeUInt16LE(2, 64 + 6); + const sectionTable = 64 + 24 + 240; + bytes.copy(bytes, sectionTable + 40, sectionTable, sectionTable + 40); + bytes.writeUInt32LE(virtualAddress, sectionTable + 40 + 12); + bytes.writeUInt32LE(rawOffset, sectionTable + 40 + 20); + bytes.writeUInt32LE(rawSize, sectionTable + 40 + 16); + bytes.copy(bytes, 768, 512, 600); + bytes.writeUInt32LE(796, 768 + 24); + bytes.write("other.pdb", 796 + 24); + await writeFile(path, bytes); + const resolved = await parseBinaryTarget(path); + if (!resolved.ok) throw resolved.error; + const buffered = parseExecutableHeader(bytes, "x64"); + if (!buffered.ok) throw new Error(buffered.error); + for (const metadata of [resolved.value, buffered.value]) + expect(metadata.format === "pe" ? metadata.codeView?.pdbPath : null).toBe( + expected, + ); + }, +); + describe("binary target I/O: PE CodeView header ranges", () => { it.each(["RSDS", "NB10"] as const)( "reads bounded %s header debug records and rejects unfinished names",