mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
75a757d8ef
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
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
|
import { readElfFixedHeader } from "../../domain/elfFixedHeader.js";
|
|
import {
|
|
elfNoteAlignment as noteAlignment,
|
|
elfNoteLayout,
|
|
} from "../../domain/native/elfNoteLayout.js";
|
|
import type {
|
|
ElfNotes,
|
|
InspectElfNotesInput,
|
|
} from "../../domain/native/elfNotes.js";
|
|
import {
|
|
elfObjectTypeName,
|
|
gnuNoteTypeName,
|
|
} from "../../domain/native/elfNotes.js";
|
|
import { BinaryFormatFailure } from "../metadata/BinaryContainer.js";
|
|
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 {
|
|
limit: number;
|
|
used: number;
|
|
hashed: number;
|
|
scanned: number;
|
|
readonly hashes: Map<string, string>;
|
|
readonly scans: Map<string, number>;
|
|
}
|
|
|
|
/** Caller can raise max_file_bytes to admit the decoded section-name table. */
|
|
export class ElfNoteSectionNameBudgetFailure extends Error {
|
|
readonly _tag = "ElfNoteSectionNameBudgetFailure";
|
|
|
|
constructor(
|
|
readonly minimumMaxFileBytes: bigint,
|
|
readonly selectedMaxFileBytes: number,
|
|
) {
|
|
super(
|
|
`ELF source and decoded section names require ${String(minimumMaxFileBytes)} bytes, above the selected max_file_bytes (${String(selectedMaxFileBytes)}).`,
|
|
);
|
|
this.name = "ElfNoteSectionNameBudgetFailure";
|
|
}
|
|
}
|
|
|
|
const fail = (message: string): never => {
|
|
throw new ArtifactReaderFailure("format", message);
|
|
};
|
|
|
|
const chargeText = (
|
|
budget: TextBudget,
|
|
bytes: number,
|
|
field: "used" | "hashed" | "scanned" = "used",
|
|
): void => {
|
|
if (
|
|
!Number.isSafeInteger(bytes) ||
|
|
bytes < 0 ||
|
|
bytes > budget.limit - budget[field]
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`ELF note text exceeds the ${String(budget.limit)}-byte budget.`,
|
|
{ boundary: "memory" },
|
|
);
|
|
budget[field] += bytes;
|
|
};
|
|
|
|
const sha256 = (bytes: Buffer): string =>
|
|
createHash("sha256").update(bytes).digest("hex");
|
|
|
|
const GNU_OS_NAMES: Readonly<Record<number, string>> = {
|
|
0: "linux",
|
|
1: "hurd",
|
|
2: "solaris",
|
|
3: "freebsd",
|
|
4: "netbsd",
|
|
5: "syllable",
|
|
6: "nacl",
|
|
};
|
|
|
|
const LIMITATIONS = [
|
|
"This workflow does not execute the image, resolve runtime notes, or verify GNU build-id uniqueness against package catalogs.",
|
|
"PT_NOTE and SHT_NOTE are examined independently; the same file range may appear twice when both tables declare it.",
|
|
] as const;
|
|
|
|
const ABSENT_LIMITATIONS = [
|
|
"No PT_NOTE program header or SHT_NOTE section was declared. That is not proof the image never carried notes in another container or overlay.",
|
|
"This workflow does not execute the image, resolve runtime notes, or verify GNU build-id uniqueness against package catalogs.",
|
|
] as const;
|
|
|
|
class ElfReader {
|
|
constructor(
|
|
readonly bytes: Buffer,
|
|
readonly little: boolean,
|
|
readonly bits: 32 | 64,
|
|
) {}
|
|
|
|
u16(offset: number): number {
|
|
this.require(offset, 2);
|
|
return this.little
|
|
? this.bytes.readUInt16LE(offset)
|
|
: this.bytes.readUInt16BE(offset);
|
|
}
|
|
|
|
u32(offset: number): number {
|
|
this.require(offset, 4);
|
|
return this.little
|
|
? this.bytes.readUInt32LE(offset)
|
|
: this.bytes.readUInt32BE(offset);
|
|
}
|
|
|
|
wordBits(offset: number): bigint {
|
|
const width = this.bits === 64 ? 8 : 4;
|
|
this.require(offset, width);
|
|
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);
|
|
}
|
|
|
|
require(offset: number, length: number): void {
|
|
if (
|
|
!Number.isSafeInteger(offset) ||
|
|
!Number.isSafeInteger(length) ||
|
|
offset < 0 ||
|
|
length < 0 ||
|
|
offset > this.bytes.length - length
|
|
)
|
|
fail(
|
|
`ELF range ${String(offset)} + ${String(length)} leaves the artifact.`,
|
|
);
|
|
}
|
|
|
|
slice(offset: number, length: number): Buffer {
|
|
this.require(offset, length);
|
|
return this.bytes.subarray(offset, offset + length);
|
|
}
|
|
|
|
cString(table: Buffer, offset: number, text: TextBudget): string | null {
|
|
if (offset < 0 || offset >= table.length) return null;
|
|
const affordable = text.limit - text.used;
|
|
const available = table.length - offset;
|
|
const scanned = Math.min(available, affordable + 1);
|
|
const end = table.subarray(offset, offset + scanned).indexOf(0);
|
|
if (end < 0) {
|
|
chargeText(text, scanned);
|
|
return null;
|
|
}
|
|
chargeText(text, end);
|
|
return table.subarray(offset, offset + end).toString("latin1");
|
|
}
|
|
}
|
|
|
|
interface ParsedNote {
|
|
readonly location: { offset: number; bytes: number };
|
|
readonly namesz: number;
|
|
readonly descsz: number;
|
|
readonly type: number;
|
|
readonly owner: string;
|
|
readonly owner_bytes_hex: string;
|
|
readonly descriptor: Buffer;
|
|
readonly descriptor_offset: number;
|
|
}
|
|
|
|
const parseNoteStream = (
|
|
reader: ElfReader,
|
|
offset: number,
|
|
size: number,
|
|
alignment: number,
|
|
maxEntries: number,
|
|
examined: { count: number },
|
|
text: TextBudget,
|
|
): ParsedNote[] => {
|
|
// Alignment padding is not read when a note's aligned size equals the
|
|
// declared table. Require the whole table first so a short file is invalid
|
|
// input instead of a provider result the service rejects.
|
|
reader.require(offset, size);
|
|
const notes: ParsedNote[] = [];
|
|
let at = 0;
|
|
while (at + 12 <= size) {
|
|
examined.count += 1;
|
|
if (examined.count > maxEntries)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`ELF note tables exceed the ${String(maxEntries)}-entry budget.`,
|
|
{ boundary: "capacity" },
|
|
);
|
|
const header = offset + at;
|
|
const namesz = reader.u32(header);
|
|
const descsz = reader.u32(header + 4);
|
|
const type = reader.u32(header + 8);
|
|
// Align offsets from the start of this note. Independently rounding
|
|
// namesz/descsz disagrees with binutils when alignment is 8 because the
|
|
// 12-byte note header is not 8-aligned (GNU property namesz=4 then starts
|
|
// its descriptor at 16, not 20).
|
|
const { descriptor: descRel, next: nextRel } = elfNoteLayout(
|
|
namesz,
|
|
descsz,
|
|
alignment,
|
|
);
|
|
const next = at + nextRel;
|
|
if (next > size) fail("ELF note entry exceeds its table.");
|
|
if (next <= at) fail("ELF note alignment did not advance.");
|
|
const nameBytes = reader.slice(header + 12, namesz);
|
|
const descriptor = reader.slice(header + descRel, descsz);
|
|
const ownerEnd = nameBytes.indexOf(0);
|
|
const ownerBytes = ownerEnd < 0 ? namesz : ownerEnd;
|
|
chargeText(text, ownerBytes + namesz * 2);
|
|
notes.push({
|
|
location: { offset: header, bytes: next - at },
|
|
namesz,
|
|
descsz,
|
|
type,
|
|
owner:
|
|
ownerEnd < 0
|
|
? nameBytes.toString("latin1")
|
|
: nameBytes.subarray(0, ownerEnd).toString("latin1"),
|
|
owner_bytes_hex: nameBytes.toString("hex"),
|
|
descriptor,
|
|
descriptor_offset: header + descRel,
|
|
});
|
|
at = next;
|
|
}
|
|
if (
|
|
at < size &&
|
|
reader.slice(offset + at, size - at).some((byte) => byte !== 0)
|
|
)
|
|
fail("ELF note table has trailing non-zero bytes.");
|
|
return notes;
|
|
};
|
|
|
|
const decodeGnu = (
|
|
owner: string,
|
|
type: number,
|
|
descriptor: Buffer,
|
|
little: boolean,
|
|
text: TextBudget,
|
|
): ElfNotes["entries"][number]["gnu"] => {
|
|
if (owner !== "GNU") return null;
|
|
if (type === 3 && descriptor.length > 0) {
|
|
chargeText(text, descriptor.length * 2);
|
|
return {
|
|
build_id_hex: descriptor.toString("hex"),
|
|
abi_tag: null,
|
|
gold_version: null,
|
|
};
|
|
}
|
|
if (type === 1 && descriptor.length === 16) {
|
|
const read = (offset: number) =>
|
|
little
|
|
? descriptor.readUInt32LE(offset)
|
|
: descriptor.readUInt32BE(offset);
|
|
const os = read(0);
|
|
return {
|
|
build_id_hex: null,
|
|
abi_tag: {
|
|
os,
|
|
os_name: GNU_OS_NAMES[os] ?? null,
|
|
major: read(4),
|
|
minor: read(8),
|
|
subminor: read(12),
|
|
},
|
|
gold_version: null,
|
|
};
|
|
}
|
|
if (type === 4) {
|
|
const range = `${String(descriptor.byteOffset)}:${String(descriptor.length)}`;
|
|
let end = text.scans.get(range);
|
|
if (end === undefined) {
|
|
chargeText(text, descriptor.length, "scanned");
|
|
end = descriptor.length;
|
|
while (end > 0 && descriptor[end - 1] === 0) end -= 1;
|
|
text.scans.set(range, end);
|
|
}
|
|
chargeText(text, end);
|
|
return {
|
|
build_id_hex: null,
|
|
abi_tag: null,
|
|
gold_version: descriptor.toString("latin1", 0, end),
|
|
};
|
|
}
|
|
return {
|
|
build_id_hex: null,
|
|
abi_tag: null,
|
|
gold_version: null,
|
|
};
|
|
};
|
|
|
|
/** Decode PT_NOTE and SHT_NOTE tables from one in-memory ELF image. */
|
|
export const parseElfNotes = (
|
|
bytes: Buffer,
|
|
artifact: ElfNotes["artifact"],
|
|
input: InspectElfNotesInput,
|
|
): ElfNotes => {
|
|
const fixed = readElfFixedHeader(bytes);
|
|
if (!fixed.ok) return fail(fixed.error);
|
|
const header = fixed.value;
|
|
const reader = new ElfReader(
|
|
bytes,
|
|
header.byteOrder === "little",
|
|
header.elfClass,
|
|
);
|
|
const wide = header.elfClass === 64;
|
|
const type = reader.u16(16);
|
|
const phoff = reader.word(wide ? 32 : 28);
|
|
const shoff = reader.word(wide ? 40 : 32);
|
|
const phentsize = reader.u16(wide ? 54 : 42);
|
|
const phnum = reader.u16(wide ? 56 : 44);
|
|
const shentsize = reader.u16(wide ? 58 : 46);
|
|
const shnum = reader.u16(wide ? 60 : 48);
|
|
const shstrndx = reader.u16(wide ? 62 : 50);
|
|
if (
|
|
phnum === 0xffff ||
|
|
shnum === 0xffff ||
|
|
shstrndx === 0xffff ||
|
|
(shoff !== 0 && shnum === 0)
|
|
)
|
|
throw new BinaryFormatFailure(
|
|
"unsupported",
|
|
"ELF extended numbering is unsupported.",
|
|
);
|
|
if (phnum !== 0) {
|
|
if (phentsize < (wide ? 56 : 32))
|
|
return fail("ELF program header width is invalid.");
|
|
reader.require(phoff, phnum * phentsize);
|
|
}
|
|
if (shnum !== 0) {
|
|
if (shentsize < (wide ? 64 : 40))
|
|
return fail("ELF section header width is invalid.");
|
|
reader.require(shoff, shnum * shentsize);
|
|
}
|
|
const text: TextBudget = {
|
|
limit: Math.min(bytes.length, input.max_file_bytes),
|
|
used: 0,
|
|
hashed: 0,
|
|
scanned: 0,
|
|
hashes: new Map(),
|
|
scans: new Map(),
|
|
};
|
|
let shstrtab: Buffer | null = null;
|
|
let sectionNameLimitation: string | undefined;
|
|
if (shnum !== 0 && shstrndx !== 0) {
|
|
if (shstrndx >= shnum)
|
|
return fail("ELF section name index is out of range.");
|
|
const namesHeader = shoff + shstrndx * shentsize;
|
|
if (reader.u32(namesHeader + 4) !== SHT_STRTAB)
|
|
return fail("ELF section name table is not a string table.");
|
|
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.wordBits(namesHeader + 8);
|
|
const decoded = decodeElfSectionNameTable(
|
|
rawNames,
|
|
flags,
|
|
header.elfClass,
|
|
reader.little,
|
|
input.max_file_bytes - bytes.length,
|
|
);
|
|
if (decoded.state === "malformed") return fail(decoded.message);
|
|
if (decoded.state === "limit")
|
|
throw new ElfNoteSectionNameBudgetFailure(
|
|
BigInt(bytes.length) + decoded.requiredBytes,
|
|
input.max_file_bytes,
|
|
);
|
|
if (decoded.state === "unsupported") {
|
|
shstrtab = null;
|
|
sectionNameLimitation = `ELF section names are unavailable because compression type ${String(decoded.compressionType)} is unsupported.`;
|
|
} else {
|
|
shstrtab = decoded.bytes;
|
|
if (decoded.compressed) text.limit = bytes.length + shstrtab.length;
|
|
}
|
|
} else if (shnum !== 0) {
|
|
sectionNameLimitation =
|
|
"ELF section names are unavailable because no section-name string table is declared.";
|
|
}
|
|
const tables: ElfNotes["tables"] = [];
|
|
const parsed: ReturnType<typeof toEntry>[] = [];
|
|
const examined = { count: 0 };
|
|
if (phnum !== 0) {
|
|
if (phoff < header.headerSize)
|
|
return fail("ELF program header table overlaps the native header.");
|
|
for (let index = 0; index < phnum; index += 1) {
|
|
const at = phoff + index * phentsize;
|
|
if (reader.u32(at) !== PT_NOTE) continue;
|
|
const fileOffset = reader.word(at + (wide ? 8 : 4));
|
|
const fileSize = reader.word(at + (wide ? 32 : 16));
|
|
const alignment = noteAlignment(reader.word(at + (wide ? 48 : 28)));
|
|
tables.push({
|
|
kind: "program",
|
|
index,
|
|
name: null,
|
|
location: { offset: fileOffset, bytes: fileSize },
|
|
alignment,
|
|
});
|
|
for (const note of parseNoteStream(
|
|
reader,
|
|
fileOffset,
|
|
fileSize,
|
|
alignment,
|
|
input.max_entries,
|
|
examined,
|
|
text,
|
|
))
|
|
parsed.push(toEntry(note, "program", index, reader.little, text));
|
|
}
|
|
}
|
|
if (shnum !== 0) {
|
|
if (shoff < header.headerSize)
|
|
return fail("ELF section header table overlaps the native header.");
|
|
for (let index = 0; index < shnum; index += 1) {
|
|
const at = shoff + index * shentsize;
|
|
if (reader.u32(at + 4) !== SHT_NOTE) continue;
|
|
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",
|
|
index,
|
|
name:
|
|
shstrtab === null ? null : reader.cString(shstrtab, nameOffset, text),
|
|
location: { offset: fileOffset, bytes: fileSize },
|
|
alignment,
|
|
});
|
|
for (const note of parseNoteStream(
|
|
reader,
|
|
fileOffset,
|
|
fileSize,
|
|
alignment,
|
|
input.max_entries,
|
|
examined,
|
|
text,
|
|
))
|
|
parsed.push(toEntry(note, "section", index, reader.little, text));
|
|
}
|
|
}
|
|
const absent = tables.length === 0;
|
|
return {
|
|
artifact,
|
|
format: wide ? "elf64" : "elf32",
|
|
byte_order: reader.little ? "little" : "big",
|
|
machine: header.machine,
|
|
type,
|
|
type_name: elfObjectTypeName(type),
|
|
tables,
|
|
entries: parsed.map((entry, index) => ({ ...entry, index })),
|
|
coverage: {
|
|
status: "complete",
|
|
examined_tables: tables.length,
|
|
examined_entries: examined.count,
|
|
},
|
|
limitations: [
|
|
...(absent ? ABSENT_LIMITATIONS : LIMITATIONS),
|
|
...(sectionNameLimitation === undefined ? [] : [sectionNameLimitation]),
|
|
],
|
|
};
|
|
};
|
|
|
|
const toEntry = (
|
|
note: ParsedNote,
|
|
tableKind: "program" | "section",
|
|
tableIndex: number,
|
|
little: boolean,
|
|
text: TextBudget,
|
|
): Omit<ElfNotes["entries"][number], "index"> => {
|
|
const gnuOwner = note.owner === "GNU";
|
|
const range = `${String(note.descriptor_offset)}:${String(note.descsz)}`;
|
|
let digest = text.hashes.get(range);
|
|
if (digest === undefined) {
|
|
chargeText(text, note.descriptor.length, "hashed");
|
|
digest = sha256(note.descriptor);
|
|
text.hashes.set(range, digest);
|
|
}
|
|
return {
|
|
table_kind: tableKind,
|
|
table_index: tableIndex,
|
|
location: note.location,
|
|
namesz: note.namesz,
|
|
descsz: note.descsz,
|
|
type: note.type,
|
|
type_name: gnuOwner ? gnuNoteTypeName(note.type) : "unknown",
|
|
owner: note.owner,
|
|
owner_bytes_hex: note.owner_bytes_hex,
|
|
descriptor: {
|
|
location: { offset: note.descriptor_offset, bytes: note.descsz },
|
|
bytes: note.descsz,
|
|
sha256: digest,
|
|
hex:
|
|
note.descsz <= HEX_DESCRIPTOR_LIMIT
|
|
? note.descriptor.toString("hex")
|
|
: null,
|
|
},
|
|
gnu: decodeGnu(note.owner, note.type, note.descriptor, little, text),
|
|
};
|
|
};
|