Files
reverse-engineering-anything/src/flutter/DartElf.ts
T
morluto 94dd7b72f8 fix(flutter): preserve producer-attested cancellation and physical failures (#10089)
* fix(flutter): preserve physical failures and cancellation identity

* fix(flutter): preserve independent abort-shaped failures

* fix(flutter): retain cancellation through failed cleanup
2026-11-11 18:32:32 +08:00

581 lines
20 KiB
TypeScript

import { assertArtifactReadActive } from "../artifacts/ArtifactFileOperation.js";
import {
observeElfStringTableEntry,
elfStringTableText,
} from "../domain/elfStringTable.js";
import { err, ok } from "../domain/result.js";
import { readElfDynamicEntry } from "../domain/elfDynamicEntry.js";
import {
readElfGnuHashSymbolCount,
readElfHashSymbolCount,
type ElfGnuHashCountWalk,
type ElfHashWordReader,
} from "../domain/elfHashSymbolCount.js";
import { readElfSectionHeader } from "../domain/elfSectionHeader.js";
import { readElfProgramHeader } from "../domain/elfProgramHeader.js";
import { readElfSymbol } from "../domain/elfSymbol.js";
import { readElfRawTableDeclarations } from "../domain/elfRawTableDeclarations.js";
import { Buffer } from "node:buffer";
import { setImmediate as yieldToEventLoop } from "node:timers/promises";
import { createElfLoadMap } from "./ElfLoadMap.js";
import {
SNAPSHOT_HASH_LENGTH,
SNAPSHOT_HASH_OFFSET,
SNAPSHOT_HASH_PATTERN,
} from "./FlutterPayloadScan.js";
import { readElfFixedHeader } from "../domain/elfFixedHeader.js";
/** Keep the event loop and cancellation live across a large DT_GNU_HASH walk. */
const finishGnuHashCount = async (
walk: ElfGnuHashCountWalk,
signal: AbortSignal | undefined,
) => {
for (let step = walk.next(); ; step = walk.next()) {
if (step.done === true) return step.value;
await yieldToEventLoop();
assertArtifactReadActive(signal);
}
};
/** Bounds-checked little-endian reader over one loaded buffer. */
class BoundedReader {
constructor(private readonly bytes: Buffer) {}
u8(offset: number): number | null {
return offset < this.bytes.length ? this.bytes.readUInt8(offset) : null;
}
u32(offset: number): number | null {
return offset + 4 <= this.bytes.length
? this.bytes.readUInt32LE(offset)
: null;
}
u64(offset: number): number | null {
const low = this.u32(offset);
const high = this.u32(offset + 4);
if (low === null || high === null || high > 0x001fffff) return null;
return high * 0x1_0000_0000 + low;
}
cString(offset: number, tableEnd: number): string | null {
if (tableEnd < 0 || tableEnd > this.bytes.length) return null;
return elfStringTableText(
observeElfStringTableEntry(this.bytes.subarray(0, tableEnd), offset, 257),
);
}
}
export interface ElfSymbol {
readonly name: string;
/** File offset mapped from the virtual address through PT_LOAD. */
readonly offset: number;
readonly size: number;
}
export interface ElfParseFailure {
readonly reason: string;
}
const SHT_DYNSYM = 11;
const SHF_ALLOC = 2;
const PT_LOAD = 1;
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;
/**
* Decode the supported ELF header and validate its table extents against
* the complete image size. The buffer may contain only the header prefix;
* this does not validate table contents or symbol availability.
*/
export const readElfHeader = (
bytes: Buffer,
totalBytes: number = bytes.length,
) => {
const fixedHeader = readElfFixedHeader(bytes, totalBytes);
if (!fixedHeader.ok) return { failure: { reason: fixedHeader.error } };
const is64 = fixedHeader.value.elfClass === 64;
const headerBytes = is64 ? ELF_HEADER_MAX_BYTES : 52;
if (fixedHeader.value.byteOrder !== "little")
return { failure: { reason: "unsupported ELF byte order" } };
if (fixedHeader.value.headerSize !== headerBytes)
return { failure: { reason: "unsupported ELF header size" } };
const e_type = fixedHeader.value.type;
if (e_type !== 1 && e_type !== 2 && e_type !== ET_DYN)
return { failure: { reason: "unsupported ELF type" } };
const raw = readElfRawTableDeclarations(bytes, fixedHeader.value);
if (!raw.ok) return { failure: { reason: raw.error.message } };
const { programs, sections } = raw.value;
const e_shoff =
sections.offset <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(sections.offset)
: null;
const e_shentsize = sections.width;
const e_shnum = sections.count;
const e_phoff =
programs.offset <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(programs.offset)
: null;
const e_phentsize = programs.width;
const e_phnum = programs.count;
const tableFits = (
offset: number | null,
width: number,
count: number,
minimumWidth: number,
): boolean =>
offset !== null &&
offset >= headerBytes &&
offset <= totalBytes &&
width >= minimumWidth &&
count > 0 &&
count <= Math.floor((totalBytes - offset) / width);
const MIN_SHENT = is64 ? 64 : 40;
// 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 sectionTableAbsent =
e_type === ET_DYN && e_shoff === 0 && e_shnum === 0;
if (
e_shoff === null ||
(!sectionTableAbsent &&
!tableFits(e_shoff, e_shentsize, e_shnum, MIN_SHENT))
)
return {
failure: {
reason: "ELF section header table is missing or out of bounds",
},
};
const MIN_PHENT = is64 ? 56 : 32;
if (
e_phoff === null ||
e_phnum === 0xffff ||
!tableFits(e_phoff, e_phentsize, e_phnum, MIN_PHENT)
)
return {
failure: {
reason: "ELF program header table is missing or out of bounds",
},
};
return {
header: {
is64,
sectionTableAbsent,
e_shoff,
e_shentsize,
e_shnum,
e_phoff,
e_phentsize,
e_phnum,
},
};
};
export interface ElfSymbolInventory {
readonly symbols: readonly ElfSymbol[];
readonly failures: readonly ElfParseFailure[];
}
/** Decode the ELF tables once and resolve only the requested definitions. */
export const readElfSymbols = async (
bytes: Buffer,
symbolNames: readonly string[],
signal?: AbortSignal,
): Promise<ElfSymbolInventory> => {
assertArtifactReadActive(signal);
const failure = (reason: string): ElfSymbolInventory => ({
symbols: [],
failures: [{ reason }],
});
const parsed = readElfHeader(bytes);
if (parsed.failure !== undefined) return failure(parsed.failure.reason);
const {
is64,
sectionTableAbsent,
e_shoff,
e_shentsize,
e_shnum,
e_phoff,
e_phentsize,
e_phnum,
} = parsed.header;
const reader = new BoundedReader(bytes);
const entrySize = is64 ? 24 : 16;
const segments: { vaddr: number; offset: number; filesz: number }[] = [];
let dynamicSegment: { offset: number; filesz: number } | null = null;
for (let index = 0; index < e_phnum; index += 1) {
if (index % 4096 === 0) {
await yieldToEventLoop();
assertArtifactReadActive(signal);
}
const base = e_phoff + index * e_phentsize;
const parsedProgram = readElfProgramHeader(
bytes.subarray(base, base + e_phentsize),
is64 ? 64 : 32,
true,
);
if (!parsedProgram.ok) return failure(parsedProgram.error.message);
const program = parsedProgram.value;
const { type } = program;
if (type !== PT_LOAD && type !== PT_DYNAMIC) continue;
if (
program.virtualAddress > BigInt(Number.MAX_SAFE_INTEGER) ||
program.offset > BigInt(Number.MAX_SAFE_INTEGER) ||
program.fileSize > BigInt(Number.MAX_SAFE_INTEGER)
)
return failure(`ELF segment ${String(index)} is not file-backed`);
const vaddr = Number(program.virtualAddress);
const offset = Number(program.offset);
const filesz = Number(program.fileSize);
if (
!Number.isSafeInteger(vaddr + filesz) ||
offset > bytes.length ||
filesz > bytes.length - offset
)
return failure(`ELF segment ${String(index)} is not file-backed`);
if (type === PT_LOAD) segments.push({ vaddr, offset, filesz });
else if (dynamicSegment !== null)
return failure("Multiple PT_DYNAMIC segments are ambiguous");
else dynamicSegment = { offset, filesz };
}
const toOffset = await createElfLoadMap(segments, signal);
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;
stringSize: number;
}[] = [];
/**
* The loader reads an allocated section through PT_LOAD, so its section
* offset must be the single file interpretation of its address range.
*/
const sectionMappingFailure = (
section: { readonly flags: bigint; readonly address: bigint },
offset: number,
size: number,
): string | undefined => {
const flags = Number(section.flags);
const address = Number(section.address);
if (!Number.isSafeInteger(flags) || !Number.isSafeInteger(address))
return "has an unrepresentable section header";
if ((flags & SHF_ALLOC) === 0) return undefined;
const mapped = toOffset(address, size);
if (!mapped.ok) return mapped.error.reason;
return mapped.value === offset
? undefined
: "section offset disagrees with its PT_LOAD mapping";
};
if (!sectionTableAbsent) {
for (let index = 0; index < e_shnum; index += 1) {
if (index % 4096 === 0) {
await yieldToEventLoop();
assertArtifactReadActive(signal);
}
const header = e_shoff + index * e_shentsize;
const parsedSection = readElfSectionHeader(
bytes.subarray(header, header + e_shentsize),
is64 ? 64 : 32,
true,
);
if (!parsedSection.ok) return failure(parsedSection.error.message);
const section = parsedSection.value;
if (section.type !== SHT_DYNSYM) continue;
const offset = Number(section.offset);
const size = Number(section.size);
const link = section.link;
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
section.entrySize !== BigInt(entrySize) ||
offset > bytes.length ||
size > bytes.length - offset ||
size % entrySize !== 0
)
return failure("Dynamic symbol table is malformed");
if (link >= e_shnum)
return failure("Dynamic symbol string section link is invalid");
const stringHeader = e_shoff + link * e_shentsize;
const parsedStrings = readElfSectionHeader(
bytes.subarray(stringHeader, stringHeader + e_shentsize),
is64 ? 64 : 32,
true,
);
if (!parsedStrings.ok) return failure(parsedStrings.error.message);
const strings = parsedStrings.value;
const stringOffset = Number(strings.offset);
const stringSize = Number(strings.size);
if (
strings.type !== 3 ||
!Number.isSafeInteger(stringOffset) ||
!Number.isSafeInteger(stringSize) ||
stringOffset > bytes.length ||
stringSize > bytes.length - stringOffset
)
return failure(
"Dynamic symbol strings are out of bounds or not a string table",
);
const symbolMapping = sectionMappingFailure(section, offset, size);
if (symbolMapping !== undefined)
return failure(`Dynamic symbol table ${symbolMapping}`);
const stringMapping = sectionMappingFailure(
strings,
stringOffset,
stringSize,
);
if (stringMapping !== undefined)
return failure(`Dynamic symbol string table ${stringMapping}`);
tables.push({
source: "section",
offset,
count: size / entrySize,
stringOffset,
stringSize,
});
}
}
// 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;
for (
let base = dynamicSegment.offset;
base + dynamicEntrySize <= dynamicSegment.offset + dynamicSegment.filesz;
base += dynamicEntrySize
) {
if ((base - dynamicSegment.offset) % (4096 * dynamicEntrySize) === 0) {
await yieldToEventLoop();
assertArtifactReadActive(signal);
}
const parsedEntry = readElfDynamicEntry(
bytes.subarray(base, base + dynamicEntrySize),
is64 ? 64 : 32,
true,
);
if (!parsedEntry.ok) return failure(parsedEntry.error.message);
// This reader historically admits tag identities as unsigned, safe numbers.
const tagBits = BigInt.asUintN(is64 ? 64 : 32, parsedEntry.value.tag);
if (tagBits > BigInt(Number.MAX_SAFE_INTEGER))
return failure("Dynamic entry is not representable");
const tag = Number(tagBits);
// ELF defines d_un as ignored for DT_NULL; validate the complete entry
// above, but do not interpret a value that has no meaning.
if (tag === DT_NULL) {
terminated = true;
break;
}
// 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,
DT_HASH,
DT_GNU_HASH,
].includes(tag)
)
continue;
if (parsedEntry.value.value > BigInt(Number.MAX_SAFE_INTEGER))
return failure("Dynamic entry is not representable");
const value = Number(parsedEntry.value.value);
if (tags.has(tag) && tags.get(tag) !== value)
return failure("Conflicting dynamic table tags");
tags.set(tag, value);
}
const sym = tags.get(DT_SYMTAB);
const str = tags.get(DT_STRTAB);
const stringSize = tags.get(DT_STRSZ);
if (
!terminated ||
sym === undefined ||
str === undefined ||
stringSize === undefined ||
tags.get(DT_SYMENT) !== entrySize
)
return failure(
"Stripped ELF lacks a complete dynamic symbol-table description",
);
const sysv = tags.get(DT_HASH);
const gnu = tags.get(DT_GNU_HASH);
const hashWords =
(address: number): ElfHashWordReader =>
(offset) => {
const mapped = toOffset(address, offset + 4);
return mapped.ok
? ok(reader.u32(mapped.value + offset)!)
: err(mapped.error.reason);
};
if (sysv === undefined && gnu === undefined)
return failure("Stripped ELF has no DT_HASH or DT_GNU_HASH symbol count");
const counted =
sysv !== undefined
? readElfHashSymbolCount(hashWords(sysv))
: await finishGnuHashCount(
readElfGnuHashSymbolCount(hashWords(gnu!), is64 ? 8 : 4),
signal,
);
// These walks pass no budget, so a failure here is always malformed.
if (!counted.ok)
return failure(
counted.error.kind === "malformed"
? counted.error.message
: counted.error.label,
);
const count = counted.value;
if (count === null)
return failure(
"Stripped ELF has an indeterminate DT_GNU_HASH symbol count",
);
const offset = toOffset(sym, count * entrySize);
const stringOffset = toOffset(str, stringSize);
if (!offset.ok)
return failure(`Dynamic symbol table ${offset.error.reason}`);
if (!stringOffset.ok)
return failure(`Dynamic string table ${stringOffset.error.reason}`);
tables.push({
source: "dynamic-segment",
offset: offset.value,
count,
stringOffset: stringOffset.value,
stringSize,
});
}
const wanted = new Set(symbolNames);
const symbols = new Map<string, ElfSymbol>();
const ambiguous = new Set<string>();
const unresolved = new Set<string>();
const failures = new Set<string>();
for (const table of tables) {
for (let index = 0; index < table.count; index += 1) {
if (index % 4096 === 0) {
await yieldToEventLoop();
assertArtifactReadActive(signal);
}
const base = table.offset + index * entrySize;
const parsedSymbol = readElfSymbol(
bytes.subarray(base, base + entrySize),
is64 ? 64 : 32,
true,
);
if (!parsedSymbol.ok) return failure(parsedSymbol.error.message);
const symbol = parsedSymbol.value;
const nameOffset = symbol.name_offset;
const sectionIndex = symbol.section_index;
const defined =
sectionIndex === 0xfff1 ||
(sectionIndex > 0 &&
sectionIndex < 0xff00 &&
(table.source === "dynamic-segment" || sectionIndex < e_shnum));
if (!defined || nameOffset >= table.stringSize) continue;
const name = reader.cString(
table.stringOffset + nameOffset,
table.stringOffset + table.stringSize,
);
if (name === null || !wanted.has(name)) continue;
if (
symbol.value > BigInt(Number.MAX_SAFE_INTEGER) ||
symbol.size > BigInt(Number.MAX_SAFE_INTEGER)
) {
failures.add(`Symbol ${name} has unrepresentable bounds`);
unresolved.add(name);
continue;
}
const value = Number(symbol.value);
const size = Number(symbol.size);
const offset = toOffset(value, size);
if (!offset.ok) {
failures.add(`Symbol ${name} ${offset.error.reason}`);
unresolved.add(name);
continue;
}
const previous = symbols.get(name);
if (
previous !== undefined &&
(previous.offset !== offset.value || previous.size !== size)
)
ambiguous.add(name);
else symbols.set(name, { name, offset: offset.value, size });
}
}
for (const name of ambiguous) {
symbols.delete(name);
failures.add(`Symbol ${name} has conflicting definitions`);
}
for (const name of unresolved) symbols.delete(name);
for (const name of wanted)
if (!symbols.has(name) && !ambiguous.has(name) && !unresolved.has(name))
failures.add(`symbol ${name} is not in the dynamic symbol table`);
return {
symbols: [...symbols.values()],
failures: [...failures].map((reason) => ({ reason })),
};
};
/** Read one Dart snapshot section header from its file offset. */
export const readSnapshotHeader = (
bytes: Buffer,
offset: number,
): {
magicValid: boolean;
kind: number | null;
headerLength: number | null;
hash: string | null;
complete: boolean;
} => {
const reader = new BoundedReader(bytes);
// The Dart magic bytes f5 f5 dc dc read little-endian. Kind and length
// fields are only meaningful in data-section headers; sections without
// the magic (instructions carry a different layout) report nulls.
const magic = reader.u32(offset);
if (magic !== 0xdcdcf5f5)
return {
magicValid: false,
kind: null,
headerLength: null,
hash: null,
complete: false,
};
const headerLength = reader.u64(offset + 4);
const kind = reader.u64(offset + 12);
const candidate = bytes
.subarray(
offset + SNAPSHOT_HASH_OFFSET,
offset + SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH,
)
.toString("latin1");
const hash = SNAPSHOT_HASH_PATTERN.test(candidate) ? candidate : null;
// Dart's stored length excludes the four magic bytes and includes the
// 20-byte fixed header and the version hash. Aligned data may follow it
// inside the exported symbol, but the serialized snapshot must fit.
// Kind 3 is FullAOT in the supported Dart 2.14 layout; enum values change
// across SDK versions, so other kinds remain uninterpreted observations.
const complete =
headerLength !== null &&
headerLength >= SNAPSHOT_HASH_OFFSET + SNAPSHOT_HASH_LENGTH - 4 &&
headerLength <= bytes.length - offset - 4 &&
kind === 3 &&
hash !== null;
return { magicValid: true, kind, headerLength, hash, complete };
};