Files
reverse-engineering-anything/src/native/NativeMachoInspection.ts
T
Kaoru0822andClaude Opus 5.5 2b2a5d0f26 fix(native): inspect arm64e.x1 slices by their exact name (#3500)
inspect_macho passed the CPU-family spelling (`arm64e`) for every dotted lipo
slice name to otool, nm, dwarfdump and vtool. That is right for the
pointer-auth ABI version suffix (`arm64e.v1`), but `arm64e.x1` is a distinct
subtype: on a universal x86_64 + arm64e.x1 executable otool failed with
"does not contain architecture: arm64e" and the whole inspection failed.

Strip only the `.v<N>` ABI version suffix and pass other variant names
exactly. vtool has no spelling for a variant subtype, so skip it for such
slices and report that build metadata comes from otool only.

Fixes #3389

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:58 -07:00

478 lines
17 KiB
TypeScript

import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
import {
inspectMachoSchema,
type NativeCommandInvocation,
} from "../domain/native/nativeInspection.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import {
MACHO_ARCHITECTURE_POLICIES,
selectMachoArchitectureName,
} from "./MachoArchitectureSelection.js";
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
import { parseLipoOutput, type LipoArchitecture } from "./parsers/lipo.js";
import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
import { parseOtoolLoadCommands } from "./parsers/otool.js";
import { reconcileMachoUuid } from "./parsers/machoUuid.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
interface NativeMachoObservation {
readonly result: JsonValue;
readonly limitations: readonly string[];
readonly locations: readonly EvidenceLocation[];
}
interface NativeMachoInspectionContext {
readonly target: BinaryTarget;
readonly signal?: AbortSignal;
readonly run: (
tool: string,
arguments_: readonly string[],
signal?: AbortSignal,
) => Promise<Result<NativeCommandCapture, AnalysisError>>;
readonly invocation: (
capture: NativeCommandCapture,
) => NativeCommandInvocation;
}
const DISCOVERY_COMMANDS = [["lipo", ["-detailed_info"]]] as const;
// llvm-nm's default dyld-info pass fakes up symbols the symbol table lacks
// from dyld metadata. It takes seconds on large apps (Keynote arm64: 5.8 s
// versus 0.01 s without it), and under -gU it can only add export-trie names.
const SYMBOL_TABLE_EXPORTS = ["nm", ["-gjU", "--no-dyldinfo"]] as const;
const DYLD_INFO_EXPORTS = ["nm", ["-gjU"]] as const;
const REQUIRED_SLICE_COMMANDS = [
["otool", ["-h", "-l"]],
SYMBOL_TABLE_EXPORTS,
["dyld_info", ["-imports"]],
["dyld_info", ["-exports"]],
["dwarfdump", ["--uuid"]],
] as const;
const OPTIONAL_VTOOL_COMMAND = ["vtool", ["-show-build"]] as const;
const VTOOL_UNAVAILABLE_LIMITATION =
"vtool is unavailable; build metadata is normalized from otool only.";
/** Inspect one Mach-O with the required native commands and normalized output. */
export const inspectNativeMacho = async (
context: NativeMachoInspectionContext,
): Promise<Result<NativeMachoObservation, AnalysisError>> => {
const captures: NativeCommandCapture[] = [];
for (const [tool, prefix] of DISCOVERY_COMMANDS) {
const captured = await context.run(
tool,
[...prefix, context.target.path],
context.signal,
);
if (!captured.ok) return err(captured.error);
captures.push(captured.value);
}
const lipo = captures.find(({ tool }) => tool === "lipo");
if (lipo === undefined) throw new TypeError("Missing lipo capture");
const { format, architectures } = parseLipoOutput(
lipo.stdout,
context.target.path,
);
const selection = selectArchitecture(context.target, architectures);
if (selection.status === "absent")
return err(
new AnalysisOutputError(
"inspect_macho",
`lipo did not list ${selection.architecture}; refusing to read another slice`,
),
);
const selectedArchitecture =
selection.status === "selected" ? selection.architecture : null;
const runSliceCommand = ([tool, prefix]: readonly [
string,
readonly string[],
]) =>
context.run(
tool,
[
...prefix,
...selectedArchitectureArguments(tool, selectedArchitecture),
context.target.path,
],
context.signal,
);
// vtool knows no variant subtypes, so no spelling selects an arm64e.x1 slice.
const vtoolSelectsSlice =
selectedArchitecture === null ||
!toolArchitectureName("vtool", selectedArchitecture).includes(".");
// Slice commands only read the selected artifact, so launch them together.
// Every launch settles before return; declared order fixes provenance and
// which failure is reported.
const [required, optional] = await Promise.all([
Promise.all(REQUIRED_SLICE_COMMANDS.map(runSliceCommand)),
vtoolSelectsSlice ? runSliceCommand(OPTIONAL_VTOOL_COMMAND) : null,
]);
for (const captured of required) {
if (!captured.ok) return err(captured.error);
captures.push(captured.value);
}
const limitations: string[] = [];
if (optional === null)
limitations.push(
`vtool cannot select the ${selectedArchitecture} slice; build metadata is normalized from otool only.`,
);
else if (optional.ok) captures.push(optional.value);
else if (optional.error._tag === "AnalysisCapabilityUnavailableError")
limitations.push(VTOOL_UNAVAILABLE_LIMITATION);
else return err(optional.error);
const nmIndex = captures.findIndex(({ tool }) => tool === "nm");
const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1];
if (nmIndex < 0 || dyldExports === undefined)
throw new TypeError("Missing nm or dyld_info export capture");
// A stripped symbol table (strip -T) can omit names the export trie keeps.
// Only then does llvm-nm's dyld-info pass add names, and its exact names
// disambiguate dyld_info rows, so rerun it rather than lose them.
if (!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)) {
const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS);
if (!withDyldInfo.ok) return err(withDyldInfo.error);
captures[nmIndex] = withDyldInfo.value;
}
const otool = captures.find(({ tool }) => tool === "otool");
if (otool === undefined) throw new TypeError("Missing otool capture");
let load: ReturnType<typeof parseOtoolLoadCommands>;
try {
load = parseOtoolLoadCommands(sliceOutput(otool));
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"inspect_macho",
"Malformed otool load-command output",
{
cause,
capturedOutput: {
stdout: otool.stdout,
stderr: otool.stderr,
truncated: false,
stdout_bytes: otool.stdoutBytes,
stderr_bytes: otool.stderrBytes,
exit_code: otool.exitCode,
signal: otool.signal,
},
},
),
);
}
const selectedArchitectureName =
selectedArchitecture ??
(architectures.length === 1 ? (architectures[0]?.name ?? null) : null);
const containerOffset =
selectedArchitectureName === null
? undefined
: format === "thin"
? 0
: (architectures.find(({ name }) => name === selectedArchitectureName)
?.file_offset ?? undefined);
const normalized = normalizeMacho(
captures,
context.invocation,
limitations,
load,
architectures,
context.target.path,
{
name: selectedArchitectureName,
container_file_offset: containerOffset ?? null,
},
);
const result =
containerOffset === undefined
? inspectMachoSchema.parse({
...normalized,
limitations: [
...normalized.limitations,
selectedArchitectureName === null
? "The selected Mach-O slice is unknown; slice-based evidence locations are unavailable."
: "The selected Mach-O slice's container file offset was not observed; slice-based evidence locations are unavailable.",
],
})
: normalized;
return ok({
result: jsonValueSchema.parse(result),
limitations: result.limitations,
locations: fileOffsetLocations(result, containerOffset),
});
};
const normalizeMacho = (
captures: readonly NativeCommandCapture[],
toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation,
additionalLimitations: readonly string[],
load: ReturnType<typeof parseOtoolLoadCommands>,
architectures: readonly LipoArchitecture[],
artifactPath: string,
selectedArchitecture: {
readonly name: string | null;
readonly container_file_offset: number | null;
},
) => {
const byTool = captureLookup(captures);
const imports = parseDyldSymbols(
sliceOutput(byTool("dyld_info", 0)),
"imports",
);
const imageBase =
load.segments.find(
(segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0,
)?.vm_address ?? null;
const nmExports = parseNmExports(byTool("nm").stdout);
const dyldExports = parseDyldSymbols(
sliceOutput(byTool("dyld_info", 1)),
"exports",
imageBase,
new Set(nmExports.map(({ name }) => name)),
);
const exports = uniqueSymbols([...dyldExports, ...nmExports]);
const dwarfdump = byTool("dwarfdump");
const uuid = reconcileMachoUuid(
load.commands,
dwarfdump.stdout,
artifactPath,
selectedArchitecture.name,
);
const provenance = captures.map(toInvocation);
// A thread-state command carries the entrypoint in architecture-specific
// register state, so an absent decoded offset means unknown, not absent.
const threadEntrypoint =
load.entrypoints.length === 0 &&
load.commands.some(
(command) =>
command.kind === "LC_UNIXTHREAD" || command.kind === "LC_THREAD",
);
const threadEntrypointLimitation =
"Thread-state entrypoints (LC_UNIXTHREAD/LC_THREAD) are retained as raw load commands; file offsets are not derived.";
const limitations = [
"Imports and exports combine dyld_info and nm; stripped or toolchain-hidden symbols may be absent.",
...(captures.some(({ tool }) => tool === "vtool")
? [
"vtool output is retained as provenance but only otool build metadata is normalized.",
]
: []),
...additionalLimitations,
...uuid.limitations,
...(threadEntrypoint ? [threadEntrypointLimitation] : []),
...(imageBase === null &&
/^\s*offset\s+symbol\s*$/mu.test(sliceOutput(byTool("dyld_info", 1)))
? [
"Export virtual addresses are unavailable because no file-backed image base was observed.",
]
: []),
];
return inspectMachoSchema.parse({
format: "mach-o",
selected_architecture: selectedArchitecture,
endian: load.endian,
word_size: load.wordSize,
file_type: load.fileType,
flags: load.flags,
uuid: uuid.value,
entrypoints: covered(
load.entrypoints,
!threadEntrypoint,
threadEntrypoint ? [threadEntrypointLimitation] : [],
),
architectures: covered(architectures, true),
build_metadata: covered(load.builds, true),
load_commands: covered(load.commands, true),
dependencies: covered(load.dependencies, true),
imports: covered(imports, false, [
"dyld_info textual imports may omit chained or toolchain-unsupported metadata.",
]),
exports: covered(exports, false, [
"Merged nm/dyld_info results may be incomplete for stripped binaries.",
...(dyldExports.some(({ sourceSymbol }) => sourceSymbol !== undefined)
? [
"Renamed re-exports are listed under their exported name; the source library's symbol name is not reported.",
]
: []),
]),
segments: covered(
load.segments.map((segment) => ({
...segment,
sections: covered(segment.sections, true),
})),
true,
),
provenance,
limitations,
});
};
/** Slice output without the echoed operand path, which can contain any text. */
const sliceOutput = (capture: NativeCommandCapture): string =>
withoutEchoedPathHeader(capture.stdout, capture.arguments.at(-1));
const captureLookup =
(captures: readonly NativeCommandCapture[]) =>
(tool: string, occurrence = 0): NativeCommandCapture => {
const capture = captures.filter((item) => item.tool === tool)[occurrence];
if (capture === undefined) throw new TypeError(`Missing ${tool} capture`);
return capture;
};
/**
* Whether every dyld_info export row names a symbol-table export. A row this
* parser cannot read counts as uncovered, so an unfamiliar format falls back.
*/
const namesEveryExport = (
nmOutput: string,
dyldExports: NativeCommandCapture,
): boolean => {
const names = new Set(parseNmExports(nmOutput).map(({ name }) => name));
return sliceOutput(dyldExports)
.split(/\r?\n/u)
.every((rawLine, index) => {
const line = rawLine.trim();
if (
line.length === 0 ||
(index === 0 && line.endsWith(":")) ||
/^-?exports:$/u.test(line) ||
/^offset\s+symbol$/u.test(line)
)
return true;
const rows = parseDyldSymbols(line, "exports", null, names);
// The parser withholds a row only when nm names both of its readings.
if (rows.length === 0)
return names.has(/^0x[\da-f]+\s+(.+)$/iu.exec(line)?.[1] ?? "");
return rows.length === 1 && names.has(rows[0]!.name);
});
};
const parseNmExports = (output: string) =>
output
.split(/\r?\n/u)
.filter((name) => name.length > 0)
.map((name) => ({
name,
address: null,
weak: null,
reexport: null,
source: "nm",
}));
const covered = <Value>(
items: readonly Value[],
exhaustive: boolean,
limitations: readonly string[] = [],
) => ({
items: [...items],
total: exhaustive ? items.length : null,
exhaustive,
limitations: [...limitations],
});
const uniqueSymbols = <Value extends { readonly name: string }>(
items: readonly Value[],
): Value[] => {
const unique = new Map<string, Value>();
for (const item of items)
if (!unique.has(item.name)) unique.set(item.name, item);
return [...unique.values()].sort((left, right) =>
compareUnicodeCodePoints(left.name, right.name),
);
};
const fileOffsetLocations = (
macho: ReturnType<typeof inspectMachoSchema.parse>,
containerOffset: number | undefined,
): EvidenceLocation[] => {
const locations = architectureLocations(macho.architectures.items);
if (containerOffset === undefined) return locations;
for (const entrypoint of macho.entrypoints.items)
locations.push({
kind: "file-offset",
offset: containerOffset + entrypoint.file_offset,
});
for (const segment of macho.segments.items) {
if (
segment.file_offset === null ||
segment.file_size === null ||
segment.file_size === 0
)
continue;
locations.push({
kind: "file-offset-range",
start: containerOffset + segment.file_offset,
end: containerOffset + segment.file_offset + segment.file_size,
});
}
return locations;
};
/** Project architecture slices into evidence file-offset locations. */
export const architectureLocations = (
architectures: readonly {
readonly file_offset: number | null;
readonly size: number | null;
}[],
): EvidenceLocation[] => {
const locations: EvidenceLocation[] = [];
for (const { file_offset: offset, size } of architectures) {
if (offset === null) continue;
locations.push(
size === null
? { kind: "file-offset", offset }
: { kind: "file-offset-range", start: offset, end: offset + size },
);
}
return locations;
};
/**
* lipo appends the pointer-auth ABI version to arm64e (`arm64e.v1`). Only
* dyld_info accepts that spelling; the other Apple tools select the slice by
* its CPU-family name. Other variants are distinct subtypes (`arm64e.x1`)
* that the tools address only by their exact name.
*/
const toolArchitectureName = (tool: string, architecture: string): string =>
tool === "dyld_info" ? architecture : architecture.replace(/\.v\d+$/u, "");
const selectedArchitectureArguments = (
tool: string,
architecture: string | null,
): string[] => {
if (architecture === null) return [];
const toolArchitecture = toolArchitectureName(tool, architecture);
return tool === "dwarfdump"
? [`--arch=${toolArchitecture}`]
: ["-arch", toolArchitecture];
};
type ArchitectureSelection =
| { readonly status: "unfiltered" }
| { readonly status: "selected"; readonly architecture: string }
| { readonly status: "absent"; readonly architecture: string };
const selectArchitecture = (
target: BinaryTarget,
available: readonly LipoArchitecture[],
): ArchitectureSelection => {
// BinaryTarget lists CPU families and lipo lists slices. A universal file
// can hold several slices of one family (arm64e and arm64e.x1), and slice
// tools without -arch print every slice.
if (
target.kind !== "executable" ||
(target.availableArchitectures.length < 2 && available.length < 2)
)
return { status: "unfiltered" };
const normalized =
target.architecture === "x86" ? "i386" : target.architecture;
const policy = MACHO_ARCHITECTURE_POLICIES[target.architecture];
const selected =
policy === undefined
? available.find(({ name }) => name === normalized)?.name
: selectMachoArchitectureName(policy, available);
if (selected !== undefined)
return { status: "selected", architecture: selected };
return { status: "absent", architecture: normalized };
};