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
This commit is contained in:
morluto
2026-10-24 02:00:10 +08:00
parent 6fbe67d66e
commit 907f4d99f5
6 changed files with 249 additions and 59 deletions
+8 -1
View File
@@ -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.
+89 -5
View File
@@ -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<readonly [number, bigint]> = [],
): 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 },
+35 -17
View File
@@ -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<number, number>();
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,
+36 -2
View File
@@ -179,13 +179,14 @@ export const buildElf32Image = (
*/
export const buildStrippedElf64Image = (
symbols: ReadonlyArray<{ name: string; value: number; size: number }>,
extraDynamicEntries: ReadonlyArray<readonly [number, bigint]> = [],
): 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<readonly [number, number]> = [
const pairs: ReadonlyArray<readonly [number, number | bigint]> = [
[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<readonly [number, bigint]> = [],
): 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([
+56
View File
@@ -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 }) => {
+25 -34
View File
@@ -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);