From 75a757d8ef41772d04bdc7963c95bc2fb08d7025 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:32:53 +0800 Subject: [PATCH] fix(elf): distinguish compressed note storage from decoded notes Refuse compressed note sections with typed unsupported-target semantics before framing or decoding, preserving stored-range checks and physical descriptor-offset provenance. Flutter retains independent build IDs with partial coverage when compressed notes are skipped. Verify the existing shared alignment fix with prefixed notes across classes and table kinds. Fixes #6869 Closes #6563 --- docs/elf-notes.md | 8 +++ docs/flutter-analysis.md | 3 + src/flutter/ElfBuildId.ts | 15 +++++ src/native/elf/ElfNoteParser.test.ts | 45 +++++++++++++++ src/native/elf/ElfNoteParser.ts | 21 +++++-- src/native/elf/ElfNotes.fixture.ts | 27 +++++++++ tests/acceptance/elfNotesCli.test.ts | 22 +++++++ tests/acceptance/flutterAnalysis.test.ts | 57 ++++++++++++++++++- .../filesystem/flutterElfNotes.test.ts | 57 ++++++++++++++++++- tests/boundary/mcp/elfNotesMcp.test.ts | 28 +++++++++ 10 files changed, 274 insertions(+), 9 deletions(-) diff --git a/docs/elf-notes.md b/docs/elf-notes.md index 6a464fce14..15eb2378ac 100644 --- a/docs/elf-notes.md +++ b/docs/elf-notes.md @@ -58,5 +58,13 @@ numbering (`PN_XNUM`, including a section count stored when `e_shnum` is 0) is unsupported and returns an unsupported-target outcome rather than invalid-input recovery. +`SHF_COMPRESSED` note sections return `unsupported_target` before their payload +is interpreted or decompressed. Their compression header is storage metadata, +not a note header. The current result requires physical file offsets for each +note and descriptor, which decoded compressed notes cannot provide. This refusal +does not label compressed note storage as malformed or allocate its declared +decoded size. Compressed section-name tables remain supported within the limits +above. + This workflow does not execute the image or verify GNU build-id uniqueness against package catalogs. diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index cc4e8a48c1..cab64e9740 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -111,6 +111,9 @@ server; older servers keep the complete request after schema discovery. Nonempty GNU build-ID descriptors up to 64 bytes are retained as lowercase hexadecimal, including IDs shorter than four bytes. Matching bytes outside declared note ranges cannot supply a build ID. + Compressed `SHT_NOTE` sections are skipped with an unsupported-compression + limitation and partial coverage; IDs from other note ranges remain candidates. + Encoded note storage is never interpreted as note headers or inflated. - Labeled strings must fit their complete grammar. `dart_version_capped` and `toolchain_capped` independently report labels omitted by grammar or projection bounds; either makes identification coverage partial. Values diff --git a/src/flutter/ElfBuildId.ts b/src/flutter/ElfBuildId.ts index 9d1cccd961..0d6f9a453d 100644 --- a/src/flutter/ElfBuildId.ts +++ b/src/flutter/ElfBuildId.ts @@ -71,6 +71,7 @@ export const readElfBuildIds = async ( string, { offset: number; size: number; alignment: number } >(); + let compressedNotes = false; tables: for (const table of [ { offset: h.e_phoff, @@ -117,6 +118,20 @@ export const readElfBuildIds = async ( size > totalBytes - offset ) return failure("ELF note range is not file-backed"); + if (!table.program) { + const flags = h.is64 + ? bytes.readBigUInt64LE(base + 8) + : BigInt(bytes.readUInt32LE(base + 8)); + if ((flags & 0x800n) !== 0n) { + if (!compressedNotes) { + failures.push({ + reason: "Compressed ELF note sections are unsupported", + }); + compressedNotes = true; + } + continue; + } + } if (size === 0) continue; const alignmentAt = base + (table.program ? (h.is64 ? 48 : 28) : h.is64 ? 48 : 32); diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 044a8feafb..eabf204a57 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -6,6 +6,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { BinaryFormatFailure } from "../metadata/BinaryContainer.js"; import { compressedElfNoteFixture, + compressedElfNoteSectionFixture, elfNote, elfNoteFixture, elfNoteTablePastEnd, @@ -103,6 +104,50 @@ it("reads the same notes from an SHT_NOTE section including its name", () => { ).toBe(true); }); +it.each([ + [32, true], + [32, false], + [64, true], + [64, false], +] as const)( + "refuses compressed ELF%i %s-endian note storage without parsing its compression header", + (bits, little) => { + expect(() => parse(compressedElfNoteSectionFixture(bits, little))).toThrow( + expect.objectContaining({ + kind: "unsupported", + message: expect.stringContaining( + "Compressed ELF note sections are unsupported", + ), + }), + ); + }, +); + +it("does not allocate the declared decoded note size, but still validates stored ranges", () => { + const bytes = compressedElfNoteSectionFixture(); + const noteSection = 64 + 64; + const storedOffset = Number(bytes.readBigUInt64LE(noteSection + 24)); + bytes.writeBigUInt64LE(0xffffffffffffffffn, storedOffset + 8); + expect(() => parse(bytes)).toThrow( + expect.objectContaining({ kind: "unsupported" }), + ); + bytes.writeBigUInt64LE(0n, noteSection + 32); + expect(() => parse(bytes)).toThrow( + expect.objectContaining({ kind: "unsupported" }), + ); + bytes.writeBigUInt64LE(BigInt(bytes.length), noteSection + 32); + expect(() => parse(bytes)).toThrow(ArtifactReaderFailure); +}); + +it("preserves unknown ELF64 section flag bits without treating them as file sizes", () => { + const bytes = elfNoteFixture({ ownership: "section" }); + bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 64 + 8); + bytes.writeBigUInt64LE(0x8000000000000000n, 64 + 2 * 64 + 8); + expect(parse(bytes).entries[0]?.gnu?.build_id_hex).toBe( + "0123456789abcdef0123456789abcdef01234567", + ); +}); + it.each([ [32, true], [64, false], diff --git a/src/native/elf/ElfNoteParser.ts b/src/native/elf/ElfNoteParser.ts index 8ec5d38f3d..60296e4ebb 100644 --- a/src/native/elf/ElfNoteParser.ts +++ b/src/native/elf/ElfNoteParser.ts @@ -19,6 +19,7 @@ import { decodeElfSectionNameTable } from "../metadata/ElfSectionNameTable.js"; const PT_NOTE = 4; const SHT_NOTE = 7; const SHT_STRTAB = 3; +const SHF_COMPRESSED = 0x800n; const HEX_DESCRIPTOR_LIMIT = 64; interface TextBudget { @@ -111,13 +112,17 @@ class ElfReader { : this.bytes.readUInt32BE(offset); } - word(offset: number): number { + wordBits(offset: number): bigint { const width = this.bits === 64 ? 8 : 4; this.require(offset, width); - if (this.bits === 32) return this.u32(offset); - const value = this.little + if (this.bits === 32) return BigInt(this.u32(offset)); + return this.little ? this.bytes.readBigUInt64LE(offset) : this.bytes.readBigUInt64BE(offset); + } + + word(offset: number): number { + const value = this.wordBits(offset); if (value > BigInt(Number.MAX_SAFE_INTEGER)) fail("ELF offset or size exceeds a safe integer."); return Number(value); @@ -353,10 +358,10 @@ export const parseElfNotes = ( const strOff = reader.word(namesHeader + (wide ? 24 : 16)); const strSize = reader.word(namesHeader + (wide ? 32 : 20)); const rawNames = reader.slice(strOff, strSize); - const flags = reader.word(namesHeader + 8); + const flags = reader.wordBits(namesHeader + 8); const decoded = decodeElfSectionNameTable( rawNames, - BigInt(flags), + flags, header.elfClass, reader.little, input.max_file_bytes - bytes.length, @@ -418,6 +423,12 @@ export const parseElfNotes = ( const nameOffset = reader.u32(at); const fileOffset = reader.word(at + (wide ? 24 : 16)); const fileSize = reader.word(at + (wide ? 32 : 20)); + reader.require(fileOffset, fileSize); + if ((reader.wordBits(at + 8) & SHF_COMPRESSED) !== 0n) + throw new BinaryFormatFailure( + "unsupported", + "Compressed ELF note sections are unsupported; descriptor file offsets require uncompressed note storage.", + ); const alignment = noteAlignment(reader.word(at + (wide ? 48 : 32))); tables.push({ kind: "section", diff --git a/src/native/elf/ElfNotes.fixture.ts b/src/native/elf/ElfNotes.fixture.ts index e404473cb9..7c2a0a653a 100644 --- a/src/native/elf/ElfNotes.fixture.ts +++ b/src/native/elf/ElfNotes.fixture.ts @@ -209,6 +209,33 @@ export const elfNoteFixture = (options: ElfNoteImageOptions = {}): Buffer => { return bytes; }; +/** A non-allocated SHT_NOTE whose stored bytes include an ELF compression header. */ +export const compressedElfNoteSectionFixture = ( + bits: 32 | 64 = 64, + little = true, +): Buffer => { + const notes = concat( + gnuBuildIdNote(undefined, little), + gnuAbiTagNote(little), + ); + const header = Buffer.alloc(bits === 64 ? 24 : 12); + write32(header, 0, 1, little); // ELFCOMPRESS_ZLIB + writeWord(header, bits === 64 ? 8 : 4, notes.length, bits, little); + writeWord(header, bits === 64 ? 16 : 8, 4, bits, little); + const bytes = elfNoteFixture({ + bits, + little, + type: 1, + ownership: "section", + sectionName: ".debug.notes", + alignment: bits === 64 ? 8 : 4, + notes: concat(header, deflateSync(notes)), + }); + const noteSection = (bits === 64 ? 64 : 52) + (bits === 64 ? 64 : 40); + writeWord(bytes, noteSection + 8, 0x800, bits, little); + return bytes; +}; + /** Valid ELF64 section count stored in section header zero per the gABI. */ export const elfExtendedSectionCountFixture = (): Buffer => { const count = 0xff00; diff --git a/tests/acceptance/elfNotesCli.test.ts b/tests/acceptance/elfNotesCli.test.ts index 8d9b734d5e..7905dd039c 100644 --- a/tests/acceptance/elfNotesCli.test.ts +++ b/tests/acceptance/elfNotesCli.test.ts @@ -4,6 +4,7 @@ import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; import { compressedElfNoteFixture, + compressedElfNoteSectionFixture, elfExtendedSectionCountFixture, elfNoteFixture, } from "../../src/native/elf/ElfNotes.fixture.js"; @@ -85,6 +86,27 @@ cliTest("reports a truncated ELF as invalid input", async ({ cli }) => { }); }); +cliTest( + "reports compressed note storage as unsupported without mutation", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-elf-notes-storage-cli-"); + const path = join(root, "debug-notes.o"); + const bytes = compressedElfNoteSectionFixture(); + await writeFile(path, bytes); + const result = await cli.run({ + arguments: ["inspect-elf-notes", path, "--json"], + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining( + "Compressed ELF note sections are unsupported", + ), + }); + expect(await readFile(path)).toEqual(bytes); + }, +); + cliTest( "reports valid extended numbering as unsupported without mutation", async ({ cli }) => { diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 2e364f1c57..7cb29276c4 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -1,4 +1,7 @@ -import { gnuBuildIdNote } from "../../src/native/elf/ElfNotes.fixture.js"; +import { + gnuBuildIdNote, + compressedElfNoteSectionFixture, +} from "../../src/native/elf/ElfNotes.fixture.js"; import { createHash } from "node:crypto"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; @@ -343,3 +346,55 @@ cliTest( } }, ); + +cliTest( + "CLI and stdio MCP retain Flutter build-ID candidates with unsupported compressed notes", + async ({ cli }) => { + const compressed = appendElfRanges( + compressedElfNoteSectionFixture(), + "program", + [{ type: 1, offset: 0, size: 0 }], + ); + const note = gnuBuildIdNote("0123456789abcdef"); + const engine = appendElfRanges( + Buffer.concat([compressed, note]), + "section", + [{ type: 7, offset: compressed.length, size: note.length }], + ); + const path = await createFlutterApk("compressed-notes.apk", [ + { path: "lib/arm64-v8a/libapp.so", bytes: aotFixtureImage() }, + { path: "lib/arm64-v8a/libflutter.so", bytes: engine }, + ]); + const output = await cli.run({ + arguments: ["identify-flutter-build", path, "--json"], + }); + expect(output.exitCode, output.stderr).toBe(0); + const cliEvidence = parseEvidence(output.json); + const { call } = await connectLocalToolsMcp(); + const response = await call("identify_flutter_build", { path }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcpEvidence = parseEvidence(response.structuredContent); + expect(mcpEvidence.normalized_result).toEqual( + cliEvidence.normalized_result, + ); + const result = flutterResultSchemas.identify_flutter_build.parse( + cliEvidence.normalized_result, + ); + expect(result).toMatchObject({ + coverage: "partial", + abis: [ + { + libflutter: { + sha256: createHash("sha256").update(engine).digest("hex"), + build_id: null, + build_id_candidates: ["0123456789abcdef"], + }, + }, + ], + }); + for (const evidence of [cliEvidence, mcpEvidence]) + expect(evidence.limitations.join("\n")).toContain( + "Compressed ELF note sections are unsupported", + ); + }, +); diff --git a/tests/boundary/filesystem/flutterElfNotes.test.ts b/tests/boundary/filesystem/flutterElfNotes.test.ts index 80062460bd..2fc7308f6e 100644 --- a/tests/boundary/filesystem/flutterElfNotes.test.ts +++ b/tests/boundary/filesystem/flutterElfNotes.test.ts @@ -5,6 +5,7 @@ import { readElfBuildIds } from "../../../src/flutter/ElfBuildId.js"; import { elfNote, elfNoteFixture, + compressedElfNoteSectionFixture, } from "../../../src/native/elf/ElfNotes.fixture.js"; import { appendElfRanges, @@ -27,11 +28,12 @@ it.each([32, 64] as const)( bits, alignment, ownership: table === "program" ? "program" : "both", - notes: Buffer.concat( - ids.map((id) => + notes: Buffer.concat([ + elfNote("Example", 1, Buffer.from([1]), true, alignment), + ...ids.map((id) => elfNote("GNU", 3, Buffer.from(id, "hex"), true, alignment), ), - ), + ]), }); if (table === "section") image.writeUInt32LE(1, bits === 64 ? 64 : 52); const root = await createTestTempDirectory("rea-flutter-alignment-"); @@ -51,6 +53,55 @@ it.each([32, 64] as const)( }, ); +it.each( + ([32, 64] as const).flatMap((bits) => [ + { bits, empty: false }, + { bits, empty: true }, + ]), +)( + "retains other ELF$bits note IDs with compressed storage (empty: $empty)", + async ({ bits, empty }) => { + const encoded = compressedElfNoteSectionFixture(bits); + const noteSection = (bits === 64 ? 64 : 52) + (bits === 64 ? 64 : 40); + if (empty) { + if (bits === 64) encoded.writeBigUInt64LE(0n, noteSection + 32); + else encoded.writeUInt32LE(0, noteSection + 20); + } + // Engine metadata parsing also requires a program directory. + const compressed = appendElfRanges(encoded, "program", [ + { type: 1, offset: 0, size: 0 }, + ]); + const bytes = appendElfRanges( + Buffer.concat([compressed, note]), + "section", + [{ type: 7, offset: compressed.length, size: note.length }], + ); + const root = await createTestTempDirectory("rea-flutter-compressed-notes-"); + const path = join(root, "engine.so"); + await writeFile(path, bytes); + const file = await open(path, "r"); + const read = vi.spyOn(file, "read"); + try { + expect(await readElfBuildIds(file, bytes.length, undefined)).toEqual({ + candidates: ["0123456789abcdef"], + failures: [{ reason: "Compressed ELF note sections are unsupported" }], + }); + const storedOffset = + bits === 64 + ? Number(compressed.readBigUInt64LE(noteSection + 24)) + : compressed.readUInt32LE(noteSection + 16); + expect(read).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + storedOffset, + ); + } finally { + await file.close(); + } + }, +); + it.each( ([32, 64] as const).flatMap((bits) => [ { bits, program: 8, section: 4, reads: 5 }, diff --git a/tests/boundary/mcp/elfNotesMcp.test.ts b/tests/boundary/mcp/elfNotesMcp.test.ts index 1156308215..62d4ad920c 100644 --- a/tests/boundary/mcp/elfNotesMcp.test.ts +++ b/tests/boundary/mcp/elfNotesMcp.test.ts @@ -7,6 +7,7 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { elfExtendedSectionCountFixture, + compressedElfNoteSectionFixture, elfNoteFixture, } from "../../../src/native/elf/ElfNotes.fixture.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -72,6 +73,33 @@ mcpTest("requires an absolute path for inspect_elf_notes", async ({ mcp }) => { } }); +mcpTest( + "reports compressed note storage as unsupported without mutation", + async ({ mcp }) => { + const { client, session } = await connect(mcp); + try { + const root = await createTestTempDirectory("rea-elf-notes-storage-mcp-"); + const path = join(root, "debug-notes.o"); + const bytes = compressedElfNoteSectionFixture(); + await writeFile(path, bytes); + const response = await client.callTool({ + name: "inspect_elf_notes", + arguments: { path }, + }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response).error).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining( + "Compressed ELF note sections are unsupported", + ), + }); + expect(await readFile(path)).toEqual(bytes); + } finally { + await session.close(); + } + }, +); + mcpTest( "reports valid extended numbering as unsupported without mutation", async ({ mcp }) => {