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:
parent
895079dd09
commit
37fe105ba1
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue