From 97fe58d3fe5675dc49367058afb321b87290ea8e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:35:01 +0800 Subject: [PATCH] fix(erlang): validate table framing before row capacity (#9606) fix(erlang): validate BEAM table extents before capacity --- src/erlang/Beam.fixture.ts | 12 +++++ src/erlang/BeamParser.test.ts | 20 ++++++-- src/erlang/BeamParser.ts | 52 ++++++++++++-------- tests/boundary/filesystem/erlangBeam.test.ts | 4 +- tests/boundary/mcp/erlangBeamMcp.test.ts | 33 ++++++++++++- 5 files changed, 94 insertions(+), 27 deletions(-) diff --git a/src/erlang/Beam.fixture.ts b/src/erlang/Beam.fixture.ts index dd921624e2..12be9bb8c0 100644 --- a/src/erlang/Beam.fixture.ts +++ b/src/erlang/Beam.fixture.ts @@ -98,6 +98,18 @@ export const erlangBeamDeclaredRowsFixture = ( return concat(Buffer.from("FOR1", "latin1"), u32(form.length), form); }; +/** BEAM table frames with all declared fixed-width row bytes present. */ +export const erlangBeamRowsFixture = ( + counts: readonly (readonly ["ExpT" | "ImpT" | "FunT", number])[], +): Buffer => + erlangBeamChunksFixture( + counts.map(([name, count]) => { + const payload = Buffer.alloc(4 + count * (name === "FunT" ? 24 : 12)); + payload.writeUInt32BE(count); + return [name, payload] as const; + }), + ); + /** BEAM form whose atom table exceeds a caller-selected atom budget. */ export const erlangBeamManyAtomsFixture = (count: number): Buffer => { const names = Array.from( diff --git a/src/erlang/BeamParser.test.ts b/src/erlang/BeamParser.test.ts index 8f0ede25ec..ec8f06c4da 100644 --- a/src/erlang/BeamParser.test.ts +++ b/src/erlang/BeamParser.test.ts @@ -8,7 +8,7 @@ import { import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { erlangBeamChunksFixture, - erlangBeamDeclaredRowsFixture, + erlangBeamRowsFixture, erlangBeamFixture, erlangBeamLegacyAtomsFixture, erlangBeamManyAtomsFixture, @@ -280,7 +280,7 @@ it("rejects combined table rows before allocating them", () => { const overflow = MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1; expect(() => parse( - erlangBeamDeclaredRowsFixture([ + erlangBeamRowsFixture([ ["ExpT", below], ["ImpT", below], ["FunT", overflow], @@ -293,10 +293,22 @@ it("rejects combined table rows before allocating them", () => { message: expect.stringContaining("row budget"), }), ); + const complete = Buffer.alloc( + 4 + (MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1) * 12, + ); + complete.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1); + expect(() => + parse( + erlangBeamChunksFixture([ + ["ExpT", complete], + ["ImpT", Buffer.alloc(1)], + ]), + ), + ).toThrow(/import table is truncated/); const count = Buffer.alloc(4); count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1); expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow( - /row budget/, + /truncated/, ); count.writeUInt32BE(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS); expect(() => parse(erlangBeamChunksFixture([["ExpT", count]]))).toThrow( @@ -359,7 +371,7 @@ it.each([ }, { resource: "table-rows", - bytes: erlangBeamDeclaredRowsFixture([ + bytes: erlangBeamRowsFixture([ ["ExpT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1], ]), settings: {}, diff --git a/src/erlang/BeamParser.ts b/src/erlang/BeamParser.ts index 72c206a974..86d5e946d7 100644 --- a/src/erlang/BeamParser.ts +++ b/src/erlang/BeamParser.ts @@ -172,15 +172,19 @@ const parseAtoms = ( return atoms; }; +const tableCount = (payload: Buffer, width: number, label: string): number => { + if (payload.length < 4) fail(`BEAM ${label} table is truncated.`); + const count = u32(payload, 0); + if (count > Math.floor((payload.length - 4) / (width * 4))) + fail(`BEAM ${label} table is truncated.`); + return count; +}; + const parseRows = ( payload: Buffer, width: number, - label: string, + count: number, ): number[][] => { - if (payload.length < 4) fail(`BEAM ${label} table is truncated.`); - const count = u32(payload, 0); - const need = 4 + count * width * 4; - if (payload.length < need) fail(`BEAM ${label} table is truncated.`); const rows: number[][] = []; for (let index = 0; index < count; index += 1) { const row: number[] = []; @@ -278,47 +282,55 @@ export const parseErlangBeam = ( atomChunk === undefined || atomFormat === null ? null : parseAtoms(atomChunk.payload, input.max_atoms, atomFormat); - const declaredRows = (name: string): number => { - const payload = payloads.get(name)?.payload; - return payload !== undefined && payload.length >= 4 ? u32(payload, 0) : 0; - }; + const exportChunk = payloads.get("ExpT"); + const importChunk = payloads.get("ImpT"); + const lambdaChunk = payloads.get("FunT"); + const exportCount = + exportChunk === undefined + ? 0 + : tableCount(exportChunk.payload, 3, "export"); + const importCount = + importChunk === undefined + ? 0 + : tableCount(importChunk.payload, 3, "import"); + // OTP skips the optional lambda parser for a zero-byte selected chunk. + const lambdaCount = + lambdaChunk === undefined || lambdaChunk.payload.length === 0 + ? 0 + : tableCount(lambdaChunk.payload, 6, "lambda"); if ( - declaredRows("ExpT") + declaredRows("ImpT") + declaredRows("FunT") > + exportCount + importCount + lambdaCount > MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS ) throw new BeamCapacityFailure( "table-rows", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, - `BEAM tables exceed the ${String( - MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS, - )}-row budget.`, + `BEAM tables exceed the ${String(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS)}-row budget.`, ); const exports = - payloads.get("ExpT") === undefined + exportChunk === undefined ? null - : parseRows(payloads.get("ExpT")!.payload, 3, "export").map((row) => ({ + : parseRows(exportChunk.payload, 3, exportCount).map((row) => ({ function_index: row[0]!, function: atomAt(atoms, row[0]!), arity: row[1]!, label: row[2]!, })); const imports = - payloads.get("ImpT") === undefined + importChunk === undefined ? null - : parseRows(payloads.get("ImpT")!.payload, 3, "import").map((row) => ({ + : parseRows(importChunk.payload, 3, importCount).map((row) => ({ module_index: row[0]!, module: atomAt(atoms, row[0]!), function_index: row[1]!, function: atomAt(atoms, row[1]!), arity: row[2]!, })); - const lambdaChunk = payloads.get("FunT"); - // OTP skips the optional lambda parser for a zero-byte selected chunk. // A four-byte zero count remains a decoded, explicitly empty table. const lambdas = lambdaChunk === undefined || lambdaChunk.payload.length === 0 ? null - : parseRows(lambdaChunk.payload, 6, "lambda").map((row) => ({ + : parseRows(lambdaChunk.payload, 6, lambdaCount).map((row) => ({ function_index: row[0]!, function: atomAt(atoms, row[0]!), arity: row[1]!, diff --git a/tests/boundary/filesystem/erlangBeam.test.ts b/tests/boundary/filesystem/erlangBeam.test.ts index e7337daf93..302a4de09b 100644 --- a/tests/boundary/filesystem/erlangBeam.test.ts +++ b/tests/boundary/filesystem/erlangBeam.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { createBeamService } from "../../../src/composition/erlang.js"; import { MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS } from "../../../src/domain/erlang/beam.js"; import { - erlangBeamDeclaredRowsFixture, + erlangBeamRowsFixture, erlangBeamFixture, } from "../../../src/erlang/Beam.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -66,7 +66,7 @@ describe("Erlang BEAM stable filesystem boundary", () => { const below = Math.floor(MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS / 2); await writeFile( path, - erlangBeamDeclaredRowsFixture([ + erlangBeamRowsFixture([ ["ExpT", below], ["ImpT", below], ["FunT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS - below * 2 + 1], diff --git a/tests/boundary/mcp/erlangBeamMcp.test.ts b/tests/boundary/mcp/erlangBeamMcp.test.ts index 8b45ed2c78..0d57115c11 100644 --- a/tests/boundary/mcp/erlangBeamMcp.test.ts +++ b/tests/boundary/mcp/erlangBeamMcp.test.ts @@ -17,6 +17,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { erlangBeamDeclaredRowsFixture, + erlangBeamRowsFixture, erlangBeamFixture, } from "../../../src/erlang/Beam.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -106,7 +107,7 @@ mcpTest( ], [ "rows", - erlangBeamDeclaredRowsFixture([ + erlangBeamRowsFixture([ ["ExpT", MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1], ]), {}, @@ -177,3 +178,33 @@ mcpTest( } }, ); + +mcpTest( + "reports impossible BEAM table counts as malformed input", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + const root = await createTestTempDirectory("rea-beam-malformed-rows-"); + try { + for (const chunk of ["ExpT", "ImpT", "FunT"]) { + const path = join(root, `${chunk}.beam`); + await writeFile( + path, + erlangBeamDeclaredRowsFixture([ + [chunk, MAX_INSPECT_ERLANG_BEAM_TABLE_ROWS + 1], + ]), + ); + const response = await client.callTool({ + name: "inspect_erlang_beam", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "invalid_request", + }); + } + } finally { + await session.close(); + } + }, + 60_000, +);