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.
This commit is contained in:
morluto
2026-10-23 19:25:29 +08:00
parent 1280e7e513
commit 5d0d243a45
5 changed files with 259 additions and 66 deletions
+9
View File
@@ -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:
+14 -1
View File
@@ -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),
+10 -1
View File
@@ -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,
+101 -64
View File
@@ -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<string>,
hasChainedFixups?: boolean,
): ParsedDyldImport[];
export function parseDyldSymbols(
output: string,
mode: "exports",
imageBase?: string | null,
knownExportNames?: ReadonlySet<string>,
): ParsedDyldExport[];
export function parseDyldSymbols(
output: string,
mode: "imports" | "exports",
imageBase: string | null = null,
knownExportNames: ReadonlySet<string> = 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<ParsedDyldSymbol>((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<ParsedDyldImport | ParsedDyldExport>((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)}`;
@@ -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<string, string> = {};
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(