diff --git a/src/native/exports/NativeExportParser.test.ts b/src/native/exports/NativeExportParser.test.ts index 5058dd3675..99a6608938 100644 --- a/src/native/exports/NativeExportParser.test.ts +++ b/src/native/exports/NativeExportParser.test.ts @@ -821,6 +821,37 @@ describe("export inventory review regressions", () => { }); }); + it.each([32, 64] as const)( + "rejects nonzero reserved fields in the ELF%s null symbol", + (bits) => { + for (const little of [true, false]) { + // ELF32/64 symbol records have no padding: every field in entry zero + // is reserved as zero by the ABI, independently of host endianness. + const width = bits === 64 ? 24 : 16; + for (let byte = 0; byte < width; byte++) { + const bytes = elfExportImage({ + bits, + little, + symbols: [{ name: "kept_export" }], + }); + bytes[0x100 + byte] = 1; + expect(() => parseNativeExports(bytes, artifact)).toThrow( + BinaryFormatFailure, + ); + } + expect( + parse( + elfExportImage({ + bits, + little, + symbols: [{ name: "kept_export" }], + }), + ).exports.map(({ name }) => name), + ).toEqual(["kept_export"]); + } + }, + ); + it("rejects a dynamic symbol table without its null entry", () => { expect(() => parseNativeExports( diff --git a/src/native/exports/NativeExportParser.ts b/src/native/exports/NativeExportParser.ts index 4dcceb67b5..fc32975e76 100644 --- a/src/native/exports/NativeExportParser.ts +++ b/src/native/exports/NativeExportParser.ts @@ -624,6 +624,13 @@ function* parseElfExports( width, "ELF dynamic symbols", ); + // Entry zero is the reserved STN_UNDEF record. Skipping it is valid only + // after every field is observed as zero; neither ELF layout has padding. + if (bytes.subarray(table, table + width).some((byte) => byte !== 0)) + throw new BinaryFormatFailure( + "malformed", + "ELF dynamic symbol table null entry has nonzero fields", + ); const nameRange = reader.fileRange( strings.offset, strings.size, diff --git a/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts b/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts index a3bfe3aaac..f225d179d9 100644 --- a/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts +++ b/tests/acceptance/applications/nativeExportsDynamicSegment.test.ts @@ -1,9 +1,10 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; 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 { elfExportImage } from "../../fixtures/binaryDiagnostics/nativeExports.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -67,3 +68,69 @@ cliTest.for(["CLI", "stdio MCP"] as const)( } }, ); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "rejects a non-null reserved ELF symbol through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-elf-null-symbol-"); + const mcp = + surface === "stdio MCP" ? await connectLocalToolsMcp() : undefined; + for (const encoding of [ + { bits: 64, little: true }, + { bits: 32, little: false }, + ] as const) { + const path = join(root, `exports-${encoding.bits}.so`); + const bytes = elfExportImage({ + ...encoding, + symbols: [{ name: "kept_export" }], + }); + // The authored fixture's dynamic symbol table starts at file offset 0x100. + bytes[0x100] = 1; + await writeFile(path, bytes); + let error: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["inspect-native-exports", path, "--json"], + }); + expect(response.exitCode).toBe(1); + error = response.json; + } else { + const response = await mcp!.call("inspect_native_exports", { path }); + expect(response.isError).toBe(true); + error = parseMcpToolError(response).error; + } + expect(error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + { + path: ["path"], + reason: "invalid_format", + message: expect.stringContaining("null entry"), + }, + ], + }, + }); + expect(Buffer.compare(await readFile(path), bytes)).toBe(0); + bytes[0x100] = 0; + await writeFile(path, bytes); + const document = + surface === "CLI" + ? ( + await cli.run({ + arguments: ["inspect-native-exports", path, "--json"], + }) + ).json + : (await mcp!.call("inspect_native_exports", { path })) + .structuredContent; + const evidence = toolContract( + "inspect_native_exports", + ).outputSchema.parse(document); + expect( + nativeExportsSchema + .parse(evidence.normalized_result) + .exports.map(({ name }) => name), + ).toEqual(["kept_export"]); + } + }, +);