Files
rea/src/native/pwndbg/CoreFileMappingAuthority.ts
T
morluto b33c0df02e fix(pwndbg): reject core mapping paths that escape sysroot
Interpret source-bound ELF64 NT_FILE descriptors once before debugger
launch. Bound counts and pathname coverage by the descriptor, retain exact
path bytes, and refuse relative names or dot segments rather than letting
GDB resolve them against the host. Pin GDB's pathname semantics to Unix.

Preserve the decoded partial observation on refusal, basic-only recovery,
and pre-admission cancellation. Share an exact mapping fixture and replace
the old fixture boolean modes with explicit report producers.

Validation: final series has 33 passing Vitest cases plus five Python
producer controls; 38 Linux x64 provider cases are explicitly skipped on
macOS. Typecheck/lint, docs checks and test discovery pass. Real GDB16.3
and strace reproduce the relative-path escape using only an owned fixture;
compiled admission admits a real absolute-path core and refuses that
relative-path core. Full native pwndbg / Linux x64 remain unverified here.
Task token, CPU and peak-family-memory costs are unmeasured; no speed claim.

Fixes #4942
2026-10-18 10:27:37 +08:00

64 lines
2.7 KiB
TypeScript

import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import type { RecordedCrash } from "../../domain/native/recordedCrash.js";
import { err, ok, type Result } from "../../domain/result.js";
/** Interpret source-bound ELF64 NT_FILE paths once, before GDB can open them. */
export function validateCoreFileMappingAuthority(
report: RecordedCrash,
snapshot: Buffer,
): Result<void, AnalysisCapabilityUnavailableError> {
for (const note of report.notes) {
if (note.type !== "NT_FILE") continue;
const refuse = (reason: string) => {
const message = `Core mapping note ${note.index} cannot be confined to the owned debugger sysroot: ${reason}.`;
return err(
new AnalysisCapabilityUnavailableError(
"pwndbg-core",
"inspect_recorded_crash",
message,
{
userMessage: `${message} Omit include_debugger_context for basic recorded evidence.`,
capturedOutput: report.decoder_diagnostics,
},
),
);
};
// The provider has already bound this range and note kind to the snapshot.
const start = Number(BigInt(note.descriptor_location.offset));
const bytes = snapshot.subarray(
start,
start + Number(BigInt(note.descriptor_location.bytes)),
);
if (bytes.length < 16) return refuse("missing mapping table header");
const count = bytes.readBigUInt64LE(0);
if (count > BigInt(Math.floor((bytes.length - 16) / 24)))
return refuse("mapping count exceeds its descriptor");
let position = 16 + Number(count) * 24;
for (let index = 0; index < Number(count); index++) {
const end = bytes.indexOf(0, position);
if (end < 0) return refuse("unterminated mapping pathname");
const path = bytes.subarray(position, end);
// Inspect Unix path bytes without lossy UTF-8 decoding or host resolution.
// Relative paths bypass GDB's sysroot; dot segments can escape its prefix.
if (path[0] !== 0x2f) return refuse("mapping pathname is not absolute");
let component = 0;
for (let cursor = 0; cursor <= path.length; cursor++) {
if (cursor !== path.length && path[cursor] !== 0x2f) continue;
const length = cursor - component;
if (
(length === 1 && path[component] === 0x2e) ||
(length === 2 &&
path[component] === 0x2e &&
path[component + 1] === 0x2e)
)
return refuse("mapping pathname contains a dot segment");
component = cursor + 1;
}
position = end + 1;
}
if (bytes.subarray(position).some((byte) => byte !== 0))
return refuse("mapping pathname coverage is unresolved");
}
return ok(undefined);
}