fix(native): keep echoed Mach-O paths out of load commands and symbols

otool and dyld_info print the operand path before a slice's records,
and the Mach-O parsers read that header as output. A file name holding
`Load command` blocks or `(from lib)` lines therefore added a forged
LC_MAIN entrypoint, corrupted the Mach header fields, and injected
imports and exports into `inspect_macho`.

Strip the exact echoed operand header (`<path>:`, `<path> (architecture
<arch>):`, `<path> [<arch>]:`) before parsing. Output without that
header, such as replayed captures, is parsed unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 23:40:35 -07:00
parent 895079dd09
commit 37fe105ba1
4 changed files with 112 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import { err, ok, type Result } from "../domain/result.js";
import type { NativeCommandCapture } from "./CommandRunner.js";
import { parseDyldSymbols } from "./parsers/dyldInfo.js";
import { parseLipoArchitectures } from "./parsers/lipo.js";
import { withoutEchoedPathHeader } from "./parsers/echoedPath.js";
import { parseOtoolLoadCommands } from "./parsers/otool.js";
interface NativeMachoObservation {
@ -128,15 +129,18 @@ const normalizeMacho = (
) => {
const byTool = captureLookup(captures);
const architectures = parseLipoArchitectures(byTool("lipo").stdout);
const load = parseOtoolLoadCommands(byTool("otool").stdout);
const imports = parseDyldSymbols(byTool("dyld_info", 0).stdout, "imports");
const load = parseOtoolLoadCommands(sliceOutput(byTool("otool")));
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(
byTool("dyld_info", 1).stdout,
sliceOutput(byTool("dyld_info", 1)),
"exports",
imageBase,
new Set(nmExports.map(({ name }) => name)),
@ -166,7 +170,7 @@ const normalizeMacho = (
...additionalLimitations,
...(threadEntrypoint ? [threadEntrypointLimitation] : []),
...(imageBase === null &&
/^\s*offset\s+symbol\s*$/mu.test(byTool("dyld_info", 1).stdout)
/^\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.",
]
@ -206,6 +210,10 @@ const normalizeMacho = (
});
};
/** 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 => {

View File

@ -0,0 +1,22 @@
import { expect, it } from "vitest";
import { withoutEchoedPathHeader } from "./echoedPath.js";
const path = "/tmp/tool\nLoad command 99\n cmd LC_MAIN";
it.each([
[`${path}:\nMach header\n`, "Mach header\n"],
[`${path} (architecture arm64):\nMach header\n`, "Mach header\n"],
[`${path} [arm64e]:\n -imports:\n`, " -imports:\n"],
[`${path}:\r\nMach header\r\n`, "Mach header\r\n"],
])("removes the exact echoed operand header from %j", (output, expected) => {
expect(withoutEchoedPathHeader(output, path)).toBe(expected);
});
it.each([
["fixture:\nMach header\n", path],
[`${path}\nMach header\n`, path],
["Mach header\n", undefined],
])("leaves output without that header unchanged: %j", (output, operand) => {
expect(withoutEchoedPathHeader(output, operand)).toBe(output);
});

View File

@ -0,0 +1,18 @@
/**
* Remove the header line otool and dyld_info print before a slice's output:
* `<path>:`, `<path> (architecture <arch>):`, or `<path> [<arch>]:`. File
* names may contain line breaks and tool keywords, so only the exact operand
* path is stripped; output without that header is returned unchanged.
*/
export const withoutEchoedPathHeader = (
output: string,
path: string | undefined,
): string => {
if (path === undefined || !output.startsWith(path)) return output;
const header = /^(?: \(architecture [^)\n]*\)| \[[^\]\n]*\])?:\r?\n/u.exec(
output.slice(path.length),
);
return header === null
? output
: output.slice(path.length + header[0].length);
};

View File

@ -0,0 +1,60 @@
import { copyFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const environment = {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
} as const;
// otool and dyld_info echo the operand path before their records.
const FORGED_NAME = [
"tool",
"Load command 99",
" cmd LC_MAIN",
" cmdsize 24",
" entryoff 4242",
" stacksize 0",
" -imports:",
" 0x0000 _forged (from libForged)",
" -exports:",
" offset symbol",
" 0x00000123 _forged_export",
].join("\n");
const resultSchema = z.object({
normalized_result: z.record(z.string(), z.unknown()),
});
describe.skipIf(process.platform !== "darwin")(
"native Mach-O CLI with echoed path text",
() => {
cliTest(
"does not read load commands or symbols from the file name",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-macho-echo-");
const inspect = async (name: string) => {
const path = join(directory, name);
await copyFile("/usr/bin/true", path);
const result = await cli.run({
arguments: ["inspect-macho", path, "--json"],
environment,
});
expect(result.exitCode).toBe(0);
const { provenance: _provenance, ...facts } = resultSchema.parse(
result.json,
).normalized_result;
return facts;
};
const forged = await inspect(FORGED_NAME);
expect(forged).toEqual(await inspect("tool"));
expect(JSON.stringify(forged)).not.toMatch(/forged|4242/u);
},
);
},
);