fix(native): preserve bounded JVM identity before FAT admission
This commit is contained in:
1 parent
21097b192b
commit
6843dbea9b
6 files changed
+131
-9
No files matched your search
@@ -426,8 +426,12 @@ Native target admission checks Mach-O slice headers before assigning FAT
|
||||
identity. A shared `CAFEBABE` prefix whose declarations do not identify Mach-O
|
||||
images receives the generic unsupported-format refusal, including JVM class
|
||||
files larger than the bounded admission probe. This does not identify a partial
|
||||
prefix as a validated JVM class. Malformed recognized FAT declarations retain
|
||||
their format-specific reasons.
|
||||
prefix as a validated JVM class. For files sharing `CAFEBABE`, target admission
|
||||
checks complete JVM framing within the same 16 MiB class identity bound as the
|
||||
Mach-O image reader. A complete class remains unsupported even if its constant
|
||||
pool resembles a known FAT CPU declaration. Above that bound, an ambiguous
|
||||
malformed FAT interpretation remains unresolved. Malformed recognized FAT
|
||||
declarations retain their format-specific reasons.
|
||||
|
||||
`inspect_artifact` and `extract_artifact` retain universal Mach-O slices under
|
||||
their exact `lipo` names, including 32-bit ARM implementations such as `armv7`,
|
||||
|
||||
@@ -75,11 +75,17 @@ import {
|
||||
type ExecutableMetadata,
|
||||
} from "../domain/binaryTarget.js";
|
||||
import {
|
||||
classifyFatContainerIdentity,
|
||||
readFatContainerHeader,
|
||||
readMachoHeaderPrefixFacts,
|
||||
type FatSliceHeaderEvidence,
|
||||
type MachoHeaderPrefixFacts,
|
||||
} from "../domain/apple/machoContainer.js";
|
||||
import {
|
||||
isJvmClassFile,
|
||||
MAX_JVM_CLASS_IDENTITY_BYTES,
|
||||
JVM_CLASS_IDENTITY_LIMIT_REASON,
|
||||
} from "../domain/apple/jvmClassFile.js";
|
||||
import {
|
||||
MachoLoadCommandCursor,
|
||||
readMachoLoadCommandTableHeader,
|
||||
@@ -865,12 +871,32 @@ const readExecutableMetadata = async (
|
||||
signal,
|
||||
});
|
||||
if (thin !== null) return thin;
|
||||
const hasJvmMagic = bytes.length >= 4 && bytes.readUInt32BE(0) === 0xcafebabe;
|
||||
if (
|
||||
hasJvmMagic &&
|
||||
fileSize > bytes.length &&
|
||||
fileSize <= MAX_JVM_CLASS_IDENTITY_BYTES
|
||||
) {
|
||||
const complete = await readHandleRange(handle, 0, fileSize, signal);
|
||||
if (complete.byteLength === fileSize && isJvmClassFile(complete))
|
||||
return err("unsupported binary format");
|
||||
}
|
||||
const fatEvidence = await readFatSliceHeaderEvidence(
|
||||
handle,
|
||||
bytes,
|
||||
fileSize,
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
hasJvmMagic &&
|
||||
fileSize > MAX_JVM_CLASS_IDENTITY_BYTES &&
|
||||
fatEvidence !== undefined &&
|
||||
classifyFatContainerIdentity(
|
||||
fatEvidence.header,
|
||||
(offset) => fatEvidence.sliceHeadersByOffset.get(offset)?.magic,
|
||||
).state === "malformed"
|
||||
)
|
||||
return err(JVM_CLASS_IDENTITY_LIMIT_REASON);
|
||||
const parsed = parseExecutableHeader(
|
||||
bytes,
|
||||
hostArchitecture,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
|
||||
import {
|
||||
MAX_JVM_CLASS_IDENTITY_BYTES,
|
||||
JVM_CLASS_IDENTITY_LIMIT_REASON,
|
||||
} from "../../domain/apple/jvmClassFile.js";
|
||||
import type {
|
||||
MachoDependency,
|
||||
MachoImageFacts,
|
||||
@@ -139,7 +143,7 @@ export const readMachoImage = async (
|
||||
const view = viewOf(head);
|
||||
const magic = view.getUint32(0, false);
|
||||
let header: ReturnType<typeof readFatContainerHeader>;
|
||||
if (magic === FAT_MAGIC && size <= MAX_LOAD_COMMAND_BYTES) {
|
||||
if (magic === FAT_MAGIC && size <= MAX_JVM_CLASS_IDENTITY_BYTES) {
|
||||
const complete = await readAt(0, size);
|
||||
header =
|
||||
complete.byteLength === size
|
||||
@@ -177,10 +181,10 @@ export const readMachoImage = async (
|
||||
// A large CAFEBABE input whose bounded prefix resembles an invalid
|
||||
// known-CPU FAT table remains unknown until its JVM identity can be
|
||||
// checked; do not turn the resource limit into a malformed claim.
|
||||
if (magic === FAT_MAGIC && size > MAX_LOAD_COMMAND_BYTES)
|
||||
if (magic === FAT_MAGIC && size > MAX_JVM_CLASS_IDENTITY_BYTES)
|
||||
throw new MachoFormatIssue(
|
||||
"unsupported",
|
||||
"FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit",
|
||||
JVM_CLASS_IDENTITY_LIMIT_REASON,
|
||||
);
|
||||
throw new MachoFormatIssue(identity.category, identity.reason);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/** Maximum complete class-file buffer retained for FAT/JVM disambiguation. */
|
||||
export const MAX_JVM_CLASS_IDENTITY_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
export const JVM_CLASS_IDENTITY_LIMIT_REASON =
|
||||
"FAT/JVM identity is unresolved because the class identity scan exceeds the 16 MiB limit";
|
||||
|
||||
/** Class-file identity used to disambiguate JVM CAFEBABE from Mach-O FAT. */
|
||||
class ClassFileCursor {
|
||||
private offset = 0;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { javaClassFile } from "../../fixtures/javaClassFile.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"refuses JVM CPU lookalikes consistently across the admission prefix through CLI and stdio MCP",
|
||||
async ({ cli }) => {
|
||||
const directory = await createTestTempDirectory("rea-jvm-admission-");
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
for (const constantBytes of [4000, 9000]) {
|
||||
const path = join(directory, `class-${constantBytes}.class`);
|
||||
const bytes = javaClassFile(constantBytes, true);
|
||||
await writeFile(path, bytes);
|
||||
const refusal = {
|
||||
code: "target_unavailable",
|
||||
category: "unsupported_target",
|
||||
details: { reason: "unsupported binary format" },
|
||||
};
|
||||
|
||||
const analyzed = await cli.run({
|
||||
arguments: ["analyze", path, "--format", "json"],
|
||||
});
|
||||
expect(analyzed.exitCode, analyzed.stderr).toBe(1);
|
||||
expect(analyzed.json).toMatchObject(refusal);
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
|
||||
const opened = await call("open_binary", { path });
|
||||
expect(opened.isError, JSON.stringify(opened)).toBe(true);
|
||||
expect(parseMcpToolError(opened).error).toMatchObject(refusal);
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -32,6 +32,10 @@ import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js";
|
||||
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { javaClassFile } from "../../fixtures/javaClassFile.js";
|
||||
import {
|
||||
MAX_JVM_CLASS_IDENTITY_BYTES,
|
||||
JVM_CLASS_IDENTITY_LIMIT_REASON,
|
||||
} from "../../../src/domain/apple/jvmClassFile.js";
|
||||
import {
|
||||
arArchive,
|
||||
CPU,
|
||||
@@ -41,12 +45,22 @@ import {
|
||||
codeSignatureCommand,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
|
||||
it.each([4000, 9000])(
|
||||
"refuses a JVM class with %i unused constant bytes without claiming FAT identity",
|
||||
async (constantBytes) => {
|
||||
it.each([
|
||||
[4000, false],
|
||||
[9000, false],
|
||||
[4000, true],
|
||||
[9000, true],
|
||||
] as const)(
|
||||
"refuses a JVM class with %i unused constant bytes and CPU lookalike %s without claiming FAT identity",
|
||||
async (constantBytes, fatCpuLookalike) => {
|
||||
const directory = await createTestTempDirectory("rea-class-target-");
|
||||
const path = join(directory, "input.class");
|
||||
await writeFile(path, javaClassFile(constantBytes));
|
||||
const bytes = javaClassFile(constantBytes, fatCpuLookalike);
|
||||
expect(parseExecutableHeader(bytes, "arm64")).toEqual({
|
||||
ok: false,
|
||||
error: "unsupported binary format",
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
const resolved = await parseBinaryTarget(path);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: false,
|
||||
@@ -61,6 +75,35 @@ it.each([4000, 9000])(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([MAX_JVM_CLASS_IDENTITY_BYTES, MAX_JVM_CLASS_IDENTITY_BYTES + 1])(
|
||||
"preserves JVM identity uncertainty at the class scan boundary for %i bytes",
|
||||
async (size) => {
|
||||
const directory = await createTestTempDirectory("rea-class-boundary-");
|
||||
const path = join(directory, "input.class");
|
||||
const classPrefix = javaClassFile(4000, true);
|
||||
classPrefix.writeUInt16BE(1, classPrefix.length - 2);
|
||||
const attribute = Buffer.alloc(6);
|
||||
attribute.writeUInt16BE(5, 0);
|
||||
attribute.writeUInt32BE(size - classPrefix.length - attribute.length, 2);
|
||||
const handle = await open(path, "w");
|
||||
try {
|
||||
await handle.write(Buffer.concat([classPrefix, attribute]));
|
||||
await handle.truncate(size);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason:
|
||||
size === MAX_JVM_CLASS_IDENTITY_BYTES
|
||||
? "unsupported binary format"
|
||||
: JVM_CLASS_IDENTITY_LIMIT_REASON,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0x01c0, 0x01c2, 0x01c4, 0xaa64])(
|
||||
"retains original PE COFF machine %i through file-backed target resolution",
|
||||
async (peMachine) => {
|
||||
|
||||
Reference in new issue
Block a user