From a8e5eaf1aa93a2611d1fa4c9b24c807f941e51c6 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Sun, 25 Oct 2026 05:19:54 -0700 Subject: [PATCH] fix(macho): derive the runtime name of each Mach-O symbol (#7105) inspect_macho reported only the symbol-table spelling (`_main`), while LLDB, dlsym and observe_native_calls use the runtime spelling (`main`). Passing a static name as a breakpoint silently selected a different function, so results from the two tools could not be joined by name. Keep `name` as the exact symbol-table fact and add a derived `runtime_name` to every export and import row, produced by one shared Mach-O symbol-name owner. Managed P/Invoke verification now uses the same owner instead of its own underscore stripping. Fixes #5298 Co-authored-by: Claude Opus 5.5 --- .../references/native-and-artifacts.md | 5 +- docs/native-investigation.md | 29 +++++++---- .../managed/managedWorkflowExamples.ts | 1 + src/domain/analysisView/machoView.test.ts | 2 + .../managed/managedNativeVerificationMatch.ts | 12 +++-- src/domain/native/machoSymbolName.test.ts | 49 +++++++++++++++++++ src/domain/native/machoSymbolName.ts | 22 +++++++++ src/domain/native/nativeCallObservation.ts | 2 +- src/domain/native/nativeInspection.ts | 11 +++++ src/native/NativeMachoInspection.ts | 16 +++++- .../mcp/nativeMachoSummaryMcp.test.ts | 2 + .../native/nativeInventory.golden.test.ts | 11 ++++- 12 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 src/domain/native/machoSymbolName.test.ts create mode 100644 src/domain/native/machoSymbolName.ts diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index 57ec1e570c..7a8f7c9aff 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -316,8 +316,9 @@ run, use `observe_native_calls` with explicit breakpoints and a bounded `duration_ms`/`max_events`. It launches a new process under LLDB, so the target runs and may change files or use the network. Hardened-runtime targets need the `get-task-allow` entitlement. -Function breakpoints use exact LLDB runtime names: a Darwin C `_main` static symbol -may be `main` at runtime. REA does not translate names. Set `module` to the image +Function breakpoints use exact LLDB runtime names: pass an `inspect_macho` export +or import `runtime_name` (`main`), not its symbol-table `name` (`_main`). REA does +not translate breakpoint names. Set `module` to the image file name when selecting that image only; otherwise loaded system images also match. Inspect retained locations and module paths before attributing a call. diff --git a/docs/native-investigation.md b/docs/native-investigation.md index d1dec81301..fbdbca2624 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -14,6 +14,10 @@ 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. +Each import and export `name` is the exact symbol-table spelling, such as +`_main`; `runtime_name` is the derived name LLDB and `dlsym` use, such as `main`, +and is null when `name` is null. See [native call observation](#native-call-observation) for +breakpoint names. ## Start with a function @@ -916,15 +920,22 @@ missing LLDB. `duration_ms` controls the later target observation window. rea observe-native-calls /Applications/Example.app '{"breakpoints":[{"kind":"objc-method","class_name":"NSURLSession","selector":"dataTaskWithRequest:completionHandler:"},{"kind":"function","name":"open","module":"libsystem_kernel.dylib"}],"duration_ms":5000,"backtrace_frames":4}' --json ``` -Function names select LLDB runtime symbols. On Darwin, a C function reported as -`_main` by `inspect_macho` or `nm` can resolve as `main` in LLDB. REA preserves the -exact selected name; it does not remove underscores or translate static names. -Use `{"kind":"function","name":"main","module":"Example"}` to limit that -runtime symbol to the executable image named `Example`, then inspect the reported -locations and image paths. Omitting `module` deliberately matches every loaded -image, including system libraries. A match outside the executable is valid for -that request and does not by itself make coverage partial. This C naming example -does not establish a translation rule for Swift, C++ or other mangled symbols. +Function names select LLDB runtime symbols. On Darwin, the Mach-O symbol table +spells language-level symbols with one leading underscore, which LLDB and `dlsym` +omit: `_main` is `main`, C++ `__ZN3rea3addEii` is `_ZN3rea3addEii`, and Swift +`_$s…` is `$s…`. Names without that underscore, such as `-[Class selector]`, are +unchanged. `inspect_macho` keeps each export and import `name` exactly as the +symbol table spells it and derives its `runtime_name`; pass `runtime_name` as the +breakpoint `name`. `observe_native_calls` preserves the exact selected name; it +does not remove underscores or translate static names, so `_main` selects a +different runtime symbol. Use `{"kind":"function","name":"main","module":"Example"}` +to limit that runtime symbol to the executable image named `Example`, then inspect +the reported locations and image paths. Omitting `module` deliberately matches +every loaded image, including system libraries. A match outside the executable is +valid for that request and does not by itself make coverage partial. Event and +location `symbol` values are LLDB display names, which are demangled for C++ and +Swift; join them to `inspect_macho` through `breakpoint_index` and the request +`name`, or through `file_address` and the export `address`. Each event is an observation: thread, image, symbol, load and file address, the raw integer argument registers, optional caller frames and, for an diff --git a/src/contracts/managed/managedWorkflowExamples.ts b/src/contracts/managed/managedWorkflowExamples.ts index 5242fc18d0..016fa198d1 100644 --- a/src/contracts/managed/managedWorkflowExamples.ts +++ b/src/contracts/managed/managedWorkflowExamples.ts @@ -246,6 +246,7 @@ const nativeExportEvidence = () => { exports: coverage([ { name: "open_native", + runtime_name: "open_native", address: "0x1000", weak: false, reexport: false, diff --git a/src/domain/analysisView/machoView.test.ts b/src/domain/analysisView/machoView.test.ts index b106c9e93b..c4a6220dfe 100644 --- a/src/domain/analysisView/machoView.test.ts +++ b/src/domain/analysisView/machoView.test.ts @@ -7,6 +7,7 @@ import { const symbol = (name: string) => ({ name, + runtime_name: name.slice(1), address: null, weak: null, reexport: null, @@ -111,6 +112,7 @@ it("preserves ambiguous import facts without adding import fields to exports", ( const imported = { ...symbol("unused"), name: null, + runtime_name: null, raw_name: "_value+0x4", name_status: "ambiguous", addend: null, diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index ee48579a25..6aa7803245 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -1,5 +1,6 @@ import { digestCanonicalValue } from "../canonicalDigest.js"; import type { Evidence } from "../evidence.js"; +import { machoRuntimeSymbolName } from "../native/machoSymbolName.js"; import type { ManagedNativeObservationFacts } from "./managedNativeVerificationSource.js"; import type { ManagedNativeBoundaryInspection } from "./managedArtifact.js"; import type { @@ -40,13 +41,14 @@ export const collectNativeSymbols = ( return { symbols, accepted, unsupported, moduleFormatsByEvidenceId }; }; -const normalizedSymbolName = (native: NativeSymbol): string => - native.source === "macho-export" && native.name.startsWith("_") - ? native.name.slice(1) +/** P/Invoke resolves a Mach-O export through dlsym, which names it without the symbol-table prefix. */ +const lookupSymbolName = (native: NativeSymbol): string | null => + native.source === "macho-export" + ? machoRuntimeSymbolName(native.name) : native.name; const sameSymbolName = (expected: string, native: NativeSymbol): boolean => - expected === normalizedSymbolName(native); + expected === lookupSymbolName(native); type ModuleCompatibility = "match" | "mismatch" | "unknown"; @@ -351,7 +353,7 @@ const matchPinvoke = ( ); const exact = candidates.find( (symbol) => - managed.import_name === normalizedSymbolName(symbol) && + managed.import_name === lookupSymbolName(symbol) && moduleRelation(symbol) === "match", ); const unknownModule = candidates.find( diff --git a/src/domain/native/machoSymbolName.test.ts b/src/domain/native/machoSymbolName.test.ts new file mode 100644 index 0000000000..97861348c4 --- /dev/null +++ b/src/domain/native/machoSymbolName.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; + +import { machoRuntimeSymbolName } from "./machoSymbolName.js"; + +it("removes exactly the one symbol-table underscore Darwin adds", () => { + expect( + [ + "_main", + "__ZN3rea3addEii", + "_$s5swbin6reaAddyS2i_SitF", + "__T0So8NSObjectCMa", + "_OBJC_CLASS_$_ReaThing", + "__OBJC_$_INSTANCE_METHODS_ReaThing", + "_objc_msgSend$addA:b:", + "__mh_execute_header", + "___stack_chk_fail", + ].map(machoRuntimeSymbolName), + ).toEqual([ + "main", + "_ZN3rea3addEii", + "$s5swbin6reaAddyS2i_SitF", + "_T0So8NSObjectCMa", + "OBJC_CLASS_$_ReaThing", + "_OBJC_$_INSTANCE_METHODS_ReaThing", + "objc_msgSend$addA:b:", + "_mh_execute_header", + "__stack_chk_fail", + ]); +}); + +it("keeps names the compiler does not prefix and their exact spelling", () => { + for (const name of [ + "-[ReaThing addA:b:]", + "+[ReaThing new]", + "ltmp0", + "radr://5614542", + "main", + " _main", + "Main", + ]) + expect(machoRuntimeSymbolName(name)).toBe(name); +}); + +it("reports no runtime name when none is known or none remains", () => { + expect(machoRuntimeSymbolName(null)).toBeNull(); + expect(machoRuntimeSymbolName("")).toBeNull(); + expect(machoRuntimeSymbolName("_")).toBeNull(); + expect(machoRuntimeSymbolName("__")).toBe("_"); +}); diff --git a/src/domain/native/machoSymbolName.ts b/src/domain/native/machoSymbolName.ts new file mode 100644 index 0000000000..7b56d18ea9 --- /dev/null +++ b/src/domain/native/machoSymbolName.ts @@ -0,0 +1,22 @@ +/** + * Runtime spelling of a Mach-O symbol-table name. + * + * Darwin compilers prefix every language-level symbol with one underscore in + * the Mach-O symbol table: C `_main`, Itanium C++ `__ZN3rea3addEii`, Swift + * `_$s…`, Objective-C class data `_OBJC_CLASS_$_…`. dyld `dlsym` and LLDB name + * those symbols without that one underscore. Names the compiler does not + * prefix, such as Objective-C method symbols (`-[Class selector]`) and + * assembler-local labels, are already spelled as the runtime names them. + * + * The symbol-table name stays the source fact; this is its derived runtime + * name. Returns null when no name is known or nothing remains after the prefix. + */ +export const machoRuntimeSymbolName = ( + symbolTableName: string | null, +): string | null => { + if (symbolTableName === null) return null; + const runtimeName = symbolTableName.startsWith("_") + ? symbolTableName.slice(1) + : symbolTableName; + return runtimeName.length === 0 ? null : runtimeName; +}; diff --git a/src/domain/native/nativeCallObservation.ts b/src/domain/native/nativeCallObservation.ts index 44ba3e6a60..e949785c79 100644 --- a/src/domain/native/nativeCallObservation.ts +++ b/src/domain/native/nativeCallObservation.ts @@ -19,7 +19,7 @@ const functionBreakpoint = { name: nativeString .min(1) .describe( - "Exact LLDB runtime symbol name, such as `open`, a C++ or mangled Swift symbol, or `-[NSString length]`. Darwin C symbol-table `_main` may be exposed by LLDB as `main`; REA preserves the selected name without translating it.", + "Exact LLDB runtime symbol name, such as `open`, a C++ or mangled Swift symbol, or `-[NSString length]`. On Darwin, pass an inspect_macho export or import `runtime_name` (`main`), not its symbol-table `name` (`_main`); REA preserves the selected name without translating it.", ), }; diff --git a/src/domain/native/nativeInspection.ts b/src/domain/native/nativeInspection.ts index 8ee28abd16..fab17ee30d 100644 --- a/src/domain/native/nativeInspection.ts +++ b/src/domain/native/nativeInspection.ts @@ -71,8 +71,18 @@ const segmentSchema = z.object({ initial_permissions: permissionSchema, sections: coverage(sectionSchema), }); +/** Derived from `name` by the shared Mach-O symbol-name owner. */ +const runtimeNameSchema = z + .string() + .nullable() + .default(null) + .describe( + "Symbol-table `name` without the one leading underscore Darwin adds to language-level symbols (`_main` is `main`, `__ZN3rea3addEii` is `_ZN3rea3addEii`), as LLDB and dlsym name it and observe_native_calls function breakpoints accept; a name without that underscore, such as `-[Class selector]`, is unchanged. Null when `name` is null, nothing remains, or the Evidence predates this field.", + ); const symbolSchema = z.object({ + /** Exact Mach-O symbol-table name, the source fact. */ name: z.string(), + runtime_name: runtimeNameSchema, address: hexAddress.nullable(), weak: z.boolean().nullable(), reexport: z.boolean().nullable(), @@ -81,6 +91,7 @@ const symbolSchema = z.object({ const importSymbolSchema = z.object({ /** Resolved symbol identity; null when the textual row has multiple parses. */ name: z.string().nullable(), + runtime_name: runtimeNameSchema, /** 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), diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 5ebc74debc..cbb7650a93 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -10,6 +10,7 @@ import { type NativeCommandInvocation, } from "../domain/native/nativeInspection.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; +import { machoRuntimeSymbolName } from "../domain/native/machoSymbolName.js"; import { err, ok, type Result } from "../domain/result.js"; import type { NativeCommandCapture } from "./CommandRunner.js"; import { @@ -296,7 +297,9 @@ const normalizeMacho = ( ); const imports = missingImportsUuid ? [] - : parseDyldImports(sliceOutput(dyldImportsCapture), hasChainedFixups); + : parseDyldImports(sliceOutput(dyldImportsCapture), hasChainedFixups).map( + withRuntimeName, + ); const imageBase = load.segments.find( (segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0, @@ -309,7 +312,9 @@ const normalizeMacho = ( imageBase, new Set(nmExports.map(({ name }) => name)), ); - const exports = uniqueSymbols([...dyldExports, ...nmExports]); + const exports = uniqueSymbols([...dyldExports, ...nmExports]).map( + withRuntimeName, + ); const dwarfdump = byTool("dwarfdump"); const uuid = reconcileMachoUuid( load.commands, @@ -489,6 +494,13 @@ const covered = ( limitations: [...limitations], }); +const withRuntimeName = ( + symbol: Value, +): Value & { readonly runtime_name: string | null } => ({ + ...symbol, + runtime_name: machoRuntimeSymbolName(symbol.name), +}); + const uniqueSymbols = ( items: readonly Value[], ): Value[] => { diff --git a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts index 8ae846b006..d25f11b74b 100644 --- a/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts +++ b/tests/boundary/mcp/nativeMachoSummaryMcp.test.ts @@ -49,6 +49,7 @@ mcpTest( const empty = { items: [], total: 0, exhaustive: true, limitations: [] }; const imported = { name: null, + runtime_name: null, raw_name: "_value+0x4", name_status: "ambiguous", addend: null, @@ -59,6 +60,7 @@ mcpTest( }; const exported = { name: "_value", + runtime_name: "value", address: "0x1000", weak: null, reexport: null, diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index ffc1915e2f..89ddd81b8e 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -275,9 +275,15 @@ it("retains native inventory facts from captured Apple tool output", async () => expect(execution.value.result).toMatchObject({ imports: { items: [ - { name: "_puts", address: null, source: "libSystem" }, + { + name: "_puts", + runtime_name: "puts", + address: null, + source: "libSystem", + }, { name: "_fixture_weak", + runtime_name: "fixture_weak", address: null, source: "", }, @@ -287,6 +293,7 @@ it("retains native inventory facts from captured Apple tool output", async () => items: expect.arrayContaining([ expect.objectContaining({ name: "_fixture_export", + runtime_name: "fixture_export", address: "0x100000468", }), expect.objectContaining({ @@ -378,6 +385,7 @@ it("reports a chained bind addend separately in native inspection", async () => items: [ { name: "_fixture_data", + runtime_name: "fixture_data", raw_name: null, name_status: "exact", addend: null, @@ -388,6 +396,7 @@ it("reports a chained bind addend separately in native inspection", async () => }, { name: null, + runtime_name: null, raw_name: "_fixture_data+0x4", name_status: "ambiguous", addend: null,