Files
rea/src/native/NativeMachoInspection.ts
T

639 lines
23 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,
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import {
inspectMachoSchema,
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 {
MACHO_ARCHITECTURE_POLICIES,
selectMachoArchitectureName,
} from "./MachoArchitectureSelection.js";
import {
parseDyldExportAddressRow,
parseDyldExports,
parseDyldImports,
} from "./parsers/dyldInfo.js";
import { parseLipoCapture, 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";
import {
conflictingUuidCommandEvidence,
isMissingUuidDyldFailure,
isUnbackedTextDyldFailure,
nativeMachoCapturedOutput,
missingUuidLimitation,
parseDyldFileTypeRefusal,
unusableNativeCommandOutput,
} from "./NativeMachoCommandDiagnostics.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,
acceptNonZeroIf?: (capture: NativeCommandCapture) => boolean,
) => 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 SYMBOL_TABLE_IMPORTS = ["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 parsedLipo = parseLipoCapture(
lipo,
"inspect_macho",
context.target.path,
);
if (!parsedLipo.ok) return parsedLipo;
const { format, architectures } = parsedLipo.value;
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,
tool === "dyld_info"
? (capture) =>
isMissingUuidDyldFailure(capture) ||
isUnbackedTextDyldFailure(capture) ||
parseDyldFileTypeRefusal(capture) !== null
: undefined,
);
// 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((command) => runSliceCommand(command)),
),
vtoolSelectsSlice ? runSliceCommand(OPTIONAL_VTOOL_COMMAND) : null,
]);
for (const captured of required) {
if (!captured.ok) return err(captured.error);
if (
(captured.value.exitCode !== 0 || captured.value.signal !== null) &&
!isMissingUuidDyldFailure(captured.value) &&
!isUnbackedTextDyldFailure(captured.value) &&
parseDyldFileTypeRefusal(captured.value) === null
)
return err(unusableNativeCommandOutput(captured.value));
captures.push(captured.value);
}
const limitations = architectures.flatMap(
(architecture) => architecture.limitations,
);
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");
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: nativeMachoCapturedOutput(otool),
},
),
);
}
const unbackedTextFailure = captures.find(isUnbackedTextDyldFailure);
if (unbackedTextFailure !== undefined) {
if (!["10", "DSYM", "MH_DSYM"].includes(load.fileType ?? ""))
return err(unusableNativeCommandOutput(unbackedTextFailure));
return err(
new AnalysisUnsupportedTargetError(
"inspect_macho",
context.target.path,
"The selected Mach-O is a dSYM companion whose unbacked text sections are unsupported by dyld_info inspection",
{
capturedOutput: nativeMachoCapturedOutput(unbackedTextFailure),
remediationAction:
"Select the matching linked Mach-O executable for native inspection; this operation does not inspect dSYM companions with unbacked text sections.",
},
),
);
}
for (const fileTypeFailure of captures) {
const refusal = parseDyldFileTypeRefusal(fileTypeFailure);
if (refusal === null) continue;
const { fileType, diagnostic } = refusal;
const typeName = fileType === "4" ? "CORE" : "FVMLIB";
if (![fileType, typeName, `MH_${typeName}`].includes(load.fileType ?? ""))
return err(unusableNativeCommandOutput(fileTypeFailure));
return err(
new AnalysisUnsupportedTargetError(
"inspect_macho",
context.target.path,
`dyld_info refused the selected Mach-O file type MH_${typeName} (${fileType}): ${diagnostic}`,
{
capturedOutput: nativeMachoCapturedOutput(fileTypeFailure),
remediationAction:
"Select a linked Mach-O executable or relocatable object for native inspection; dyld_info refused the selected file type.",
},
),
);
}
const uuidlessDyldFailure = captures.find(isMissingUuidDyldFailure);
if (
uuidlessDyldFailure !== undefined &&
load.commands.some(({ kind }) => kind === "LC_UUID")
)
return err(conflictingUuidCommandEvidence(uuidlessDyldFailure));
// 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. Refusals above are not export rows.
if (
!isMissingUuidDyldFailure(dyldExports) &&
!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)
) {
const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS);
if (!withDyldInfo.ok) return err(withDyldInfo.error);
captures[nmIndex] = withDyldInfo.value;
}
const dyldImports = captures.find(
({ tool, arguments: arguments_ }) =>
tool === "dyld_info" && arguments_[0] === "-imports",
);
if (dyldImports === undefined)
throw new TypeError("Missing dyld_info import capture");
const hasChainedFixups = load.commands.some(
({ kind }) => kind === "LC_DYLD_CHAINED_FIXUPS",
);
let parsedImports = parseDyldImports(
sliceOutput(dyldImports),
hasChainedFixups,
);
if (
!isMissingUuidDyldFailure(dyldImports) &&
parsedImports.needsNameInventory
) {
const importNames = await runSliceCommand(SYMBOL_TABLE_IMPORTS);
if (!importNames.ok) return err(importNames.error);
captures.push(importNames.value);
parsedImports = parseDyldImports(
sliceOutput(dyldImports),
hasChainedFixups,
new Set(parseNmSymbolNames(importNames.value.stdout)),
);
}
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,
format,
context.invocation,
limitations,
load,
parsedImports.items,
captures[nmIndex]!,
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[],
containerFormat: "thin" | "fat",
toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation,
additionalLimitations: readonly string[],
load: ReturnType<typeof parseOtoolLoadCommands>,
parsedImports: ReturnType<typeof parseDyldImports>["items"],
exportNames: NativeCommandCapture,
architectures: readonly LipoArchitecture[],
artifactPath: string,
selectedArchitecture: {
readonly name: string | null;
readonly container_file_offset: number | null;
},
) => {
const byTool = captureLookup(captures);
const dyldImportsCapture = byTool("dyld_info", 0);
const dyldExportsCapture = byTool("dyld_info", 1);
const missingImportsUuid = isMissingUuidDyldFailure(dyldImportsCapture);
const missingExportsUuid = isMissingUuidDyldFailure(dyldExportsCapture);
const imports = missingImportsUuid ? [] : parsedImports.map(withRuntimeName);
const imageBase =
load.segments.find(
(segment) => segment.file_offset === 0 && (segment.file_size ?? 0) > 0,
)?.vm_address ?? null;
const nmExports = parseNmExports(exportNames.stdout);
const dyldExports = missingExportsUuid
? []
: parseDyldExports(
sliceOutput(dyldExportsCapture),
imageBase,
new Set(nmExports.map(({ name }) => name)),
);
const exports = uniqueSymbols([...dyldExports, ...nmExports]).map(
withRuntimeName,
);
const dwarfdump = byTool("dwarfdump");
const uuid = reconcileMachoUuid(
load.commands,
dwarfdump.stdout,
artifactPath,
selectedArchitecture.name === null
? null
: {
name: selectedArchitecture.name,
dwarfdumpName: toolArchitectureName(
"dwarfdump",
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,
...(missingImportsUuid ? [missingUuidLimitation("imports")] : []),
...(missingExportsUuid ? [missingUuidLimitation("exports")] : []),
...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: containerFormat === "fat" ? "mach-o-universal" : "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,
load.omittedDependencyCommands.length === 0,
dependencyPayloadLimitations(load.omittedDependencyCommands),
),
imports: covered(imports, false, [
"dyld_info textual imports may omit chained or toolchain-unsupported metadata.",
...(missingImportsUuid ? [missingUuidLimitation("imports")] : []),
...(imports.some(({ name_status }) => name_status === "ambiguous")
? [
"Import text contains name, flags, library, or addend boundaries whose split is ambiguous.",
]
: []),
]),
exports: covered(exports, false, [
...(missingExportsUuid
? [missingUuidLimitation("exports")]
: [
"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(parseNmSymbolNames(nmOutput));
return sliceOutput(dyldExports)
.split("\n")
.every((rawLine, index) => {
// Keep a trailing carriage return: it belongs to the exported name.
const line = rawLine.trimStart();
if (
line.length === 0 ||
(index === 0 && line.endsWith(":")) ||
/^-?exports:$/u.test(line) ||
/^offset\s+symbol$/u.test(line)
)
return true;
const rows = parseDyldExports(line, null, names);
// The parser withholds a row only when nm names both of its readings.
if (rows.length === 0)
return names.has(parseDyldExportAddressRow(line)?.text ?? "");
return rows.length === 1 && names.has(rows[0]!.name);
});
};
// nm -j ends each name with LF; a carriage return before it is part of the name.
const parseNmSymbolNames = (output: string): string[] =>
output.split("\n").filter((name) => name.length > 0);
const parseNmExports = (output: string) =>
parseNmSymbolNames(output).map((name) => ({
name,
address: null,
weak: null,
reexport: null,
source: "nm",
}));
const dependencyPayloadLimitations = (
commands: readonly string[],
): string[] => {
if (commands.length === 0) return [];
const counts = new Map<string, number>();
for (const command of commands)
counts.set(command, (counts.get(command) ?? 0) + 1);
const described = [...counts].map(
([command, count]) => `${count} ${command}`,
);
return [
`Dependency inventory omits ${described.join(", ")} because otool -l does not print that command's linkedit install name. trace_dylib_resolution reads the payload.`,
];
};
const covered = <Value>(
items: readonly Value[],
exhaustive: boolean,
limitations: readonly string[] = [],
) => ({
items: [...items],
total: exhaustive ? items.length : null,
exhaustive,
limitations: [...limitations],
});
const withRuntimeName = <Value extends { readonly name: string | null }>(
symbol: Value,
): Value & { readonly runtime_name: string | null } => ({
...symbol,
runtime_name: machoRuntimeSymbolName(symbol.name),
});
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 };
};