From 37fe105ba168488d05226f2912bed802e2d48d75 Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:40:35 -0700 Subject: [PATCH] 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 (`:`, ` (architecture ):`, ` []:`) before parsing. Output without that header, such as replayed captures, is parsed unchanged. Co-Authored-By: Claude Opus 5.5 --- src/native/NativeMachoInspection.ts | 16 +++-- src/native/parsers/echoedPath.test.ts | 22 +++++++ src/native/parsers/echoedPath.ts | 18 ++++++ .../cli/nativeMachoEchoedPathCli.test.ts | 60 +++++++++++++++++++ 4 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/native/parsers/echoedPath.test.ts create mode 100644 src/native/parsers/echoedPath.ts create mode 100644 tests/boundary/cli/nativeMachoEchoedPathCli.test.ts diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 4414e23a6..933d934a1 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -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 => { diff --git a/src/native/parsers/echoedPath.test.ts b/src/native/parsers/echoedPath.test.ts new file mode 100644 index 000000000..de523dd45 --- /dev/null +++ b/src/native/parsers/echoedPath.test.ts @@ -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); +}); diff --git a/src/native/parsers/echoedPath.ts b/src/native/parsers/echoedPath.ts new file mode 100644 index 000000000..29be40354 --- /dev/null +++ b/src/native/parsers/echoedPath.ts @@ -0,0 +1,18 @@ +/** + * Remove the header line otool and dyld_info print before a slice's output: + * `:`, ` (architecture ):`, or ` []:`. 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); +}; diff --git a/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts b/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts new file mode 100644 index 000000000..0c362d263 --- /dev/null +++ b/tests/boundary/cli/nativeMachoEchoedPathCli.test.ts @@ -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); + }, + ); + }, +);