From 5d0d243a45c9132e1aff75d454ab53d86a74cc31 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:25:29 +0800 Subject: [PATCH] fix(macho)!: preserve ambiguous chained import names Keep the exact producer expression and report unknown symbol identity when chained-import text cannot distinguish a binding addend from a literal name suffix. Fixes #6648 BREAKING CHANGE: inspect_macho import names can be null when identity is ambiguous. Use raw_name for the observed expression and name_status to distinguish it from an exact symbol name. --- docs/native-investigation.md | 9 + src/domain/native/nativeInspection.ts | 15 +- src/native/NativeMachoInspection.ts | 11 +- src/native/parsers/dyldInfo.ts | 165 +++++++++++------- .../native/nativeInventory.golden.test.ts | 125 +++++++++++++ 5 files changed, 259 insertions(+), 66 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index ee80f42f6e..264f956bba 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -6,6 +6,15 @@ Use a native provider to discover functions, inspect instructions and pseudocode follow references, and examine database types. Static resources and runtime observation complement that analysis when they answer a specific question. +`inspect_macho` reports build platforms from the selected slice. Legacy +`LC_VERSION_MIN_IPHONEOS`, `LC_VERSION_MIN_TVOS` and +`LC_VERSION_MIN_WATCHOS` commands are interpreted with the Mach-O CPU type, so +Intel slices report simulator platforms while device CPU slices keep the +device platform. A chained-import row ending in `+0x...` is ambiguous in +dyld's text output: it may be an addend or part of a literal symbol name. REA +keeps the full expression in `raw_name`, sets `name` and `addend` to null, and +marks `name_status: "ambiguous"`; it does not synthesize a symbol identity. + ## Start with a function Choose an installed provider from [setup](installation.md#hopper). For Ghidra: diff --git a/src/domain/native/nativeInspection.ts b/src/domain/native/nativeInspection.ts index 889c3436f6..ca3637d0fc 100644 --- a/src/domain/native/nativeInspection.ts +++ b/src/domain/native/nativeInspection.ts @@ -77,6 +77,19 @@ const symbolSchema = z.object({ reexport: z.boolean().nullable(), source: z.string().nullable(), }); +const importSymbolSchema = z.object({ + /** Resolved symbol identity; null when the textual row has multiple parses. */ + name: z.string().nullable(), + /** Exact import text retained when a chained suffix makes identity ambiguous. */ + raw_name: z.string().nullable().default(null), + name_status: z.enum(["exact", "ambiguous"]).nullable().default(null), + /** Addend is unknown when only the ambiguous text expression is available. */ + addend: hexAddress.nullable().default(null), + address: hexAddress.nullable(), + weak: z.boolean().nullable(), + reexport: z.boolean().nullable(), + source: z.string().nullable(), +}); const dependencySchema = z.object({ path: z.string(), kind: z.string(), @@ -132,7 +145,7 @@ export const inspectMachoSchema = z.object({ build_metadata: coverage(buildMetadataSchema), load_commands: coverage(loadCommandSchema), dependencies: coverage(dependencySchema), - imports: coverage(symbolSchema), + imports: coverage(importSymbolSchema), exports: coverage(symbolSchema), segments: coverage(segmentSchema), provenance: z.array(nativeCommandInvocationSchema), diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 75f39d5cd3..138c314f65 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -246,9 +246,18 @@ const normalizeMacho = ( const dyldExportsCapture = byTool("dyld_info", 1); const missingImportsUuid = isMissingUuidDyldFailure(dyldImportsCapture); const missingExportsUuid = isMissingUuidDyldFailure(dyldExportsCapture); + const hasChainedFixups = load.commands.some( + ({ kind }) => kind === "LC_DYLD_CHAINED_FIXUPS", + ); const imports = missingImportsUuid ? [] - : parseDyldSymbols(sliceOutput(dyldImportsCapture), "imports"); + : parseDyldSymbols( + sliceOutput(dyldImportsCapture), + "imports", + null, + new Set(), + hasChainedFixups, + ); const imageBase = load.segments.find( (segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0, diff --git a/src/native/parsers/dyldInfo.ts b/src/native/parsers/dyldInfo.ts index bf710c4e49..0f8b8a1123 100644 --- a/src/native/parsers/dyldInfo.ts +++ b/src/native/parsers/dyldInfo.ts @@ -1,92 +1,129 @@ -interface ParsedDyldSymbol { - readonly name: string; +interface ParsedDyldSymbolFields { readonly address: string | null; readonly weak: boolean | null; readonly reexport: boolean | null; readonly source: string | null; +} + +interface ParsedDyldImport extends ParsedDyldSymbolFields { + readonly name: string | null; + /** Full producer text when the symbol/addend split cannot be established. */ + readonly raw_name: string | null; + readonly name_status: "exact" | "ambiguous"; + readonly addend: string | null; +} + +interface ParsedDyldExport extends ParsedDyldSymbolFields { + readonly name: string; /** The source library's name for a symbol it re-exports under another name. */ readonly sourceSymbol?: string; } /** Decode Apple dyld inventory rows, projecting export offsets to VM addresses. */ -export const parseDyldSymbols = ( +export function parseDyldSymbols( + output: string, + mode: "imports", + imageBase?: string | null, + knownExportNames?: ReadonlySet, + hasChainedFixups?: boolean, +): ParsedDyldImport[]; +export function parseDyldSymbols( + output: string, + mode: "exports", + imageBase?: string | null, + knownExportNames?: ReadonlySet, +): ParsedDyldExport[]; +export function parseDyldSymbols( output: string, mode: "imports" | "exports", imageBase: string | null = null, knownExportNames: ReadonlySet = new Set(), -) => { + hasChainedFixups = false, +): (ParsedDyldImport | ParsedDyldExport)[] { const offsets = /^\s*offset\s+symbol\s*$/mu.test(output); // dyld_info ends rows with LF. A carriage return before it belongs to the // symbol name, so export rows keep their trailing text. - return output.split("\n").flatMap((rawLine) => { - const line = mode === "exports" ? rawLine.trimStart() : rawLine.trim(); - if (mode === "imports") { - const imported = - /^(?:0x[\da-f]+\s+)?(.+?)(?:\s+\[([^\]]+)\])?\s+\(from (.+)\)$/iu.exec( - line, - ); - if (imported?.[1] !== undefined) + return output + .split("\n") + .flatMap((rawLine) => { + const line = mode === "exports" ? rawLine.trimStart() : rawLine.trim(); + if (mode === "imports") { + const imported = + /^(?:0x[\da-f]+\s+)?(.+?)(?:\s+\[([^\]]+)\])?\s+\(from (.+)\)$/iu.exec( + line, + ); + if (imported?.[1] !== undefined) { + const ambiguousSuffix = hasChainedFixups + ? /^(.*)\+0x[\da-f]+$/iu.test(imported[1]) + : false; + return [ + { + name: ambiguousSuffix ? null : imported[1], + raw_name: ambiguousSuffix ? imported[1] : null, + name_status: ambiguousSuffix ? "ambiguous" : "exact", + addend: null, + address: null, + weak: /\bweak-import\b/u.test(imported[2] ?? "") ? true : null, + reexport: null, + source: imported[3] ?? null, + }, + ]; + } + return []; + } + // dyld_info prints `_sym (from lib)`, or `_new (_old from lib)` when the + // source library names the symbol differently. Split the trailing + // parenthesized suffix with string scans so a library path containing + // " from ", or a source symbol containing parentheses such as + // `operator()`, cannot pull the match into nested lazy quantifiers. + const reexport = parseReexportRow(line, knownExportNames); + if (reexport !== null) return [ { - name: imported[1], + name: reexport.name, address: null, - weak: /\bweak-import\b/u.test(imported[2] ?? "") ? true : null, - reexport: null, - source: imported[3] ?? null, + weak: null, + reexport: true, + source: reexport.source, + ...(reexport.sourceSymbol === undefined + ? {} + : { sourceSymbol: reexport.sourceSymbol }), }, ]; - return []; - } - // dyld_info prints `_sym (from lib)`, or `_new (_old from lib)` when the - // source library names the symbol differently. Split the trailing - // parenthesized suffix with string scans so a library path containing - // " from ", or a source symbol containing parentheses such as - // `operator()`, cannot pull the match into nested lazy quantifiers. - const reexport = parseReexportRow(line, knownExportNames); - if (reexport !== null) + const exported = /^(0x[\da-f]+)\s+(.+?)(?:\s+\[([^\]]+)\])?$/isu.exec( + line, + ); + if (exported?.[1] === undefined || exported[2] === undefined) return []; + const rawName = /^0x[\da-f]+\s+(.+)$/isu.exec(line)?.[1]; + const literalName = + rawName !== undefined && knownExportNames.has(rawName); + // If nm establishes both interpretations, the textual row cannot bind an address or flags. + // Let the caller retain the exact nm names without guessing which one owns this row. + if ( + literalName && + exported[3] !== undefined && + knownExportNames.has(exported[2]) + ) + return []; + const annotation = literalName ? undefined : exported[3]; + const absolute = /\babsolute\b/u.test(annotation ?? ""); + const address = + offsets && !absolute + ? imageBase === null + ? null + : `0x${(BigInt(imageBase) + BigInt(exported[1])).toString(16)}` + : canonicalHex(exported[1]); return [ { - name: reexport.name, - address: null, - weak: null, - reexport: true, - source: reexport.source, - ...(reexport.sourceSymbol === undefined - ? {} - : { sourceSymbol: reexport.sourceSymbol }), + name: literalName ? rawName : exported[2], + address, + weak: /\bweak-def\b/u.test(annotation ?? "") ? true : null, + reexport: false, + source: null, }, ]; - const exported = /^(0x[\da-f]+)\s+(.+?)(?:\s+\[([^\]]+)\])?$/isu.exec(line); - if (exported?.[1] === undefined || exported[2] === undefined) return []; - const rawName = /^0x[\da-f]+\s+(.+)$/isu.exec(line)?.[1]; - const literalName = rawName !== undefined && knownExportNames.has(rawName); - // If nm establishes both interpretations, the textual row cannot bind an address or flags. - // Let the caller retain the exact nm names without guessing which one owns this row. - if ( - literalName && - exported[3] !== undefined && - knownExportNames.has(exported[2]) - ) - return []; - const annotation = literalName ? undefined : exported[3]; - const absolute = /\babsolute\b/u.test(annotation ?? ""); - const address = - offsets && !absolute - ? imageBase === null - ? null - : `0x${(BigInt(imageBase) + BigInt(exported[1])).toString(16)}` - : canonicalHex(exported[1]); - return [ - { - name: literalName ? rawName : exported[2], - address, - weak: /\bweak-def\b/u.test(annotation ?? "") ? true : null, - reexport: false, - source: null, - }, - ]; - }); -}; + }); +} const canonicalHex = (value: string | undefined): string | null => value === undefined ? null : `0x${BigInt(value).toString(16)}`; diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index b30e9d1a1d..b8f37160bc 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -299,6 +299,9 @@ it("retains native inventory facts from captured Apple tool output", async () => ).toEqual([ { name: "__Block_copy", + raw_name: null, + name_status: "exact", + addend: null, address: null, weak: null, reexport: null, @@ -306,6 +309,9 @@ it("retains native inventory facts from captured Apple tool output", async () => }, { name: "_objc_bp_assist_cfg_np", + raw_name: null, + name_status: "exact", + addend: null, address: null, weak: true, reexport: null, @@ -335,6 +341,56 @@ it("retains native inventory facts from captured Apple tool output", async () => }); }); +it("reports a chained bind addend separately in native inspection", async () => { + const outputs: Record = {}; + for (const tool of ["lipo", "otool", "nm", "dwarfdump", "vtool"]) + outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); + outputs.otool = withOtoolMachHeader( + `${outputs.otool ?? ""}Load command 17 + cmd LC_DYLD_CHAINED_FIXUPS + cmdsize 16 + dataoff 100 + datasize 20 +`, + ); + outputs["dyld_info:-imports"] = + "-imports:\n 0x0000 _fixture_data (from libFixture)\n 0x0001 _fixture_data+0x4 (from libFixture)\n"; + outputs["dyld_info:-exports"] = await fixture("dyld-inventory/exports.txt"); + const client = new NativeMacOSProvider( + {}, + new FixtureRunner(outputs), + "darwin", + ).createClient(machoTarget("/private/fixture")); + const execution = await client.execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ + imports: { + items: [ + { + name: "_fixture_data", + raw_name: null, + name_status: "exact", + addend: null, + address: null, + weak: null, + reexport: null, + source: "libFixture", + }, + { + name: null, + raw_name: "_fixture_data+0x4", + name_status: "ambiguous", + addend: null, + address: null, + weak: null, + reexport: null, + source: "libFixture", + }, + ], + }, + }); +}); + it("reports a renamed re-export once under its exported name", async () => { const client = new NativeMacOSProvider( {}, @@ -414,6 +470,9 @@ it("retains symbol names containing spaces in dyld inventory rows", () => { ).toEqual([ { name: "_entry with space", + raw_name: null, + name_status: "exact", + addend: null, address: null, weak: null, reexport: null, @@ -624,6 +683,72 @@ it("keeps true weak annotations when nm establishes the unannotated name", () => ]); }); +it("retains chained import suffixes as ambiguous raw text", () => { + expect( + parseDyldSymbols( + "0x0000 _fixture_data (from libFixture)\n0x0001 _fixture_data+0x4 (from libFixture)\n0x0002 _fixture_weak+0x8 [weak-import] (from libFixture)", + "imports", + null, + new Set(), + true, + ), + ).toEqual([ + { + name: "_fixture_data", + raw_name: null, + name_status: "exact", + addend: null, + address: null, + weak: null, + reexport: null, + source: "libFixture", + }, + { + name: null, + raw_name: "_fixture_data+0x4", + name_status: "ambiguous", + addend: null, + address: null, + weak: null, + reexport: null, + source: "libFixture", + }, + { + name: null, + raw_name: "_fixture_weak+0x8", + name_status: "ambiguous", + addend: null, + address: null, + weak: true, + reexport: null, + source: "libFixture", + }, + ]); + expect( + parseDyldSymbols( + "0x0001 _literal+0x4 (from libFixture)", + "imports", + null, + new Set(), + true, + ), + ).toEqual([ + { + name: null, + raw_name: "_literal+0x4", + name_status: "ambiguous", + addend: null, + address: null, + weak: null, + reexport: null, + source: "libFixture", + }, + ]); + expect( + parseDyldSymbols("0x0001 _literal+0x4 (from libFixture)", "imports"), + ).toMatchObject([{ name: "_literal+0x4", name_status: "exact" }]); +}); + it("leaves an ambiguous short and literal name to the caller's nm inventory", () => { expect( parseDyldSymbols(