mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
* feat(native): recover ELF function ranges from unwind tables inspect_elf_unwind_ranges and inspect-elf-unwind-ranges read .eh_frame and .eh_frame_hdr from one local ELF32 or ELF64 image and list the function ranges its FDEs name, with CIE facts, LSDA and personality, the containing section and an exact symbol where one starts at the address. Nothing is executed and no disassembler is needed. Pointer encodings follow the DW_EH_PE rules with raw zero as null, discarded FDEs are counted and skipped, the .eh_frame_hdr search table is compared with the decoded FDEs, and record, row and symbol budgets are bounded. verify:elf:unwind-ranges compares the reported starts with llvm-dwarfdump or readelf on a compiled C++ program. * fix(elf): keep unresolved unwind facts out of reported ranges Skip FDEs whose initial location is indirect and do not locate .eh_frame through an indirect eh_frame_ptr, since pointer slots are not functions. Stop an unbounded header-located scan at a CIE with an unrecognized augmentation, report ignored duplicate PT_GNU_EH_FRAME headers as incomplete coverage, and accept a null personality stored under an indirect encoding. Keep only symbols at decoded FDE starts, cache name observations per string-table offset, and decode names only for the returned page under a fixed 16 MiB text allowance, so repeated or long names no longer grow memory with the symbol count. * fix(scripts): run the ELF unwind verifier under owned process trees Launch probes, compiler drivers, the stripper, the frame dump and the inspection through createVerifierCommands, as verify-binary-hardening does, so a timeout or interrupt stops the whole tree. The temporary directory is removed only after the owned commands have closed. * docs(elf): move ELF unwind ranges guide under docs/guides/native The documentation guide assigns analyst workflows to docs/guides/<domain>/. Move the guide there, place its navigation and directory entries in the Native binaries group, and update links from the docs index, CLI guide, spec and authored skill. The public /elf-unwind-ranges route is unchanged. * fix(elf): give unwind text limits their own recovery and skip indirect tables Throw a dedicated failure when a page's symbol names exceed the 16 MiB text allowance, and map it to a resource error that tells the caller to request a smaller limit rather than raise max_ranges. Read a CIE augmentation with one bounded NUL search and keep it as a byte view, decoded only for returned CIEs, so a long augmentation is never built up character by character. Leave an .eh_frame_hdr search table with an indirect encoding unread: the header is incomplete, the comparison is unavailable, and the table no longer bounds a header-located .eh_frame scan. * fix(elf): resolve header count and table forms and name IFUNC resolvers Leave a non-zero .eh_frame_hdr FDE count under an indirect encoding unresolved, since it is the address of a slot that is not followed, and mark the header incomplete without reading the table. Treat a DW_EH_PE_omit table encoding as an omitted search table before testing the indirect bit, so a header that declares a count without a table stays present with complete coverage, and accept that form in output validation. Match STT_GNU_IFUNC symbols at exact FDE starts after function symbols, with a limitation that the name is the resolver, not the implementation it selects at run time. * fix(elf): require allocated unwind pointer bases * fix(elf): map unwind header relative base * fix(elf): validate unwind load mappings * test(elf): cover mapped nonallocated frame agreement --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
247 lines
7.3 KiB
JavaScript
247 lines
7.3 KiB
JavaScript
/**
|
|
* Build a stripped C++ ELF and compare .eh_frame FDE starts with readelf.
|
|
* Not part of the default check. Exits non-zero when the host has no ELF
|
|
* C++ compiler or no readelf.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { withVerifierCleanup } from "./lib/verifier-cleanup.mjs";
|
|
import { createVerifierCommands } from "./lib/verify-commands.mjs";
|
|
|
|
/**
|
|
* Every probe, compiler driver and inspection runs as an owned process tree,
|
|
* so a timeout or close also stops the children a driver starts. Close it
|
|
* before removing the compiled outputs.
|
|
*/
|
|
const commands = await createVerifierCommands();
|
|
|
|
/**
|
|
* Ctrl-C or SIGTERM aborts the running command instead of ending Node, so the
|
|
* owned process trees and the compiled outputs are still cleaned up below.
|
|
*/
|
|
const interruption = new AbortController();
|
|
const interrupt = (signal) =>
|
|
interruption.abort(
|
|
new Error(`ELF unwind ranges verifier interrupted by ${signal}`),
|
|
);
|
|
process.on("SIGINT", interrupt);
|
|
process.on("SIGTERM", interrupt);
|
|
|
|
const run = (command, args, timeout) =>
|
|
commands.exec(command, args, {
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
timeout,
|
|
signal: interruption.signal,
|
|
});
|
|
|
|
const available = async (command) => {
|
|
// zig has no --version flag; it reports through the `version` subcommand.
|
|
const probe = command === "zig" ? ["version"] : ["--version"];
|
|
try {
|
|
await run(command, probe, 30000);
|
|
return true;
|
|
} catch (failure) {
|
|
// An absent tool or a failing probe means the candidate is unavailable.
|
|
if (failure?.code === "ENOENT" || typeof failure?.code === "number")
|
|
return false;
|
|
throw failure;
|
|
}
|
|
};
|
|
|
|
const firstAvailable = async (candidates) => {
|
|
for (const command of candidates)
|
|
if (await available(command)) return command;
|
|
return null;
|
|
};
|
|
|
|
const sourceText = `
|
|
struct Guard { ~Guard(); };
|
|
Guard::~Guard() {}
|
|
int may_throw(int value) {
|
|
Guard guard;
|
|
if (value < 0) throw value;
|
|
return value + 1;
|
|
}
|
|
int main() {
|
|
try { return may_throw(1); }
|
|
catch (int) { return 2; }
|
|
}
|
|
`;
|
|
|
|
const compileArguments = (compiler, source, output) => {
|
|
if (compiler === "zig")
|
|
return [
|
|
"c++",
|
|
"-target",
|
|
"x86_64-linux-musl",
|
|
"-O0",
|
|
"-g",
|
|
source,
|
|
"-o",
|
|
output,
|
|
];
|
|
if (compiler === "clang++")
|
|
return [
|
|
"--target=x86_64-linux-gnu",
|
|
"-O0",
|
|
"-g",
|
|
"-fexceptions",
|
|
source,
|
|
"-o",
|
|
output,
|
|
];
|
|
return ["-O0", "-g", "-fexceptions", source, "-o", output];
|
|
};
|
|
|
|
const compile = async (compiler, source, output) => {
|
|
await run(compiler, compileArguments(compiler, source, output), 120000);
|
|
};
|
|
|
|
const stripArguments = (stripper, input, output) => {
|
|
if (stripper === "llvm-strip") return ["--strip-all", "-o", output, input];
|
|
if (stripper === "llvm-objcopy") return ["--strip-all", input, output];
|
|
if (stripper === "zig") return ["objcopy", "--strip-all", input, output];
|
|
return ["-o", output, input];
|
|
};
|
|
|
|
const stripCopy = async (stripper, input, output) => {
|
|
await run(stripper, stripArguments(stripper, input, output), 30000);
|
|
};
|
|
|
|
// llvm-readelf has no --debug-dump=frames; llvm-dwarfdump --eh-frame prints
|
|
// the same `pc=<start>..<end>` FDE ranges under an `.eh_frame contents:` head.
|
|
const frameDump = async (readelf, path) =>
|
|
(
|
|
await run(
|
|
readelf,
|
|
readelf === "llvm-dwarfdump"
|
|
? ["--eh-frame", path]
|
|
: ["--debug-dump=frames", path],
|
|
30000,
|
|
)
|
|
).stdout;
|
|
|
|
const ehFrameStarts = async (readelf, path) => {
|
|
const stdout = await frameDump(readelf, path);
|
|
const starts = new Set();
|
|
let inEhFrame = false;
|
|
for (const line of stdout.split("\n")) {
|
|
if (/Contents of the \.eh_frame\b|^\.eh_frame contents:/u.test(line)) {
|
|
inEhFrame = true;
|
|
continue;
|
|
}
|
|
if (/Contents of the \.debug_frame\b|^\.\w+ contents:/u.test(line)) {
|
|
inEhFrame = false;
|
|
continue;
|
|
}
|
|
if (!inEhFrame) continue;
|
|
const match = /pc=(0x[0-9a-fA-F]+|[0-9a-fA-F]+)\.\./u.exec(line);
|
|
// readelf and llvm-dwarfdump print bare hex; BigInt needs the 0x prefix.
|
|
if (match?.[1] !== undefined)
|
|
starts.add(
|
|
BigInt(match[1].startsWith("0x") ? match[1] : `0x${match[1]}`),
|
|
);
|
|
}
|
|
return starts;
|
|
};
|
|
|
|
const inspectedStarts = async (path) => {
|
|
const starts = new Set();
|
|
let offset = 0;
|
|
for (;;) {
|
|
const { stdout: output } = await run(
|
|
process.execPath,
|
|
[
|
|
resolve("scripts/rea.mjs"),
|
|
"inspect-elf-unwind-ranges",
|
|
path,
|
|
"--json",
|
|
"--offset",
|
|
String(offset),
|
|
"--limit",
|
|
"4096",
|
|
"--max-ranges",
|
|
"65536",
|
|
],
|
|
60000,
|
|
);
|
|
const report = JSON.parse(output).normalized_result;
|
|
for (const range of report.ranges) starts.add(BigInt(range.start));
|
|
if (report.page.next_offset === null) {
|
|
assert.equal(report.eh_frame.status, "present");
|
|
assert.equal(starts.size, report.coverage.total_ranges);
|
|
return starts;
|
|
}
|
|
offset = report.page.next_offset;
|
|
}
|
|
};
|
|
|
|
// The root holds every compiled image. Remove it only after the owned
|
|
// commands have settled, and keep it when their cleanup failed.
|
|
let root;
|
|
let commandsClosed = false;
|
|
let unavailable = false;
|
|
await withVerifierCleanup(async () => {
|
|
const compiler = await firstAvailable(["zig", "clang++", "g++"]);
|
|
const readelf = await firstAvailable([
|
|
"readelf",
|
|
"greadelf",
|
|
"llvm-dwarfdump",
|
|
]);
|
|
const stripper = await firstAvailable([
|
|
"llvm-strip",
|
|
"llvm-objcopy",
|
|
"zig",
|
|
"strip",
|
|
]);
|
|
if (compiler === null || readelf === null || stripper === null) {
|
|
console.error(
|
|
"verify:elf:unwind-ranges needs an ELF C++ compiler (zig, clang++, or g++) and readelf (readelf, greadelf, or llvm-dwarfdump), plus a stripper.",
|
|
);
|
|
unavailable = true;
|
|
return;
|
|
}
|
|
root = await mkdtemp(join(tmpdir(), "rea-elf-unwind-"));
|
|
const source = join(root, "ranges.cpp");
|
|
const unstripped = join(root, "ranges.unstripped");
|
|
const stripped = join(root, "ranges.stripped");
|
|
await writeFile(source, sourceText);
|
|
await compile(compiler, source, unstripped);
|
|
await stripCopy(stripper, unstripped, stripped);
|
|
const expected = await ehFrameStarts(readelf, unstripped);
|
|
const observed = await inspectedStarts(stripped);
|
|
assert.ok(expected.size > 0, "readelf listed no .eh_frame FDEs");
|
|
const missing = [...expected].filter((start) => !observed.has(start));
|
|
const extra = [...observed].filter((start) => !expected.has(start));
|
|
assert.deepEqual(
|
|
{ missing: missing.map(String), extra: extra.map(String) },
|
|
{ missing: [], extra: [] },
|
|
);
|
|
console.log(
|
|
JSON.stringify({
|
|
compiler,
|
|
readelf,
|
|
fde_starts: expected.size,
|
|
}),
|
|
);
|
|
}, [
|
|
async () => {
|
|
await commands.close();
|
|
commandsClosed = true;
|
|
},
|
|
async () => {
|
|
if (commandsClosed && root !== undefined)
|
|
await rm(root, { recursive: true, force: true });
|
|
},
|
|
async () => {
|
|
process.off("SIGINT", interrupt);
|
|
process.off("SIGTERM", interrupt);
|
|
},
|
|
]);
|
|
// A signal that lands outside a command, such as during the final rm, is still
|
|
// a failed run. An earlier failure already left through the rejection above.
|
|
if (interruption.signal.aborted) throw interruption.signal.reason;
|
|
if (unavailable) process.exitCode = 1;
|