fix(pe): preserve biased export ordinals

Accept the format-derived ordinal range for PE export slots.
This commit is contained in:
morluto
2026-10-23 18:17:17 +08:00
parent 0473a3091c
commit 73d85f6299
5 changed files with 99 additions and 17 deletions
+4 -2
View File
@@ -321,10 +321,12 @@ The complete Evidence identifies the stable artifact snapshot by path, byte
count and SHA-256.
- PE rows reproduce the export directory: each used export-address-table slot
with its `ordinal`, one row per name (aliases share an ordinal), `address`
with its biased `ordinal` (`OrdinalBase` plus the slot index), one row per
name (aliases share an ordinal), `address`
as the preferred-base virtual address, or `forwarder` text when the slot
forwards to another DLL. Unused zero-address slots are omitted;
ordinal-only exports have a null `name`. The
ordinal-only exports have a null `name`. The name-ordinal table stores an
unbiased 16-bit EAT index; reported biased ordinals may exceed 16 bits. The
directory's DLL name, ordinal base and timestamp are reported, and an image
without an export directory is a complete empty inventory.
- ELF rows list defined `.dynsym` symbols with global, weak or GNU-unique
+4 -1
View File
@@ -4,6 +4,9 @@ import { absoluteLocalPathStringSchema } from "../localPath.js";
const uint = z.number().int().min(0).max(0xffffffff);
const address = z.string().regex(/^0x[0-9a-f]+$/u);
// PE ordinals are the DWORD OrdinalBase plus an index into the DWORD-sized
// export address table. The last valid index is one less than its entry count.
const MAX_PE_EXPORT_ORDINAL = 0xffffffff + 0xfffffffe;
/** Hard ceiling on the caller-selected input read budget. */
export const MAX_INSPECT_NATIVE_EXPORTS_FILE_BYTES = 536870912;
@@ -33,7 +36,7 @@ export type InspectNativeExportsInput = z.infer<
*/
const peExportSchema = z.strictObject({
name: z.string().min(1).nullable(),
ordinal: z.number().int().min(0).max(0xffff),
ordinal: z.number().int().min(0).max(MAX_PE_EXPORT_ORDINAL),
address: address.nullable(),
forwarder: z.string().min(1).nullable(),
});
+21 -8
View File
@@ -115,16 +115,29 @@ describe("PE export directories", () => {
expect(result.limitations[0]).toContain("declares no export directory");
});
it("refuses ordinals beyond the 16-bit ordinal space", () => {
expect(() =>
parseNativeExports(
it("preserves biased ordinals beyond WORD and DWORD", () => {
const cases = [
{ ordinalBase: 0x10000, index: 0, ordinal: 0x10000 },
{ ordinalBase: 0xffffffff, index: 0, ordinal: 0xffffffff },
{
ordinalBase: 0xffffffff,
index: 1,
ordinal: 0x1_0000_0000,
},
];
for (const { ordinalBase, index, ordinal } of cases) {
const result = parse(
peExportImage({
functions: [{ rva: 0x2000 }],
ordinalBase: 0xffff + 1,
ordinalBase,
functions: Array.from({ length: index + 1 }, (_, row) =>
row === index ? { rva: 0x2000 } : null,
),
names: [{ name: "Named", index }],
}),
artifact,
),
).toThrow("16-bit ordinals");
);
expect(result.exports).toMatchObject([{ name: "Named", ordinal }]);
}
});
it("ignores a trailing unused slot after the last 16-bit ordinal", () => {
-5
View File
@@ -188,11 +188,6 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
// A zero slot is an unused ordinal, not an export.
if (rva === 0) continue;
const ordinal = ordinalBase + index;
if (ordinal > 0xffff)
throw new BinaryFormatFailure(
"malformed",
"PE exported ordinal exceeds 16-bit ordinals",
);
let target: Pick<PeExport, "address" | "forwarder">;
if (rva >= directory.virtual_address && rva < forwarderEnd) {
try {
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { nativeExportsSchema } from "../../../src/domain/native/nativeExports.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { peExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
@@ -45,5 +46,73 @@ cliTest(
},
);
},
30_000,
);
cliTest.for(["CLI", "stdio MCP"] as const)(
"preserves biased PE ordinals above the name-table index width through %s",
async (surface, { cli }) => {
const root = await createTestTempDirectory("rea-pe-biased-ordinal-");
const cases = [
{ name: "word-base", base: 0x10000, index: 0, ordinal: 0x10000 },
{
name: "maximum-dword-base",
base: 0xffffffff,
index: 0,
ordinal: 0xffffffff,
},
{
name: "sum-above-dword",
base: 0xffffffff,
index: 1,
ordinal: 0x1_0000_0000,
},
];
const mcp =
surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined;
for (const fixture of cases) {
const path = join(root, `${fixture.name}.dll`);
const functions = Array.from({ length: fixture.index + 1 }, (_, row) =>
row === fixture.index ? { rva: 0x1300 } : null,
);
await writeFile(
path,
peExportImage({
ordinalBase: fixture.base,
functions,
names: [{ name: "Named", index: fixture.index }],
}),
);
let document: unknown;
if (surface === "CLI") {
const response = await cli.run({
arguments: ["inspect-native-exports", path, "--json"],
});
expect(response.exitCode, response.stderr).toBe(0);
document = toolContract("inspect_native_exports").outputSchema.parse(
response.json,
);
} else {
const response = await mcp!.call("inspect_native_exports", { path });
expect(response.isError, JSON.stringify(response)).not.toBe(true);
document = response.structuredContent;
}
const evidence = toolContract(
"inspect_native_exports",
).outputSchema.parse(document);
expect(
nativeExportsSchema.parse(evidence.normalized_result),
).toMatchObject({
format: "pe",
coverage: {
status: "complete",
examined_entries: fixture.index + 1,
exports: 1,
},
exports: [{ name: "Named", ordinal: fixture.ordinal }],
});
}
},
);