From 907f4d99f5c1dd8b57cc90fd53503f67d3d3f47d Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:00:10 +0800 Subject: [PATCH] fix(flutter): derive dynamic symbol tables from their source Decode only consumed dynamic-entry payloads. Discover PT_DYNAMIC symbols when retained sections omit SHT_DYNSYM, with table provenance governing section-index applicability. Preserve malformed present-table failures and shared source fixtures. Fixes #6851 Fixes #6884 --- docs/flutter-analysis.md | 9 +- src/flutter/DartElf.test.ts | 94 ++++++++++++++++++-- src/flutter/DartElf.ts | 52 +++++++---- src/flutter/FlutterBuild.fixture.ts | 38 +++++++- tests/acceptance/flutterAnalysis.test.ts | 56 ++++++++++++ tests/boundary/mcp/dartAotSummaryMcp.test.ts | 59 ++++++------ 6 files changed, 249 insertions(+), 59 deletions(-) diff --git a/docs/flutter-analysis.md b/docs/flutter-analysis.md index cff9a1d258..cc4e8a48c1 100644 --- a/docs/flutter-analysis.md +++ b/docs/flutter-analysis.md @@ -154,9 +154,16 @@ server; older servers keep the complete request after schema discovery. - **Selected ABI.** AOT inspection applies the library budget to the selected ABI after selection; an oversized unselected library does not block it. Undefined dynamic-symbol imports are not treated as snapshot exports. + A retained section directory without `SHT_DYNSYM` uses the same bounded + `PT_DYNAMIC` symbol description as a section-stripped image. Malformed + present symbol-table sections remain failures. Section-stripped images require a bounded symbol count from `DT_HASH` or `DT_GNU_HASH`; layouts without either are refused. Symbol names and their - terminators must fit their linked string table. + terminators must fit their linked string table. Only dynamic entries used for + symbol discovery are interpreted as numeric bounds. ABI-ignored payloads, + including [`DT_BIND_NOW`](https://gabi.xinuos.com/elf/08-dynamic.html#dynamic-section), + do not change discovery; their stored bytes still contribute to the selected + artifact's digest. - REA performs **no hash-to-SDK lookup**: mapping the snapshot hash to a Dart SDK release requires an external dataset and is deliberately out of scope for this family. diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index da1557138a..573121171a 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -40,11 +40,16 @@ const imageWithSections = (): Buffer => { }; /** The same layout without section headers: symbols live in PT_DYNAMIC. */ -const strippedImageWithSections = (): Buffer => { - const elf = buildStrippedElf64Image([ - { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, - { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, - ]); +const strippedImageWithSections = ( + extraDynamicEntries: ReadonlyArray = [], +): Buffer => { + const elf = buildStrippedElf64Image( + [ + { name: "_kDartVmSnapshotData", value: 0x400, size: 52 }, + { name: "_kDartIsolateSnapshotData", value: 0x800, size: 52 }, + ], + extraDynamicEntries, + ); const section = snapshotSection(GALLERY_HASH); return Buffer.concat([ elf, @@ -90,6 +95,38 @@ describe("readElfSymbol", () => { expect(header.kind).toBe(3); }); + it.each([ + [16, "DT_SYMBOLIC"], + [22, "DT_TEXTREL"], + [24, "DT_BIND_NOW"], + ] as const)( + "ignores the payload of %s (%s) without losing symbols", + async (tag, _name) => { + const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"]; + const control = await readElfSymbols( + strippedImageWithSections([[tag, 0n]]), + names, + ); + expect(control.failures).toEqual([]); + expect(control.symbols).toHaveLength(2); + const image = strippedImageWithSections([[tag, 0xffffffffffffffffn]]); + const original = Buffer.from(image); + expect(await readElfSymbols(image, names)).toEqual(control); + expect(image).toEqual(original); + }, + ); + + it.each([4, 5, 6, 10, 11, 0x6ffffef5])( + "keeps representability checks for consumed dynamic tag %s", + async (tag) => { + const image = strippedImageWithSections([[tag, 0xffffffffffffffffn]]); + expect(await readElfSymbols(image, ["_kDartVmSnapshotData"])).toEqual({ + symbols: [], + failures: [{ reason: "Dynamic entry is not representable" }], + }); + }, + ); + it("refuses a stripped image whose PT_DYNAMIC carries no symbols", async () => { // Drop the PT_DYNAMIC program header by leaving only PT_LOAD. const image = strippedImageWithSections(); @@ -144,6 +181,53 @@ describe("readElfSymbol", () => { ); }); +describe("dynamic symbols without a SHT_DYNSYM section", () => { + it.each([false, true])( + "uses PT_DYNAMIC with a retained directory (data sections: %s)", + async (retainDataSections) => { + const original = strippedImageWithSections(); + const image = appendElfRanges( + original, + "section", + retainDataSections + ? [ + { type: 1, offset: 0x400, size: 52 }, + { type: 1, offset: 0x800, size: 52 }, + ] + : [], + ); + const names = ["_kDartVmSnapshotData", "_kDartIsolateSnapshotData"]; + const control = await readElfSymbols(original, names); + expect(control.failures).toEqual([]); + expect(control.symbols).toHaveLength(2); + const before = Buffer.from(image); + expect(await readElfSymbols(image, names)).toEqual(control); + expect(image).toEqual(before); + }, + ); + + it("keeps malformed present dynsym sections as failures despite usable PT_DYNAMIC", async () => { + const image = appendElfRanges(strippedImageWithSections(), "section", [ + { type: 11, offset: 0, size: 0 }, + ]); + expect(await readElfSymbols(image, ["_kDartVmSnapshotData"])).toEqual({ + symbols: [], + failures: [{ reason: "Dynamic symbol table is malformed" }], + }); + }); + + it("keeps bounded dynamic metadata requirements with a retained directory", async () => { + const image = appendElfRanges(strippedImageWithSections(), "section", []); + // The first entry is DT_SYMTAB, whose address must remain representable. + const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); + image.writeBigUInt64LE(0xffffffffffffffffn, dynamic + 8); + expect(await readElfSymbols(image, ["_kDartVmSnapshotData"])).toEqual({ + symbols: [], + failures: [{ reason: "Dynamic entry is not representable" }], + }); + }); +}); + describe("ELF symbol-table ownership", () => { it.each([ { address: 0x400, offset: 0x700, size: 52 }, diff --git a/src/flutter/DartElf.ts b/src/flutter/DartElf.ts index 91d6ba4445..b334ffe73c 100644 --- a/src/flutter/DartElf.ts +++ b/src/flutter/DartElf.ts @@ -65,10 +65,12 @@ const PT_DYNAMIC = 2; /** Position-independent executable images carry PT_DYNAMIC. */ const ET_DYN = 3; const DT_NULL = 0; +const DT_HASH = 4; const DT_STRTAB = 5; const DT_SYMTAB = 6; const DT_STRSZ = 10; const DT_SYMENT = 11; +const DT_GNU_HASH = 0x6ffffef5; /** ELF32 needs 52 bytes, ELF64 needs 64; retain at most this prefix. */ export const ELF_HEADER_MAX_BYTES = 64; @@ -115,17 +117,18 @@ export const readElfHeader = ( count > 0 && count <= Math.floor((totalBytes - offset) / width); const MIN_SHENT = is64 ? 64 : 40; - // Section-stripped ET_DYN images keep their exported symbols reachable - // through PT_DYNAMIC instead of section headers; every other shape still - // needs a usable section header table. + // Section-stripped ET_DYN images may omit the section directory. This + // describes header availability, not which symbol-table source is usable. // A nonzero shoff with shnum=0 uses extended numbering, which this // parser does not support; it must not masquerade as a stripped image. - const dynamicFallback = e_type === ET_DYN && e_shoff === 0 && e_shnum === 0; + const sectionTableAbsent = + e_type === ET_DYN && e_shoff === 0 && e_shnum === 0; if ( e_shoff === null || e_shentsize === null || e_shnum === null || - (!dynamicFallback && !tableFits(e_shoff, e_shentsize, e_shnum, MIN_SHENT)) + (!sectionTableAbsent && + !tableFits(e_shoff, e_shentsize, e_shnum, MIN_SHENT)) ) return { failure: { @@ -149,7 +152,7 @@ export const readElfHeader = ( return { header: { is64, - dynamicFallback, + sectionTableAbsent, e_shoff, e_shentsize, e_shnum, @@ -180,7 +183,7 @@ export const readElfSymbols = async ( if (parsed.failure !== undefined) return failure(parsed.failure.reason); const { is64, - dynamicFallback, + sectionTableAbsent, e_shoff, e_shentsize, e_shnum, @@ -221,6 +224,7 @@ export const readElfSymbols = async ( if (segments.length === 0) return failure("ELF image has no PT_LOAD segments"); const tables: { + source: "section" | "dynamic-segment"; offset: number; count: number; stringOffset: number; @@ -248,7 +252,7 @@ export const readElfSymbols = async ( ? undefined : "section offset disagrees with its PT_LOAD mapping"; }; - if (!dynamicFallback) { + if (!sectionTableAbsent) { for (let index = 0; index < e_shnum; index += 1) { if (index % 4096 === 0) { await yieldToEventLoop(); @@ -303,13 +307,18 @@ export const readElfSymbols = async ( if (stringMapping !== undefined) return failure(`Dynamic symbol string table ${stringMapping}`); tables.push({ + source: "section", offset, count: size / entrySize, stringOffset, stringSize, }); } - } else if (dynamicSegment !== null) { + } + // A retained section directory may omit SHT_DYNSYM while PT_DYNAMIC still + // describes the runtime symbols. A malformed present section already failed + // above; it must not be bypassed by an alternative interpretation. + if (tables.length === 0 && dynamicSegment !== null) { const tags = new Map(); const dynamicEntrySize = is64 ? 16 : 8; let terminated = false; @@ -330,14 +339,22 @@ export const readElfSymbols = async ( terminated = true; break; } - const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); - if (value === null) return failure("Dynamic entry is not representable"); + // Interpret d_un only for the symbol-table description consumed here. + // In particular, DT_SYMBOLIC, DT_TEXTREL and DT_BIND_NOW have ignored + // payloads under the ELF ABI; their arbitrary bits are not numeric bounds. if ( - ![DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, 4, 0x6ffffef5].includes( - tag, - ) + ![ + DT_SYMTAB, + DT_STRTAB, + DT_STRSZ, + DT_SYMENT, + DT_HASH, + DT_GNU_HASH, + ].includes(tag) ) continue; + const value = is64 ? reader.u64(base + 8) : reader.u32(base + 4); + if (value === null) return failure("Dynamic entry is not representable"); if (tags.has(tag) && tags.get(tag) !== value) return failure("Conflicting dynamic table tags"); tags.set(tag, value); @@ -358,8 +375,8 @@ export const readElfSymbols = async ( // DT_HASH's nchain is the dynsym count. GNU hash instead records the // first hashed index and bounded buckets/chains. No payload-to-EOF walk. let count: number; - const sysv = tags.get(4); - const gnu = tags.get(0x6ffffef5); + const sysv = tags.get(DT_HASH); + const gnu = tags.get(DT_GNU_HASH); if (sysv !== undefined) { const at = toOffset(sysv, 8); if (!at.ok) return failure(`DT_HASH header ${at.error.reason}`); @@ -420,6 +437,7 @@ export const readElfSymbols = async ( if (!stringOffset.ok) return failure(`Dynamic string table ${stringOffset.error.reason}`); tables.push({ + source: "dynamic-segment", offset: offset.value, count, stringOffset: stringOffset.value, @@ -444,7 +462,7 @@ export const readElfSymbols = async ( sectionIndex === 0xfff1 || (sectionIndex > 0 && sectionIndex < 0xff00 && - (dynamicFallback || sectionIndex < e_shnum)); + (table.source === "dynamic-segment" || sectionIndex < e_shnum)); if (!defined || nameOffset >= table.stringSize) continue; const name = reader.cString( table.stringOffset + nameOffset, diff --git a/src/flutter/FlutterBuild.fixture.ts b/src/flutter/FlutterBuild.fixture.ts index 65ce3323da..8567b488c6 100644 --- a/src/flutter/FlutterBuild.fixture.ts +++ b/src/flutter/FlutterBuild.fixture.ts @@ -179,13 +179,14 @@ export const buildElf32Image = ( */ export const buildStrippedElf64Image = ( symbols: ReadonlyArray<{ name: string; value: number; size: number }>, + extraDynamicEntries: ReadonlyArray = [], ): Buffer => { const ELF_HEADER = 64; const PHENT = 56; const PHNUM = 2; const dynamicEntry = 16; // DT_SYMTAB, DT_STRTAB, DT_STRSZ, DT_SYMENT, DT_HASH, DT_NULL. - const DYNAMIC_ENTRIES = 6; + const DYNAMIC_ENTRIES = 6 + extraDynamicEntries.length; const phoff = ELF_HEADER; const dynamicOff = phoff + PHENT * PHNUM; const dynsymOff = dynamicOff + DYNAMIC_ENTRIES * dynamicEntry; @@ -228,12 +229,13 @@ export const buildStrippedElf64Image = ( image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 8); // p_offset image.writeBigUInt64LE(BigInt(dynamicOff), dyn + 0x10); // p_vaddr image.writeBigUInt64LE(BigInt(DYNAMIC_ENTRIES * dynamicEntry), dyn + 0x20); - const pairs: ReadonlyArray = [ + const pairs: ReadonlyArray = [ [6, dynsymOff], [5, dynstrOff], [10, strtabBytes], [11, 24], [4, hashOff], + ...extraDynamicEntries, ]; pairs.forEach(([tag, value], index) => { const base = dynamicOff + index * dynamicEntry; @@ -259,6 +261,38 @@ export const buildStrippedElf64Image = ( return image; }; +/** Four snapshot exports and their pool, discovered without section headers. */ +export const strippedAotFixtureImage = ( + extraDynamicEntries: ReadonlyArray = [], +): Buffer => { + const symbols = buildStrippedElf64Image( + [ + { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, + { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, + { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, + { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, + ], + extraDynamicEntries, + ); + const vmSection = snapshotSection(REFERENCE_HASH); + const isolateSection = Buffer.concat([ + snapshotSection(REFERENCE_HASH), + Buffer.from([0]), + Buffer.from("package:example/main.dart\0dart:core\0Example\0"), + ]); + return Buffer.concat([ + symbols, + Buffer.alloc(0x1000 - symbols.length), + vmSection, + Buffer.alloc(0x1800 - 0x1000 - vmSection.length), + Buffer.alloc(16), + Buffer.alloc(0x2000 - 0x1800 - 16), + isolateSection, + Buffer.alloc(0x600 - isolateSection.length), + Buffer.alloc(16), + ]); +}; + /** A minimal Dart snapshot data section carrying a 32-hex hash. */ export const snapshotSection = (hash: string): Buffer => Buffer.concat([ diff --git a/tests/acceptance/flutterAnalysis.test.ts b/tests/acceptance/flutterAnalysis.test.ts index 7d268a9999..2e364f1c57 100644 --- a/tests/acceptance/flutterAnalysis.test.ts +++ b/tests/acceptance/flutterAnalysis.test.ts @@ -6,6 +6,7 @@ import { parseEvidence } from "../../src/domain/evidence.js"; import { flutterResultSchemas } from "../../src/domain/flutter/flutterBuildAnalysis.js"; import { aotFixtureImage, + strippedAotFixtureImage, engineFixtureImage, REFERENCE_HASH, appendElfRanges, @@ -18,6 +19,61 @@ import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { cliTest } from "../support/cli/cliFixture.js"; +cliTest( + "CLI and stdio MCP preserve AOT facts and exact artifact identity across ignored DT_BIND_NOW values", + async ({ cli }) => { + const { call } = await connectLocalToolsMcp(); + const reports = []; + const digests = []; + for (const ignoredValue of [0n, 0xffffffffffffffffn]) { + const image = strippedAotFixtureImage([[24, ignoredValue]]); + const path = await createFlutterApk( + `bind-now-${String(ignoredValue)}.apk`, + [{ path: "lib/x86_64/libapp.so", bytes: image }], + ); + const result = await cli.run({ + arguments: ["inspect-dart-aot", path, "--abi", "x86_64", "--json"], + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = toolContract("inspect_dart_aot").outputSchema.parse( + result.json, + ); + const report = flutterResultSchemas.inspect_dart_aot.parse( + evidence.normalized_result, + ); + const response = await call("inspect_dart_aot", { path, abi: "x86_64" }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(parseEvidence(response.structuredContent)).toEqual(evidence); + const digest = createHash("sha256").update(image).digest("hex"); + expect(report).toMatchObject({ + coverage: "complete", + libapp: { + sha256: digest, + coverage: "complete", + snapshot_hash: REFERENCE_HASH, + snapshot_hash_sources: 2, + }, + string_pool: { + coverage: "complete", + package_uris: ["package:example/main.dart"], + }, + }); + expect(report.libapp.sections).toHaveLength(4); + expect(evidence.subject).toMatchObject({ + local_path: path, + digest: { sha256: report.target.sha256 }, + }); + reports.push({ + sections: report.libapp.sections, + string_pool: report.string_pool, + }); + digests.push(digest); + } + expect(reports[1]).toEqual(reports[0]); + expect(digests[1]).not.toBe(digests[0]); + }, +); + cliTest( "CLI and stdio MCP retain Flutter identity, selected ABI and duplicate-member refusals", async ({ cli }) => { diff --git a/tests/boundary/mcp/dartAotSummaryMcp.test.ts b/tests/boundary/mcp/dartAotSummaryMcp.test.ts index b9132022c5..0d4cf5af4e 100644 --- a/tests/boundary/mcp/dartAotSummaryMcp.test.ts +++ b/tests/boundary/mcp/dartAotSummaryMcp.test.ts @@ -1,4 +1,5 @@ import { unlink } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished, vi } from "vitest"; import { FlutterBuildAnalysisService } from "../../../src/application/flutter/FlutterBuildAnalysisService.js"; @@ -14,40 +15,14 @@ import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAn import type { JsonValue } from "../../../src/domain/jsonValue.js"; import { REFERENCE_HASH, + appendElfRanges, aotFixtureImage, - buildStrippedElf64Image, - snapshotSection, + strippedAotFixtureImage, } from "../../../src/flutter/FlutterBuild.fixture.js"; import { createFlutterApk } from "../../fixtures/flutterApk.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; -const strippedAotImage = (): Buffer => { - const symbols = buildStrippedElf64Image([ - { name: "_kDartVmSnapshotData", value: 0x1000, size: 52 }, - { name: "_kDartVmSnapshotInstructions", value: 0x1800, size: 16 }, - { name: "_kDartIsolateSnapshotData", value: 0x2000, size: 0x600 }, - { name: "_kDartIsolateSnapshotInstructions", value: 0x2600, size: 16 }, - ]); - const vmSection = snapshotSection(REFERENCE_HASH); - const isolateSection = Buffer.concat([ - snapshotSection(REFERENCE_HASH), - Buffer.from([0]), - Buffer.from("package:example/main.dart\0dart:core\0Example\0"), - ]); - return Buffer.concat([ - symbols, - Buffer.alloc(0x1000 - symbols.length), - vmSection, - Buffer.alloc(0x1800 - 0x1000 - vmSection.length), - Buffer.alloc(16), - Buffer.alloc(0x2000 - 0x1800 - 16), - isolateSection, - Buffer.alloc(0x600 - isolateSection.length), - Buffer.alloc(16), - ]); -}; - const dynamicSegmentOffset = (image: Buffer): number => Number(image.readBigUInt64LE(64 + 56 + 8)); @@ -333,14 +308,24 @@ it("rejects unavailable retention, invalid delivery and cancellation before prod expect(execute).not.toHaveBeenCalled(); }); -it.each([0n, 0xffffffffffffffffn])( - "accepts a complete PT_DYNAMIC DT_NULL entry with ignored value %s through MCP", - async (ignoredValue) => { - const image = strippedAotImage(); +it.each([ + { ignoredValue: 0n, retainedSections: false }, + { ignoredValue: 0xffffffffffffffffn, retainedSections: false }, + { ignoredValue: 0n, retainedSections: true }, +])( + "accepts bounded PT_DYNAMIC through MCP (ignored value: $ignoredValue, retained sections: $retainedSections)", + async ({ ignoredValue, retainedSections }) => { + const image = strippedAotFixtureImage(); const dynamic = dynamicSegmentOffset(image); image.writeBigUInt64LE(ignoredValue, dynamic + 5 * 16 + 8); + const selected = retainedSections + ? appendElfRanges(image, "section", [ + { type: 1, offset: 0x1000, size: 52 }, + { type: 1, offset: 0x2000, size: 0x600 }, + ]) + : image; const path = await createFlutterApk("dt-null.apk", [ - { path: "lib/arm64-v8a/libapp.so", bytes: image }, + { path: "lib/arm64-v8a/libapp.so", bytes: selected }, ]); const provider = createFlutterBuildAnalysisProvider(); onTestFinished(() => provider.close()); @@ -360,6 +345,12 @@ it.each([0n, 0xffffffffffffffffn])( coverage: "complete", snapshot_hash: REFERENCE_HASH, snapshot_hash_sources: 2, + sha256: createHash("sha256").update(selected).digest("hex"), + }); + expect(report.libapp.sections).toHaveLength(4); + expect(report.string_pool).toMatchObject({ + coverage: "complete", + package_uris: ["package:example/main.dart"], }); }, ); @@ -378,7 +369,7 @@ it.each([ ])( "refuses unusable dynamic tables for $label", async ({ truncate, reason }) => { - const image = strippedAotImage(); + const image = strippedAotFixtureImage(); const dynamic = dynamicSegmentOffset(image); if (truncate) { image.writeBigUInt64LE(5n * 16n + 15n, 64 + 56 + 0x20);