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:
parent
4a4ae9b2b9
commit
10400fdb57
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -70,4 +70,5 @@ const universalTarget = (path: string): BinaryTarget => ({
|
|||
format: "mach-o-universal",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
universalSlice: { offset: 0, size: 32 },
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue