fix(native): bound export inventories and read header-backed PE tables

Address review of the export-table reader:

- Charge every retained row and its name, forwarder and version text
  against shared 262144-row and 16 MiB allowances before storing it, so
  repeated long names or PE aliases cannot exhaust memory.
- Map PE RVAs through the headers as well as sections; the PE image
  reader now exposes its SizeOfHeaders mapping.
- Keep ELF exports with version "unknown" when .gnu.version lies outside
  the file, and report an empty version-definition name as unresolved.
- Reject a .dynsym without its mandatory null entry as malformed.
- Decode the ELF section directory once.
- Correct the docs on which gaps drop rows and which keep them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822andClaude Opus 5.5 committed 2026-10-22 08:08:30 +00:00
1 parent 65a3128873
commit 871613860d
7 files changed
+295 -102

No files matched your search

+10 -6
View File
@@ -334,13 +334,17 @@ count and SHA-256.
`"unknown"` when the version table cannot be read. A hidden version is not
the default version that a by-name `dlsym` lookup selects.
Unreadable names, out-of-range name ordinals, unreadable forwarders and
unresolved version indexes are listed in `issues`, the affected entries are
not reported, and coverage is `partial`. An ELF without section headers is
`unsupported`: enumerating dynamic symbols from the dynamic segment is not
Unreadable names, out-of-range name ordinals and unreadable forwarders are
listed in `issues`, their entries are not reported, and coverage is `partial`.
Unresolved symbol versions keep their rows: the version name is null for an
index no definition names, and `version` is `"unknown"` when the version table
cannot be read; coverage is likewise `partial`. A dynamic symbol table without
its null entry is malformed. An ELF without section headers is `unsupported`:
enumerating dynamic symbols from the dynamic segment is not
implemented. Mach-O exports are reported by `inspect_macho`.
`max_file_bytes` defaults to 256 MiB (maximum 512 MiB; CLI `--max-file-bytes`).
An export or dynamic symbol table above 262144 entries fails without partial
success. Export rows do not establish that a symbol is reachable, safe to call
Export directories in PE headers are read like those in sections. An export or
dynamic symbol table above 262144 entries, more than 262144 rows, or more than
16 MiB of retained names fails without partial success. Export rows do not establish that a symbol is reachable, safe to call
or loaded at runtime.
@@ -282,6 +282,82 @@ describe("ELF dynamic symbol exports", () => {
});
});
describe("export inventory review regressions", () => {
it("reads an export directory stored in the PE headers", () => {
const result = parse(
peExportImage({
inHeaders: true,
functions: [{ rva: 0x2000 }, { forwarder: "OTHER.Target" }],
names: [
{ name: "HeaderExport", index: 0 },
{ name: "HeaderForward", index: 1 },
],
}),
);
expect(result).toMatchObject({
export_directory: { rva: 0x200, dll_name: "fixture.dll" },
coverage: { status: "complete", exports: 2 },
});
expect(result.exports.map(({ name }) => name)).toEqual([
"HeaderExport",
"HeaderForward",
]);
});
it("bounds retained name text before storing rows", () => {
// 300 rows naming one 60000-byte string retain about 18 MB of text.
const long = "n".repeat(60000);
const bytes = elfExportImage({
symbols: Array.from({ length: 300 }, () => ({ name: long })),
});
expect(bytes.length).toBeLessThan(100_000);
expect(() => parseNativeExports(bytes, artifact)).toThrow(
BinaryResourceFailure,
);
});
it("keeps exports when the version table lies outside the file", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "a" }, { name: "b", version: 2 }],
versionDefinitions: ["lib.so", "V1"],
versionsOutsideFile: true,
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual(["unknown", "unknown"]);
expect(result.coverage.status).toBe("partial");
});
it("reports an empty version name as unresolved", () => {
const result = parse(
elfExportImage({
symbols: [{ name: "a", version: 2 }],
versionDefinitions: ["lib.so", ""],
}),
);
expect(
result.format === "elf" && result.exports.map(({ version }) => version),
).toEqual([{ index: 2, name: null, hidden: false }]);
expect(result.issues).toMatchObject([{ code: "version_unresolved" }]);
expect(result.coverage.status).toBe("partial");
});
it("rejects a dynamic symbol table without its null entry", () => {
expect(() =>
parseNativeExports(
elfExportImage({ symbols: [], emptyDynamicSymbols: true }),
artifact,
),
).toThrow(BinaryFormatFailure);
});
});
it("refuses formats other than PE and ELF", () => {
const macho = Buffer.alloc(64);
macho.writeUInt32LE(0xfeedfacf, 0);
+54 -20
View File
@@ -4,7 +4,7 @@ import {
BinaryResourceFailure,
mappedFileOffset,
} from "../metadata/BinaryContainer.js";
import { readElfImage } from "../metadata/ElfImage.js";
import { architectureForElfMachine } from "../metadata/ElfImage.js";
import { readElfString, readElfSymbol } from "../metadata/ElfSymbol.js";
import {
readNativeDirectoryFromBuffer,
@@ -28,6 +28,25 @@ const SHT_GNU_VERSYM = 0x6fffffff;
const PE_EXPORT_DIRECTORY = 0;
const PE_EXPORT_DIRECTORY_BYTES = 40;
const MAXIMUM_NAME_BYTES = 4096;
/** Text retained across all rows; names can repeat one long string table entry. */
const MAXIMUM_RETAINED_TEXT_BYTES = 16 * 1024 * 1024;
/** Charge every retained row and its text before it is stored. */
const createRetention = () => {
let rows = 0;
let text = 0;
return (...strings: readonly (string | null)[]): void => {
rows++;
rowBudget(rows, "Export inventory");
for (const value of strings) text += value?.length ?? 0;
if (text > MAXIMUM_RETAINED_TEXT_BYTES)
throw new BinaryResourceFailure(
"structure",
MAXIMUM_RETAINED_TEXT_BYTES,
`Export names exceed the ${String(MAXIMUM_RETAINED_TEXT_BYTES)}-byte retained-text allowance`,
);
};
};
const hex = (value: bigint | number) => `0x${value.toString(16)}`;
@@ -88,13 +107,12 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
],
};
const reader = new BinaryReader(bytes, true);
const mappings =
image.pe_header_mapping === undefined
? image.mappings
: [image.pe_header_mapping, ...image.mappings];
const map = (rva: number, size: number): number =>
mappedFileOffset(
image.mappings,
base + BigInt(rva),
size,
image.zero_fills,
);
mappedFileOffset(mappings, base + BigInt(rva), size, image.zero_fills);
const cString = (rva: number): string => {
const start = map(rva, 1);
const end = bytes
@@ -167,6 +185,7 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
}
const forwarderEnd = directory.virtual_address + directory.size;
const retain = createRetention();
const exports: PeExport[] = [];
for (let index = 0; index < functionCount; index++) {
const rva = reader.u32(functions + index * 4);
@@ -188,11 +207,15 @@ const parsePeExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
} else target = { address: hex(base + BigInt(rva)), forwarder: null };
const exportNames = namesByFunction.get(index);
if (exportNames !== undefined)
for (const name of exportNames)
for (const name of exportNames) {
retain(name, target.forwarder);
exports.push({ name, ordinal, ...target });
}
// A slot whose only name was unreadable is reported through its issue.
else if (!namedFunctions.has(index))
else if (!namedFunctions.has(index)) {
retain(target.forwarder);
exports.push({ name: null, ordinal, ...target });
}
}
return {
...result,
@@ -293,15 +316,14 @@ const readVersionDefinitions = (
/** Read defined exported entries of `.dynsym` with their symbol versions. */
const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
const image = readElfImage(bytes);
const directory = readNativeDirectoryFromBuffer(bytes, "elf");
const reader = new BinaryReader(bytes, directory.little);
const result = {
artifact,
format: "elf" as const,
architecture: image.architecture,
bits: image.bits,
byte_order: image.byte_order,
architecture: architectureForElfMachine(directory.machine),
bits: directory.bits,
byte_order: directory.little ? ("little" as const) : ("big" as const),
};
const { sections } = directory;
if (sections.length === 0)
@@ -335,11 +357,13 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
if (
dynsym.entry_size !== BigInt(width) ||
dynsym.size % BigInt(width) !== 0n ||
// The table must hold at least its mandatory null symbol.
dynsym.size < BigInt(width) ||
strings?.type !== SHT_STRTAB
)
throw new BinaryFormatFailure(
"malformed",
"ELF dynamic symbol table entry size, length or string-table link is invalid",
"ELF dynamic symbol table entry size, length, null entry or string-table link is invalid",
);
const count = Number(dynsym.size / BigInt(width));
rowBudget(count, "ELF dynamic symbol table");
@@ -364,17 +388,25 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
);
let versions: number | null = null;
if (versym !== undefined) {
if (versym.size === BigInt(count * 2))
try {
if (versym.size !== BigInt(count * 2))
throw new BinaryFormatFailure(
"malformed",
`Symbol version table has ${String(versym.size)} bytes for ${String(count)} symbols`,
);
versions = reader.table(versym.offset, count, 2, "ELF symbol versions");
else
} catch (cause) {
// Keep every export; their versions become unknown.
issues.push({
code: "version_unresolved",
location: hex(versym.offset),
message: `Symbol version table has ${String(versym.size)} bytes for ${String(count)} symbols`,
message: failureMessage(cause),
});
}
}
const definitions = readVersionDefinitions(reader, sections, issues);
const unresolvedVersions = new Set<number>();
const retain = createRetention();
const exports: ElfExport[] = [];
for (let index = 1; index < count; index++) {
const at = table + index * width;
@@ -399,18 +431,19 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
const versionIndex = raw & 0x7fff;
if (versionIndex === 0) continue;
if (versionIndex >= 2) {
const name = definitions.get(versionIndex) ?? null;
const defined = definitions.get(versionIndex);
const name = defined === undefined || defined === "" ? null : defined;
if (name === null && !unresolvedVersions.has(versionIndex)) {
unresolvedVersions.add(versionIndex);
issues.push({
code: "version_unresolved",
location: hex(symbol.value),
message: `Version index ${String(versionIndex)}, first used by symbol ${String(index)}, is named by no version definition`,
message: `Version index ${String(versionIndex)}, first used by symbol ${String(index)}, has no named version definition`,
});
}
version = {
index: versionIndex,
name: name === "" ? null : name,
name,
hidden: (raw & 0x8000) !== 0,
};
}
@@ -434,6 +467,7 @@ const parseElfExports = (bytes: Buffer, artifact: Artifact): NativeExports => {
});
continue;
}
retain(name, typeof version === "object" ? (version?.name ?? null) : null);
exports.push({
name,
value: hex(symbol.value),
+5
View File
@@ -95,6 +95,11 @@ export interface BinaryContainer {
readonly byte_order: "little" | "big";
/** Declared PE preferred base, when available. */
readonly image_base?: bigint;
/**
* File-backed PE headers (`SizeOfHeaders`, clipped to the file), which the
* loader maps at the image base. Kept apart from section `mappings`.
*/
readonly pe_header_mapping?: FileMapping;
/** PE optional-header data directories, indexed as declared. */
readonly pe_data_directories?: readonly {
readonly virtual_address: number;
+1 -1
View File
@@ -104,7 +104,7 @@ export const readElfImage = (bytes: Buffer): BinaryContainer => {
};
/** Map ELF machine identifiers to stable architecture names. */
const architectureForElfMachine = (machine: number): string => {
export const architectureForElfMachine = (machine: number): string => {
const names = new Map([
[3, "386"],
[8, "mips"],
+6
View File
@@ -87,6 +87,11 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => {
return { virtual_address: reader.u32(entry), size: reader.u32(entry + 4) };
});
const imageBase = reader.word(optional + (bits === 64 ? 24 : 28), bits);
const headerMapping: FileMapping = {
address: imageBase,
offset: 0,
size: Math.min(reader.u32(optional + 60), bytes.length),
};
const count = reader.u16(pe + 6);
const start = reader.table(
BigInt(optional + optionalSize),
@@ -157,6 +162,7 @@ export const readPeImage = (bytes: Buffer): BinaryContainer => {
format: "pe",
image_base: imageBase,
pe_data_directories: dataDirectories,
pe_header_mapping: headerMapping,
architecture: names.get(machine) ?? `pe-machine-${machine}`,
bits,
byte_order: "little",
+143 -75
View File
@@ -7,6 +7,8 @@ export type PeFixtureFunction =
const SECTION_RVA = 0x1000;
const SECTION_OFFSET = 0x400;
const SECTION_BYTES = 0x400;
/** SizeOfHeaders; header RVAs map to the same file offsets. */
const HEADER_BYTES = 0x400;
/**
* Minimal PE32/PE32+ image whose only section holds an export directory at
@@ -24,6 +26,8 @@ export const peExportImage = (options: {
};
readonly exportDirectory?: boolean;
readonly declaredFunctions?: number;
/** Store the export directory and its tables in the PE headers. */
readonly inHeaders?: boolean;
}): Buffer => {
const bits = options.bits ?? 64;
const bytes = Buffer.alloc(SECTION_OFFSET + SECTION_BYTES);
@@ -41,6 +45,7 @@ export const peExportImage = (options: {
if (wide) bytes.writeBigUInt64LE(0x180000000n, optional + 24);
else bytes.writeUInt32LE(0x10000000, optional + 28);
bytes.writeUInt32LE(16, optional + directoriesAt - 4);
bytes.writeUInt32LE(HEADER_BYTES, optional + 60);
const section = optional + optionalSize;
bytes.write(".edata", section, "ascii");
bytes.writeUInt32LE(SECTION_BYTES, section + 8);
@@ -50,16 +55,18 @@ export const peExportImage = (options: {
bytes.writeUInt32LE(0x40000040, section + 36);
if (options.exportDirectory === false) return bytes;
const at = (rva: number) => rva - SECTION_RVA + SECTION_OFFSET;
const at = (rva: number) =>
rva < HEADER_BYTES ? rva : rva - SECTION_RVA + SECTION_OFFSET;
const base = options.inHeaders === true ? 0x200 : SECTION_RVA;
const functions = options.functions ?? [];
const names = options.names ?? [];
// Directory, address table, name table, ordinals, then strings; forwarders
// stay inside the 0x200-byte declared directory range.
const eat = SECTION_RVA + 0x40;
const nameTable = SECTION_RVA + 0x80;
const ordinalTable = SECTION_RVA + 0xc0;
let forwarderText = SECTION_RVA + 0x100;
let text = SECTION_RVA + 0x200;
const eat = base + 0x40;
const nameTable = base + 0x80;
const ordinalTable = base + 0xc0;
let forwarderText = base + 0x100;
let text = base + (options.inHeaders === true ? 0x180 : 0x200);
const string = (value: string, inside: boolean): number => {
const rva = inside ? forwarderText : text;
bytes.write(`${value}\0`, at(rva), "latin1");
@@ -67,9 +74,9 @@ export const peExportImage = (options: {
else text += value.length + 1;
return rva;
};
bytes.writeUInt32LE(SECTION_RVA, optional + directoriesAt);
bytes.writeUInt32LE(base, optional + directoriesAt);
bytes.writeUInt32LE(0x200, optional + directoriesAt + 4);
const directory = at(SECTION_RVA);
const directory = at(base);
bytes.writeUInt32LE(0x5f5e0ff, directory + 4);
bytes.writeUInt32LE(string("fixture.dll", false), directory + 12);
bytes.writeUInt32LE(options.ordinalBase ?? 1, directory + 16);
@@ -117,7 +124,7 @@ export interface ElfFixtureSymbol {
/**
* Minimal ELF shared object with `.dynsym`, `.dynstr`, `.gnu.version` and
* `.gnu.version_d`. Version definitions are indexed from 1 in order.
* `.gnu.version_d`, laid out in order. Version definitions are indexed from 1.
*/
export const elfExportImage = (options: {
readonly bits?: 32 | 64;
@@ -127,13 +134,53 @@ export const elfExportImage = (options: {
readonly versionDefinitions?: readonly string[];
/** Declare a `.gnu.version` table that is one entry short. */
readonly truncatedVersions?: boolean;
/** Point `.gnu.version` past the end of the file. */
readonly versionsOutsideFile?: boolean;
/** Declare a zero-byte `.dynsym`, without its null entry. */
readonly emptyDynamicSymbols?: boolean;
readonly sectionHeaders?: boolean;
readonly dynamicSymbols?: boolean;
}): Buffer => {
const bits = options.bits ?? 64;
const little = options.little ?? true;
const wide = bits === 64;
const bytes = Buffer.alloc(0x1000);
const symbolWidth = wide ? 24 : 16;
const sectionWidth = wide ? 64 : 40;
const symbols = [null, ...options.symbols];
const definitions = options.versionDefinitions ?? [];
const dynstr: string[] = ["\0"];
let dynstrBytes = 1;
const strings = new Map<string, number>();
const intern = (value: string): number => {
const known = strings.get(value);
if (known !== undefined) return known;
const offset = dynstrBytes;
strings.set(value, offset);
dynstr.push(`${value}\0`);
dynstrBytes += value.length + 1;
return offset;
};
const definitionNames = definitions.map(intern);
const symbolNames = symbols.map((symbol) =>
symbol === null ? 0 : (symbol.nameOffset ?? intern(symbol.name)),
);
const shstrtab =
"\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_d\0.shstrtab\0";
let cursor = 0x100;
const place = (size: number): number => {
const offset = cursor;
cursor = Math.ceil((cursor + size) / 16) * 16;
return offset;
};
const dynsymAt = place(symbols.length * symbolWidth);
const dynstrAt = place(dynstrBytes);
const versymAt = place(symbols.length * 2);
const verdefAt = place(definitions.length * 28);
const shstrtabAt = place(shstrtab.length);
const sectionAt = place(6 * sectionWidth);
const bytes = Buffer.alloc(cursor);
const u16 = (offset: number, value: number) =>
little
? bytes.writeUInt16LE(value, offset)
@@ -151,53 +198,36 @@ export const elfExportImage = (options: {
u16(16, 3);
u16(18, options.machine ?? (wide ? 62 : 40));
u32(20, 1);
const headerSize = wide ? 64 : 52;
u16(wide ? 52 : 40, headerSize);
u16(wide ? 52 : 40, wide ? 64 : 52);
u16(wide ? 54 : 42, wide ? 56 : 32);
u16(wide ? 58 : 46, wide ? 64 : 40);
u16(wide ? 58 : 46, sectionWidth);
if (options.sectionHeaders === false) return bytes;
const dynstr = ["\0"];
let dynstrBytes = 1;
const intern = (value: string): number => {
const offset = dynstrBytes;
dynstr.push(`${value}\0`);
dynstrBytes += value.length + 1;
return offset;
};
const definitions = options.versionDefinitions ?? [];
const definitionNames = definitions.map(intern);
const symbolWidth = wide ? 24 : 16;
const symbols = [null, ...options.symbols];
const dynsymAt = 0x200;
symbols.forEach((symbol, index) => {
if (symbol === null) return;
const at = dynsymAt + index * symbolWidth;
const info = ((symbol.binding ?? 1) << 4) | (symbol.type ?? 2);
const other = symbol.visibility ?? 0;
const name = symbol.nameOffset ?? intern(symbol.name);
u32(at, name);
const value = symbol.value ?? BigInt(0x1000 + index * 16);
u32(at, symbolNames[index]!);
if (wide) {
bytes[at + 4] = info;
bytes[at + 5] = other;
u16(at + 6, symbol.section ?? 1);
word(at + 8, symbol.value ?? BigInt(0x1000 + index * 16));
word(at + 8, value);
word(at + 16, symbol.size ?? 16n);
} else {
word(at + 4, symbol.value ?? BigInt(0x1000 + index * 16));
word(at + 4, value);
word(at + 8, symbol.size ?? 16n);
bytes[at + 12] = info;
bytes[at + 13] = other;
u16(at + 14, symbol.section ?? 1);
}
});
const dynstrAt = 0x600;
Buffer.from(dynstr.join(""), "latin1").copy(bytes, dynstrAt);
const versymAt = 0x900;
symbols.forEach((symbol, index) =>
u16(versymAt + index * 2, symbol?.version ?? (index === 0 ? 0 : 1)),
);
const verdefAt = 0xa00;
definitions.forEach((_, index) => {
const at = verdefAt + index * 28;
u16(at, 1);
@@ -208,49 +238,21 @@ export const elfExportImage = (options: {
u32(at + 16, index === definitions.length - 1 ? 0 : 28);
u32(at + 20, definitionNames[index]!);
});
const shstrtab =
"\0.dynsym\0.dynstr\0.gnu.version\0.gnu.version_d\0.shstrtab\0";
const shstrtabAt = 0xc00;
bytes.write(shstrtab, shstrtabAt, "latin1");
const sections: (readonly [
number,
number,
number,
number,
number,
number,
number,
])[] = [
// name, type, offset, size, link, info, entsize
[0, 0, 0, 0, 0, 0, 0],
...(options.dynamicSymbols === false
? []
: ([
[1, 11, dynsymAt, symbols.length * symbolWidth, 2, 1, symbolWidth],
[9, 3, dynstrAt, dynstrBytes, 0, 0, 0],
[
17,
0x6fffffff,
versymAt,
(symbols.length - (options.truncatedVersions === true ? 1 : 0)) * 2,
1,
0,
2,
],
[
30,
0x6ffffffd,
verdefAt,
definitions.length * 28,
2,
definitions.length,
0,
],
] as const)),
[45, 3, shstrtabAt, shstrtab.length, 0, 0, 0],
];
const sectionAt = 0xd00;
const sectionWidth = wide ? 64 : 40;
const sections = elfSectionRows(options, {
dynsymAt,
dynsym: symbols.length * symbolWidth,
symbolWidth,
dynstrAt,
dynstrBytes,
versymAt,
versym: symbols.length * 2,
verdefAt,
definitions: definitions.length,
shstrtabAt,
shstrtab: shstrtab.length,
end: cursor,
});
sections.forEach(([name, type, offset, size, link, info, entsize], index) => {
const at = sectionAt + index * sectionWidth;
u32(at, name);
@@ -266,3 +268,69 @@ export const elfExportImage = (options: {
u16(wide ? 62 : 50, sections.length - 1);
return bytes;
};
type ElfSectionRow = readonly [
name: number,
type: number,
offset: number,
size: number,
link: number,
info: number,
entsize: number,
];
/** Section header rows for the fixture layout, with the requested defects. */
const elfSectionRows = (
options: Parameters<typeof elfExportImage>[0],
layout: {
readonly dynsymAt: number;
readonly dynsym: number;
readonly symbolWidth: number;
readonly dynstrAt: number;
readonly dynstrBytes: number;
readonly versymAt: number;
readonly versym: number;
readonly verdefAt: number;
readonly definitions: number;
readonly shstrtabAt: number;
readonly shstrtab: number;
readonly end: number;
},
): readonly ElfSectionRow[] => [
[0, 0, 0, 0, 0, 0, 0],
...(options.dynamicSymbols === false
? []
: ([
[
1,
11,
layout.dynsymAt,
options.emptyDynamicSymbols === true ? 0 : layout.dynsym,
2,
1,
layout.symbolWidth,
],
[9, 3, layout.dynstrAt, layout.dynstrBytes, 0, 0, 0],
[
17,
0x6fffffff,
options.versionsOutsideFile === true
? layout.end + 64
: layout.versymAt,
layout.versym - (options.truncatedVersions === true ? 2 : 0),
1,
0,
2,
],
[
30,
0x6ffffffd,
layout.verdefAt,
layout.definitions * 28,
2,
layout.definitions,
0,
],
] satisfies ElfSectionRow[])),
[45, 3, layout.shstrtabAt, layout.shstrtab, 0, 0, 0],
];