feat(ghidra): admit DOS MZ targets on Windows P0 (#7144)

* feat(ghidra): admit DOS MZ targets on Windows P0

Windows Ghidra P0 refused dos-mz although the MzLoader import, 16-bit
real-mode language attestation and load-image evidence are host-neutral.
Admit dos-mz in inspectWindowsP0TargetSupport and run verify:ghidra:dos on
Windows: inherit the system environment there, expect annotation and naming
edits to be refused (P0 has no mutation authority), and skip verifier
lineage like the 6502 lane. Explicit COM stays refused on Windows; its
verifier is built around annotation edits.

Refs #7121

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ghidra): check naming refusal in the read-only DOS lane

With mutable: false the address-boundary helper skipped every naming
request, so the Windows report overstated its coverage. Assert that
set_address_name and set_addresses_names are refused with
capability_unavailable and leave the entry name unchanged, and declare
the optional mutable argument in the .d.mts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ghidra): unwrap failed profile Result before asserting

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(ghidra): expect an unchanged DOS entry on Windows P0 verification

Windows P0 refuses edits, so verifyGhidraAddressBoundaries skips the
rename-and-restore and the entry keeps its imported symbol source. The
post-check still expected user_defined, which only follows a rename.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hasan ZayniandClaude Sonnet 5.5 authored and GitHub committed 2026-11-06 15:39:03 +08:00
1 parent fb841a9596
commit 1cc979c104
12 files changed
+162 -54

No files matched your search

+3 -2
View File
@@ -175,8 +175,9 @@ are separate from the initialized MZ module. Headerless COM requires the
from the filename or unrecognized bytes. DOS extenders without an admitted
MZ real-mode entry remain outside this import boundary.
DOS MZ is available through Ghidra, with no Hopper support claim. It is outside
the Windows native PE P0 boundary. macOS x64 DOS verification remains unverified.
DOS MZ is available through Ghidra, with no Hopper support claim. Windows Ghidra
P0 admits DOS MZ read-only: function annotations and naming edits are refused
there, and explicit COM is not admitted. macOS x64 DOS verification remains unverified.
## Real verification
+2 -1
View File
@@ -1143,7 +1143,8 @@ and `set_addresses_names` name a function entry or any other mapped address
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
require Hopper; Windows P0 remains read-only.
Windows P0 admits native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, plus explicitly
Windows P0 admits native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs,
[DOS MZ executables](ghidra-dos.md), plus explicitly
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
unsupported on Windows.
+2 -1
View File
@@ -1211,7 +1211,8 @@ is present. A denied trace reports the `debugger-attach-denied` reason.
Install a Ghidra 12.x (>=12.1) release and the 64-bit full JDK it declares, then configure REA to
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
the matching native decompiler. Experimental Windows x64 P0 admits native
x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, plus explicitly selected
x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, [DOS MZ
executables](ghidra-dos.md), plus explicitly selected
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
+4 -3
View File
@@ -467,13 +467,14 @@ references, timeout recovery, and cleanup. Linux, other hosts, and the larger
## DOS Ghidra analysis
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
on Linux x64 or macOS x64/arm64. It generates a source-owned MZ fixture without
on Linux x64, macOS x64/arm64, or Windows x64 P0. It generates a source-owned MZ fixture without
a DOS emulator or compiler, then checks real 16-bit decoding, segment
relocation, near/far calls, decompilation, disjoint function body ranges,
stable CLI/MCP observations, unchanged source bytes, and owned process/project
cleanup. Raw p-code address-space selector tokens are reported separately from
the stable observation comparison. Linux x64 and macOS arm64 are verified;
macOS x64 remains unverified. This lane is separate from host-native and optional cross-format
the stable observation comparison. Linux x64, macOS arm64 and Windows x64 are
verified; macOS x64 remains unverified. On Windows P0 the lane checks that
annotation edits are refused instead of exercising them. This lane is separate from host-native and optional cross-format
verification. See [DOS analysis](ghidra-dos.md).
`npm run verify:ghidra:com` uses a generated headerless fixture with no compiler,
+4 -3
View File
@@ -17,7 +17,8 @@ additional permission flag or degraded mode is required.
- Node.js 22.x (>=22.23.4), 24.x (>=24.22.1), or 26.12.1+;
- an operator-installed Ghidra 12.x (>=12.1) distribution (verified with 12.1.4);
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
- an explicit native, non-managed x86, x86-64, ARMNT (Thumb-2) or ARM64 PE application/DLL, or a
- an explicit native, non-managed x86, x86-64, ARMNT (Thumb-2) or ARM64 PE application/DLL, a
[DOS MZ executable](ghidra-dos.md), or a
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
- the 27 read-only Ghidra inventory, memory, and function-analysis operations.
@@ -26,8 +27,8 @@ PE load-image inspection returns an explicit unsupported attestation status.
Independent verification covers admitted DOS MZ, explicit COM, explicit
[raw-binary imports](raw-binary.md), and explicit
[GameCube DOL images](ghidra-dol.md) on their supported hosts, including the
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
to native PE and raw NMOS 6502 images. For x64 PE
admitted DOS MZ and raw NMOS 6502 mappings on Windows. Windows P0 admission remains limited
to native PE, DOS MZ, and raw NMOS 6502 images; explicit COM is not admitted. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation: MethodTable relationships, virtual slots and frozen strings.
@@ -1,8 +1,12 @@
import type { Client } from "@modelcontextprotocol/client";
/** Verify segmented aliases and restore the entry's observed public name. */
/**
* Verify segmented aliases and restore the entry's observed public name.
* With `mutable: false` (Windows P0), annotation and naming edits must be refused.
*/
export function verifyGhidraAddressBoundaries(
client: Pick<Client, "callTool">,
entry: string,
cli: (command: string, address: string, flags: string[]) => Promise<unknown>,
options?: { readonly mutable?: boolean },
): Promise<void>;
+75 -24
View File
@@ -1,8 +1,16 @@
import { requireMcpToolError } from "./mcp-verifier-results.mjs";
import assert from "node:assert/strict";
/** Exercise segmented-address truncation through real CLI/MCP reads and edits. */
export async function verifyGhidraAddressBoundaries(client, entry, cli) {
/**
* Exercise segmented-address truncation through real CLI/MCP reads and edits.
* A read-only provider (Windows P0) must refuse edits before parsing them.
*/
export async function verifyGhidraAddressBoundaries(
client,
entry,
cli,
{ mutable = true } = {},
) {
const query = async (name, args) => {
const reply = await client.callTool(
{ name, arguments: args },
@@ -45,6 +53,14 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
);
assert.equal(reply.isError, true, `${name} accepted ${address}`);
const error = requireMcpToolError(reply);
if (!mutable && name === "annotate_native_function") {
assert.equal(
error?.code,
"capability_unavailable",
JSON.stringify(reply),
);
continue;
}
assert.equal(error?.code, "invalid_request", JSON.stringify(reply));
assert.equal(error.details.operation, name);
const issues = JSON.stringify(error.details.issues);
@@ -65,6 +81,63 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
await query("read_bytes", { address: padded, length: 1 }),
await query("read_bytes", { address: entry, length: 1 }),
);
if (mutable) await verifyAliasNaming(query, entry, padded);
else await verifyNamingRefused(client, query, entry, padded);
for (const [command, flags, operation] of [
["read-bytes", ["--length", "1"], "read_bytes"],
[
"annotate-native-function",
["--name", "MUST_NOT_ALIAS"],
"annotate_native_function",
],
]) {
let failure;
try {
await cli(command, representations[0], flags);
} catch (error) {
assert.equal(error.code, 1);
failure = JSON.parse(error.stdout);
}
if (!mutable && operation === "annotate_native_function") {
assert.equal(failure?.code, "capability_unavailable");
continue;
}
assert.equal(failure?.code, "invalid_request");
assert.equal(failure.details.operation, operation);
assert.match(
JSON.stringify(failure.details.issues),
/cannot be represented without truncation/u,
);
}
}
/** A read-only provider refuses both naming operations and keeps the entry name. */
async function verifyNamingRefused(client, query, entry, padded) {
const originalName = await query("address_name", { address: entry });
for (const [name, args] of [
["set_address_name", { address: entry, name: "MUST_NOT_RENAME" }],
[
"set_addresses_names",
{ names: { [entry]: "MUST_NOT_RENAME", [padded]: "MUST_NOT_RENAME" } },
],
]) {
const reply = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.equal(reply.isError, true, `${name} was not refused`);
const error = requireMcpToolError(reply);
assert.equal(error?.code, "capability_unavailable", JSON.stringify(reply));
}
assert.equal(
await query("address_name", { address: entry }),
originalName,
"A refused naming edit changed the entry name",
);
}
/** Equivalent spellings of one address share or reject one naming edit together. */
async function verifyAliasNaming(query, entry, padded) {
const originalName = await query("address_name", { address: entry });
assert.equal(
typeof originalName,
@@ -127,26 +200,4 @@ export async function verifyGhidraAddressBoundaries(client, entry, cli) {
throw cleanupCause;
}
}
for (const [command, flags, operation] of [
["read-bytes", ["--length", "1"], "read_bytes"],
[
"annotate-native-function",
["--name", "MUST_NOT_ALIAS"],
"annotate_native_function",
],
]) {
let failure;
try {
await cli(command, representations[0], flags);
} catch (error) {
assert.equal(error.code, 1);
failure = JSON.parse(error.stdout);
}
assert.equal(failure?.code, "invalid_request");
assert.equal(failure.details.operation, operation);
assert.match(
JSON.stringify(failure.details.issues),
/cannot be represented without truncation/u,
);
}
}
+24 -13
View File
@@ -35,10 +35,10 @@ import { verifyGhidraAddressBoundaries } from "./lib/real-ghidra-address-boundar
// This lane intentionally has no game paths, binary seeds or compiler dependency.
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-dos.mjs");
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"verify:ghidra:dos supports Linux/macOS hosts only; Windows P0 is PE x86-64 only.",
);
if (!["linux", "darwin", "win32"].includes(process.platform))
throw new Error("verify:ghidra:dos supports Linux, macOS and Windows hosts.");
// Windows P0 is read-only: annotation and naming edits must be refused.
const windows = process.platform === "win32";
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"verify:ghidra:dos prerequisite missing: GHIDRA_INSTALL_DIR (bring your own Ghidra).",
@@ -72,6 +72,8 @@ await writeFile(targetPath, fixture.bytes, { mode: 0o600 });
const sha256 = digest(fixture.bytes);
const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
const env = {
// Windows children need the system environment (SystemRoot, profile paths).
...(windows ? { ...process.env, TMP: runtime, TEMP: runtime } : {}),
PATH: process.env.PATH ?? "/usr/bin:/bin",
TMPDIR: runtime,
REA_LOG_LEVEL: "silent",
@@ -396,21 +398,25 @@ try {
client,
identities.entry.address,
cliEvidence,
{ mutable: !windows },
);
const entrySymbol = entryWindow.procedure.symbol;
assert.ok(entrySymbol != null, "The DOS entry needs observed symbol facts");
// Restoring the name is itself a user edit, so Ghidra records user provenance.
// Windows P0 refuses edits and never renames, so its entry stays unchanged.
assert.deepEqual(
await call("read_function_instructions", {
procedure: identities.entry.address,
}),
{
...entryWindow,
procedure: {
...entryWindow.procedure,
symbol: { ...entrySymbol, source: "user_defined" },
},
},
windows
? entryWindow
: {
...entryWindow,
procedure: {
...entryWindow.procedure,
symbol: { ...entrySymbol, source: "user_defined" },
},
},
"Restoring the entry name changed more than its symbol provenance",
);
assert.equal(
@@ -474,7 +480,11 @@ try {
"The complete body is Ghidra's observed AddressSet, not independently proven historical compiler ownership.",
"Language/compiler identity is bound by the production authenticated bridge handshake; profile loader is the declared import policy.",
"Native high-pcode flow is excluded from cross-runtime parity: LOAD/STORE address-space selector constants are process-specific in current Ghidra output. Both dossiers are still strictly parsed.",
"Windows P0 remains unsupported for DOS MZ.",
...(windows
? [
"Windows P0 is read-only: annotation and naming edits were checked as refused, not exercised.",
]
: []),
],
};
} catch (error) {
@@ -510,7 +520,8 @@ try {
}
assert.ok(report, "No DOS proof report was produced");
const verifierRun = await completeVerifierRun(run);
assertVerifierCleanupComplete(verifierRun.process_lineage);
// Windows provider jobs are verified by their native owners, as in the 6502 lane.
if (!windows) assertVerifierCleanupComplete(verifierRun.process_lineage);
process.stdout.write(
`${JSON.stringify({ verifier_run: verifierRun, ...report, cleanup: "complete" })}\n`,
);
+36 -2
View File
@@ -334,8 +334,6 @@ describe("Ghidra provider", () => {
},
},
});
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("admits parsed Hexagon ELF targets through Ghidra profile selection and declines them on Windows", async () => {
const parsed = parseExecutableHeader(elf(1, 1, 164), "x64");
@@ -761,6 +759,42 @@ describe("Ghidra platform support", () => {
});
});
describe("Ghidra Windows DOS admission", () => {
it("admits DOS MZ through the POSIX real-mode import but not explicit COM (#7121)", async () => {
const windows = provider(installationHost("win32"));
const target: BinaryTarget = {
path: "C:\\fixtures\\legacy.exe",
sha256: "a".repeat(64),
kind: "executable",
format: "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
};
expect(windows.inspectTargetSupport(target)).toMatchObject({
status: "supported",
diagnostics: { host_platform: "win32", target_format: "dos-mz" },
});
expect(
windows.inspectTargetSupport({ ...target, format: "dos-com" }),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
});
const resolved = await windows.resolveAnalysisProfile(target);
if (!resolved.ok) throw resolved.error;
expect(resolved.value).toMatchObject({
profile: {
parameters: {
loader: "MzLoader",
load_image_evidence: "independent-mz-mapping-relocations-v2",
language_id: "x86:LE:16:Real Mode",
load_segment: "0x1000",
},
},
});
});
});
describe("Ghidra Windows platform support", () => {
it("keeps Windows P0 unavailable until native isolation authority exists", async () => {
const reason = "Authored native artifact unavailable diagnostic";
+5 -2
View File
@@ -694,7 +694,10 @@ const inspectWindowsP0TargetSupport = (
// The raw 6502 lane uses the same immutable snapshot and native ownership boundary.
if (target.format === "raw-binary" && target.architecture === "6502")
return { status: "supported", code: null, reason: null, diagnostics };
// DOL is verified on POSIX hosts only; Windows P0 does not admit it, like the other non-PE lanes.
// DOS MZ reuses the host-neutral MzLoader import and its attested load image.
if (target.format === "dos-mz")
return { status: "supported", code: null, reason: null, diagnostics };
// DOL is verified on POSIX hosts only; Windows P0 does not admit it.
if (target.format === "gamecube-dol")
return {
status: "unsupported",
@@ -708,7 +711,7 @@ const inspectWindowsP0TargetSupport = (
status: "unsupported",
code: "target_format_unsupported",
reason:
"Windows Ghidra P0 accepts native PE and explicitly selected raw 6502 targets only.",
"Windows Ghidra P0 accepts native PE, DOS MZ, and explicitly selected raw 6502 targets only.",
diagnostics,
};
if (!WINDOWS_P0_NATIVE_PE_ARCHITECTURES.includes(target.architecture))
+1 -1
View File
@@ -28,7 +28,7 @@ export const healthLimitations = Object.freeze([
/** Additional limitations applied to the experimental Windows x64 P0 boundary. */
export const windowsP0Limitations = Object.freeze([
"Windows Ghidra P0 accepts native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.",
"Windows Ghidra P0 accepts native x86, x86-64, ARMNT (Thumb-2) and ARM64 PE applications and DLLs, DOS MZ images, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.",
"The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.",
"Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.",
]);
@@ -17,8 +17,8 @@ import {
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import type { AndroidJniSelection } from "../../../src/domain/android/androidJniBindings.js";
import {
type AndroidJniSelection,
androidJniLibraryCheckpointSchema,
type AndroidJniLibraryCheckpoint,
} from "../../../src/domain/android/androidJniBindings.js";