fix(ida): thin the selected FAT slice into the private headless input (#2201)

Universal Mach-O admission reported the selected slice architecture while the IDA provider's private input copy handed the whole FAT container to upstream idalib, which then picked its own slice (x86_64 for /bin/cat) while Evidence labeled the session arm64. FAT parsing now exports the selected slice's byte range as universalSlice, and the private input copy writes exactly those bytes: the whole source digest still anchors the copy against admission-time changes, and the thinned result anchors the post-operation identity check.

Thin targets keep the whole-file copy, and mach-o-universal remains the container format across Evidence, snapshots, and provider routing.

Validation: typecheck, oxlint (no new warnings), oxfmt, and targeted vitest runs — ida adapters 12 files/116 tests, domain and services projects 60 files/736 tests, ghidra/hopper behavior 2 files/50 tests, filesystem and universal-extraction boundary 2 files/49 tests, native signature MCP 1 file/2 tests, plus a new thinning suite (5 tests, red before the fix: 3 failed). The thinned product was verified byte-identical to the `clang -arch arm64` input of a `lipo -create` universal fixture. Controlled fixtures only; no live IDA analysis was performed for this change.

Closes #2139
This commit is contained in:
akushonkamen 2026-10-12 22:07:49 +08:00 committed by GitHub
parent 4a4ae9b2b9
commit 10400fdb57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 242 additions and 29 deletions

View File

@ -127,14 +127,25 @@ describe("analysis snapshot contract", () => {
] as const)(
"preserves %s target and subject formats",
(format, architecture) => {
const target = {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable" as const,
format,
architecture,
availableArchitectures: [architecture],
};
const target: BinaryTarget =
format === "mach-o-universal"
? {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable",
format,
architecture,
availableArchitectures: [architecture],
universalSlice: { offset: 0, size: 32 },
}
: {
path: ANALYSIS_SNAPSHOT_TARGET.path,
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable",
format,
architecture,
availableArchitectures: [architecture],
};
const evidence = createEvidence(target, ANALYSIS_SNAPSHOT_PROVIDER, {
operation: "analyze_function",
parameters: {},

View File

@ -1,6 +1,7 @@
import type {
BinaryArchitecture,
MipsElfMetadata,
UniversalMachOSlice,
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
import { mzWindowsHeaderOffset, parseDosMzHeader } from "./dosMz.js";
@ -15,17 +16,22 @@ export type ExecutableMetadata =
readonly mips: MipsElfMetadata;
}
| {
readonly format:
| "mach-o"
| "mach-o-universal"
| "elf"
| "dos-mz"
| "dos-com";
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly universalSlice?: never;
}
| {
readonly format: "mach-o-universal";
readonly architecture: Exclude<BinaryArchitecture, "mips">;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
"mips"
>[];
readonly universalSlice: UniversalMachOSlice;
}
| {
readonly format: "pe";
@ -157,10 +163,15 @@ const parseFatMachO = (
return err(
`truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`,
);
const sliceOffset = Number(slice.offset);
const sliceSize = Number(slice.size);
if (!Number.isSafeInteger(sliceOffset) || !Number.isSafeInteger(sliceSize))
return err(`FAT slice for ${preferred} exceeds addressable byte bounds`);
return ok({
format: "mach-o-universal",
architecture: preferred,
availableArchitectures: architectures,
universalSlice: { offset: sliceOffset, size: sliceSize },
});
};

View File

@ -44,6 +44,12 @@ export interface MipsAbiFlags {
readonly flags2: number;
}
/** Byte range of the selected slice inside its universal Mach-O container. */
export interface UniversalMachOSlice {
readonly offset: number;
readonly size: number;
}
interface BinaryTargetIdentity {
readonly path: string;
readonly sourcePath?: string;
@ -83,12 +89,15 @@ export type BinaryTarget =
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format:
| "mach-o"
| "mach-o-universal"
| "elf"
| "dos-mz"
| "dos-com";
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly universalSlice?: never;
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
readonly format: "mach-o-universal";
/** Selected slice range a provider must analyze instead of the container. */
readonly universalSlice: UniversalMachOSlice;
readonly executableRole?: never;
readonly managed?: never;
})

View File

@ -242,6 +242,7 @@ describe("Ghidra Mach-O slice support", () => {
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
universalSlice: { offset: 0, size: 32 },
};
expect(ghidra.inspectTargetSupport(universal)).toMatchObject({

View File

@ -11,6 +11,7 @@ it("routes physical universal Mach-O containers through Hopper's FAT loader", ()
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
universalSlice: { offset: 0, size: 32 },
},
"fat64",
);

View File

@ -506,7 +506,8 @@ export class IdaSessionClient implements AnalysisClient {
onObserved?.(raw);
if (
!sameIdaHostPath(metadata.input_path, this.#workspace.inputPath) ||
(await idaFileDigest(this.#workspace.inputPath)) !== this.target.sha256
(await idaFileDigest(this.#workspace.inputPath)) !==
this.#workspace.inputSha256
)
throw new AnalysisProtocolError(
"IDA headless input identity changed after target admission.",

View File

@ -1,6 +1,6 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, constants } from "node:fs";
import { copyFile, realpath } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { open, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join, normalize } from "node:path";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
@ -23,6 +23,7 @@ export const sameIdaHostPath = (left: string, right: string): boolean =>
/** Private input copy isolates upstream database and sidecar writes from the original. */
export class IdaWorkspace {
readonly sessionId = `rea-${randomUUID()}`;
#inputSha256: string | undefined;
private constructor(
private readonly runtime: PrivateRuntimeRoot,
readonly inputPath: string,
@ -34,6 +35,11 @@ export class IdaWorkspace {
return this.runtime.path;
}
/** SHA-256 of the private input bytes copyInput admitted; undefined until prepared. */
get inputSha256(): string | undefined {
return this.#inputSha256;
}
/** Allocate a protected workspace so its owner can retain it before input preparation. */
static async allocate(
target: BinaryTarget,
@ -49,13 +55,54 @@ export class IdaWorkspace {
return new IdaWorkspace(runtime, inputPath, target);
}
/** Copy and verify admitted bytes after the session owns this workspace's cleanup. */
/**
* Copy and verify admitted bytes after the session owns this workspace's
* cleanup. Universal Mach-O containers are thinned to the selected slice so
* the upstream engine analyzes the architecture admission reported; the
* whole-source digest still anchors the copy against admission-time changes.
*/
async copyInput(): Promise<void> {
await copyFile(this.target.path, this.inputPath, constants.COPYFILE_EXCL);
if ((await idaFileDigest(this.inputPath)) !== this.target.sha256)
throw new AnalysisProtocolError(
"IDA target changed between admission and the private input copy; reopen the target.",
);
const slice =
this.target.format === "mach-o-universal"
? this.target.universalSlice
: undefined;
const source = await open(this.target.path, "r");
try {
const input = await open(this.inputPath, "wx");
try {
const sourceHash = createHash("sha256");
const inputHash = createHash("sha256");
const chunk = Buffer.allocUnsafe(64 * 1024);
let position = 0;
for (;;) {
const observed = await source.read(chunk, 0, chunk.length, position);
if (observed.bytesRead === 0) break;
const bytes = chunk.subarray(0, observed.bytesRead);
sourceHash.update(bytes);
const start =
slice === undefined ? position : Math.max(position, slice.offset);
const end =
slice === undefined
? position + bytes.length
: Math.min(position + bytes.length, slice.offset + slice.size);
const admitted = bytes.subarray(start - position, end - position);
if (admitted.length > 0) {
inputHash.update(admitted);
await input.write(admitted);
}
position += observed.bytesRead;
}
if (sourceHash.digest("hex") !== this.target.sha256)
throw new AnalysisProtocolError(
"IDA target changed between admission and the private input copy; reopen the target.",
);
this.#inputSha256 = inputHash.digest("hex");
} finally {
await input.close();
}
} finally {
await source.close();
}
}
/** Delete only this object's privately allocated directory after worker cleanup. */

View File

@ -0,0 +1,131 @@
import { createHash } from "node:crypto";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../application/BinaryTargetResolver.js";
import { parseExecutableHeader } from "../domain/binaryTarget.js";
import { fat } from "../domain/binaryTarget.fixture.js";
import {
createIdaTarget,
RecordingIdaMcp,
} from "../../tests/fixtures/idaMcp.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { IdaSessionClient } from "./IdaSessionClient.js";
import { IdaWorkspace } from "./IdaWorkspace.js";
const roots: string[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
/** Write a two-slice universal fixture and resolve it for the given host. */
const universalTarget = async (hostArchitecture: "arm64" | "x64") => {
const directory = await createTestTempDirectory("rea-ida-thin-");
roots.push(directory);
const path = join(directory, "universal");
const bytes = fat([0x01000007, 0x0100000c]);
await writeFile(path, bytes);
const resolved = await parseBinaryTarget(path, { hostArchitecture });
if (!resolved.ok) throw resolved.error;
if (resolved.value.format !== "mach-o-universal")
throw new Error("the universal fixture did not resolve as a FAT container");
return { bytes, target: resolved.value };
};
describe("IDA workspace input thinning", () => {
it.skipIf(process.platform === "win32")(
"thins the selected arm64 slice into the private headless input",
async () => {
const { bytes, target } = await universalTarget("arm64");
const workspace = await IdaWorkspace.allocate(target);
roots.push(workspace.directory);
await workspace.copyInput();
const input = await readFile(workspace.inputPath);
const { offset, size } = target.universalSlice;
expect(offset).toBeGreaterThan(0);
expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true);
expect(input.length).toBe(size);
expect(input.readUInt32BE(0)).toBe(0xfeedfacf);
expect(input.readUInt32BE(4)).toBe(0x0100000c);
expect(parseExecutableHeader(input, "arm64")).toMatchObject({
ok: true,
value: { format: "mach-o", architecture: "arm64" },
});
expect(createHash("sha256").update(input).digest("hex")).toBe(
workspace.inputSha256,
);
await workspace.remove();
},
);
it.skipIf(process.platform === "win32")(
"thins the x86_64 slice when the host is intel",
async () => {
const { bytes, target } = await universalTarget("x64");
const workspace = await IdaWorkspace.allocate(target);
roots.push(workspace.directory);
await workspace.copyInput();
const input = await readFile(workspace.inputPath);
const { offset, size } = target.universalSlice;
expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true);
expect(input.readUInt32BE(4)).toBe(0x01000007);
await workspace.remove();
},
);
it("keeps a whole-file copy for thin targets", async () => {
const { root, target } = await createIdaTarget("arm64");
roots.push(root);
const workspace = await IdaWorkspace.allocate(target, root);
roots.push(workspace.directory);
await workspace.copyInput();
const input = await readFile(workspace.inputPath);
const source = await readFile(target.path);
expect(input.equals(source)).toBe(true);
expect(workspace.inputSha256).toBe(target.sha256);
await workspace.remove();
});
it.skipIf(process.platform === "win32")(
"rejects a universal source that changed after admission",
async () => {
const { target } = await universalTarget("arm64");
const workspace = await IdaWorkspace.allocate({
...target,
sha256: "a".repeat(64),
});
roots.push(workspace.directory);
await expect(workspace.copyInput()).rejects.toThrow("target changed");
await workspace.remove();
},
);
it("binds a headless session to the thinned input identity", async () => {
const { target } = await universalTarget("arm64");
const root = await createTestTempDirectory("rea-ida-thin-root-");
roots.push(root);
const producer = new RecordingIdaMcp(target, "headless");
const client = new IdaSessionClient(
{
command: "fixture",
args: [],
env: {},
mode: "headless",
timeoutMs: 1000,
workspaceRoot: root,
},
target,
producer,
);
expect((await client.execute("health", {})).ok).toBe(true);
const open = producer.calls.find(({ name }) => name === "idb_open");
expect(String(open?.args.input_path)).toContain("universal");
expect((await client.close()).ok).toBe(true);
});
});

View File

@ -70,4 +70,5 @@ const universalTarget = (path: string): BinaryTarget => ({
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["arm64"],
universalSlice: { offset: 0, size: 32 },
});