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
This commit is contained in:
morluto
2026-10-24 02:32:53 +08:00
parent 25aaf89287
commit 75a757d8ef
10 changed files with 274 additions and 9 deletions
+8
View File
@@ -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.
+3
View File
@@ -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
+15
View File
@@ -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);
+45
View File
@@ -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],
+16 -5
View File
@@ -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",
+27
View File
@@ -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;
+22
View File
@@ -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 }) => {
+56 -1
View File
@@ -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",
);
},
);
@@ -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 },
+28
View File
@@ -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 }) => {