diff --git a/.github/workflows/real-recorded-crash.yml b/.github/workflows/real-recorded-crash.yml
new file mode 100644
index 000000000..5138df212
--- /dev/null
+++ b/.github/workflows/real-recorded-crash.yml
@@ -0,0 +1,99 @@
+name: Real recorded crash evidence
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - ".github/workflows/real-recorded-crash.yml"
+ - "bridge/pwntools/**"
+ - "bridge/pwndbg/**"
+ - "src/native/pwntools/**"
+ - "src/native/pwndbg/**"
+ - "src/domain/native/recordedCrash*"
+ - "src/application/binaryDiagnostics/**"
+ - "src/contracts/native/binaryDiagnosticsToolContracts.ts"
+ - "src/contracts/toolEffects.ts"
+ - "src/contracts/toolOutputSchemaGroups.ts"
+ - "src/composition/binaryDiagnostics.ts"
+ - "src/server/registerRecordedCrashTools.ts"
+ - "src/server/createServer.ts"
+ - "src/server/sessionAvailabilityPolicy.ts"
+ - "src/cli/binaryDiagnosticsCommands.ts"
+ - "src/cliCommandNames.ts"
+ - "src/process/**"
+ - "src/artifacts/readStableArtifact*.ts"
+ - "src/domain/analysisError*.ts"
+ - "scripts/verify-recorded-crash.mjs"
+ - "scripts/verify/recorded-crash/**"
+ - "scripts/lib/recorded-core-fixtures.py"
+ - "scripts/fixtures/recorded-crash.c"
+ - "third_party/pwndbg/**"
+ - "package.json"
+ - "package-lock.json"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: real-recorded-crash-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: --max-old-space-size=768
+ HUSKY: "0"
+ UV_CONCURRENT_BUILDS: "1"
+ UV_CONCURRENT_INSTALLS: "1"
+ UV_PYTHON_DOWNLOADS: never
+
+jobs:
+ real-recorded-crash:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci
+ - name: Install GDB on the disposable CI runner
+ shell: bash
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install --yes --no-install-recommends gdb
+ - name: Acquire exact unchanged upstreams in isolated runner storage
+ shell: bash
+ run: |
+ command -v gcc
+ command -v gdb
+ command -v strace
+ core_decoder_root="${RUNNER_TEMP}/rea-core-decoder"
+ core_pwndbg_root="${RUNNER_TEMP}/rea-core-pwndbg"
+ core_pwndbg_venv="${RUNNER_TEMP}/rea-core-pwndbg-venv"
+ python3 -m venv "${core_decoder_root}"
+ "${core_decoder_root}/bin/python" -m pip install --disable-pip-version-check \
+ 'pwntools==4.15.0' 'pyelftools==0.33' 'unicorn==2.1.2' 'uv==0.12.0'
+ git init "${core_pwndbg_root}"
+ git -C "${core_pwndbg_root}" remote add origin https://github.com/pwndbg/pwndbg.git
+ git -C "${core_pwndbg_root}" fetch --depth=1 origin 55596aa676a2756efb8d67034f31675170eabe48
+ git -C "${core_pwndbg_root}" checkout --detach FETCH_HEAD
+ test "$(git -C "${core_pwndbg_root}" rev-parse HEAD)" = 55596aa676a2756efb8d67034f31675170eabe48
+ UV_PROJECT_ENVIRONMENT="${core_pwndbg_venv}" \
+ "${core_decoder_root}/bin/uv" sync --project "${core_pwndbg_root}" \
+ --frozen --no-editable --no-default-groups --python /usr/bin/python3 --no-managed-python
+ test -z "$(git -C "${core_pwndbg_root}" status --porcelain --untracked-files=no)"
+ echo "REA_PWNTOOLS_PYTHON=${core_decoder_root}/bin/python" >> "${GITHUB_ENV}"
+ echo "REA_PWNDBG_GDB=$(command -v gdb)" >> "${GITHUB_ENV}"
+ echo "REA_PWNDBG_GDBINIT=${core_pwndbg_root}/gdbinit.py" >> "${GITHUB_ENV}"
+ echo "REA_PWNDBG_VENV_PATH=${core_pwndbg_venv}" >> "${GITHUB_ENV}"
+ echo "REA_VERIFY_STRACE_COMMAND=$(command -v strace)" >> "${GITHUB_ENV}"
+ - name: Build and install the exact package in isolated runner storage
+ shell: bash
+ run: |
+ npm run build:cached
+ npm pack --ignore-scripts --json --pack-destination "${RUNNER_TEMP}" > "${RUNNER_TEMP}/rea-core-pack.json"
+ core_package_name=$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"))[0].filename' "${RUNNER_TEMP}/rea-core-pack.json")
+ npm install --prefix "${RUNNER_TEMP}/rea-core-installed" --ignore-scripts --no-fund --no-audit "${RUNNER_TEMP}/${core_package_name}"
+ - name: Verify installed CLI and MCP against the source-owned recording
+ shell: bash
+ run: node scripts/verify-recorded-crash.mjs "${RUNNER_TEMP}/rea-core-installed/node_modules/rea-agents/scripts/rea.mjs"
diff --git a/README.md b/README.md
index 348e18829..225f13db8 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,7 @@ Additional tools and host support depend on the target:
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://morluto.github.io/rea/guides/native/) |
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) |
+| Recorded Linux crashes | Raw notes, every recorded thread's registers/signals and optional mapping candidates | Caller-supplied pwntools; optional GDB/pwndbg; [recorded crashes](docs/recorded-crashes.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
diff --git a/README_ar.md b/README_ar.md
index 8bc08d594..a1dcb398d 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -217,7 +217,7 @@ rea setup
| ---------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
-| أدوات الملفات الثنائية | 9 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة على Linux |
+| أدوات الملفات الثنائية | 10 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة وسجلات الأعطال على Linux |
| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
diff --git a/README_ja.md b/README_ja.md
index 85fd9ba55..b10306692 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -240,7 +240,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
-| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 |
+| ネイティブバイナリ | 10 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウト、静的な保護機構の証拠と記録済みクラッシュ |
| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
diff --git a/README_ko.md b/README_ko.md
index 3eb11722e..cea69ecb3 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -207,7 +207,7 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- |
| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
-| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 |
+| 네이티브 바이너리 | 10 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃, 정적 보호 증거와 기록된 크래시 |
| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 |
| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
diff --git a/README_zh.md b/README_zh.md
index 9b187b5ce..4631c3563 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -202,20 +202,20 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
## 调查工具目录
-| 工具类别 | 数量 | 用途 |
-| ----------------- | ---: | ----------------------------------------------------------------------------------------------------------------- |
-| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
-| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
-| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 |
-| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
-| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
-| 固件 | 2 | Linux 固件区域检查与显式提取 |
-| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
-| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
-| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
-| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
-| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
-| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
+| 工具类别 | 数量 | 用途 |
+| ----------------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
+| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
+| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
+| 原生二进制工具 | 10 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局、静态保护证据与历史崩溃记录 |
+| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
+| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
+| 固件 | 2 | Linux 固件区域检查与显式提取 |
+| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
+| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
+| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
+| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
+| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
+| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
diff --git a/bridge/pwndbg/core_context.py b/bridge/pwndbg/core_context.py
new file mode 100644
index 000000000..57f86f688
--- /dev/null
+++ b/bridge/pwndbg/core_context.py
@@ -0,0 +1,101 @@
+"""Fixed core-only GDB/pwndbg operation; no caller commands or historical PID lookup."""
+import json
+import os
+from pathlib import Path
+import resource
+import gdb
+
+OUTPUT_BYTES = 64 * 1024 * 1024
+
+
+class ContextFailure(Exception):
+ def __init__(self, reason, message):
+ super().__init__(message)
+ self.reason = reason
+
+
+def inspect(request):
+ if gdb.selected_inferior().connection is not None:
+ raise ContextFailure("decoder", "Owned GDB unexpectedly has an inferior connection before core loading.")
+ root = Path(os.environ["REA_CORE_CONTEXT_REQUEST"]).parent
+ empty = root / "no-libraries"
+ empty.mkdir(mode=0o700)
+ for command in ["set auto-load off", "set debuginfod enabled off", "set auto-solib-add off", "set history save off", "set pagination off",
+ "set sysroot ./no-libraries", "set solib-search-path ./no-libraries"]:
+ gdb.execute(command, to_string=True)
+ entry = request["gdbinit"]
+ try:
+ exec(compile(Path(entry).read_bytes(), entry, "exec"), {"__name__": "__main__", "__file__": entry})
+ import pwndbg.aglib.vmmap
+ import pwndbg.lib.version
+ except (ImportError, OSError) as error:
+ raise ContextFailure("unavailable", "Selected pwndbg could not initialize: " + str(error)) from error
+ version = pwndbg.lib.version.__version__
+ if version != "2026.09.15" and not version.startswith("2026.09.15 build: "):
+ raise ContextFailure("unsupported", "Expected unchanged pwndbg2026.09.15; selected plugin reports " + version + ".")
+ gdb.execute('set context-sections ""', to_string=True)
+ # core-file takes a raw filename and retains quote characters. Use a fixed
+ # relative filename in the owned cwd so no selected path becomes GDB syntax.
+ if Path(request["snapshot_path"]) != root.parent / "object.snapshot":
+ raise ContextFailure("decoder", "Core context request did not select the shared owned snapshot.")
+ gdb.execute("core-file ../object.snapshot", to_string=True)
+ inferior = gdb.selected_inferior()
+ if inferior.connection is None or inferior.connection.type != "core" or gdb.current_progspace().filename is not None:
+ raise ContextFailure("decoder", "Requested core-only boundary was not established; no mapping query performed.")
+ maps = []
+ for page in pwndbg.aglib.vmmap.get():
+ flags = page.flags
+ maps.append({"start": hex(page.start), "end": hex(page.end), "offset": hex(page.offset), "reported_flags": flags,
+ "permissions": {"read": bool(flags & 4), "write": bool(flags & 2), "execute": bool(flags & 1)} if 1 <= flags <= 7 else None,
+ "pathname_display": page.objfile, "current_file_identity": "unknown"})
+ return {"status": "available", "gdb_version": gdb.VERSION, "pwndbg_version": version, "connection": "core", "executable": None,
+ "historical_pid": inferior.pid, "reported_limits": {"address_space_bytes": resource.getrlimit(resource.RLIMIT_AS)[0], "cpu_seconds": resource.getrlimit(resource.RLIMIT_CPU)[0], "file_size_bytes": resource.getrlimit(resource.RLIMIT_FSIZE)[0]}, "maps": maps, "confidence": "derived", "completeness": "unknown",
+ "diagnostics": {"stdout": "", "stderr": "", "truncated": False}}
+
+
+def failure_exit(request_path, status, marker):
+ try:
+ descriptor = os.open(Path(request_path).parent / "resource.failure", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ os.write(descriptor, marker)
+ os.close(descriptor)
+ except (OSError, MemoryError):
+ pass
+ os._exit(status)
+
+
+def main():
+ request_path = os.environ["REA_CORE_CONTEXT_REQUEST"]
+ request = json.loads(Path(request_path).read_text(encoding="utf-8"))
+ reserve = bytearray(1024 * 1024)
+ try:
+ reply = {"ok": True, "value": inspect(request)}
+ except ContextFailure as error:
+ reply = {"ok": False, "reason": error.reason, "message": str(error)}
+ except MemoryError:
+ reserve = None
+ failure_exit(request_path, 75, b"M")
+ except Exception as error:
+ if isinstance(error, OSError) and error.errno == 12:
+ reserve = None
+ failure_exit(request_path, 75, b"M")
+ reply = {"ok": False, "reason": "decoder", "message": type(error).__name__ + ": " + str(error)}
+ try:
+ encoded = json.dumps(reply, ensure_ascii=True, allow_nan=False).encode("utf-8")
+ if len(encoded) > OUTPUT_BYTES:
+ encoded = json.dumps({"ok": False, "reason": "output-limit", "message": "Complete debugger context exceeds64 MiB reply budget."}).encode("utf-8")
+ descriptor = os.open(request["reply_path"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(descriptor, "wb") as handle:
+ handle.write(encoded)
+ except MemoryError:
+ reserve = None
+ failure_exit(request_path, 75, b"M")
+ except OSError as error:
+ if error.errno == 27:
+ failure_exit(request_path, 76, b"F")
+ if error.errno == 12:
+ reserve = None
+ failure_exit(request_path, 75, b"M")
+ raise
+
+
+main()
diff --git a/bridge/pwndbg/launch.py b/bridge/pwndbg/launch.py
new file mode 100644
index 000000000..7439167e2
--- /dev/null
+++ b/bridge/pwndbg/launch.py
@@ -0,0 +1,39 @@
+"""Catch typed resource failures while loading or running the owned debugger launcher.
+
+Only this package's fixed sibling is executed; caller input stays in request.json.
+Interpreter startup failures before this boundary remain unclassified.
+"""
+import os
+import sys
+
+
+def resource_exit(status, marker):
+ try:
+ if len(sys.argv) > 2:
+ descriptor = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ os.write(descriptor, marker)
+ os.close(descriptor)
+ except (OSError, MemoryError):
+ # Without a complete marker the parent keeps this failure unclassified.
+ pass
+ os._exit(status)
+
+try:
+ implementation = os.path.join(os.path.dirname(__file__), "launch_impl.py")
+ with open(implementation, "rb") as handle:
+ source = handle.read()
+ exec(compile(source, implementation, "exec"), {
+ "__file__": implementation,
+ "__name__": "__main__",
+ })
+except MemoryError:
+ # Reserved status: an observed allocation failure, including imports/compile.
+ resource_exit(75, b"M")
+except OSError as error:
+ # Linux ENOMEM is an observed allocation failure, independent of error text.
+ if error.errno == 12:
+ resource_exit(75, b"M")
+ # Linux EFBIG is observed directly, including a partial limits/reply write.
+ if error.errno == 27:
+ resource_exit(76, b"F")
+ raise
diff --git a/bridge/pwndbg/launch_impl.py b/bridge/pwndbg/launch_impl.py
new file mode 100644
index 000000000..c1cd04b04
--- /dev/null
+++ b/bridge/pwndbg/launch_impl.py
@@ -0,0 +1,34 @@
+"""Lower inherited limits before replacing this owned process with GDB."""
+import json
+import os
+from pathlib import Path
+import sys
+
+runtime_path = Path(__file__).parent.parent / "pwntools" / "decoder_runtime.py"
+runtime = {"__file__": str(runtime_path), "__name__": "rea_decoder_runtime"}
+exec(compile(runtime_path.read_bytes(), str(runtime_path), "exec"), runtime)
+
+
+class LaunchFailure(Exception):
+ def __init__(self, reason, message):
+ super().__init__(message)
+ self.reason = reason
+
+
+def launch(snapshot, cache):
+ request_path = sys.argv[-1]
+ request = json.loads(Path(request_path).read_text(encoding="utf-8"))
+ bridge = str(Path(__file__).with_name("core_context.py"))
+ environment = dict(os.environ, REA_CORE_CONTEXT_REQUEST=request_path)
+ # Only fixed package code appears in the GDB Python command. Selected paths stay JSON data.
+ command = "python exec(compile(open(" + repr(bridge) + ", 'rb').read(), " + repr(bridge) + ", 'exec'), {'__name__':'__main__','__file__':" + repr(bridge) + "})"
+ try:
+ os.execve(request["gdb"], [request["gdb"], "-nx", "-nh", "--batch", "--quiet",
+ "-iex", "set auto-load off", "-iex", "set debuginfod enabled off",
+ "-iex", "set auto-solib-add off", "-ex", command], environment)
+ except OSError as error:
+ raise LaunchFailure("unavailable", "Selected GDB could not start (" + str(error.errno) + "): " + request["gdb"] + "; " + str(error)) from error
+
+
+if __name__ == "__main__":
+ runtime["main"](sys.argv[-1], launch, LaunchFailure, "pwndbg-core-launcher")
diff --git a/bridge/pwntools/decoder_runtime.py b/bridge/pwntools/decoder_runtime.py
new file mode 100644
index 000000000..6681dffee
--- /dev/null
+++ b/bridge/pwntools/decoder_runtime.py
@@ -0,0 +1,59 @@
+"""Shared bounded reply/limit protocol, independent of the supplied artifact format."""
+import json
+import os
+from pathlib import Path
+import resource
+import sys
+
+OUTPUT_BYTES = 64 * 1024 * 1024
+
+def lower_resource_limits():
+ limits = {}
+ for name, kind, maximum in (
+ ("address_space_bytes", resource.RLIMIT_AS, 3 * 1024**3),
+ ("cpu_seconds", resource.RLIMIT_CPU, 30),
+ ("file_size_bytes", resource.RLIMIT_FSIZE, OUTPUT_BYTES),
+ ):
+ soft, hard = resource.getrlimit(kind)
+ finite = [maximum] + [value for value in (soft, hard) if value != resource.RLIM_INFINITY]
+ effective = min(finite)
+ # Preserve the inherited hard boundary and never raise a caller's soft limit.
+ resource.setrlimit(kind, (effective, hard))
+ limits[name] = effective
+ return limits
+
+
+def main(request_path, inspect, failure_type, profile):
+ request = json.loads(Path(request_path).read_text(encoding="utf-8"))
+ os.environ["PWNLIB_NOTERM"] = "1"
+ os.environ["PWNLIB_CACHE_DIR"] = str(Path(request_path).parent / "cache")
+ limits = None
+ # Release bounded emergency headroom before constructing a MemoryError reply.
+ # The outer exit-status contract also covers allocation failure during serialization.
+ memory_reserve = bytearray(1024 * 1024)
+ try:
+ limits = lower_resource_limits()
+ descriptor = os.open(Path(request_path).parent / "limits.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
+ json.dump(limits, handle)
+ value = inspect(Path(request["snapshot_path"]), Path(request_path).parent / "cache")
+ value["limitations"].append("Effective owned Python resource soft limits: " + json.dumps(limits, sort_keys=True) + ". Inherited tighter limits are retained.")
+ reply = {"ok": True, "profile": profile, "value": value}
+ except failure_type as error:
+ reply = {"ok": False, "reason": error.reason, "message": str(error)}
+ except MemoryError:
+ memory_reserve = None
+ reply = {"ok": False, "reason": "resource-limit", "message": "pwntools memory allocation failed under the effective resource limits; the exact allocation cause is unknown.", "reported_limits": limits}
+ except Exception as error:
+ if isinstance(error, OSError) and error.errno == 12:
+ memory_reserve = None
+ reply = {"ok": False, "reason": "resource-limit", "message": "pwntools reported OSError(ENOMEM); the exact allocation cause is unknown.", "reported_limits": limits}
+ else:
+ reply = {"ok": False, "reason": "decoder", "message": type(error).__name__ + ": " + str(error)}
+ encoded = json.dumps(reply, ensure_ascii=True, allow_nan=False).encode("utf-8")
+ if len(encoded) > OUTPUT_BYTES:
+ encoded = json.dumps({"ok": False, "reason": "output-limit", "message": "Complete artifact result exceeds the 64 MiB reply budget."}).encode("utf-8")
+ descriptor = os.open(request["reply_path"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(descriptor, "wb") as handle:
+ handle.write(encoded)
+
diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py
index 92b3ea9e8..0cae2b46f 100644
--- a/bridge/pwntools/layout_impl.py
+++ b/bridge/pwntools/layout_impl.py
@@ -65,7 +65,7 @@ def dynamic_name_reference(tags, offset, image, content):
if not starts or not sizes:
raise LayoutFailure("format", "ELF dependency requires DT_STRTAB and DT_STRSZ.")
if len(starts) != 1 or len(sizes) != 1:
- raise LayoutFailure("unsupported", "ELF dependency string-table tags are ambiguous: " + repr({"DT_STRTAB": starts, "DT_STRSZ": sizes}))
+ raise LayoutFailure("unsupported-target", "ELF dependency string-table tags are ambiguous: " + repr({"DT_STRTAB": starts, "DT_STRSZ": sizes}))
if not 0 <= offset < sizes[0]:
raise LayoutFailure("format", f"ELF dependency name offset {offset} lies outside declared DT_STRSZ {sizes[0]}.")
offsets = set()
@@ -75,7 +75,7 @@ def dynamic_name_reference(tags, offset, image, content):
if starts[0] >= h.p_vaddr and starts[0] + sizes[0] <= h.p_vaddr + h.p_filesz:
offsets.add(h.p_offset + starts[0] - h.p_vaddr)
if len(offsets) != 1:
- raise LayoutFailure("unsupported", "Dynamic string table lacks a unique file-backed mapping: " + repr({"DT_STRTAB": starts, "DT_STRSZ": sizes, "candidate_file_offsets": sorted(offsets)}))
+ raise LayoutFailure("unsupported-target", "Dynamic string table lacks a unique file-backed mapping: " + repr({"DT_STRTAB": starts, "DT_STRSZ": sizes, "candidate_file_offsets": sorted(offsets)}))
return name_in_table(None, offsets.pop(), sizes[0], offset, content)
@@ -117,13 +117,13 @@ def inspect_elf(path, cache):
if len(content) < 16:
raise LayoutFailure("format", "Selected ELF identification is truncated.")
if content[4] != 2 or content[5] != 1:
- raise LayoutFailure("unsupported", "Initial layout profile requires ELF64 little-endian objects.")
+ raise LayoutFailure("unsupported-target", "Initial layout profile requires ELF64 little-endian objects.")
if len(content) < 64:
raise LayoutFailure("format", "Selected ELF64 header is truncated.")
if int.from_bytes(content[18:20], "little") != 62:
- raise LayoutFailure("unsupported", "Initial layout profile requires x86-64 ELF objects.")
+ raise LayoutFailure("unsupported-target", "Initial layout profile requires x86-64 ELF objects.")
if int.from_bytes(content[16:18], "little") not in (1, 2, 3):
- raise LayoutFailure("unsupported", "Selected ELF type is outside the EXEC/DYN/REL layout profile; recorded core analysis is separate.")
+ raise LayoutFailure("unsupported-target", "Selected ELF type is outside the EXEC/DYN/REL layout profile; recorded core analysis is separate.")
from pwnlib.context import context
with context.local(cache_dir=str(cache), log_level="warning"):
from elftools.common.exceptions import ELFError
@@ -169,9 +169,9 @@ def inspect_elf(path, cache):
try:
image = LayoutELF(str(path), checksec=False)
if image.arch != "amd64" or image.bits != 64 or image.endian != "little":
- raise LayoutFailure("unsupported", "Initial layout profile requires x86-64 ELF64 little-endian objects.")
+ raise LayoutFailure("unsupported-target", "Initial layout profile requires x86-64 ELF64 little-endian objects.")
if image.header.e_type not in ("ET_EXEC", "ET_DYN", "ET_REL"):
- raise LayoutFailure("unsupported", "Selected ELF type is outside the EXEC/DYN/REL layout profile; recorded core analysis is separate.")
+ raise LayoutFailure("unsupported-target", "Selected ELF type is outside the EXEC/DYN/REL layout profile; recorded core analysis is separate.")
length = len(content)
all_sections = list(image.iter_sections())
shstrings = image.get_section(section_name_index) if all_sections and section_name_index != 0 else None
@@ -301,56 +301,9 @@ def inspect_elf(path, cache):
raise LayoutFailure("format", "Selected ELF failed unchanged upstream structural parsing: " + str(error)) from error
-def lower_resource_limits():
- limits = {}
- for name, kind, maximum in (
- ("address_space_bytes", resource.RLIMIT_AS, 3 * 1024**3),
- ("cpu_seconds", resource.RLIMIT_CPU, 30),
- ("file_size_bytes", resource.RLIMIT_FSIZE, OUTPUT_BYTES),
- ):
- soft, hard = resource.getrlimit(kind)
- finite = [maximum] + [value for value in (soft, hard) if value != resource.RLIM_INFINITY]
- effective = min(finite)
- # Preserve the inherited hard boundary and never raise a caller's soft limit.
- resource.setrlimit(kind, (effective, hard))
- limits[name] = effective
- return limits
-
-
-def main(request_path):
- request = json.loads(Path(request_path).read_text(encoding="utf-8"))
- os.environ["PWNLIB_NOTERM"] = "1"
- os.environ["PWNLIB_CACHE_DIR"] = str(Path(request_path).parent / "cache")
- limits = None
- # Release bounded emergency headroom before constructing a MemoryError reply.
- # The outer exit-status contract also covers allocation failure during serialization.
- memory_reserve = bytearray(1024 * 1024)
- try:
- limits = lower_resource_limits()
- descriptor = os.open(Path(request_path).parent / "limits.json", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
- with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
- json.dump(limits, handle)
- value = inspect_elf(Path(request["snapshot_path"]), Path(request_path).parent / "cache")
- value["limitations"].append("Effective owned Python resource soft limits: " + json.dumps(limits, sort_keys=True) + ". Inherited tighter limits are retained.")
- reply = {"ok": True, "profile": PROFILE, "value": value}
- except LayoutFailure as error:
- reply = {"ok": False, "reason": error.reason, "message": str(error)}
- except MemoryError:
- memory_reserve = None
- reply = {"ok": False, "reason": "resource-limit", "message": "pwntools memory allocation failed under the effective resource limits; the exact allocation cause is unknown.", "reported_limits": limits}
- except Exception as error:
- if isinstance(error, OSError) and error.errno == 12:
- memory_reserve = None
- reply = {"ok": False, "reason": "resource-limit", "message": "pwntools reported OSError(ENOMEM); the exact allocation cause is unknown.", "reported_limits": limits}
- else:
- reply = {"ok": False, "reason": "decoder", "message": type(error).__name__ + ": " + str(error)}
- encoded = json.dumps(reply, ensure_ascii=True, allow_nan=False).encode("utf-8")
- if len(encoded) > OUTPUT_BYTES:
- encoded = json.dumps({"ok": False, "reason": "output-limit", "message": "Complete ELF layout exceeds the 64 MiB reply budget."}).encode("utf-8")
- descriptor = os.open(request["reply_path"], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
- with os.fdopen(descriptor, "wb") as handle:
- handle.write(encoded)
-
if __name__ == "__main__":
- main(sys.argv[-1])
+ runtime_path = Path(__file__).with_name("decoder_runtime.py")
+ runtime = {"__file__": str(runtime_path), "__name__": "rea_decoder_runtime"}
+ exec(compile(runtime_path.read_bytes(), str(runtime_path), "exec"), runtime)
+ runtime["main"](sys.argv[-1], inspect_elf, LayoutFailure, PROFILE)
diff --git a/bridge/pwntools/recorded_crash.py b/bridge/pwntools/recorded_crash.py
new file mode 100644
index 000000000..f8bc3f305
--- /dev/null
+++ b/bridge/pwntools/recorded_crash.py
@@ -0,0 +1,39 @@
+"""Catch typed resource failures while loading or running the owned recorded-core adapter.
+
+Only this package's fixed sibling is executed; caller input stays in request.json.
+Interpreter startup failures before this boundary remain unclassified.
+"""
+import os
+import sys
+
+
+def resource_exit(status, marker):
+ try:
+ if len(sys.argv) > 2:
+ descriptor = os.open(sys.argv[1], os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ os.write(descriptor, marker)
+ os.close(descriptor)
+ except (OSError, MemoryError):
+ # Without a complete marker the parent keeps this failure unclassified.
+ pass
+ os._exit(status)
+
+try:
+ implementation = os.path.join(os.path.dirname(__file__), "recorded_crash_impl.py")
+ with open(implementation, "rb") as handle:
+ source = handle.read()
+ exec(compile(source, implementation, "exec"), {
+ "__file__": implementation,
+ "__name__": "__main__",
+ })
+except MemoryError:
+ # Reserved status: an observed allocation failure, including imports/compile.
+ resource_exit(75, b"M")
+except OSError as error:
+ # Linux ENOMEM is an observed allocation failure, independent of error text.
+ if error.errno == 12:
+ resource_exit(75, b"M")
+ # Linux EFBIG is observed directly, including a partial limits/reply write.
+ if error.errno == 27:
+ resource_exit(76, b"F")
+ raise
diff --git a/bridge/pwntools/recorded_crash_impl.py b/bridge/pwntools/recorded_crash_impl.py
new file mode 100644
index 000000000..84aa72ac5
--- /dev/null
+++ b/bridge/pwntools/recorded_crash_impl.py
@@ -0,0 +1,143 @@
+"""Bounded Linux core evidence via unchanged ELFFile/structs and pwntools ctypes.
+
+No Corefile-selected thread, executable guessing, process lookup or target launch.
+"""
+import base64
+import ctypes
+import importlib.metadata
+import io
+from pathlib import Path
+import sys
+
+PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
+
+
+class CoreFailure(Exception):
+ def __init__(self, reason, message):
+ super().__init__(message)
+ self.reason = reason
+
+
+def encoded(value):
+ return base64.b64encode(value).decode("ascii")
+
+
+def location(offset, size, limit):
+ if not 0 <= offset <= limit or not 0 <= size <= limit - offset:
+ raise CoreFailure("format", "Recorded source range exceeds its declared container.")
+ return {"offset": hex(offset), "bytes": hex(size)}
+
+
+def inspect_core(path, cache):
+ for package, required in {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"}.items():
+ try:
+ actual = importlib.metadata.version(package)
+ except importlib.metadata.PackageNotFoundError as error:
+ raise CoreFailure("unavailable", "Selected Python lacks " + package + " " + required + ".") from error
+ if actual != required:
+ raise CoreFailure("unsupported", "Expected " + package + " " + required + "; selected Python reports " + actual + ".")
+ content = path.read_bytes()
+ if len(content) < 16 or content[:4] != b"\x7fELF":
+ raise CoreFailure("format", "Selected recording lacks a complete ELF identification.")
+ if content[4:6] != b"\x02\x01":
+ raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ELF64 little-endian.")
+ if len(content) < 64:
+ raise CoreFailure("format", "Selected ELF64 core header is truncated.")
+ # Linux commonly emits ELFOSABI_NONE (0), also accepted as GNU/Linux (3).
+ # Other declared OSABIs must never select Linux note ctypes.
+ if content[7] not in (0, 3) or content[8] != 0:
+ raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".")
+ if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62:
+ raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.")
+ from elftools.common.exceptions import ELFError
+ from elftools.common.utils import struct_parse
+ from elftools.elf.elffile import ELFFile
+ from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
+ try:
+ image = ELFFile(io.BytesIO(content))
+ header = image.header
+ count = image.num_segments()
+ if count and header.e_phentsize < image.structs.Elf_Phdr.sizeof():
+ raise CoreFailure("format", "Program-header entry is shorter than the required ELF64 structure.")
+ location(header.e_phoff, count * header.e_phentsize, len(content))
+ segments, notes, padding, threads, signals = [], [], [], [], []
+ for segment_index, segment in enumerate(image.iter_segments()):
+ h = segment.header
+ backing = h.p_type != "PT_NULL" and h.p_filesz > 0
+ if backing:
+ location(h.p_offset, h.p_filesz, len(content))
+ if h.p_type == "PT_LOAD" and h.p_filesz > h.p_memsz:
+ raise CoreFailure("format", "Recorded PT_LOAD file bytes exceed memory size.")
+ segments.append({"index": segment_index, "type": h.p_type,
+ "header_location": location(header.e_phoff + segment_index * header.e_phentsize, header.e_phentsize, len(content)),
+ "offset": hex(h.p_offset), "file_size": hex(h.p_filesz), "memory_size": hex(h.p_memsz),
+ "virtual_address": hex(h.p_vaddr), "physical_address": hex(h.p_paddr), "alignment": hex(h.p_align), "flags": hex(h.p_flags), "file_backing": "file" if backing else "none"})
+ if h.p_type != "PT_NOTE" or h.p_filesz == 0:
+ continue
+ position, end = h.p_offset, h.p_offset + h.p_filesz
+ header_size = image.structs.Elf_Nhdr.sizeof()
+ while position < end:
+ if end - position < header_size:
+ raw = content[position:end]
+ if any(raw):
+ raise CoreFailure("format", "Nonzero trailing note bytes lack a complete note header.")
+ padding.append({"segment_index": segment_index, "location": location(position, len(raw), end), "bytes_base64": encoded(raw)})
+ break
+ note = struct_parse(image.structs.Elf_Nhdr, image.stream, stream_pos=position)
+ owner_start = position + header_size
+ descriptor_start = owner_start + ((note.n_namesz + 3) // 4) * 4
+ note_end = descriptor_start + ((note.n_descsz + 3) // 4) * 4
+ location(position, note_end - position, end)
+ owner = content[owner_start:owner_start + note.n_namesz]
+ if owner and b"\0" not in owner:
+ raise CoreFailure("format", "Declared note owner has no NUL terminator.")
+ display = owner.split(b"\0", 1)[0].decode("utf-8", "replace") if owner else None
+ descriptor = content[descriptor_start:descriptor_start + note.n_descsz]
+ note_index = len(notes)
+ notes.append({"index": note_index, "segment_index": segment_index, "type": note.n_type,
+ "location": location(position, note_end - position, end),
+ "owner_location": location(owner_start, len(owner), end), "owner_bytes_base64": encoded(owner), "owner_display": display,
+ "descriptor_location": location(descriptor_start, len(descriptor), end), "descriptor_bytes_base64": encoded(descriptor)})
+ position = note_end
+ # Only the initial Linux CORE ABI is interpreted. Other owners retain exact bytes.
+ if owner != b"CORE\0":
+ continue
+ if note.n_type == "NT_PRSTATUS":
+ if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64):
+ raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.")
+ status = elf_prstatus_amd64.from_buffer_copy(descriptor)
+ registers = []
+ for name, kind in user_regs_struct_amd64._fields_:
+ relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
+ registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
+ threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
+ elif note.n_type == "NT_SIGINFO":
+ if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
+ raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.")
+ signal = elf_siginfo_64.from_buffer_copy(descriptor)
+ number = ctypes.c_int32(signal.si_signo).value
+ code = ctypes.c_int32(signal.si_code).value
+ known = number == 11 and code in (1, 2)
+ signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value,
+ "fault_address": hex(signal.sigfault_addr) if known else None,
+ "fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"})
+ return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
+ "segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals,
+ "note_interpretation_completeness": "unknown",
+ "limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.",
+ "Historical PID and signal values are recorded metadata, not current process identity or attach authority.",
+ "Only exact Linux CORE NT_PRSTATUS/NT_SIGINFO owners are interpreted; every descriptor and owner retains original bytes. Other note semantics remain unknown.",
+ "NT_SIGINFO thread association is unknown. Only SIGSEGV SEGV_MAPERR/SEGV_ACCERR initially establish a fault-address union meaning.",
+ "PT_LOAD file bytes are recorded bytes. Undumped memory and current executable/library identity remain unknown; no current host file is resolved from a recorded pathname.",
+ "Missing notes/registers/signals do not establish their absence at the original crash. Full unwinding, executable pairing and live control are not provided."]}
+ except CoreFailure:
+ raise
+ except (ELFError, ValueError, AssertionError, IndexError) as error:
+ raise CoreFailure("format", "Recorded core failed unchanged upstream structural parsing: " + str(error)) from error
+
+
+if __name__ == "__main__":
+ runtime_path = Path(__file__).with_name("decoder_runtime.py")
+ runtime = {"__file__": str(runtime_path), "__name__": "rea_decoder_runtime"}
+ exec(compile(runtime_path.read_bytes(), str(runtime_path), "exec"), runtime)
+ runtime["main"](sys.argv[-1], inspect_core, CoreFailure, PROFILE)
diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index 8bbb9a147..b02bfafbe 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -26,7 +26,7 @@ graph TD
end
subgraph "Provider Layers"
- PWNTOOLS["src/native/pwntools/
BYO offline ELF adapter
owned Python process, unchanged upstream tables/heuristics"]
+ PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/
BYO offline ELF/core adapters
owned snapshots/Python and optional core-only GDB
unchanged upstream parsers, ABI structures and mapping API"]
EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
explicit carrier parsing and inferred interface adapter"]
HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"]
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index cc1d22510..9c34726a4 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -7,10 +7,10 @@
"client": "2.3.1",
"core": "2.3.1"
},
- "skill_version": "32"
+ "skill_version": "33"
},
"tools": {
- "total": 138,
+ "total": 139,
"families": [
{
"id": "direct",
@@ -84,13 +84,14 @@
{
"id": "native",
"surface": "native-provider",
- "count": 9,
+ "count": 10,
"tools": [
"capture_native_ui_scenario",
"demangle_swift",
"inspect_binary_layout",
"inspect_macho",
"inspect_plist",
+ "inspect_recorded_crash",
"inspect_signature",
"list_architectures",
"observe_native_calls",
@@ -385,7 +386,7 @@
"id": "pwntools-elf",
"name": "REA pwntools ELF adapter",
"version": "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2",
- "capabilities": ["inspect_binary_layout"]
+ "capabilities": ["inspect_binary_layout", "inspect_recorded_crash"]
},
{
"id": "rea-android-application",
@@ -601,7 +602,7 @@
}
],
"cli": {
- "primary_count": 93,
+ "primary_count": 94,
"commands": [
"address-to-file-offset",
"analyze",
@@ -658,6 +659,7 @@
"inspect-native-instruction",
"inspect-native-load-image",
"inspect-plist",
+ "inspect-recorded-crash",
"inspect-signature",
"inspect-web-event-listeners",
"inspect-web-network-capture",
@@ -706,15 +708,15 @@
},
"runtime_catalog": {
"counts": {
- "cli_commands": 93,
- "mcp_tools": 138,
+ "cli_commands": 94,
+ "mcp_tools": 139,
"mcp_prompts": 6
},
"digests": {
- "tools_sha256": "61b501f81acddb52ea80408c936ceec19b73e08331f8fdd05eb503708373e0fa",
+ "tools_sha256": "9ee5392d18f04f25b5c97c1b69a3644a0d81e52c813167a6f140791bc692741d",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
- "combined_sha256": "8ed2bd4b71684e3e391f909841edf32b68da20eede1f46f0dd6081e235644260",
- "providers_sha256": "44935c127fd040af492f2da591dfcd782d766770d306b91242216674220047ed"
+ "combined_sha256": "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a",
+ "providers_sha256": "954b499c76514890062d2b6c936d73cb3a9c45412d522a081125a325e81cdec5"
}
}
}
diff --git a/docs/recorded-crashes.md b/docs/recorded-crashes.md
new file mode 100644
index 000000000..d68db74fd
--- /dev/null
+++ b/docs/recorded-crashes.md
@@ -0,0 +1,88 @@
+# Recorded crash evidence
+
+`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
+Linux ELF64 x86-64 little-endian core independently of an active binary target.
+
+```sh
+export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
+rea inspect-recorded-crash ./crash.core --json
+```
+
+The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2.
+REA does not install these dependencies. ELFFile/its original ELF structures and
+pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter
+adds bounds, representation preservation and lifecycle ownership.
+The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI
+with ABI version zero. Other declared ABIs return `unsupported_target` before
+note interpretation; an unspecified OSABI alone does not prove OS origin.
+
+## Recorded observations
+
+- Artifact path, original SHA-256 and size; full normalized result and Evidence
+ are inline through CLI and MCP.
+- Original program headers and recorded segment file/memory ranges. Undumped
+ memory and current executable/library identity remain unknown.
+- Every note's original identity, owner/descriptor bytes and source ranges,
+ including unfamiliar owners and note kinds.
+- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
+ historical PID, signal and lossless uint64 registers bound to source bytes.
+ Each supported note has exactly one interpretation; each thread contains the
+ complete 27-register ABI set, with names bound to their original ABI offsets.
+ Physical note ranges cannot overlap or leave segment gaps. ELF and note headers
+ are independently bound to the snapshot, including extended program counts.
+- Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the
+ fault-address union; other variants retain raw bytes and unknown meaning.
+
+Historical PIDs are metadata. They never select a live process or authorize
+attach/control. Missing notes do not establish absence at the crash; note
+interpretation completeness and signal-thread association remain unknown.
+
+## Optional debugger mapping context
+
+```sh
+export REA_PWNDBG_GDB=/absolute/existing/gdb
+export REA_PWNDBG_GDBINIT=/absolute/unchanged/pwndbg/gdbinit.py
+export REA_PWNDBG_VENV_PATH=/absolute/existing/pwndbg-venv
+rea inspect-recorded-crash ./crash.core --debugger-context --json
+```
+
+MCP uses `{ "path": "/artifacts/crash.core", "include_debugger_context": true }`.
+Omitting this meaningful facet requests only recorded evidence. Requested but
+unavailable context returns an actionable failure; it never silently claims
+debugger coverage. GDB defaults to `/usr/bin/gdb`; the plugin and virtual
+environment require explicit absolute paths. Initial local verification covers
+Linux x64, GDB 13.1 and unchanged pwndbg 2026.09.15.
+
+The outer provider owns both processes and one stable core snapshot. An owned
+Python launcher lowers inherited soft limits before replacing itself with GDB.
+GDB starts without initfiles, auto-load, debuginfod or automatic shared-library
+loading. Owned empty library search paths and caches are used, with plugin
+updates disabled. Fixed package code verifies a core connection and no paired
+executable before querying mapping candidates. No caller GDB command language is
+exposed. The exact observed GDB/plugin version strings are retained.
+
+Mapping names are producer display metadata, with unknown current file identity.
+Map permissions are derived from reported flags; zero/unfamiliar flags leave
+permissions unknown. These maps do not establish page contents or an executable
+match. Core source bytes remain available independently of this enrichment.
+An Evidence envelope containing these maps is `derived`; inspection without
+debugger context is `observed`.
+
+## Bounds and verification
+
+Initial Linux x64 limits: 32 MiB input, 64 MiB decoder reply, 1 MiB diagnostics per
+stage, 30 seconds owned wall deadline per stage, 3 GiB address-space soft ceiling
+and 30 seconds CPU soft ceiling. Tighter inherited soft/hard limits are retained;
+hard limits are not raised. Complete oversized results fail explicitly. These
+process limits do not constitute a sandbox for caller-supplied engine code.
+
+Resource exit statuses need matching private failure-branch markers. Unmarked
+launcher failures retain their actual status/output and unknown resource cause.
+Cancellation, output truncation and independent owned cleanup remain explicit.
+
+`npm run verify:recorded:crash` uses source-owned fixtures and caller-supplied
+GDB/Python/pwndbg/strace. Fixture generation explicitly launches its owned test
+program; subsequent inspection only reads the resulting core. Real acceptance
+checks recorded threads, high registers, missing/malformed notes, opaque owners,
+CLI/MCP parity, optional debugger context and historical PID collision with an
+owned live sentinel. Original inputs and host configuration remain unchanged.
diff --git a/docs/testing.md b/docs/testing.md
index 1316ffd26..7abebdae8 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -653,6 +653,28 @@ unknowns, malformed carriers and independent cleanup. An optional positional
entrypoint verifies a fresh installed package. It acquires no engine, compiler
or chain dependency and does not execute a contract on a chain.
+### Recorded crash evidence
+
+`npm run verify:recorded:crash` is a separate Linux x64 lane. It requires GCC,
+GDB, absolute `REA_PWNTOOLS_PYTHON` with the offline ELF profile above,
+`REA_PWNDBG_GDBINIT` and `REA_PWNDBG_VENV_PATH` with unchanged pwndbg 2026.09.15,
+and `REA_VERIFY_STRACE_COMMAND`. Its disposable CI runner installs GDB, checks out the exact upstream
+commit and installs its frozen lockfile in isolated runner storage. No developer
+host configuration or core-pattern setting changes.
+
+Fixture generation explicitly runs an owned source-built two-thread program
+under GDB to create a recording. Subsequent public CLI/MCP inspection verifies
+lossless high registers, signed signals, note source bytes, malformed/missing
+notes, unfamiliar owners, optional core-only mapping context and actionable
+missing/unsupported plugin errors. A historical-PID collision fixture references
+an owned live sentinel; inspection syscall traces reject process attach/memory
+access, provider lookups of that PID's `/proc` files and attempted Internet sockets. Traces admit
+the observed upstream startup helpers (`iconv -l`, the selected checkout's Git
+version lookup) and REA ownership inspection separately from target execution.
+This is fixture evidence, not a sandbox claim. Inputs remain unchanged and the
+sentinel must stay alive; owned cleanup and empty verifier descendants are required. Pass an
+installed package entrypoint as the script's first argument for package coverage.
+
## Agent evaluation and conformance records
Evaluate native, JavaScript, managed and browser investigation tasks through a
diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json
index 5dd871cfd..ec344ff8d 100644
--- a/docs/verification/managed-conformance-manifest.json
+++ b/docs/verification/managed-conformance-manifest.json
@@ -1,5 +1,5 @@
{
- "manifest_id": "ecm_366b46bcafd1815cf76b4b9dd1799e2230a32692a5d1ebb5de33d1673f7a9264",
+ "manifest_id": "ecm_8a67e7a3578e49c8d8d22107eab8f1258094dc40041d14deee93189fd0c514b2",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
- "sha256": "2428bbffffdabfe27409cb1f61a4acf062aa11545629f1a1df4dc72c9db364a8"
+ "sha256": "ef398cf4b5ee7fb5e0bcf42ca787049ec145e1d911ee7951017fee1cb3980d74"
}
],
"claims": [
diff --git a/package.json b/package.json
index ea92d1402..b85a2d7b8 100644
--- a/package.json
+++ b/package.json
@@ -68,7 +68,15 @@
"bridge/pwntools/layout_impl.py",
"third_party/pwntools/README.md",
"third_party/evmole/README.md",
- "third_party/evmole/LICENSE"
+ "third_party/evmole/LICENSE",
+ "bridge/pwntools/decoder_runtime.py",
+ "bridge/pwntools/recorded_crash.py",
+ "bridge/pwntools/recorded_crash_impl.py",
+ "bridge/pwndbg/launch.py",
+ "bridge/pwndbg/launch_impl.py",
+ "bridge/pwndbg/core_context.py",
+ "third_party/pwndbg/README.md",
+ "third_party/pwndbg/LICENSE.md"
],
"scripts": {
"deps:check": "node scripts/check-dependency-install.mjs",
@@ -202,7 +210,8 @@
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs",
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
- "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs"
+ "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
+ "verify:recorded:crash": "npm run build:cached && node scripts/verify-recorded-crash.mjs"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
diff --git a/scripts/fixtures/recorded-crash.c b/scripts/fixtures/recorded-crash.c
new file mode 100644
index 000000000..a7baa7da1
--- /dev/null
+++ b/scripts/fixtures/recorded-crash.c
@@ -0,0 +1,24 @@
+#include
+#include
+#include
+#include
+static atomic_int ready;
+static void *recorded_thread(void *unused) {
+ (void)unused;
+ __asm__ volatile("movabs $0xfedcba9876543210, %%r12" ::: "r12");
+ atomic_store(&ready, 1);
+ for (;;) pause();
+ return 0;
+}
+__attribute__((noinline)) static void fixture_crash(uint64_t value) {
+ volatile uint64_t *pointer = (volatile uint64_t *)(uintptr_t)0x123;
+ *pointer = value;
+}
+int main(void) {
+ pthread_t thread;
+ pthread_attr_t attr;
+ if (pthread_attr_init(&attr) || pthread_attr_setstacksize(&attr, 65536) || pthread_create(&thread, &attr, recorded_thread, 0)) return 1;
+ while (!atomic_load(&ready)) usleep(1000);
+ fixture_crash(UINT64_C(0x1122334455667788));
+ return 0;
+}
diff --git a/scripts/lib/recorded-core-fixtures.py b/scripts/lib/recorded-core-fixtures.py
new file mode 100644
index 000000000..cc8d65443
--- /dev/null
+++ b/scripts/lib/recorded-core-fixtures.py
@@ -0,0 +1,94 @@
+"""Source-owned mutations built with the same unchanged upstream ELF/ABI structures."""
+import ctypes
+import io
+import json
+from pathlib import Path
+import sys
+from elftools.elf.elffile import ELFFile
+from elftools.construct import Container
+from pwnlib.elf.datatypes import elf_prstatus_amd64
+
+source, directory, sentinel = Path(sys.argv[1]), Path(sys.argv[2]), int(sys.argv[3])
+original = source.read_bytes()
+image = ELFFile(io.BytesIO(original))
+segments = list(image.iter_segments())
+notes = [note for segment in segments if segment.header.p_type == "PT_NOTE" for note in segment.iter_notes()]
+status = next(note for note in notes if note.n_name == "CORE" and note.n_type == "NT_PRSTATUS")
+descriptor_start = lambda note: note.n_offset + image.structs.Elf_Nhdr.sizeof() + ((note.n_namesz + 3) // 4) * 4
+files = {}
+
+
+def save(name, content):
+ path = directory / (name + ".core")
+ path.write_bytes(content)
+ files[name] = str(path)
+
+
+def note_header(content, note, **changes):
+ header = dict(note)
+ header.update(changes)
+ encoded = image.structs.Elf_Nhdr.build(Container(**header))
+ content[note.n_offset:note.n_offset + len(encoded)] = encoded
+
+
+save("truncated-header", original[:32])
+for name, osabi, version in [("linux-osabi", 3, 0), ("solaris-osabi", 6, 0), ("freebsd-osabi", 9, 0), ("unsupported-abi-version", 0, 1)]:
+ content = bytearray(original)
+ content[7:9] = bytes([osabi, version])
+ save(name, content)
+content = bytearray(original)
+section = Container(sh_name=0, sh_type="SHT_NULL", sh_flags=0, sh_addr=0, sh_offset=0, sh_size=0, sh_link=0, sh_info=len(segments), sh_addralign=0, sh_entsize=0)
+content.extend(image.structs.Elf_Shdr.build(section))
+header = Container(**dict(image.header, e_phnum=0xffff, e_shoff=len(original), e_shentsize=image.structs.Elf_Shdr.sizeof(), e_shnum=1, e_shstrndx=0))
+content[:image.structs.Elf_Ehdr.sizeof()] = image.structs.Elf_Ehdr.build(header)
+save("extended-program-count", content)
+for index, segment in enumerate(segments):
+ if segment.header.p_type != "PT_NOTE":
+ continue
+ content = bytearray(original)
+ raw_notes = original[segment.header.p_offset:segment.header.p_offset + segment.header.p_filesz]
+ content.extend(raw_notes + b"\0\0\0")
+ header = Container(**dict(segment.header, p_offset=len(original), p_filesz=len(raw_notes) + 3))
+ start = image.header.e_phoff + index * image.header.e_phentsize
+ content[start:start + image.structs.Elf_Phdr.sizeof()] = image.structs.Elf_Phdr.build(header)
+ save("zero-note-tail", content)
+ content[-1] = 1
+ save("nonzero-note-tail", content)
+ break
+content = bytearray(original)
+note_header(content, status, n_descsz=1)
+save("truncated-status", content)
+content = bytearray(original)
+note_header(content, status, n_namesz=len(original))
+save("truncated-owner", content)
+content = bytearray(original)
+content[status.n_offset + image.structs.Elf_Nhdr.sizeof()] = 255
+save("opaque-owner", content)
+content = bytearray(original)
+for note in notes:
+ if note.n_type == "NT_SIGINFO":
+ note_header(content, note, n_type=0x7ffeff)
+save("unknown-signals", content)
+content = bytearray(original)
+for index, segment in enumerate(segments):
+ if segment.header.p_type == "PT_NOTE":
+ header = dict(segment.header, p_type="PT_NULL")
+ encoded = image.structs.Elf_Phdr.build(Container(**header))
+ start = image.header.e_phoff + index * image.header.e_phentsize
+ content[start:start + len(encoded)] = encoded
+save("missing-notes", content)
+content = bytearray(original)
+start = descriptor_start(status) + elf_prstatus_amd64.pr_pid.offset
+content[start:start + ctypes.sizeof(ctypes.c_uint32)] = (0xfffffffe).to_bytes(4, "little")
+save("negative-pid", content)
+content = bytearray(original)
+content[start:start + ctypes.sizeof(ctypes.c_uint32)] = sentinel.to_bytes(4, "little")
+for note in notes:
+ if note.n_name == "CORE" and note.n_type == "NT_PRPSINFO":
+ # Build with unchanged pyelftools; no hard-coded PID offset or host file resolution.
+ raw = dict(note.n_desc, pr_pid=sentinel)
+ encoded = image.structs.Elf_Prpsinfo.build(Container(**raw))
+ offset = descriptor_start(note)
+ content[offset:offset + len(encoded)] = encoded
+save("historical-pid-collision", content)
+print(json.dumps(files))
diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs
index 8c0c88685..8b83d2c29 100644
--- a/scripts/verify-binary-layout.mjs
+++ b/scripts/verify-binary-layout.mjs
@@ -1032,8 +1032,8 @@ try {
"invalid_input",
],
["undersized-program-header", undersizedProgramHeader, "invalid_input"],
- ["arm64", unsupported, "unsupported_provider"],
- ["core", core, "unsupported_provider"],
+ ["arm64", unsupported, "unsupported_target"],
+ ["core", core, "unsupported_target"],
]) {
const path = join(root.path, name);
await writeFile(path, bytes);
diff --git a/scripts/verify-recorded-crash.mjs b/scripts/verify-recorded-crash.mjs
new file mode 100644
index 000000000..eb59749c7
--- /dev/null
+++ b/scripts/verify-recorded-crash.mjs
@@ -0,0 +1,318 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { writeFile, readFile, copyFile } from "node:fs/promises";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
+import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
+import { verifyCompleteToolCatalog } from "./lib/verify-package-core.mjs";
+import { connectRecordedCrash } from "./verify/recorded-crash/public.mjs";
+import {
+ createRecordedCore,
+ fixtureCommand,
+ startCoreSentinel,
+} from "./verify/recorded-crash/fixtures.mjs";
+import { verifyCoreInspectionTrace } from "./verify/recorded-crash/trace.mjs";
+
+const environment = Object.fromEntries(
+ Object.entries(process.env).filter(([, value]) => typeof value === "string"),
+);
+environment.REA_PWNDBG_GDB ??= "/usr/bin/gdb";
+for (const key of [
+ "REA_PWNTOOLS_PYTHON",
+ "REA_PWNDBG_GDB",
+ "REA_PWNDBG_GDBINIT",
+ "REA_PWNDBG_VENV_PATH",
+ "REA_VERIFY_STRACE_COMMAND",
+])
+ if (!isAbsolute(environment[key] ?? ""))
+ throw new Error(
+ `verify:recorded:crash requires absolute ${key}; see docs/recorded-crashes.md. No dependencies are installed by this verifier.`,
+ );
+if (process.platform !== "linux" || process.arch !== "x64")
+ throw new Error("verify:recorded:crash requires Linux x64.");
+const run = createVerifierRun();
+environment.REA_PROCESS_RUN_ID = run.run_id;
+const root = await PrivateRuntimeRoot.create({ prefix: "rea-core-verifier-" });
+const options = { root: root.path, environment, runId: run.run_id };
+const entrypoint =
+ process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
+const failures = [];
+const sessions = [];
+let sentinel;
+let cases = 0;
+let trace;
+let catalogTools;
+try {
+ for (const [command, args] of [
+ ["gcc", ["--version"]],
+ [environment.REA_PWNDBG_GDB, ["--version"]],
+ [environment.REA_VERIFY_STRACE_COMMAND, ["-V"]],
+ ]) {
+ try {
+ await fixtureCommand(command, args, options);
+ } catch (cause) {
+ throw new Error(
+ `verify:recorded:crash prerequisite unavailable: ${command}`,
+ { cause },
+ );
+ }
+ }
+ const core = await createRecordedCore(options);
+ sentinel = await startCoreSentinel(options);
+ sentinel.assertAlive();
+ const fixtures = JSON.parse(
+ await fixtureCommand(
+ environment.REA_PWNTOOLS_PYTHON,
+ [
+ "-I",
+ fileURLToPath(
+ new URL("./lib/recorded-core-fixtures.py", import.meta.url),
+ ),
+ core,
+ root.path,
+ String(sentinel.pid),
+ ],
+ options,
+ ),
+ );
+ const spaced = join(root.path, "recording with spaces.core");
+ await copyFile(core, spaced);
+ const basic = await connectRecordedCrash({ entrypoint, environment });
+ sessions.push(basic);
+ const advertised = (await basic.client.listTools()).tools;
+ assert.equal(
+ advertised.filter((tool) => tool.name === "inspect_recorded_crash").length,
+ 1,
+ );
+ const catalog = await verifyCompleteToolCatalog(basic.client, {
+ timeout: 90_000,
+ });
+ for (const name of [
+ "inspect_binary_layout",
+ "inspect_recorded_crash",
+ "inspect_evm_interface",
+ ])
+ assert.ok(catalog.includes(name));
+ catalogTools = catalog.length;
+ for (const mode of ["cli", "mcp"]) {
+ const report = await basic.inspect(mode, spaced);
+ assert.equal(report.threads.length, 2);
+ assert.ok(
+ report.threads.some((thread) =>
+ thread.registers.some(
+ (register) =>
+ register.name === "rdi" && register.value === "0x1122334455667788",
+ ),
+ ),
+ );
+ assert.ok(
+ report.threads.some((thread) =>
+ thread.registers.some(
+ (register) =>
+ register.name === "r12" && register.value === "0xfedcba9876543210",
+ ),
+ ),
+ );
+ assert.ok(
+ report.signals.some(
+ (signal) =>
+ signal.number === 11 &&
+ signal.code === 1 &&
+ signal.fault_address === "0x123",
+ ),
+ );
+ assert.ok(
+ report.signals.some(
+ (signal) => signal.code === -6 && signal.fault_address === null,
+ ),
+ );
+ cases++;
+ const enriched = await basic.inspect(mode, spaced, {
+ debuggerContext: true,
+ });
+ assert.deepEqual(enriched.threads, report.threads);
+ assert.deepEqual(enriched.notes, report.notes);
+ assert.ok(enriched.debugger.pwndbg_version.startsWith("2026.09.15"));
+ assert.equal(enriched.debugger.connection, "core");
+ assert.equal(enriched.debugger.executable, null);
+ assert.ok(enriched.debugger.maps.length > 0);
+ assert.ok(
+ enriched.debugger.maps.every(
+ (map) => map.reported_flags !== 0 || map.permissions === null,
+ ),
+ );
+ assert.ok(
+ enriched.debugger.maps.every(
+ (map) => map.current_file_identity === "unknown",
+ ),
+ );
+ assert.ok(
+ enriched.debugger.reported_limits.address_space_bytes <= 3 * 1024 ** 3,
+ );
+ assert.ok(enriched.debugger.reported_limits.cpu_seconds <= 30);
+ assert.ok(
+ enriched.debugger.reported_limits.file_size_bytes <= 64 * 1024 ** 2,
+ );
+ assert.ok(enriched.diagnostics.stdout.includes("[core decoder]"));
+ assert.equal(enriched.decoder_diagnostics.truncated, false);
+ cases++;
+ for (const name of [
+ "truncated-header",
+ "truncated-status",
+ "truncated-owner",
+ "nonzero-note-tail",
+ ]) {
+ await basic.inspect(mode, fixtures[name], { category: "invalid_input" });
+ cases++;
+ }
+ const opaque = await basic.inspect(mode, fixtures["opaque-owner"]);
+ assert.ok(
+ opaque.notes.some(
+ (note) => Buffer.from(note.owner_bytes_base64, "base64")[0] === 255,
+ ),
+ );
+ assert.equal(opaque.threads.length, 1);
+ cases++;
+ const noSignals = await basic.inspect(mode, fixtures["unknown-signals"]);
+ assert.equal(noSignals.signals.length, 0);
+ assert.equal(noSignals.threads.length, 2);
+ cases++;
+ const noNotes = await basic.inspect(mode, fixtures["missing-notes"]);
+ assert.equal(noNotes.notes.length, 0);
+ assert.equal(noNotes.threads.length, 0);
+ assert.equal(noNotes.note_interpretation_completeness, "unknown");
+ cases++;
+ const negative = await basic.inspect(mode, fixtures["negative-pid"]);
+ assert.ok(negative.threads.some((thread) => thread.historical_pid === -2));
+ cases++;
+ const linux = await basic.inspect(mode, fixtures["linux-osabi"]);
+ assert.deepEqual(linux.threads, report.threads);
+ assert.deepEqual(linux.signals, report.signals);
+ cases++;
+ const extended = await basic.inspect(
+ mode,
+ fixtures["extended-program-count"],
+ );
+ assert.deepEqual(extended.threads, report.threads);
+ assert.deepEqual(extended.signals, report.signals);
+ cases++;
+ const padded = await basic.inspect(mode, fixtures["zero-note-tail"]);
+ assert.equal(padded.threads.length, 2);
+ assert.ok(
+ padded.note_padding.some((padding) => padding.bytes_base64 === "AAAA"),
+ );
+ cases++;
+ for (const name of [
+ "solaris-osabi",
+ "freebsd-osabi",
+ "unsupported-abi-version",
+ ]) {
+ await basic.inspect(mode, fixtures[name], {
+ category: "unsupported_target",
+ });
+ cases++;
+ }
+ const collision = await basic.inspect(
+ mode,
+ fixtures["historical-pid-collision"],
+ { debuggerContext: true },
+ );
+ assert.ok(
+ collision.threads.some(
+ (thread) => thread.historical_pid === sentinel.pid,
+ ),
+ );
+ sentinel.assertAlive();
+ cases++;
+ }
+ for (const [name, changes] of [
+ ["missing", { REA_PWNDBG_GDBINIT: join(root.path, "missing-plugin.py") }],
+ [
+ "wrong-profile",
+ { REA_PWNDBG_GDBINIT: join(root.path, "wrong-profile.py") },
+ ],
+ ]) {
+ if (name === "wrong-profile") {
+ // Source-owned failure injection changes only the in-memory reported version.
+ // The selected upstream checkout and its installed files remain unchanged.
+ await writeFile(
+ changes.REA_PWNDBG_GDBINIT,
+ `from pathlib import Path\nentry = ${JSON.stringify(environment.REA_PWNDBG_GDBINIT)}\nexec(compile(Path(entry).read_bytes(), entry, 'exec'), {'__name__': '__main__', '__file__': entry})\nimport pwndbg.lib.version\npwndbg.lib.version.__version__ = '0.0.0-source-owned'\n`,
+ );
+ }
+ const session = await connectRecordedCrash({
+ entrypoint,
+ environment: { ...environment, ...changes },
+ });
+ sessions.push(session);
+ for (const mode of ["cli", "mcp"]) {
+ const error = await session.inspect(mode, core, {
+ debuggerContext: true,
+ category: "unsupported_provider",
+ });
+ assert.ok(
+ JSON.stringify(error).includes(
+ name === "missing" ? "REA_PWNDBG_GDBINIT" : "source-owned",
+ ),
+ );
+ cases++;
+ }
+ }
+ const executableCarrier = join(root.path, "executable-as-core");
+ const executableBytes = Buffer.from(await readFile(core));
+ executableBytes.writeUInt16LE(2, 16);
+ await writeFile(executableCarrier, executableBytes);
+ for (const mode of ["cli", "mcp"]) {
+ const error = await basic.inspect(mode, executableCarrier, {
+ category: "unsupported_target",
+ });
+ assert.ok(JSON.stringify(error).includes("ET_CORE"));
+ cases++;
+ }
+ trace = await verifyCoreInspectionTrace({
+ ...options,
+ entrypoint,
+ core: fixtures["historical-pid-collision"],
+ sentinel,
+ });
+ cases += trace.length;
+ assert.ok((await readFile(core)).length > 0);
+} catch (cause) {
+ failures.push(cause);
+} finally {
+ for (const close of [
+ ...sessions.map((session) => () => session.close()),
+ ...(sentinel ? [() => sentinel.close()] : []),
+ () => root.close(),
+ ]) {
+ try {
+ await close();
+ } catch (cause) {
+ failures.push(cause);
+ }
+ }
+}
+const verifier = await completeVerifierRun(run);
+try {
+ assert.equal(verifier.process_lineage.status, "verified");
+ assert.deepEqual(verifier.process_lineage.descendants, []);
+} catch (cause) {
+ failures.push(cause);
+}
+const result = {
+ status: failures.length === 0 ? "passed" : "failed",
+ public_cases: cases,
+ catalog_tools: catalogTools,
+ profile:
+ "Linux x64; pwntools4.15.0/pyelftools0.33/Unicorn2.1.2; core-only BYO GDB/pwndbg2026.09.15",
+ fixture_generation: "owned test program executed",
+ inspection_trace: trace,
+ verifier,
+};
+console.log(JSON.stringify(result, null, 2));
+if (failures.length > 0)
+ throw new AggregateError(
+ failures,
+ "Recorded crash verification failed; cleanup failures are retained.",
+ );
diff --git a/scripts/verify/recorded-crash/fixtures.mjs b/scripts/verify/recorded-crash/fixtures.mjs
new file mode 100644
index 000000000..2ad46803f
--- /dev/null
+++ b/scripts/verify/recorded-crash/fixtures.mjs
@@ -0,0 +1,142 @@
+import assert from "node:assert/strict";
+import { fileURLToPath } from "node:url";
+import { join } from "node:path";
+import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js";
+import {
+ spawnOwnedProviderProcess,
+ ProviderProcessSupervisor,
+} from "../../../dist/process/ProviderProcess.js";
+import { cleanupOwnedProcessGroup } from "../../../dist/process/ProcessOwnership.js";
+
+/** Execute fixture tooling under an owned deadline; fixture generation deliberately runs its own test program. */
+export async function fixtureCommand(
+ command,
+ arguments_,
+ { root, environment, runId },
+) {
+ const result = await runOwnedCommand(
+ {
+ command,
+ arguments: arguments_,
+ cwd: root,
+ runId,
+ hostEnvironment: environment,
+ expectedCommand: null,
+ },
+ { timeoutMs: 45_000, diagnosticBytes: 1024 * 1024 },
+ );
+ return result.stdout.text;
+}
+
+/** Build and record a source-owned two-thread crash without changing kernel core settings. */
+export async function createRecordedCore(options) {
+ const executable = join(options.root, "fixture-program");
+ const core = join(options.root, "recording.core");
+ await fixtureCommand(
+ "gcc",
+ [
+ "-g",
+ "-O0",
+ "-pthread",
+ "-fno-pie",
+ "-no-pie",
+ fileURLToPath(
+ new URL("../../fixtures/recorded-crash.c", import.meta.url),
+ ),
+ "-o",
+ executable,
+ ],
+ options,
+ );
+ // Fixed relative arguments avoid putting selected filesystem paths in GDB command syntax.
+ await fixtureCommand(
+ options.environment.REA_PWNDBG_GDB,
+ [
+ "-nx",
+ "-nh",
+ "--batch",
+ "--quiet",
+ "-iex",
+ "set auto-load off",
+ "-iex",
+ "set debuginfod enabled off",
+ "-ex",
+ "set startup-with-shell off",
+ "-ex",
+ "set disable-randomization off",
+ "-ex",
+ "file ./fixture-program",
+ "-ex",
+ "run",
+ "-ex",
+ "generate-core-file ./recording.core",
+ ],
+ options,
+ );
+ return core;
+}
+
+/** Retain authority over an unrelated live process used only as a historical-PID collision oracle. */
+export async function startCoreSentinel(options) {
+ const launch = await spawnOwnedProviderProcess({
+ command: process.execPath,
+ arguments: [
+ "-e",
+ "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)",
+ ],
+ cwd: options.root,
+ hostEnvironment: options.environment,
+ runId: options.runId,
+ });
+ const supervisor = new ProviderProcessSupervisor({
+ ...launch,
+ ownsProcessLifetime: true,
+ cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
+ });
+ try {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => finish(new Error("Sentinel readiness timed out")),
+ 10_000,
+ );
+ const onData = () => {
+ if (supervisor.snapshot().stdout.text.includes("ready\n")) finish();
+ };
+ const onExit = () =>
+ finish(new Error("Sentinel exited before readiness"));
+ function finish(error) {
+ clearTimeout(timer);
+ launch.process.stdout.off("data", onData);
+ launch.process.off("exit", onExit);
+ if (error) reject(error);
+ else resolve();
+ }
+ launch.process.stdout.on("data", onData);
+ launch.process.once("exit", onExit);
+ onData();
+ });
+ } catch (cause) {
+ try {
+ await supervisor.stop();
+ } finally {
+ supervisor.dispose();
+ }
+ throw cause;
+ }
+ return {
+ pid: launch.process.pid,
+ assertAlive() {
+ assert.equal(launch.process.exitCode, null);
+ assert.equal(launch.process.signalCode, null);
+ process.kill(launch.process.pid, 0);
+ },
+ async close() {
+ try {
+ const stopped = await supervisor.stop();
+ assert.notEqual(stopped.status, "incomplete", JSON.stringify(stopped));
+ } finally {
+ supervisor.dispose();
+ }
+ },
+ };
+}
diff --git a/scripts/verify/recorded-crash/public.mjs b/scripts/verify/recorded-crash/public.mjs
new file mode 100644
index 000000000..144845df7
--- /dev/null
+++ b/scripts/verify/recorded-crash/public.mjs
@@ -0,0 +1,133 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { parseEvidence } from "../../../dist/domain/evidence.js";
+import { recordedCrashSchema } from "../../../dist/domain/native/recordedCrash.js";
+import { mcpTextValue } from "../../lib/mcp-verifier-results.mjs";
+
+const execute = promisify(execFile);
+
+/** Exercise the public installed CLI and SDK transport with the same selected environment. */
+export async function connectRecordedCrash({ entrypoint, environment }) {
+ const client = new Client({ name: "recorded-crash-verifier", version: "1" });
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+ });
+ try {
+ await client.connect(transport);
+ } catch (cause) {
+ await transport.close();
+ throw cause;
+ }
+ return {
+ client,
+ async inspect(mode, path, { debuggerContext = false, category } = {}) {
+ const original = await readFile(path);
+ let evidence;
+ if (mode === "mcp") {
+ const response = await client.callTool(
+ {
+ name: "inspect_recorded_crash",
+ arguments: { path, include_debugger_context: debuggerContext },
+ },
+ { timeout: 90_000 },
+ );
+ const value = JSON.parse(mcpTextValue(response));
+ if (category !== undefined) {
+ assert.equal(response.isError, true);
+ assert.equal(value.error.category, category, JSON.stringify(value));
+ assert.deepEqual(await readFile(path), original);
+ return value.error;
+ }
+ assert.notEqual(response.isError, true, JSON.stringify(value));
+ evidence = parseEvidence(value.evidence);
+ assert.deepEqual(value.result, evidence.normalized_result);
+ } else {
+ assert.equal(mode, "cli");
+ let response;
+ try {
+ response = await execute(
+ process.execPath,
+ [
+ entrypoint,
+ "inspect-recorded-crash",
+ path,
+ ...(debuggerContext ? ["--debugger-context"] : []),
+ "--json",
+ ],
+ { env: environment, timeout: 90_000, maxBuffer: 32 * 1024 * 1024 },
+ );
+ } catch (cause) {
+ if (category === undefined || typeof cause.code !== "number")
+ throw cause;
+ const error = JSON.parse(cause.stdout);
+ assert.equal(error.category, category, JSON.stringify(error));
+ assert.deepEqual(await readFile(path), original);
+ return error;
+ }
+ assert.equal(category, undefined, "Expected an explicit failure");
+ evidence = parseEvidence(JSON.parse(response.stdout));
+ }
+ const report = recordedCrashSchema.parse(evidence.normalized_result);
+ assert.equal(
+ evidence.confidence,
+ debuggerContext ? "derived" : "observed",
+ );
+ assert.deepEqual(evidence.raw_result, report);
+ assert.equal(evidence.subject.local_path, path);
+ assert.equal(report.artifact.path, path);
+ assert.equal(
+ report.artifact.sha256,
+ createHash("sha256").update(original).digest("hex"),
+ );
+ assert.equal(report.artifact.bytes, original.length);
+ assert.equal(
+ evidence.provider.version,
+ "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2",
+ );
+ assert.equal(report.target_execution, "not-performed");
+ assert.equal(report.live_process_identity, "unknown");
+ assert.equal(report.diagnostics.truncated, false);
+ assert.equal(
+ report.debugger.status,
+ debuggerContext ? "available" : "not-requested",
+ );
+ for (const note of report.notes) {
+ for (const [encoded, range] of [
+ [note.owner_bytes_base64, note.owner_location],
+ [note.descriptor_bytes_base64, note.descriptor_location],
+ ]) {
+ const start = Number(BigInt(range.offset));
+ assert.deepEqual(
+ Buffer.from(encoded, "base64"),
+ original.subarray(start, start + Number(BigInt(range.bytes))),
+ );
+ }
+ }
+ for (const thread of report.threads) {
+ assert.equal(thread.registers.length, 27);
+ for (const register of thread.registers)
+ assert.equal(
+ BigInt(register.value),
+ original.readBigUInt64LE(Number(BigInt(register.location.offset))),
+ );
+ }
+ assert.deepEqual(await readFile(path), original);
+ return report;
+ },
+ async close() {
+ try {
+ await client.close();
+ } finally {
+ await transport.close();
+ }
+ },
+ };
+}
diff --git a/scripts/verify/recorded-crash/trace-client.mjs b/scripts/verify/recorded-crash/trace-client.mjs
new file mode 100644
index 000000000..f38f1d6cf
--- /dev/null
+++ b/scripts/verify/recorded-crash/trace-client.mjs
@@ -0,0 +1,11 @@
+import { connectRecordedCrash } from "./public.mjs";
+
+const session = await connectRecordedCrash({
+ entrypoint: process.argv[2],
+ environment: process.env,
+});
+try {
+ await session.inspect("mcp", process.argv[3], { debuggerContext: true });
+} finally {
+ await session.close();
+}
diff --git a/scripts/verify/recorded-crash/trace.mjs b/scripts/verify/recorded-crash/trace.mjs
new file mode 100644
index 000000000..abe3cecb7
--- /dev/null
+++ b/scripts/verify/recorded-crash/trace.mjs
@@ -0,0 +1,173 @@
+import assert from "node:assert/strict";
+import { readdir, readFile, realpath } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+import { isAbsolute, join, resolve } from "node:path";
+import { runOwnedCommand } from "../../../dist/process/OwnedCommand.js";
+
+/** Check attempted syscalls, including failed attempts, on a recorded PID that collides with an owned live sentinel. */
+export async function verifyCoreInspectionTrace({
+ root,
+ environment,
+ entrypoint,
+ core,
+ sentinel,
+ runId,
+}) {
+ const gdb = await realpath(environment.REA_PWNDBG_GDB);
+ const python = await realpath(environment.REA_PWNTOOLS_PYTHON);
+ const plugin = resolve(environment.REA_PWNDBG_GDBINIT, "..");
+ const helperPaths = (name) =>
+ new Set([
+ join(environment.REA_PWNDBG_VENV_PATH, "bin", name),
+ ...(environment.PATH ?? "")
+ .split(":")
+ .filter(isAbsolute)
+ .map((directory) => join(directory, name)),
+ ]);
+ const psPaths = helperPaths("ps");
+ const iconvPaths = helperPaths("iconv");
+ const gitPaths = helperPaths("git");
+ const observations = [];
+ for (const mode of ["cli", "mcp"]) {
+ sentinel.assertAlive();
+ const prefix = join(root, `inspection-${mode}.trace`);
+ const invocation =
+ mode === "cli"
+ ? [
+ entrypoint,
+ "inspect-recorded-crash",
+ core,
+ "--debugger-context",
+ "--json",
+ ]
+ : [
+ fileURLToPath(new URL("./trace-client.mjs", import.meta.url)),
+ entrypoint,
+ core,
+ ];
+ await runOwnedCommand(
+ {
+ command: environment.REA_VERIFY_STRACE_COMMAND,
+ arguments: [
+ "-ff",
+ "-qq",
+ "-s",
+ "8192",
+ "-e",
+ "trace=%process,%network,%file,ptrace,process_vm_readv,process_vm_writev",
+ "-o",
+ prefix,
+ process.execPath,
+ ...invocation,
+ ],
+ cwd: root,
+ hostEnvironment: environment,
+ expectedCommand: null,
+ runId,
+ },
+ { timeoutMs: 90_000, diagnosticBytes: 1024 * 1024 },
+ );
+ const executions = [];
+ const ownershipReads = [];
+ let gdbObserved = false;
+ for (const file of await readdir(root)) {
+ if (!file.startsWith(`inspection-${mode}.trace.`)) continue;
+ const text = await readFile(join(root, file), "utf8");
+ const ownershipTrace = text
+ .split("\n")
+ .some(
+ (line) =>
+ /^execve\("\/(?:usr\/)?bin\/ps",/u.test(line) &&
+ line.includes(
+ '["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]',
+ ) &&
+ line.endsWith(" = 0"),
+ );
+ const pidReads = text
+ .split("\n")
+ .filter((line) => line.includes(`/proc/${sentinel.pid}/`));
+ if (ownershipTrace) {
+ for (const line of pidReads) {
+ assert.ok(
+ new RegExp(
+ `/proc/${sentinel.pid}/(?:stat|status|cmdline|wchan|environ)"`,
+ "u",
+ ).test(line),
+ `Unexpected ownership observation of the sentinel: ${line}`,
+ );
+ ownershipReads.push(line);
+ }
+ } else
+ assert.deepEqual(
+ pidReads,
+ [],
+ `Historical PID caused a provider lookup in ${file}: ${pidReads.join("\n")}`,
+ );
+ assert.ok(
+ !/\bptrace\(/u.test(text),
+ `Inspection attempted ptrace: ${file}`,
+ );
+ assert.ok(
+ !/\bprocess_vm_(?:readv|writev)\(/u.test(text),
+ `Inspection attempted process memory access: ${file}`,
+ );
+ assert.ok(
+ !/\b(?:socket|connect|sendto|sendmsg)\([^\n]*(?:AF_INET|AF_INET6)/u.test(
+ text,
+ ),
+ `Inspection attempted an Internet socket: ${file}`,
+ );
+ for (const line of text.split("\n")) {
+ if (!/execve(?:at)?\(/u.test(line)) continue;
+ const match = /^execve\("([^"]+)"/u.exec(line);
+ assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
+ assert.ok(
+ / = (?:0|-1 [A-Z]+)/u.test(line),
+ `Incomplete execution observation: ${line}`,
+ );
+ const executable = await realpath(match[1]).catch(() => match[1]);
+ const ownership =
+ psPaths.has(match[1]) &&
+ line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
+ const iconv =
+ iconvPaths.has(match[1]) && line.includes('["iconv", "-l"]');
+ const version =
+ gitPaths.has(match[1]) &&
+ line.includes(
+ "[" +
+ [
+ "git",
+ "--git-dir",
+ join(plugin, ".git"),
+ "rev-parse",
+ "--short",
+ "HEAD",
+ ]
+ .map((argument) => JSON.stringify(argument))
+ .join(", ") +
+ "]",
+ );
+ assert.ok(
+ [process.execPath, python, gdb].includes(executable) ||
+ ownership ||
+ iconv ||
+ version,
+ `Unexpected attempted host execution: ${line}`,
+ );
+ if (executable === gdb) gdbObserved = true;
+ executions.push(executable);
+ }
+ }
+ assert.ok(gdbObserved, "Trace must include the actual core-only GDB stage");
+ assert.ok(executions.includes(python));
+ sentinel.assertAlive();
+ observations.push({
+ mode,
+ executions,
+ historical_pid: sentinel.pid,
+ sentinel: "still-alive",
+ ownership_process_table_reads: ownershipReads,
+ });
+ }
+ return observations;
+}
diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md
index 5dad1c17a..d8e2c9d90 100644
--- a/skills/reverse-engineer-anything/SKILL.md
+++ b/skills/reverse-engineer-anything/SKILL.md
@@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
- version: "32"
- tool_count: 138
- catalog_digest: "8ed2bd4b71684e3e391f909841edf32b68da20eede1f46f0dd6081e235644260"
+ version: "33"
+ tool_count: 139
+ catalog_digest: "4c5f67d076baf821b5cb6d6fb2cc0f6c274e96b627b85b892577760a6f71ab1a"
---
# REA
@@ -109,6 +109,11 @@ explicit path or endpoint and do not need it.
caller-supplied pwntools without opening a disassembler database. Preserve its
raw locations and inference/coverage limits; see the
[offline binary guide](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
+- Supplied Linux x86-64 ELF core: `inspect_recorded_crash`. Read every recorded
+ thread, raw note and source-bound register without opening a live target.
+ Historical PIDs do not select live processes. Request `include_debugger_context`
+ only when core-only mapping candidates help; see the
+ [recorded crash guide](https://github.com/morluto/rea/blob/main/docs/recorded-crashes.md).
- Native executable, library, or analysis database: `open_binary`, then
use focused analysis tools directly; call `binary_overview` when metadata or
inventory context is useful and available from the selected provider.
diff --git a/skills/reverse-engineer-anything/references/native-and-artifacts.md b/skills/reverse-engineer-anything/references/native-and-artifacts.md
index 527e2afb0..bf42555e8 100644
--- a/skills/reverse-engineer-anything/references/native-and-artifacts.md
+++ b/skills/reverse-engineer-anything/references/native-and-artifacts.md
@@ -1,5 +1,21 @@
# Native, managed, and packaged artifacts
+## Recorded Linux crashes
+
+Use `inspect_recorded_crash` with an explicit Linux x86-64 ELF core path to
+inspect historical thread registers, signals, raw notes and original file
+ranges. It is independent of the active disassembler target. Source-bound
+register values and note bytes are observations; missing notes, signal-thread
+association and current executable/library identity remain unknown. PIDs are
+historical metadata and never authorize live attach or control.
+
+The optional `include_debugger_context` facet adds mapping candidates through
+caller-supplied GDB/pwndbg in an owned core-only session. Display names do not
+establish current file identity; zero reported flags leave permissions unknown.
+Requested unavailable context fails with setup guidance. See
+[recorded crashes](https://github.com/morluto/rea/blob/main/docs/recorded-crashes.md)
+for exact upstream profiles, bounds and verification coverage.
+
## Native targets
After `open_binary`, use focused search, procedure, or function tools directly.
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index ea627693e..b600de075 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -35,6 +35,7 @@ export type AvailabilityPolicy = {
readonly processCaptureEnabled: boolean;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
readonly binaryLayoutEnabled?: boolean;
+ readonly recordedCrashEnabled?: boolean;
readonly firmwareInspectionEnabled?: boolean;
readonly firmwareExtractionEnabled?: boolean;
readonly androidAnalysisEnabled?: boolean;
@@ -298,6 +299,14 @@ const workflowAvailabilityFor = ({
remediation:
"The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.",
};
+ if (name === "inspect_recorded_crash")
+ return policy.recordedCrashEnabled === true
+ ? { reason: "available", remediation: null }
+ : {
+ reason: "provider_missing",
+ remediation:
+ "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
+ };
if (kind === "firmware-provider") {
const enabled =
name === "inspect_firmware_regions"
diff --git a/src/application/binaryDiagnostics/RecordedCrashPort.ts b/src/application/binaryDiagnostics/RecordedCrashPort.ts
new file mode 100644
index 000000000..3f9551c67
--- /dev/null
+++ b/src/application/binaryDiagnostics/RecordedCrashPort.ts
@@ -0,0 +1,19 @@
+import type {
+ ExecutionOptions,
+ ProviderIdentity,
+} from "../AnalysisProvider.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type {
+ InspectRecordedCrashInput,
+ RecordedCrash,
+} from "../../domain/native/recordedCrash.js";
+import type { Result } from "../../domain/result.js";
+
+/** Replaceable decoder of supplied recordings; historical PID is never live authority. */
+export interface RecordedCrashPort {
+ readonly identity: ProviderIdentity;
+ inspect(
+ input: InspectRecordedCrashInput,
+ options?: ExecutionOptions,
+ ): Promise>;
+}
diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts
new file mode 100644
index 000000000..7390f4f31
--- /dev/null
+++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts
@@ -0,0 +1,148 @@
+import { expect, it } from "vitest";
+import { RecordedCrashService } from "./RecordedCrashService.js";
+import {
+ recordedCrashFixture,
+ RECORDED_CRASH_TEST_PROVIDER,
+ recordedCrashDebuggerFixture,
+} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js";
+import { parseEvidence } from "../../domain/evidence.js";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+import { err, ok } from "../../domain/result.js";
+
+it("binds original bytes, inline observations, unknowns and producing identity", async () => {
+ const fixture = recordedCrashFixture();
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => Promise.resolve(ok(fixture)),
+ });
+ const result = await service.inspect({ path: fixture.artifact.path });
+ if (!result.ok) throw result.error;
+ const evidence = parseEvidence(result.value);
+ expect(evidence.normalized_result).toEqual(fixture);
+ expect(evidence.raw_result).toEqual(fixture);
+ expect(evidence.subject?.digest.sha256).toBe(fixture.artifact.sha256);
+ expect(evidence.provider).toEqual(RECORDED_CRASH_TEST_PROVIDER);
+ expect(evidence.confidence).toBe("observed");
+});
+
+it("labels an envelope containing debugger-derived maps as derived", async () => {
+ const fixture = recordedCrashFixture();
+ fixture.debugger = recordedCrashDebuggerFixture();
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => Promise.resolve(ok(fixture)),
+ });
+ const result = await service.inspect({
+ path: fixture.artifact.path,
+ include_debugger_context: true,
+ });
+ if (!result.ok) throw result.error;
+ const evidence = parseEvidence(result.value);
+ expect(evidence.confidence).toBe("derived");
+ expect(evidence.normalized_result).toEqual(fixture);
+});
+
+it.each([
+ { path: "relative.elf" },
+ { path: "/selected.core", approval: true },
+ { path: 123 },
+ {},
+])(
+ "rejects malformed selection before starting the provider: %j",
+ async (input) => {
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => {
+ throw new Error("must not run");
+ },
+ });
+ expect(await service.inspect(input)).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisInputError" },
+ });
+ },
+);
+
+it("preserves a provider's actionable unsupported reason", async () => {
+ const failure = new AnalysisCapabilityUnavailableError(
+ "fixture-recorded-crash",
+ "inspect_recorded_crash",
+ "ELF core belongs to recorded-crash analysis.",
+ );
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => Promise.resolve(err(failure)),
+ });
+ expect(await service.inspect({ path: "/source.elf" })).toEqual(err(failure));
+});
+
+it("cancels before invocation and after an acquired observation", async () => {
+ const controller = new AbortController();
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => {
+ controller.abort();
+ return Promise.resolve(ok(recordedCrashFixture()));
+ },
+ });
+ expect(
+ await service.inspect(
+ { path: "/artifacts/source-owned.core" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisCancelledError",
+ capturedOutput: recordedCrashFixture().diagnostics,
+ },
+ });
+ const untouched = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => {
+ throw new Error("must not run");
+ },
+ });
+ expect(
+ await untouched.inspect(
+ { path: "/source.elf" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+});
+
+it.each(["different-path", "invalid-digest", "outside-range"])(
+ "rejects provider evidence at the next boundary: %s",
+ async (problem) => {
+ const fixture = recordedCrashFixture();
+ if (problem === "different-path") fixture.artifact.path = "/different.elf";
+ if (problem === "invalid-digest") fixture.artifact.sha256 = "unobserved";
+ if (problem === "outside-range")
+ fixture.segments.push({
+ index: 0,
+ type: "PT_LOAD",
+ offset: "0x200",
+ header_location: { offset: "0x0", bytes: "0x38" },
+ file_backing: "file",
+ file_size: "0x1",
+ memory_size: "0x1",
+ virtual_address: "0x20000000000001",
+ physical_address: "0x0",
+ alignment: "0x1",
+ flags: "0x5",
+ });
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => Promise.resolve(ok(fixture)),
+ });
+ expect(
+ await service.inspect({ path: "/artifacts/source-owned.core" }),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisOutputError",
+ capturedOutput: fixture.diagnostics,
+ },
+ });
+ },
+);
diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts
new file mode 100644
index 000000000..69307498a
--- /dev/null
+++ b/src/application/binaryDiagnostics/RecordedCrashService.ts
@@ -0,0 +1,97 @@
+import { isAbsolute } from "node:path";
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import type { RecordedCrashPort } from "./RecordedCrashPort.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisInputError,
+ AnalysisOutputError,
+} from "../../domain/analysisErrorCore.js";
+import { createEvidence, type Evidence } from "../../domain/evidence.js";
+import {
+ inspectRecordedCrashInputSchema,
+ recordedCrashSchema,
+} from "../../domain/native/recordedCrash.js";
+import { projectInputIssues } from "../../domain/inputIssueProjection.js";
+import { err, ok, type Result } from "../../domain/result.js";
+
+const OPERATION = "inspect_recorded_crash";
+
+/** Shared CLI/MCP validation and Evidence composition for recorded crash inspection. */
+export class RecordedCrashService {
+ constructor(readonly provider: RecordedCrashPort) {}
+
+ /** Bind complete observations to the caller-selected artifact before creating Evidence. */
+ async inspect(
+ rawInput: unknown,
+ options?: ExecutionOptions,
+ ): Promise> {
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION));
+ const input = inspectRecordedCrashInputSchema.safeParse(rawInput);
+ if (!input.success)
+ return err(
+ new AnalysisInputError(
+ OPERATION,
+ { cause: input.error },
+ projectInputIssues(input.error.issues, rawInput),
+ ),
+ );
+ if (!isAbsolute(input.data.path))
+ return err(
+ new AnalysisInputError(OPERATION, undefined, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: "Expected an absolute filesystem path on this host.",
+ },
+ ]),
+ );
+ const inspected = await this.provider.inspect(input.data, options);
+ if (!inspected.ok) return inspected;
+ const diagnostics = recordedCrashSchema.shape.diagnostics.safeParse(
+ inspected.value.diagnostics,
+ );
+ const outputOptions = diagnostics.success
+ ? { capturedOutput: diagnostics.data }
+ : undefined;
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION, outputOptions));
+ const report = recordedCrashSchema.safeParse(inspected.value);
+ if (
+ !report.success ||
+ report.data.artifact.path !== input.data.path ||
+ (report.data.debugger.status === "available") !==
+ input.data.include_debugger_context
+ )
+ return err(
+ new AnalysisOutputError(
+ OPERATION,
+ "Recorded crash provider returned malformed evidence or changed the selected artifact identity.",
+ outputOptions,
+ ),
+ );
+ const value = report.data;
+ return ok(
+ createEvidence(
+ {
+ path: value.artifact.path,
+ format: "elf",
+ architecture: "x86_64",
+ sha256: value.artifact.sha256,
+ },
+ this.provider.identity,
+ {
+ operation: OPERATION,
+ parameters: input.data,
+ result: value,
+ rawResult: value,
+ confidence:
+ value.debugger.status === "available" ? "derived" : "observed",
+ limitations: value.limitations,
+ locations: [{ kind: "artifact-path", path: value.artifact.path }],
+ },
+ ),
+ );
+ }
+}
diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts
index e6f63f7ef..560a11935 100644
--- a/src/cli/binaryDiagnosticsCommands.ts
+++ b/src/cli/binaryDiagnosticsCommands.ts
@@ -1,6 +1,9 @@
import { z } from "incur";
import { resolve } from "node:path";
-import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
+import {
+ createRecordedCrashService,
+ createBinaryLayoutService,
+} from "../composition/binaryDiagnostics.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
@@ -32,4 +35,29 @@ export const registerBinaryDiagnosticsCommands = (
}),
),
});
+ const recorded = createRecordedCrashService(environment);
+ cli.command(CLI_COMMANDS.inspectRecordedCrash, {
+ description:
+ "Inspect supplied core registers/signals and optional debugger mapping evidence",
+ args: z.object({ path: z.string().describe("Local Linux amd64 ELF core") }),
+ options: z.object({
+ debuggerContext: z
+ .boolean()
+ .default(false)
+ .describe("Add BYO core-only GDB/pwndbg mapping candidates"),
+ }),
+ run: ({ args, options }) =>
+ withCommandCancellation((signal) =>
+ logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => {
+ const result = await recorded.inspect(
+ {
+ path: resolve(args.path),
+ include_debugger_context: options.debuggerContext,
+ },
+ { signal },
+ );
+ return result.ok ? result.value : projectAnalysisError(result.error);
+ }),
+ ),
+ });
};
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index 83d219446..61a85bb25 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -39,6 +39,7 @@ export const CLI_COMMANDS = Object.freeze({
resolveNativeCallTargets: "resolve-native-call-targets",
inspectAssetCatalog: "inspect-asset-catalog",
inspectManagedArtifact: "inspect-managed-artifact",
+ inspectRecordedCrash: "inspect-recorded-crash",
inspectBinaryLayout: "inspect-binary-layout",
inspectFirmwareRegions: "inspect-firmware-regions",
extractFirmware: "extract-firmware",
diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts
index cece28a5e..7fab8d73a 100644
--- a/src/composition/binaryDiagnostics.ts
+++ b/src/composition/binaryDiagnostics.ts
@@ -1,3 +1,5 @@
+import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
+import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js";
import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js";
@@ -6,3 +8,9 @@ export const createBinaryLayoutService = (
environment: Readonly = process.env,
): BinaryLayoutService =>
new BinaryLayoutService(new PwntoolsLayoutProvider(environment));
+
+/** Compose recorded-core inspection without acquiring a debugger or toolchain. */
+export const createRecordedCrashService = (
+ environment: Readonly = process.env,
+): RecordedCrashService =>
+ new RecordedCrashService(new PwntoolsRecordedCrashProvider(environment));
diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts
index c49780c03..28d199a8b 100644
--- a/src/contracts/native/binaryDiagnosticsToolContracts.ts
+++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts
@@ -1,3 +1,7 @@
+import {
+ inspectRecordedCrashInputSchema,
+ recordedCrashSchema,
+} from "../../domain/native/recordedCrash.js";
import {
inspectBinaryLayoutInputSchema,
binaryLayoutSchema,
@@ -23,4 +27,26 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
},
],
},
+ {
+ name: "inspect_recorded_crash",
+ ...toolContractMetadata("inspect_recorded_crash"),
+ kind: "native-provider",
+ description:
+ "Inspect a supplied Linux ELF64 amd64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Initial Linux x64 profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
+ inputSchema: inspectRecordedCrashInputSchema,
+ outputSchema: evidenceResultOf(recordedCrashSchema),
+ examples: [
+ {
+ title: "Inspect recorded registers and signal evidence",
+ input: { path: "/artifacts/crash.core" },
+ },
+ {
+ title: "Add core-only debugger mapping candidates",
+ input: {
+ path: "/artifacts/crash.core",
+ include_debugger_context: true,
+ },
+ },
+ ],
+ },
] as const satisfies readonly ToolContract[];
diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts
index fd416c7c6..558f4551f 100644
--- a/src/contracts/toolEffects.ts
+++ b/src/contracts/toolEffects.ts
@@ -70,6 +70,11 @@ export const TOOL_EFFECTS: Readonly> = {
launchesProcess: true,
accessesNetwork: true,
}),
+ inspect_recorded_crash: effects({
+ mutatesSession: true,
+ writesFilesystem: true,
+ launchesProcess: true,
+ }),
inspect_binary_layout: effects({
mutatesSession: true,
writesFilesystem: true,
diff --git a/src/domain/native/recordedCrash.test.ts b/src/domain/native/recordedCrash.test.ts
new file mode 100644
index 000000000..7a050d417
--- /dev/null
+++ b/src/domain/native/recordedCrash.test.ts
@@ -0,0 +1,284 @@
+import { expect, it } from "vitest";
+import {
+ inspectRecordedCrashInputSchema,
+ recordedCrashSchema,
+} from "./recordedCrash.js";
+import {
+ recordedCrashFixture,
+ recordedCrashSignalFixture,
+} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js";
+
+it("preserves high uint64 registers and historical thread identity", () => {
+ const value = recordedCrashSchema.parse(recordedCrashFixture());
+ expect(
+ value.threads[0]?.registers.find(({ name }) => name === "rdi")?.value,
+ ).toBe("0x1122334455667788");
+ expect(value.live_process_identity).toBe("unknown");
+ expect(
+ inspectRecordedCrashInputSchema.parse({ path: "/selected.core" })
+ .include_debugger_context,
+ ).toBe(false);
+});
+
+it("preserves a short zero-filled note-segment tail", () => {
+ const value = recordedCrashSignalFixture();
+ value.segments = value.segments.map((segment) => ({
+ ...segment,
+ file_size: "0x19b",
+ }));
+ value.note_padding.push({
+ segment_index: 0,
+ location: { offset: "0x218", bytes: "0x3" },
+ bytes_base64: "AAAA",
+ });
+ expect(recordedCrashSchema.safeParse(value).success).toBe(true);
+});
+
+it.each([
+ "missing-thread",
+ "duplicate-thread",
+ "missing-signal",
+ "duplicate-signal",
+ "missing-register",
+ "unknown-register",
+ "duplicated-physical-thread",
+ "duplicated-physical-signal",
+ "omitted-physical-note",
+ "expanded-note-span",
+ "forged-padding",
+])("rejects an incomplete or duplicate interpretation: %s", (scenario) => {
+ const value = recordedCrashSignalFixture();
+ if (
+ scenario === "duplicated-physical-thread" ||
+ scenario === "duplicated-physical-signal"
+ ) {
+ const noteIndex = scenario === "duplicated-physical-thread" ? 0 : 1;
+ const note = value.notes[noteIndex];
+ if (note === undefined) throw new Error("missing fixture note");
+ value.notes.push({ ...note, index: 2 });
+ if (noteIndex === 0)
+ value.threads.push(
+ ...value.threads.map((thread) => ({ ...thread, note_index: 2 })),
+ );
+ else
+ value.signals.push(
+ ...value.signals.map((signal) => ({ ...signal, note_index: 2 })),
+ );
+ }
+ if (
+ ["omitted-physical-note", "expanded-note-span", "forged-padding"].includes(
+ scenario,
+ )
+ ) {
+ value.notes = value.notes.filter((note) => note.index === 0);
+ value.signals = [];
+ }
+ if (scenario === "expanded-note-span")
+ value.notes = value.notes.map((note) => ({
+ ...note,
+ location: { ...note.location, bytes: "0x198" },
+ }));
+ if (scenario === "forged-padding")
+ value.note_padding.push({
+ segment_index: 0,
+ location: { offset: "0x1e4", bytes: "0x34" },
+ bytes_base64: Buffer.alloc(52).toString("base64"),
+ });
+ if (scenario === "missing-thread") value.threads = [];
+ if (scenario === "duplicate-thread")
+ value.threads = [...value.threads, ...value.threads];
+ if (scenario === "missing-signal") value.signals = [];
+ if (scenario === "duplicate-signal")
+ value.signals = [...value.signals, ...value.signals];
+ if (scenario === "missing-register")
+ value.threads = value.threads.map((thread) => ({
+ ...thread,
+ registers: thread.registers.slice(1),
+ }));
+ if (scenario === "unknown-register")
+ value.threads = value.threads.map((thread) => ({
+ ...thread,
+ registers: thread.registers.map((register) =>
+ register.name === "rdi"
+ ? { ...register, name: "unrecognized" }
+ : register,
+ ),
+ }));
+ expect(recordedCrashSchema.safeParse(value).success).toBe(false);
+});
+
+it.each([
+ [
+ "unsafe register number",
+ (v: ReturnType) => ({
+ ...v,
+ threads: [
+ {
+ ...v.threads[0],
+ registers: [
+ {
+ name: "rdi",
+ value: 9007199254740992,
+ location: { offset: "0x104", bytes: "0x8" },
+ },
+ ],
+ },
+ ],
+ }),
+ ],
+ [
+ "source outside artifact",
+ (v: ReturnType) => ({
+ ...v,
+ artifact: { ...v.artifact, bytes: 120 },
+ }),
+ ],
+ [
+ "invalid note reference",
+ (v: ReturnType) => ({
+ ...v,
+ threads: [{ ...v.threads[0], note_index: 1 }],
+ }),
+ ],
+ [
+ "register outside descriptor",
+ (v: ReturnType) => ({
+ ...v,
+ threads: [
+ {
+ ...v.threads[0],
+ registers: [
+ {
+ name: "rdi",
+ value: "0x0",
+ location: { offset: "0x1e4", bytes: "0x8" },
+ },
+ ],
+ },
+ ],
+ }),
+ ],
+ [
+ "raw descriptor length",
+ (v: ReturnType) => ({
+ ...v,
+ notes: v.notes.map((n) => ({ ...n, descriptor_bytes_base64: "AA==" })),
+ }),
+ ],
+ [
+ "different owner ABI",
+ (v: ReturnType) => ({
+ ...v,
+ notes: v.notes.map((n) => ({ ...n, owner_display: "OTHER" })),
+ }),
+ ],
+ [
+ "raw owner ABI hidden by a CORE display label",
+ (v: ReturnType) => ({
+ ...v,
+ notes: v.notes.map((n) => ({
+ ...n,
+ owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
+ })),
+ }),
+ ],
+ [
+ "duplicate register",
+ (v: ReturnType) => ({
+ ...v,
+ threads: v.threads.map((t) => ({
+ ...t,
+ registers: [...t.registers, ...t.registers],
+ })),
+ }),
+ ],
+] as const)("rejects %s before Evidence serialization", (_name, change) =>
+ expect(
+ recordedCrashSchema.safeParse(change(recordedCrashFixture())).success,
+ ).toBe(false),
+);
+
+it("does not interpret a signal through a mislabeled raw owner ABI", () => {
+ const value = recordedCrashFixture();
+ expect(
+ recordedCrashSchema.safeParse({
+ ...value,
+ threads: [],
+ notes: value.notes.map((note) => ({
+ ...note,
+ type: "NT_SIGINFO",
+ owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
+ })),
+ signals: [
+ {
+ note_index: 0,
+ number: 11,
+ code: 1,
+ errno: 0,
+ fault_address: "0x123",
+ fault_address_meaning: "recorded-sigsegv-address",
+ thread_association: "unknown",
+ },
+ ],
+ }).success,
+ ).toBe(false);
+});
+
+it("keeps absent map flags unknown and validates reported permission bits", () => {
+ const value = recordedCrashFixture();
+ const context = {
+ status: "available",
+ gdb_version: "fixture",
+ pwndbg_version: "fixture",
+ connection: "core",
+ executable: null,
+ historical_pid: 123,
+ reported_limits: {
+ address_space_bytes: 3221225472,
+ cpu_seconds: 30,
+ file_size_bytes: 67108864,
+ },
+ maps: [
+ {
+ start: "0x1000",
+ end: "0x2000",
+ offset: "0x0",
+ reported_flags: 0,
+ permissions: null,
+ pathname_display: "recorded pathname",
+ current_file_identity: "unknown",
+ },
+ ],
+ confidence: "derived",
+ completeness: "unknown",
+ diagnostics: value.diagnostics,
+ } as const;
+ expect(
+ recordedCrashSchema.safeParse({ ...value, debugger: context }).success,
+ ).toBe(true);
+ expect(
+ recordedCrashSchema.safeParse({
+ ...value,
+ debugger: {
+ ...context,
+ maps: context.maps.map((m) => ({
+ ...m,
+ permissions: { read: false, write: false, execute: false },
+ })),
+ },
+ }).success,
+ ).toBe(false);
+ expect(
+ recordedCrashSchema.safeParse({
+ ...value,
+ debugger: {
+ ...context,
+ maps: context.maps.map((m) => ({
+ ...m,
+ reported_flags: 4,
+ permissions: { read: false, write: false, execute: false },
+ })),
+ },
+ }).success,
+ ).toBe(false);
+});
diff --git a/src/domain/native/recordedCrash.ts b/src/domain/native/recordedCrash.ts
new file mode 100644
index 000000000..7668e63ce
--- /dev/null
+++ b/src/domain/native/recordedCrash.ts
@@ -0,0 +1,362 @@
+import { z } from "zod";
+import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
+
+const hex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/);
+const index = z.number().int().nonnegative();
+const signed32 = z.number().int().min(-2147483648).max(2147483647);
+const base64 = z
+ .string()
+ .regex(
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/,
+ );
+const range = z.strictObject({ offset: hex, bytes: hex });
+const diagnostics = z.strictObject({
+ stdout: z.string(),
+ stderr: z.string(),
+ truncated: z.boolean(),
+});
+// Exact b"CORE\0"; a lossy display label cannot establish the Linux note ABI.
+const LINUX_CORE_OWNER_BASE64 = "Q09SRQA=";
+
+/** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */
+export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
+ "r15",
+ "r14",
+ "r13",
+ "r12",
+ "rbp",
+ "rbx",
+ "r11",
+ "r10",
+ "r9",
+ "r8",
+ "rax",
+ "rcx",
+ "rdx",
+ "rsi",
+ "rdi",
+ "orig_rax",
+ "rip",
+ "cs",
+ "eflags",
+ "rsp",
+ "ss",
+ "fs_base",
+ "gs_base",
+ "ds",
+ "es",
+ "fs",
+ "gs",
+];
+const supportedRegisters = new Set(RECORDED_LINUX_AMD64_REGISTERS);
+
+/** Select an explicit recording and optionally request debugger-derived mapping context. */
+export const inspectRecordedCrashInputSchema = z.strictObject({
+ path: z
+ .string()
+ .min(1)
+ .describe("Absolute filesystem path to a supplied Linux ELF core"),
+ include_debugger_context: z
+ .boolean()
+ .default(false)
+ .describe(
+ "Add core-only mapping candidates through caller-supplied GDB/pwndbg",
+ ),
+});
+
+/** Debugger map candidates preserve reported flags; zero does not establish absent permissions. */
+export const recordedCrashDebuggerSchema = z.discriminatedUnion("status", [
+ z.strictObject({ status: z.literal("not-requested") }),
+ z.strictObject({
+ status: z.literal("available"),
+ gdb_version: z.string().min(1),
+ pwndbg_version: z.string().min(1),
+ connection: z.literal("core"),
+ executable: z.null(),
+ historical_pid: signed32,
+ reported_limits: z.strictObject({
+ address_space_bytes: index,
+ cpu_seconds: index,
+ file_size_bytes: index,
+ }),
+ maps: z.array(
+ z.strictObject({
+ start: hex,
+ end: hex,
+ offset: hex,
+ reported_flags: index,
+ permissions: z
+ .strictObject({
+ read: z.boolean(),
+ write: z.boolean(),
+ execute: z.boolean(),
+ })
+ .nullable(),
+ pathname_display: z.string(),
+ current_file_identity: z.literal("unknown"),
+ }),
+ ),
+ confidence: z.literal("derived"),
+ completeness: z.literal("unknown"),
+ diagnostics,
+ }),
+]);
+
+/** Portable recorded observations; registers and source coordinates remain lossless. */
+const recordedCrashObjectSchema = z.strictObject({
+ artifact: z.strictObject({
+ path: z.string().min(1),
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
+ bytes: index,
+ }),
+ format: z.literal("elf-core"),
+ architecture: z.literal("x86_64-little-endian"),
+ target_execution: z.literal("not-performed"),
+ live_process_identity: z.literal("unknown"),
+ segments: z.array(
+ z.strictObject({
+ index,
+ type: z.union([z.string(), index]),
+ header_location: range,
+ offset: hex,
+ file_size: hex,
+ memory_size: hex,
+ virtual_address: hex,
+ physical_address: hex,
+ alignment: hex,
+ flags: hex,
+ file_backing: z.enum(["file", "none"]),
+ }),
+ ),
+ notes: z.array(
+ z.strictObject({
+ index,
+ segment_index: index,
+ type: z.union([z.string(), index]),
+ location: range,
+ owner_location: range,
+ owner_bytes_base64: base64,
+ owner_display: z.string().nullable(),
+ descriptor_location: range,
+ descriptor_bytes_base64: base64,
+ }),
+ ),
+ note_padding: z.array(
+ z.strictObject({
+ segment_index: index,
+ location: range,
+ bytes_base64: base64,
+ }),
+ ),
+ threads: z.array(
+ z.strictObject({
+ note_index: index,
+ historical_pid: signed32,
+ recorded_current_signal: z.number().int().min(-32768).max(32767),
+ registers: z.array(
+ z.strictObject({
+ name: z.string().min(1),
+ value: hex,
+ location: range,
+ }),
+ ),
+ }),
+ ),
+ signals: z.array(
+ z.strictObject({
+ note_index: index,
+ number: signed32,
+ code: signed32,
+ errno: signed32,
+ fault_address: hex.nullable(),
+ fault_address_meaning: z.enum(["recorded-sigsegv-address", "unknown"]),
+ thread_association: z.literal("unknown"),
+ }),
+ ),
+ debugger: recordedCrashDebuggerSchema,
+ note_interpretation_completeness: z.literal("unknown"),
+ decoder_diagnostics: diagnostics,
+ diagnostics,
+ limitations: z.array(z.string()),
+});
+
+/** Provider payload excludes identity and diagnostics bound by the owned snapshot boundary. */
+export const recordedCrashPayloadSchema = recordedCrashObjectSchema.omit({
+ artifact: true,
+ diagnostics: true,
+ debugger: true,
+ decoder_diagnostics: true,
+});
+
+/** Validate the next consumer's file ranges, note links and register representations. */
+export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
+ (value, context) => {
+ const fail = (message: string) =>
+ context.addIssue({ code: "custom", message });
+ const within = (
+ item: z.output,
+ start = 0n,
+ end = BigInt(value.artifact.bytes),
+ ) => {
+ const offset = BigInt(item.offset);
+ const bytes = BigInt(item.bytes);
+ return offset >= start && offset <= end && bytes <= end - offset;
+ };
+ for (const issue of recordedNoteCoverageIssues(
+ value.segments,
+ value.notes,
+ value.note_padding,
+ ))
+ fail(issue);
+ for (const [position, segment] of value.segments.entries()) {
+ if (position !== segment.index || !within(segment.header_location))
+ fail("Invalid original segment identity or header range.");
+ if (
+ segment.file_backing === "file" &&
+ !within({ offset: segment.offset, bytes: segment.file_size })
+ )
+ fail("Recorded segment exceeds artifact bytes.");
+ if (
+ segment.type === "PT_LOAD" &&
+ BigInt(segment.file_size) > BigInt(segment.memory_size)
+ )
+ fail("Recorded PT_LOAD file size exceeds memory size.");
+ }
+ for (const [position, note] of value.notes.entries()) {
+ const segment = value.segments[note.segment_index];
+ if (position !== note.index || segment?.type !== "PT_NOTE") {
+ fail("Invalid original note identity or segment reference.");
+ continue;
+ }
+ const decodedLength = (raw: string) =>
+ BigInt(
+ (raw.length / 4) * 3 -
+ (raw.endsWith("==") ? 2 : raw.endsWith("=") ? 1 : 0),
+ );
+ if (
+ decodedLength(note.owner_bytes_base64) !==
+ BigInt(note.owner_location.bytes) ||
+ decodedLength(note.descriptor_bytes_base64) !==
+ BigInt(note.descriptor_location.bytes)
+ )
+ fail("Raw note lengths differ from their source ranges.");
+ const start = BigInt(segment.offset);
+ const end = start + BigInt(segment.file_size);
+ if (
+ !within(note.location, start, end) ||
+ !within(
+ note.owner_location,
+ BigInt(note.location.offset),
+ BigInt(note.location.offset) + BigInt(note.location.bytes),
+ ) ||
+ !within(
+ note.descriptor_location,
+ BigInt(note.location.offset),
+ BigInt(note.location.offset) + BigInt(note.location.bytes),
+ )
+ )
+ fail("Note source ranges exceed their recorded segment.");
+ }
+ for (const padding of value.note_padding) {
+ const segment = value.segments[padding.segment_index];
+ if (
+ segment?.type !== "PT_NOTE" ||
+ !within(
+ padding.location,
+ BigInt(segment.offset),
+ BigInt(segment.offset) + BigInt(segment.file_size),
+ )
+ )
+ fail("Invalid recorded note padding range.");
+ }
+ for (const [kind, rows] of [
+ ["NT_PRSTATUS", value.threads],
+ ["NT_SIGINFO", value.signals],
+ ] as const) {
+ const matchingNotes = value.notes.filter(
+ (note) =>
+ note.type === kind &&
+ note.owner_bytes_base64 === LINUX_CORE_OWNER_BASE64,
+ );
+ if (
+ rows.length !== matchingNotes.length ||
+ new Set(rows.map(({ note_index }) => note_index)).size !== rows.length
+ )
+ fail(
+ "Each supported raw note requires exactly one corresponding interpretation.",
+ );
+ }
+ for (const thread of value.threads) {
+ const note = value.notes[thread.note_index];
+ if (
+ note?.type !== "NT_PRSTATUS" ||
+ note.owner_display !== "CORE" ||
+ note.owner_bytes_base64 !== LINUX_CORE_OWNER_BASE64
+ ) {
+ fail("Thread references a different note kind.");
+ continue;
+ }
+ if (
+ thread.registers.length !== RECORDED_LINUX_AMD64_REGISTERS.length ||
+ !thread.registers.every(({ name }) => supportedRegisters.has(name)) ||
+ new Set(thread.registers.map(({ name }) => name)).size !==
+ thread.registers.length
+ )
+ fail(
+ "Each recorded thread requires every supported register exactly once.",
+ );
+ for (const register of thread.registers)
+ if (
+ BigInt(register.location.bytes) !== 8n ||
+ !within(
+ register.location,
+ BigInt(note.descriptor_location.offset),
+ BigInt(note.descriptor_location.offset) +
+ BigInt(note.descriptor_location.bytes),
+ )
+ )
+ fail("Register source exceeds its descriptor.");
+ }
+ for (const signal of value.signals) {
+ if (
+ value.notes[signal.note_index]?.type !== "NT_SIGINFO" ||
+ value.notes[signal.note_index]?.owner_display !== "CORE" ||
+ value.notes[signal.note_index]?.owner_bytes_base64 !==
+ LINUX_CORE_OWNER_BASE64
+ )
+ fail("Signal references a different note kind.");
+ const known =
+ signal.number === 11 && (signal.code === 1 || signal.code === 2);
+ if (
+ known !== (signal.fault_address !== null) ||
+ (signal.fault_address !== null) !==
+ (signal.fault_address_meaning === "recorded-sigsegv-address")
+ )
+ fail("Signal union meaning does not match recorded SIGSEGV code.");
+ }
+ if (value.debugger.status === "available")
+ for (const map of value.debugger.maps) {
+ if (BigInt(map.end) < BigInt(map.start))
+ fail("Debugger mapping end precedes its start.");
+ if (
+ map.permissions !== null &&
+ (map.permissions.read !== Boolean(map.reported_flags & 4) ||
+ map.permissions.write !== Boolean(map.reported_flags & 2) ||
+ map.permissions.execute !== Boolean(map.reported_flags & 1))
+ )
+ fail("Mapping permissions differ from reported flags.");
+ if (
+ (map.reported_flags === 0 || map.reported_flags > 7) !==
+ (map.permissions === null)
+ )
+ fail(
+ "Unreported or unfamiliar map flags require unknown permissions.",
+ );
+ }
+ },
+);
+
+export type InspectRecordedCrashInput = z.output<
+ typeof inspectRecordedCrashInputSchema
+>;
+export type RecordedCrash = z.output;
diff --git a/src/domain/native/recordedCrashNoteCoverage.ts b/src/domain/native/recordedCrashNoteCoverage.ts
new file mode 100644
index 000000000..dbe7e51cd
--- /dev/null
+++ b/src/domain/native/recordedCrashNoteCoverage.ts
@@ -0,0 +1,111 @@
+type Range = { readonly offset: string; readonly bytes: string };
+type Segment = {
+ readonly index: number;
+ readonly type: string | number;
+ readonly offset: string;
+ readonly file_size: string;
+};
+type Note = {
+ readonly segment_index: number;
+ readonly location: Range;
+ readonly owner_location: Range;
+ readonly descriptor_location: Range;
+};
+type Padding = {
+ readonly segment_index: number;
+ readonly location: Range;
+ readonly bytes_base64: string;
+};
+const align = (size: bigint) => ((size + 3n) / 4n) * 4n;
+const layoutMatches = (note: Note): boolean => {
+ const start = BigInt(note.location.offset);
+ const owner = BigInt(note.owner_location.bytes);
+ const descriptor = BigInt(note.descriptor_location.bytes);
+ return (
+ BigInt(note.owner_location.offset) === start + 12n &&
+ BigInt(note.descriptor_location.offset) === start + 12n + align(owner) &&
+ BigInt(note.location.bytes) === 12n + align(owner) + align(descriptor)
+ );
+};
+const paddingMatches = (
+ item: Padding,
+ segment: Segment | undefined,
+): boolean => {
+ const bytes = BigInt(item.location.bytes);
+ const decodedLength =
+ (item.bytes_base64.length / 4) * 3 -
+ (item.bytes_base64.endsWith("==")
+ ? 2
+ : item.bytes_base64.endsWith("=")
+ ? 1
+ : 0);
+ return (
+ bytes > 0n &&
+ bytes < 12n &&
+ BigInt(decodedLength) === bytes &&
+ /^A*={0,2}$/.test(item.bytes_base64) &&
+ segment !== undefined &&
+ BigInt(item.location.offset) + bytes ===
+ BigInt(segment.offset) + BigInt(segment.file_size)
+ );
+};
+
+/** Require exact aligned note layouts and one complete, nonoverlapping physical coverage. */
+export function recordedNoteCoverageIssues(
+ segments: readonly Segment[],
+ notes: readonly Note[],
+ padding: readonly Padding[],
+): readonly string[] {
+ const issues: string[] = [];
+ const bySegment = new Map();
+ const all: Range[] = [];
+ const add = (segment: number, range: Range) => {
+ const ranges = bySegment.get(segment) ?? [];
+ ranges.push(range);
+ bySegment.set(segment, ranges);
+ all.push(range);
+ };
+ for (const note of notes) {
+ if (!layoutMatches(note))
+ issues.push(
+ "Recorded note layout differs from its aligned header/owner/descriptor ranges.",
+ );
+ add(note.segment_index, note.location);
+ }
+ for (const item of padding) {
+ if (!paddingMatches(item, segments[item.segment_index]))
+ issues.push(
+ "Recorded note padding must be a short zero-filled segment tail.",
+ );
+ add(item.segment_index, item.location);
+ }
+ const compare = (left: Range, right: Range) =>
+ BigInt(left.offset) < BigInt(right.offset)
+ ? -1
+ : BigInt(left.offset) > BigInt(right.offset)
+ ? 1
+ : 0;
+ let end = 0n;
+ for (const range of all.sort(compare)) {
+ const start = BigInt(range.offset);
+ if (start < end)
+ issues.push("Physical recorded note/padding ranges overlap or repeat.");
+ if (start + BigInt(range.bytes) > end) end = start + BigInt(range.bytes);
+ }
+ for (const segment of segments) {
+ if (segment.type !== "PT_NOTE") continue;
+ let cursor = BigInt(segment.offset);
+ for (const range of (bySegment.get(segment.index) ?? []).sort(compare)) {
+ if (BigInt(range.offset) !== cursor)
+ issues.push(
+ "Recorded note/padding ranges leave a gap or overlap in their segment.",
+ );
+ cursor = BigInt(range.offset) + BigInt(range.bytes);
+ }
+ if (cursor !== BigInt(segment.offset) + BigInt(segment.file_size))
+ issues.push(
+ "Recorded note/padding ranges do not completely cover their segment.",
+ );
+ }
+ return issues;
+}
diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts
index d56f777c4..0840aaf24 100644
--- a/src/generatedPackageMetadata.ts
+++ b/src/generatedPackageMetadata.ts
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
- skillVersion: "32",
+ skillVersion: "33",
} as const;
diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts
new file mode 100644
index 000000000..48f940549
--- /dev/null
+++ b/src/native/pwndbg/PwndbgCoreContext.ts
@@ -0,0 +1,232 @@
+import { access, mkdir, stat, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { randomUUID } from "node:crypto";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
+import type { AnalysisCapturedOutput } from "../../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisOutputError,
+} from "../../domain/analysisErrorCore.js";
+import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
+import {
+ recordedCrashDebuggerSchema,
+ type RecordedCrash,
+} from "../../domain/native/recordedCrash.js";
+import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
+import { runOwnedCommand } from "../../process/OwnedCommand.js";
+import {
+ capturedPwntoolsOutput,
+ pwntoolsDecoderFailure,
+} from "../pwntools/PwntoolsFailures.js";
+import { PWNTOOLS_LIMITS } from "../pwntools/PwntoolsRelease.js";
+import { readPwntoolsFailureEvidence } from "../pwntools/PwntoolsResourceLimits.js";
+import type { PwntoolsLauncher } from "../pwntools/PwntoolsDecoder.js";
+
+const OPERATION = "inspect_recorded_crash";
+const replySchema = z.discriminatedUnion("ok", [
+ z.strictObject({ ok: z.literal(true), value: recordedCrashDebuggerSchema }),
+ z.strictObject({
+ ok: z.literal(false),
+ reason: z.enum([
+ "unavailable",
+ "unsupported",
+ "decoder",
+ "format",
+ "output-limit",
+ ]),
+ message: z.string(),
+ }),
+]);
+
+/** Add mapping candidates from an owned core-only debugger, using the existing snapshot. */
+export const inspectPwndbgCore = async ({
+ environment,
+ rootPath,
+ diagnostics,
+ options,
+ launcher,
+}: {
+ readonly environment: Readonly;
+ readonly rootPath: string;
+ readonly diagnostics: AnalysisCapturedOutput;
+ readonly options: ExecutionOptions | undefined;
+ readonly launcher?: PwntoolsLauncher;
+}): Promise => {
+ const gdb = environment.REA_PWNDBG_GDB ?? "/usr/bin/gdb";
+ const entry = environment.REA_PWNDBG_GDBINIT ?? "";
+ const venv = environment.REA_PWNDBG_VENV_PATH ?? "";
+ const unavailable = (message: string, cause?: unknown) =>
+ new AnalysisCapabilityUnavailableError("pwndbg-core", OPERATION, message, {
+ ...(cause === undefined ? {} : { cause }),
+ userMessage: message,
+ capturedOutput: diagnostics,
+ });
+ for (const [key, path, kind] of [
+ ["REA_PWNDBG_GDB", gdb, "file"],
+ ["REA_PWNDBG_GDBINIT", entry, "file"],
+ ["REA_PWNDBG_VENV_PATH", venv, "directory"],
+ ] as const) {
+ try {
+ if (!isAbsolute(path))
+ throw new Error("Expected an absolute caller-supplied path.");
+ await access(
+ path,
+ key === "REA_PWNDBG_GDB" ? constants.X_OK : constants.R_OK,
+ );
+ const information = await stat(path);
+ if (kind === "file" ? !information.isFile() : !information.isDirectory())
+ throw new Error(`Expected a ${kind}.`);
+ } catch (cause: unknown) {
+ throw unavailable(
+ `${key} is unavailable at ${path}: ${cause instanceof Error ? cause.message : String(cause)}. Configure the requested core-only context, or omit include_debugger_context for basic recorded evidence.`,
+ cause,
+ );
+ }
+ }
+ const stage = join(rootPath, "debugger");
+ await mkdir(stage, { mode: 0o700 });
+ const requestPath = join(stage, "request.json");
+ const replyPath = join(stage, "reply.json");
+ await writeFile(
+ requestPath,
+ JSON.stringify({
+ snapshot_path: join(rootPath, "object.snapshot"),
+ reply_path: replyPath,
+ gdb,
+ gdbinit: entry,
+ venv,
+ }),
+ { flag: "wx", mode: 0o600 },
+ );
+ let output: AnalysisCapturedOutput | undefined;
+ try {
+ options?.signal?.throwIfAborted();
+ const execution = await runOwnedCommand(
+ {
+ command: environment.REA_PWNTOOLS_PYTHON ?? "",
+ arguments: [
+ "-I",
+ fileURLToPath(
+ new URL("../../../bridge/pwndbg/launch.py", import.meta.url),
+ ),
+ join(stage, "resource.failure"),
+ requestPath,
+ ],
+ cwd: stage,
+ runId: `rea-recorded-core-debugger-${randomUUID()}`,
+ hostEnvironment: {
+ ...environment,
+ PWNDBG_NO_AUTOUPDATE: "1",
+ PWNDBG_VENV_PATH: venv,
+ PWNLIB_NOTERM: "1",
+ TERM: "dumb",
+ HOME: stage,
+ XDG_CACHE_HOME: join(stage, "cache"),
+ XDG_CONFIG_HOME: join(stage, "config"),
+ PYTHONNOUSERSITE: "1",
+ OPENBLAS_NUM_THREADS: "1",
+ OMP_NUM_THREADS: "1",
+ },
+ },
+ {
+ timeoutMs: PWNTOOLS_LIMITS.timeoutMs,
+ diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
+ },
+ {
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ ...(launcher === undefined ? {} : { launcher }),
+ },
+ );
+ output = capturedPwntoolsOutput(execution);
+ let reply: z.output;
+ try {
+ const raw = await readStableArtifact(
+ replyPath,
+ PWNTOOLS_LIMITS.outputBytes,
+ options?.signal,
+ );
+ reply = replySchema.parse(JSON.parse(raw.bytes.toString("utf8")));
+ } catch (cause: unknown) {
+ if (options?.signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, {
+ capturedOutput: recordedCrashStageOutput(diagnostics, output),
+ });
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Core-only debugger reply failed: ${cause instanceof Error ? cause.message : String(cause)}`,
+ {
+ cause,
+ capturedOutput: recordedCrashStageOutput(diagnostics, output),
+ },
+ );
+ }
+ if (!reply.ok) {
+ if (reply.reason === "unavailable" || reply.reason === "unsupported")
+ throw new AnalysisCapabilityUnavailableError(
+ "pwndbg-core",
+ OPERATION,
+ reply.message,
+ {
+ userMessage: reply.message,
+ capturedOutput: recordedCrashStageOutput(diagnostics, output),
+ },
+ );
+ if (reply.reason === "format" || reply.reason === "output-limit")
+ throw new AnalysisOutputError(OPERATION, reply.message, {
+ capturedOutput: recordedCrashStageOutput(diagnostics, output),
+ });
+ throw new ProviderAdapterError("pwndbg-core", OPERATION, {
+ diagnostics: {
+ stage: "debugger",
+ reason: reply.message,
+ captured_output: { ...recordedCrashStageOutput(diagnostics, output) },
+ },
+ });
+ }
+ if (reply.value.status !== "available")
+ throw new AnalysisOutputError(
+ OPERATION,
+ "Requested debugger context returned no core evidence.",
+ { capturedOutput: recordedCrashStageOutput(diagnostics, output) },
+ );
+ return { ...reply.value, diagnostics: output };
+ } catch (cause: unknown) {
+ if (
+ options?.signal?.aborted &&
+ (cause === options.signal.reason ||
+ (cause instanceof Error && cause.name === "AbortError"))
+ )
+ throw new AnalysisCancelledError(OPERATION, {
+ capturedOutput: recordedCrashStageOutput(diagnostics, output),
+ });
+ throw pwntoolsDecoderFailure(
+ cause,
+ "debugger",
+ gdb,
+ environment.REA_PWNTOOLS_PYTHON ?? "",
+ await readPwntoolsFailureEvidence(cause, stage),
+ {
+ operation: OPERATION,
+ providerId: "pwndbg-core",
+ precedingOutput: diagnostics,
+ },
+ );
+ }
+};
+
+/** Preserve both stages' exact text and their independently observed truncation state. */
+export const recordedCrashStageOutput = (
+ basic: AnalysisCapturedOutput,
+ debuggerOutput?: AnalysisCapturedOutput,
+): AnalysisCapturedOutput =>
+ debuggerOutput === undefined
+ ? basic
+ : {
+ stdout: `[core decoder]\n${basic.stdout}\n[debugger]\n${debuggerOutput.stdout}`,
+ stderr: `[core decoder]\n${basic.stderr}\n[debugger]\n${debuggerOutput.stderr}`,
+ truncated: basic.truncated || debuggerOutput.truncated,
+ };
diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts
new file mode 100644
index 000000000..eacc2d63e
--- /dev/null
+++ b/src/native/pwntools/PwntoolsDecoder.ts
@@ -0,0 +1,333 @@
+import {
+ pwntoolsDecoderFailure,
+ pwntoolsUnavailable,
+ capturedPwntoolsOutput,
+} from "./PwntoolsFailures.js";
+import { randomUUID } from "node:crypto";
+import { access, stat, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
+import type { ProviderIdentity } from "../../application/AnalysisProvider.js";
+import type { AnalysisCapturedOutput } from "../../domain/analysisErrorBase.js";
+import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
+ AnalysisInputError,
+ AnalysisOutputError,
+ AnalysisResourceConstraintError,
+} from "../../domain/analysisErrorCore.js";
+import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
+import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
+import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
+import { err, ok, type Result } from "../../domain/result.js";
+import { runOwnedCommand } from "../../process/OwnedCommand.js";
+import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
+import {
+ pwntoolsResourceLimitsSchema,
+ readPwntoolsFailureEvidence,
+} from "./PwntoolsResourceLimits.js";
+import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
+import {
+ PWNTOOLS_PROVIDER_IDENTITY,
+ PWNTOOLS_LIMITS,
+} from "./PwntoolsRelease.js";
+
+/** Owned launcher seam shared by the offline pwntools artifact adapters. */
+export type PwntoolsLauncher = NonNullable<
+ Parameters[2]
+>["launcher"];
+interface DecoderReport {
+ readonly diagnostics: AnalysisCapturedOutput;
+}
+/** Adapter-selected semantics around a shared owned pwntools process boundary. */
+export interface PwntoolsDecoderProfile<
+ Input extends { readonly path: string },
+ Payload,
+ Report extends DecoderReport,
+> {
+ readonly identity: ProviderIdentity;
+ readonly operation: string;
+ readonly bridge: URL;
+ readonly payloadSchema: z.ZodType;
+ readonly project: (context: {
+ readonly input: Input;
+ readonly payload: Payload;
+ readonly snapshot: { readonly bytes: Buffer; readonly sha256: string };
+ readonly rootPath: string;
+ readonly diagnostics: AnalysisCapturedOutput;
+ readonly options: ExecutionOptions | undefined;
+ }) => Report | Promise;
+}
+
+/** Thin owned-process adapter; all ELF parsing and heuristics come from unchanged upstream code. */
+export class PwntoolsDecoder<
+ Input extends { readonly path: string },
+ Payload,
+ Report extends DecoderReport,
+> {
+ readonly identity: ProviderIdentity;
+ constructor(
+ readonly profile: PwntoolsDecoderProfile,
+ readonly environment: Readonly,
+ readonly launcher?: PwntoolsLauncher,
+ readonly createRuntime: () => Promise<
+ Pick
+ > = () => PrivateRuntimeRoot.create({ prefix: "rea-artifact-decoder-" }),
+ ) {
+ this.identity = profile.identity;
+ }
+
+ /** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */
+ async inspect(
+ input: Input,
+ options?: ExecutionOptions,
+ ): Promise> {
+ const OPERATION = this.profile.operation;
+ const context = { operation: OPERATION, providerId: this.identity.id };
+ const replySchema = z.discriminatedUnion("ok", [
+ z.strictObject({
+ ok: z.literal(true),
+ profile: z.literal(PWNTOOLS_PROVIDER_IDENTITY.version),
+ value: this.profile.payloadSchema,
+ }),
+ z.strictObject({
+ ok: z.literal(false),
+ reason: z.enum([
+ "format",
+ "unsupported",
+ "unsupported-target",
+ "unavailable",
+ "resource-limit",
+ "output-limit",
+ "decoder",
+ ]),
+ message: z.string(),
+ reported_limits: pwntoolsResourceLimitsSchema.nullable().optional(),
+ }),
+ ]);
+ let root: Pick | undefined;
+ let result: Result;
+ let phase: "configuration" | "artifact-read" | "decoder" = "configuration";
+ const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? "";
+ let selectedPath = executablePath;
+ try {
+ if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
+ if (process.platform !== "linux" || process.arch !== "x64")
+ throw new AnalysisCapabilityUnavailableError(
+ this.identity.id,
+ OPERATION,
+ "The initial real-verified pwntools artifact profile supports Linux x64 only.",
+ );
+ if (!isAbsolute(selectedPath))
+ throw pwntoolsUnavailable(
+ "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.",
+ selectedPath,
+ undefined,
+ undefined,
+ context,
+ );
+ await access(selectedPath, constants.X_OK);
+ if (!(await stat(selectedPath)).isFile())
+ throw pwntoolsUnavailable(
+ "Selected Python executable must be a regular executable file, not a directory: " +
+ selectedPath,
+ selectedPath,
+ undefined,
+ undefined,
+ context,
+ );
+ phase = "artifact-read";
+ selectedPath = input.path;
+ const snapshot = await readStableArtifact(
+ input.path,
+ PWNTOOLS_LIMITS.inputBytes,
+ options?.signal,
+ );
+ phase = "decoder";
+ root = await this.createRuntime();
+ const requestPath = join(root.path, "request.json");
+ const snapshotPath = join(root.path, "object.snapshot");
+ const replyPath = join(root.path, "reply.json");
+ await writeFile(snapshotPath, snapshot.bytes, {
+ flag: "wx",
+ mode: 0o600,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
+ await writeFile(
+ requestPath,
+ JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }),
+ { flag: "wx", mode: 0o600 },
+ );
+ const execution = await runOwnedCommand(
+ {
+ command: this.environment.REA_PWNTOOLS_PYTHON ?? "",
+ arguments: [
+ "-I",
+ fileURLToPath(this.profile.bridge),
+ join(root.path, "resource.failure"),
+ requestPath,
+ ],
+ cwd: root.path,
+ runId: `rea-artifact-decoder-${randomUUID()}`,
+ hostEnvironment: {
+ ...this.environment,
+ PWNLIB_NOTERM: "1",
+ OPENBLAS_NUM_THREADS: "1",
+ OMP_NUM_THREADS: "1",
+ },
+ },
+ {
+ timeoutMs: PWNTOOLS_LIMITS.timeoutMs,
+ diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
+ },
+ {
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ ...(this.launcher === undefined ? {} : { launcher: this.launcher }),
+ },
+ );
+ const capturedOutput = capturedPwntoolsOutput(execution);
+ let reply: z.output;
+ try {
+ const file = await readStableArtifact(
+ replyPath,
+ PWNTOOLS_LIMITS.outputBytes,
+ options?.signal,
+ );
+ reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
+ } catch (cause: unknown) {
+ if (options?.signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { capturedOutput });
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Owned pwntools decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`,
+ { cause, capturedOutput },
+ );
+ }
+ if (!reply.ok) {
+ if (reply.reason === "unsupported-target")
+ throw new AnalysisUnsupportedTargetError(
+ OPERATION,
+ input.path,
+ reply.message,
+ { capturedOutput },
+ );
+ if (reply.reason === "resource-limit")
+ throw new AnalysisResourceConstraintError(
+ OPERATION,
+ "memory",
+ reply.message,
+ reply.reported_limits ?? null,
+ { capturedOutput },
+ );
+ if (reply.reason === "format")
+ throw new AnalysisInputError(OPERATION, { capturedOutput }, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: reply.message,
+ },
+ ]);
+ if (reply.reason === "unavailable")
+ throw pwntoolsUnavailable(
+ reply.message,
+ this.environment.REA_PWNTOOLS_PYTHON ?? "",
+ undefined,
+ capturedOutput,
+ context,
+ );
+ if (reply.reason === "unsupported")
+ throw new AnalysisCapabilityUnavailableError(
+ this.identity.id,
+ OPERATION,
+ reply.message,
+ { userMessage: reply.message, capturedOutput },
+ );
+ if (reply.reason === "output-limit")
+ throw new AnalysisOutputError(OPERATION, reply.message, {
+ capturedOutput,
+ });
+ throw new ProviderAdapterError(this.identity.id, OPERATION, {
+ diagnostics: {
+ phase,
+ failure_kind: reply.reason,
+ reason: reply.message,
+ stdout: execution.stdout.text,
+ stderr: execution.stderr.text,
+ captured_output: { ...capturedOutput },
+ },
+ });
+ }
+ result = ok(
+ await this.profile.project({
+ input,
+ payload: reply.value,
+ snapshot,
+ rootPath: root.path,
+ diagnostics: capturedOutput,
+ options,
+ }),
+ );
+ } catch (cause: unknown) {
+ const evidence = await readPwntoolsFailureEvidence(cause, root?.path);
+ result = err(
+ options?.signal?.aborted &&
+ (cause === options.signal.reason ||
+ (cause instanceof Error && cause.name === "AbortError"))
+ ? new AnalysisCancelledError(OPERATION)
+ : pwntoolsDecoderFailure(
+ cause,
+ phase,
+ selectedPath,
+ executablePath,
+ evidence,
+ context,
+ ),
+ );
+ }
+ if (root !== undefined) {
+ try {
+ await root.close();
+ } catch (cause: unknown) {
+ return err(
+ new ProviderCleanupError(
+ this.identity.id,
+ [root.path],
+ {
+ reason: cause instanceof Error ? cause.message : String(cause),
+ previous_error: result.ok
+ ? null
+ : projectAnalysisError(result.error),
+ ...(result.ok
+ ? {
+ captured_output: {
+ stdout: result.value.diagnostics.stdout,
+ stderr: result.value.diagnostics.stderr,
+ truncated: result.value.diagnostics.truncated,
+ },
+ }
+ : {}),
+ },
+ { operation: OPERATION },
+ ),
+ );
+ }
+ }
+ return result.ok && options?.signal?.aborted
+ ? err(
+ new AnalysisCancelledError(OPERATION, {
+ capturedOutput: {
+ stdout: result.value.diagnostics.stdout,
+ stderr: result.value.diagnostics.stderr,
+ truncated: result.value.diagnostics.truncated,
+ },
+ }),
+ )
+ : result;
+ }
+}
diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts
index 5e595f6de..f5e1b2654 100644
--- a/src/native/pwntools/PwntoolsFailures.ts
+++ b/src/native/pwntools/PwntoolsFailures.ts
@@ -25,33 +25,58 @@ import {
PWNTOOLS_FILE_SIZE_FAILURE_EXIT,
} from "./PwntoolsRelease.js";
-const OPERATION = "inspect_binary_layout";
+/** Operation identity supplied by each adapter sharing the pwntools boundary. */
+export interface PwntoolsFailureContext {
+ readonly operation: string;
+ readonly providerId: string;
+ readonly precedingOutput?: AnalysisCapturedOutput;
+}
+const DEFAULT_CONTEXT = {
+ operation: "inspect_binary_layout",
+ providerId: PWNTOOLS_PROVIDER_IDENTITY.id,
+};
/** Keep read, engine, output and owned-lifecycle failure reasons distinct. */
-export const pwntoolsLayoutFailure = (
+export const pwntoolsDecoderFailure = (
cause: unknown,
phase: string,
path: string,
executablePath = path,
failureEvidence: PwntoolsFailureEvidence = {},
+ context: PwntoolsFailureContext = DEFAULT_CONTEXT,
): AnalysisError => {
const limitReport = failureEvidence.limits;
if (cause instanceof AnalysisError) return cause;
if (cause instanceof OwnedCommandFailure) {
- const outputOptions =
+ const currentOutput =
cause.snapshot === null
? undefined
- : { capturedOutput: capturedPwntoolsOutput(cause.snapshot) };
+ : capturedPwntoolsOutput(cause.snapshot);
+ const combinedOutput =
+ context.precedingOutput === undefined
+ ? currentOutput
+ : currentOutput === undefined
+ ? context.precedingOutput
+ : {
+ stdout: `[core decoder]\n${context.precedingOutput.stdout}\n[debugger]\n${currentOutput.stdout}`,
+ stderr: `[core decoder]\n${context.precedingOutput.stderr}\n[debugger]\n${currentOutput.stderr}`,
+ truncated:
+ context.precedingOutput.truncated || currentOutput.truncated,
+ };
+ const outputOptions =
+ combinedOutput === undefined
+ ? undefined
+ : { capturedOutput: combinedOutput };
if (cause.cleanupFailure !== null)
return new ProviderCleanupError(
- PWNTOOLS_PROVIDER_IDENTITY.id,
+ context.providerId,
cause.resources,
{
reason: cause.cleanupFailure,
- ...(cause.snapshot === null
+ ...(combinedOutput === undefined
? {}
: {
- captured_output: { ...capturedPwntoolsOutput(cause.snapshot) },
+ captured_output: { ...combinedOutput },
}),
previous_error: {
failure_kind: cause.reason,
@@ -62,18 +87,22 @@ export const pwntoolsLayoutFailure = (
stderr: cause.snapshot?.stderr.text ?? null,
},
},
- { operation: OPERATION, cause },
+ { operation: context.operation, cause },
);
if (cause.reason === "cancelled")
- return new AnalysisCancelledError(OPERATION, outputOptions);
+ return new AnalysisCancelledError(context.operation, outputOptions);
if (cause.reason === "timeout")
return new AnalysisTimeoutError(
- OPERATION,
+ context.operation,
PWNTOOLS_LIMITS.timeoutMs,
outputOptions,
);
if (cause.reason === "output-limit")
- return new AnalysisOutputError(OPERATION, cause.message, outputOptions);
+ return new AnalysisOutputError(
+ context.operation,
+ cause.message,
+ outputOptions,
+ );
if (
cause.reason === "process" &&
(cause.snapshot?.signal === "SIGXFSZ" ||
@@ -82,7 +111,7 @@ export const pwntoolsLayoutFailure = (
cause.snapshot.signal === null))
)
return new AnalysisResourceConstraintError(
- OPERATION,
+ context.operation,
"file-size",
(cause.snapshot.signal === "SIGXFSZ"
? "Owned Python terminated with SIGXFSZ; the exact signal cause is unknown."
@@ -95,7 +124,7 @@ export const pwntoolsLayoutFailure = (
);
if (cause.reason === "process" && cause.snapshot?.signal === "SIGXCPU")
return new AnalysisResourceConstraintError(
- OPERATION,
+ context.operation,
"cpu",
"Owned Python terminated with SIGXCPU; the exact signal cause is unknown." +
(limitReport?.failure === null || limitReport === undefined
@@ -111,7 +140,7 @@ export const pwntoolsLayoutFailure = (
cause.snapshot.signal === null
)
return new AnalysisResourceConstraintError(
- OPERATION,
+ context.operation,
"memory",
"The owned Python bridge reported a memory allocation failure without a structured reply; the exact allocation cause is unknown." +
(limitReport?.failure === null || limitReport === undefined
@@ -132,20 +161,27 @@ export const pwntoolsLayoutFailure = (
`Selected Python could not launch (${String(cause.code)}): ${executablePath}. Check the configured executable, its interpreter and host execute access.`,
executablePath,
String(cause.code),
+ undefined,
+ context,
);
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "integrity")
- return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
- cause,
- });
+ return new AnalysisArtifactChangedError(
+ context.operation,
+ path,
+ cause.message,
+ {
+ cause,
+ },
+ );
if (cause.reason === "cancelled")
- return new AnalysisCancelledError(OPERATION);
+ return new AnalysisCancelledError(context.operation);
if (
cause.reason === "limit" ||
cause.reason === "path" ||
cause.reason === "format"
)
- return new AnalysisInputError(OPERATION, { cause }, [
+ return new AnalysisInputError(context.operation, { cause }, [
{
path: ["path"],
reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
@@ -159,19 +195,26 @@ export const pwntoolsLayoutFailure = (
`Selected Python executable is unavailable (${String(cause.code)}): ${path}. Check the caller-selected path and execute access.`,
path,
String(cause.code),
+ undefined,
+ context,
);
if (
phase === "artifact-read" &&
(cause.code === "EACCES" || cause.code === "EPERM")
)
- return new AnalysisAccessDeniedError(OPERATION, path, cause.code, {
- cause,
- });
+ return new AnalysisAccessDeniedError(
+ context.operation,
+ path,
+ cause.code,
+ {
+ cause,
+ },
+ );
if (
phase === "artifact-read" &&
(cause.code === "ENOENT" || cause.code === "ENOTDIR")
)
- return new AnalysisInputError(OPERATION, { cause }, [
+ return new AnalysisInputError(context.operation, { cause }, [
{
path: ["path"],
reason: "invalid_value",
@@ -179,11 +222,14 @@ export const pwntoolsLayoutFailure = (
},
]);
}
- return new ProviderAdapterError(PWNTOOLS_PROVIDER_IDENTITY.id, OPERATION, {
+ return new ProviderAdapterError(context.providerId, context.operation, {
cause,
diagnostics: {
phase,
path,
+ ...(context.precedingOutput === undefined
+ ? {}
+ : { preceding_output: { ...context.precedingOutput } }),
reason: cause instanceof Error ? cause.message : String(cause),
...(failureEvidence.marker === undefined
? {}
@@ -214,16 +260,17 @@ export const pwntoolsUnavailable = (
path: string,
systemCode?: string,
capturedOutput?: AnalysisCapturedOutput,
+ context: PwntoolsFailureContext = DEFAULT_CONTEXT,
): ProviderSelectionError =>
new ProviderSelectionError({
...(capturedOutput === undefined ? {} : { capturedOutput }),
- operation: OPERATION,
+ operation: context.operation,
reason: "provider_unavailable",
- requestedProviderId: PWNTOOLS_PROVIDER_IDENTITY.id,
- candidateIds: [PWNTOOLS_PROVIDER_IDENTITY.id],
+ requestedProviderId: context.providerId,
+ candidateIds: [context.providerId],
rejections: [
{
- providerId: PWNTOOLS_PROVIDER_IDENTITY.id,
+ providerId: context.providerId,
code: "provider_unavailable",
reason,
diagnostics: {
diff --git a/src/native/pwntools/PwntoolsLayoutProvider.ts b/src/native/pwntools/PwntoolsLayoutProvider.ts
index 4e543d105..f9346e397 100644
--- a/src/native/pwntools/PwntoolsLayoutProvider.ts
+++ b/src/native/pwntools/PwntoolsLayoutProvider.ts
@@ -1,381 +1,74 @@
-import {
- pwntoolsLayoutFailure,
- pwntoolsUnavailable,
- capturedPwntoolsOutput,
-} from "./PwntoolsFailures.js";
-import { randomUUID } from "node:crypto";
-import { access, stat, writeFile } from "node:fs/promises";
-import { constants } from "node:fs";
-import { isAbsolute, join } from "node:path";
-import { fileURLToPath } from "node:url";
-import { z } from "zod";
-import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
-import type { BinaryLayoutPort } from "../../application/binaryDiagnostics/BinaryLayoutPort.js";
-import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
-import type { AnalysisError } from "../../domain/analysisErrorBase.js";
-import {
- AnalysisCancelledError,
- AnalysisCapabilityUnavailableError,
- AnalysisInputError,
- AnalysisOutputError,
- AnalysisResourceConstraintError,
-} from "../../domain/analysisErrorCore.js";
-import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
-import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
-import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
-import { err, ok, type Result } from "../../domain/result.js";
import {
binaryLayoutPayloadSchema,
binaryLayoutSchema,
type BinaryLayout,
type InspectBinaryLayoutInput,
} from "../../domain/native/binaryLayout.js";
-import { runOwnedCommand } from "../../process/OwnedCommand.js";
-import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
-import {
- pwntoolsResourceLimitsSchema,
- type PwntoolsLimitReport,
- type PwntoolsFailureEvidence,
-} from "./PwntoolsResourceLimits.js";
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
+import { PwntoolsDecoder, type PwntoolsLauncher } from "./PwntoolsDecoder.js";
+import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsRelease.js";
import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
-import {
- PWNTOOLS_PROVIDER_IDENTITY,
- PWNTOOLS_LIMITS,
- PWNTOOLS_FILE_SIZE_FAILURE_EXIT,
- PWNTOOLS_MEMORY_FAILURE_EXIT,
-} from "./PwntoolsRelease.js";
+import type { BinaryLayoutPort } from "../../application/binaryDiagnostics/BinaryLayoutPort.js";
+import type { z } from "zod";
-const OPERATION = "inspect_binary_layout";
-const decoded = binaryLayoutPayloadSchema;
-const replySchema = z.discriminatedUnion("ok", [
- z.strictObject({
- ok: z.literal(true),
- profile: z.literal(PWNTOOLS_PROVIDER_IDENTITY.version),
- value: decoded,
- }),
- z.strictObject({
- ok: z.literal(false),
- reason: z.enum([
- "format",
- "unsupported",
- "unavailable",
- "resource-limit",
- "output-limit",
- "decoder",
- ]),
- message: z.string(),
- reported_limits: pwntoolsResourceLimitsSchema.nullable().optional(),
- }),
-]);
-
-type Launcher = NonNullable[2]>["launcher"];
-
-/** Thin owned-process adapter; all ELF parsing and heuristics come from unchanged upstream code. */
-export class PwntoolsLayoutProvider implements BinaryLayoutPort {
- readonly identity = PWNTOOLS_PROVIDER_IDENTITY;
+/** ELF-specific projection around the shared owned pwntools decoder boundary. */
+export class PwntoolsLayoutProvider
+ extends PwntoolsDecoder<
+ InspectBinaryLayoutInput,
+ z.output,
+ BinaryLayout
+ >
+ implements BinaryLayoutPort
+{
constructor(
- readonly environment: Readonly,
- readonly launcher?: Launcher,
- readonly createRuntime: () => Promise<
+ environment: Readonly,
+ launcher?: PwntoolsLauncher,
+ createRuntime: () => Promise<
Pick
> = () => PrivateRuntimeRoot.create({ prefix: "rea-elf-layout-" }),
- ) {}
-
- /** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */
- async inspect(
- input: InspectBinaryLayoutInput,
- options?: ExecutionOptions,
- ): Promise> {
- let root: Pick | undefined;
- let result: Result;
- let phase: "configuration" | "artifact-read" | "decoder" = "configuration";
- const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? "";
- let selectedPath = executablePath;
- try {
- if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
- if (process.platform !== "linux" || process.arch !== "x64")
- throw new AnalysisCapabilityUnavailableError(
- this.identity.id,
- OPERATION,
- "The real-verified pwntools layout profile currently supports Linux x64 only.",
- );
- if (!isAbsolute(selectedPath))
- throw pwntoolsUnavailable(
- "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.",
- selectedPath,
- );
- await access(selectedPath, constants.X_OK);
- if (!(await stat(selectedPath)).isFile())
- throw pwntoolsUnavailable(
- "Selected Python executable must be a regular executable file, not a directory: " +
- selectedPath,
- selectedPath,
- );
- phase = "artifact-read";
- selectedPath = input.path;
- const snapshot = await readStableArtifact(
- input.path,
- PWNTOOLS_LIMITS.inputBytes,
- options?.signal,
- );
- phase = "decoder";
- root = await this.createRuntime();
- const requestPath = join(root.path, "request.json");
- const snapshotPath = join(root.path, "object.snapshot");
- const replyPath = join(root.path, "reply.json");
- await writeFile(snapshotPath, snapshot.bytes, {
- flag: "wx",
- mode: 0o600,
- ...(options?.signal === undefined ? {} : { signal: options.signal }),
- });
- await writeFile(
- requestPath,
- JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }),
- { flag: "wx", mode: 0o600 },
- );
- const execution = await runOwnedCommand(
- {
- command: this.environment.REA_PWNTOOLS_PYTHON ?? "",
- arguments: [
- "-I",
- fileURLToPath(
- new URL("../../../bridge/pwntools/layout.py", import.meta.url),
- ),
- join(root.path, "resource.failure"),
- requestPath,
- ],
- cwd: root.path,
- runId: `rea-elf-layout-${randomUUID()}`,
- hostEnvironment: {
- ...this.environment,
- PWNLIB_NOTERM: "1",
- OPENBLAS_NUM_THREADS: "1",
- OMP_NUM_THREADS: "1",
- },
- },
- {
- timeoutMs: PWNTOOLS_LIMITS.timeoutMs,
- diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes,
- },
- {
- ...(options?.signal === undefined ? {} : { signal: options.signal }),
- ...(this.launcher === undefined ? {} : { launcher: this.launcher }),
- },
- );
- const capturedOutput = capturedPwntoolsOutput(execution);
- let reply: z.output;
- try {
- const file = await readStableArtifact(
- replyPath,
- PWNTOOLS_LIMITS.outputBytes,
- options?.signal,
- );
- reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
- } catch (cause: unknown) {
- if (options?.signal?.aborted)
- throw new AnalysisCancelledError(OPERATION, { capturedOutput });
- throw new AnalysisOutputError(
- OPERATION,
- `Owned ELF decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`,
- { cause, capturedOutput },
- );
- }
- if (!reply.ok) {
- if (reply.reason === "resource-limit")
- throw new AnalysisResourceConstraintError(
- OPERATION,
- "memory",
- reply.message,
- reply.reported_limits ?? null,
- { capturedOutput },
- );
- if (reply.reason === "format")
- throw new AnalysisInputError(OPERATION, { capturedOutput }, [
- {
- path: ["path"],
- reason: "invalid_format",
- message: reply.message,
+ ) {
+ super(
+ {
+ identity: PWNTOOLS_PROVIDER_IDENTITY,
+ operation: "inspect_binary_layout",
+ bridge: new URL("../../../bridge/pwntools/layout.py", import.meta.url),
+ payloadSchema: binaryLayoutPayloadSchema,
+ project: ({ input, payload, snapshot, diagnostics }) => {
+ const value = binaryLayoutSchema.safeParse({
+ ...payload,
+ artifact: {
+ path: input.path,
+ sha256: snapshot.sha256,
+ bytes: snapshot.bytes.length,
},
- ]);
- if (reply.reason === "unavailable")
- throw pwntoolsUnavailable(
- reply.message,
- this.environment.REA_PWNTOOLS_PYTHON ?? "",
- undefined,
- capturedOutput,
- );
- if (reply.reason === "unsupported")
- throw new AnalysisCapabilityUnavailableError(
- this.identity.id,
- OPERATION,
- reply.message,
- { userMessage: reply.message, capturedOutput },
- );
- if (reply.reason === "output-limit")
- throw new AnalysisOutputError(OPERATION, reply.message, {
- capturedOutput,
+ diagnostics,
});
- throw new ProviderAdapterError(this.identity.id, OPERATION, {
- diagnostics: {
- phase,
- failure_kind: reply.reason,
- reason: reply.message,
- stdout: execution.stdout.text,
- stderr: execution.stderr.text,
- captured_output: { ...capturedOutput },
- },
- });
- }
- const validated = binaryLayoutSchema.safeParse({
- ...reply.value,
- artifact: {
- path: input.path,
- sha256: snapshot.sha256,
- bytes: snapshot.bytes.length,
- },
- diagnostics: {
- stdout: execution.stdout.text,
- stderr: execution.stderr.text,
- truncated: capturedOutput.truncated,
- },
- });
- if (!validated.success)
- throw new AnalysisOutputError(
- OPERATION,
- `ELF reply contains invalid source ranges or value meanings: ${validated.error.issues[0]?.message ?? "schema mismatch"}`,
- { capturedOutput },
- );
- for (const table of validated.data.packed_relative_relocations) {
- const start = Number(BigInt(table.location.offset));
- const length = Number(BigInt(table.location.bytes));
- if (
- !Buffer.from(table.encoded_bytes_base64, "base64").equals(
- snapshot.bytes.subarray(start, start + length),
- )
- )
- throw new AnalysisOutputError(
- OPERATION,
- "Reported packed relocation bytes differ from the selected snapshot at their original file range.",
- { capturedOutput },
- );
- }
- result = ok(validated.data);
- } catch (cause: unknown) {
- let limitReport: PwntoolsLimitReport | undefined;
- let marker: PwntoolsFailureEvidence["marker"];
- if (
- cause instanceof OwnedCommandFailure &&
- cause.snapshot?.signal === null &&
- (cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT ||
- cause.snapshot.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT) &&
- root !== undefined
- ) {
- try {
- const reported = await readStableArtifact(
- join(root.path, "resource.failure"),
- 1,
- );
- const expected =
- cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT
- ? "M"
- : "F";
- if (!reported.bytes.equals(Buffer.from(expected, "ascii")))
- throw new Error(
- "Private bridge failure marker does not match the observed exit status.",
+ if (!value.success)
+ throw new AnalysisOutputError(
+ "inspect_binary_layout",
+ `ELF reply contains invalid source ranges or value meanings: ${value.error.issues[0]?.message ?? "schema mismatch"}`,
+ { capturedOutput: diagnostics },
);
- marker = {
- resource: expected === "M" ? "memory" : "file-size",
- failure: null,
- };
- } catch (markerFailure: unknown) {
- marker = {
- resource: null,
- failure:
- markerFailure instanceof Error
- ? markerFailure.message
- : String(markerFailure),
- };
- }
- }
- if (
- cause instanceof OwnedCommandFailure &&
- (cause.snapshot?.signal === "SIGXCPU" ||
- cause.snapshot?.signal === "SIGXFSZ" ||
- ((cause.snapshot?.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT ||
- cause.snapshot?.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT) &&
- cause.snapshot.signal === null)) &&
- root !== undefined
- ) {
- try {
- const reported = await readStableArtifact(
- join(root.path, "limits.json"),
- 4096,
- );
- limitReport = {
- limits: pwntoolsResourceLimitsSchema.parse(
- JSON.parse(reported.bytes.toString("utf8")),
- ),
- failure: null,
- };
- } catch (reportFailure: unknown) {
- limitReport = {
- limits: null,
- failure:
- reportFailure instanceof Error
- ? reportFailure.message
- : String(reportFailure),
- };
- }
- }
- result = err(
- options?.signal?.aborted &&
- (cause === options.signal.reason ||
- (cause instanceof Error && cause.name === "AbortError"))
- ? new AnalysisCancelledError(OPERATION)
- : pwntoolsLayoutFailure(cause, phase, selectedPath, executablePath, {
- ...(limitReport === undefined ? {} : { limits: limitReport }),
- ...(marker === undefined ? {} : { marker }),
- }),
- );
- }
- if (root !== undefined) {
- try {
- await root.close();
- } catch (cause: unknown) {
- return err(
- new ProviderCleanupError(
- this.identity.id,
- [root.path],
- {
- reason: cause instanceof Error ? cause.message : String(cause),
- previous_error: result.ok
- ? null
- : projectAnalysisError(result.error),
- ...(result.ok
- ? {
- captured_output: {
- stdout: result.value.diagnostics.stdout,
- stderr: result.value.diagnostics.stderr,
- truncated: result.value.diagnostics.truncated,
- },
- }
- : {}),
- },
- { operation: OPERATION },
- ),
- );
- }
- }
- return result.ok && options?.signal?.aborted
- ? err(
- new AnalysisCancelledError(OPERATION, {
- capturedOutput: {
- stdout: result.value.diagnostics.stdout,
- stderr: result.value.diagnostics.stderr,
- truncated: result.value.diagnostics.truncated,
- },
- }),
- )
- : result;
+ for (const table of value.data.packed_relative_relocations) {
+ const start = Number(BigInt(table.location.offset));
+ const length = Number(BigInt(table.location.bytes));
+ if (
+ !Buffer.from(table.encoded_bytes_base64, "base64").equals(
+ snapshot.bytes.subarray(start, start + length),
+ )
+ )
+ throw new AnalysisOutputError(
+ "inspect_binary_layout",
+ "Reported packed relocation bytes differ from the selected snapshot at their original file range.",
+ { capturedOutput: diagnostics },
+ );
+ }
+ return value.data;
+ },
+ },
+ environment,
+ launcher,
+ createRuntime,
+ );
}
}
diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts
new file mode 100644
index 000000000..e0d1ea8d2
--- /dev/null
+++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts
@@ -0,0 +1,144 @@
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
+import {
+ recordedCrashPayloadSchema,
+ recordedCrashSchema,
+ type InspectRecordedCrashInput,
+ type RecordedCrash,
+} from "../../domain/native/recordedCrash.js";
+import type { RecordedCrashPort } from "../../application/binaryDiagnostics/RecordedCrashPort.js";
+import { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js";
+import { PwntoolsDecoder, type PwntoolsLauncher } from "./PwntoolsDecoder.js";
+import { PWNTOOLS_PROVIDER_IDENTITY } from "./PwntoolsRelease.js";
+import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js";
+import { validateRecordedCrashStructure } from "./RecordedCrashStructureBindings.js";
+import {
+ inspectPwndbgCore,
+ recordedCrashStageOutput,
+} from "../pwndbg/PwndbgCoreContext.js";
+import type { z } from "zod";
+
+/** Preserve every recorded thread while optionally enriching the same owned snapshot. */
+export class PwntoolsRecordedCrashProvider
+ extends PwntoolsDecoder<
+ InspectRecordedCrashInput,
+ z.output,
+ RecordedCrash
+ >
+ implements RecordedCrashPort
+{
+ constructor(
+ environment: Readonly,
+ launcher?: PwntoolsLauncher,
+ createRuntime: () => Promise<
+ Pick
+ > = () => PrivateRuntimeRoot.create({ prefix: "rea-recorded-crash-" }),
+ ) {
+ super(
+ {
+ identity: PWNTOOLS_PROVIDER_IDENTITY,
+ operation: "inspect_recorded_crash",
+ bridge: new URL(
+ "../../../bridge/pwntools/recorded_crash.py",
+ import.meta.url,
+ ),
+ payloadSchema: recordedCrashPayloadSchema,
+ project: async ({
+ input,
+ payload,
+ snapshot,
+ rootPath,
+ diagnostics,
+ options,
+ }) => {
+ const validate = (debuggerContext: RecordedCrash["debugger"]) =>
+ recordedCrashSchema.safeParse({
+ ...payload,
+ artifact: {
+ path: input.path,
+ sha256: snapshot.sha256,
+ bytes: snapshot.bytes.length,
+ },
+ debugger: debuggerContext,
+ decoder_diagnostics: diagnostics,
+ diagnostics:
+ debuggerContext.status === "available"
+ ? recordedCrashStageOutput(
+ diagnostics,
+ debuggerContext.diagnostics,
+ )
+ : diagnostics,
+ });
+ const checked = validate({ status: "not-requested" });
+ if (!checked.success)
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ `Recorded core reply is invalid: ${checked.error.issues[0]?.message ?? "schema mismatch"}`,
+ { capturedOutput: diagnostics },
+ );
+ const value = checked.data;
+ validateRecordedCrashStructure(value, snapshot.bytes, diagnostics);
+ const checkBytes = (
+ raw: string,
+ range: { readonly offset: string; readonly bytes: string },
+ ) => {
+ const start = Number(BigInt(range.offset));
+ const length = Number(BigInt(range.bytes));
+ if (
+ !Buffer.from(raw, "base64").equals(
+ snapshot.bytes.subarray(start, start + length),
+ )
+ )
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ "Reported raw note bytes differ from the selected snapshot.",
+ { capturedOutput: diagnostics },
+ );
+ };
+ for (const note of value.notes) {
+ checkBytes(note.owner_bytes_base64, note.owner_location);
+ checkBytes(note.descriptor_bytes_base64, note.descriptor_location);
+ }
+ for (const padding of value.note_padding)
+ checkBytes(padding.bytes_base64, padding.location);
+ validateRecordedCrashSources(value, snapshot.bytes, diagnostics);
+ for (const thread of value.threads)
+ for (const register of thread.registers) {
+ const start = Number(BigInt(register.location.offset));
+ if (
+ snapshot.bytes.readBigUInt64LE(start) !== BigInt(register.value)
+ )
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ "Reported register value differs from its recorded source bytes.",
+ { capturedOutput: diagnostics },
+ );
+ }
+ if (!input.include_debugger_context) return value;
+ const context = await inspectPwndbgCore({
+ environment,
+ rootPath,
+ diagnostics,
+ options,
+ ...(launcher === undefined ? {} : { launcher }),
+ });
+ const enriched = validate(context);
+ if (!enriched.success)
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ `Recorded debugger context is invalid: ${enriched.error.issues[0]?.message ?? "schema mismatch"}`,
+ {
+ capturedOutput:
+ context.status === "available"
+ ? recordedCrashStageOutput(diagnostics, context.diagnostics)
+ : diagnostics,
+ },
+ );
+ return enriched.data;
+ },
+ },
+ environment,
+ launcher,
+ createRuntime,
+ );
+ }
+}
diff --git a/src/native/pwntools/PwntoolsResourceLimits.ts b/src/native/pwntools/PwntoolsResourceLimits.ts
index ad00d92b4..dcbe91d5a 100644
--- a/src/native/pwntools/PwntoolsResourceLimits.ts
+++ b/src/native/pwntools/PwntoolsResourceLimits.ts
@@ -1,3 +1,10 @@
+import { join } from "node:path";
+import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
+import { OwnedCommandFailure } from "../../process/OwnedCommand.js";
+import {
+ PWNTOOLS_MEMORY_FAILURE_EXIT,
+ PWNTOOLS_FILE_SIZE_FAILURE_EXIT,
+} from "./PwntoolsRelease.js";
import { z } from "zod";
import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js";
@@ -30,3 +37,78 @@ export interface PwntoolsFailureEvidence {
readonly failure: string | null;
};
}
+
+/** Read only bounded owned reports; unverified reserved statuses stay unclassified. */
+export const readPwntoolsFailureEvidence = async (
+ cause: unknown,
+ rootPath?: string,
+): Promise => {
+ let limitReport: PwntoolsLimitReport | undefined;
+ let marker: PwntoolsFailureEvidence["marker"];
+ if (
+ cause instanceof OwnedCommandFailure &&
+ cause.snapshot?.signal === null &&
+ (cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT ||
+ cause.snapshot.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT) &&
+ rootPath !== undefined
+ ) {
+ try {
+ const reported = await readStableArtifact(
+ join(rootPath, "resource.failure"),
+ 1,
+ );
+ const expected =
+ cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT ? "M" : "F";
+ if (!reported.bytes.equals(Buffer.from(expected, "ascii")))
+ throw new Error(
+ "Private bridge failure marker does not match the observed exit status.",
+ );
+ marker = {
+ resource: expected === "M" ? "memory" : "file-size",
+ failure: null,
+ };
+ } catch (markerFailure: unknown) {
+ marker = {
+ resource: null,
+ failure:
+ markerFailure instanceof Error
+ ? markerFailure.message
+ : String(markerFailure),
+ };
+ }
+ }
+ if (
+ cause instanceof OwnedCommandFailure &&
+ (cause.snapshot?.signal === "SIGXCPU" ||
+ cause.snapshot?.signal === "SIGXFSZ" ||
+ ((cause.snapshot?.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT ||
+ cause.snapshot?.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT) &&
+ cause.snapshot.signal === null)) &&
+ rootPath !== undefined
+ ) {
+ try {
+ const reported = await readStableArtifact(
+ join(rootPath, "limits.json"),
+ 4096,
+ );
+ limitReport = {
+ limits: pwntoolsResourceLimitsSchema.parse(
+ JSON.parse(reported.bytes.toString("utf8")),
+ ),
+ failure: null,
+ };
+ } catch (reportFailure: unknown) {
+ limitReport = {
+ limits: null,
+ failure:
+ reportFailure instanceof Error
+ ? reportFailure.message
+ : String(reportFailure),
+ };
+ }
+ }
+ return {
+ ...(limitReport === undefined ? {} : { limits: limitReport }),
+ ...(marker === undefined ? {} : { marker }),
+ };
+};
diff --git a/src/native/pwntools/RecordedCrashSourceBindings.ts b/src/native/pwntools/RecordedCrashSourceBindings.ts
new file mode 100644
index 000000000..4de2555d2
--- /dev/null
+++ b/src/native/pwntools/RecordedCrashSourceBindings.ts
@@ -0,0 +1,68 @@
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
+import {
+ RECORDED_LINUX_AMD64_REGISTERS,
+ type RecordedCrash,
+} from "../../domain/native/recordedCrash.js";
+
+const registerOffsets = new Map(
+ RECORDED_LINUX_AMD64_REGISTERS.map((name, index) => [name, 112 + index * 8]),
+);
+
+/** Bind interpreted values and register identities to Linux amd64 source bytes. */
+export function validateRecordedCrashSources(
+ report: RecordedCrash,
+ snapshot: Buffer,
+ diagnostics: RecordedCrash["decoder_diagnostics"],
+): void {
+ const fail = () => {
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ "Reported PID or signal value differs from its recorded Linux amd64 source bytes.",
+ { capturedOutput: diagnostics },
+ );
+ };
+ const descriptor = (noteIndex: number, minimumBytes: number): Buffer => {
+ const note = report.notes[noteIndex];
+ if (note === undefined) return fail();
+ const start = Number(BigInt(note.descriptor_location.offset));
+ const length = Number(BigInt(note.descriptor_location.bytes));
+ if (length < minimumBytes) return fail();
+ return snapshot.subarray(start, start + length);
+ };
+ // Fixed offsets independently checked against unchanged pwntools 4.15.0
+ // elf_prstatus_amd64 / elf_siginfo_64; this is the sole supported note ABI.
+ for (const thread of report.threads) {
+ const bytes = descriptor(thread.note_index, 336);
+ if (
+ thread.historical_pid !== bytes.readInt32LE(32) ||
+ thread.recorded_current_signal !== bytes.readInt16LE(12)
+ )
+ fail();
+ const note = report.notes[thread.note_index];
+ if (note === undefined) return fail();
+ for (const register of thread.registers) {
+ const offset = registerOffsets.get(register.name);
+ if (
+ offset === undefined ||
+ BigInt(register.location.offset) !==
+ BigInt(note.descriptor_location.offset) + BigInt(offset)
+ )
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ "Reported register source does not match its Linux amd64 register identity.",
+ { capturedOutput: diagnostics },
+ );
+ }
+ }
+ for (const signal of report.signals) {
+ const bytes = descriptor(signal.note_index, 32);
+ if (
+ signal.number !== bytes.readInt32LE(0) ||
+ signal.errno !== bytes.readInt32LE(4) ||
+ signal.code !== bytes.readInt32LE(8) ||
+ (signal.fault_address !== null &&
+ BigInt(signal.fault_address) !== bytes.readBigUInt64LE(16))
+ )
+ fail();
+ }
+}
diff --git a/src/native/pwntools/RecordedCrashStructureBindings.ts b/src/native/pwntools/RecordedCrashStructureBindings.ts
new file mode 100644
index 000000000..4f80ef60d
--- /dev/null
+++ b/src/native/pwntools/RecordedCrashStructureBindings.ts
@@ -0,0 +1,133 @@
+import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
+import type { RecordedCrash } from "../../domain/native/recordedCrash.js";
+
+// Exact amd64/core enum projections in the pinned pyelftools 0.33 profile.
+const segmentKinds = new Map([
+ [0, "PT_NULL"],
+ [1, "PT_LOAD"],
+ [2, "PT_DYNAMIC"],
+ [3, "PT_INTERP"],
+ [4, "PT_NOTE"],
+ [5, "PT_SHLIB"],
+ [6, "PT_PHDR"],
+ [7, "PT_TLS"],
+ [0x60000000, "PT_LOOS"],
+ [0x6fffffff, "PT_HIOS"],
+ [0x6474e550, "PT_GNU_EH_FRAME"],
+ [0x6474e551, "PT_GNU_STACK"],
+ [0x6474e552, "PT_GNU_RELRO"],
+ [0x6474e553, "PT_GNU_PROPERTY"],
+]);
+const noteKinds = new Map([
+ [1, "NT_PRSTATUS"],
+ [2, "NT_FPREGSET"],
+ [3, "NT_PRPSINFO"],
+ [4, "NT_TASKSTRUCT"],
+ [6, "NT_AUXV"],
+ [0x53494749, "NT_SIGINFO"],
+ [0x46494c45, "NT_FILE"],
+]);
+const matchesKind = (
+ raw: number,
+ reported: string | number,
+ kinds: ReadonlyMap,
+): boolean => {
+ const known = kinds.get(raw);
+ return typeof reported === "string"
+ ? reported === known
+ : known === undefined && reported === raw;
+};
+
+const segmentMatches = (
+ segment: RecordedCrash["segments"][number],
+ raw: Buffer,
+): boolean => {
+ const type = raw.readUInt32LE(0);
+ return (
+ matchesKind(type, segment.type, segmentKinds) &&
+ BigInt(segment.flags) === BigInt(raw.readUInt32LE(4)) &&
+ segment.file_backing ===
+ (type === 0 || raw.readBigUInt64LE(32) === 0n ? "none" : "file") &&
+ (
+ [
+ [segment.offset, 8],
+ [segment.virtual_address, 16],
+ [segment.physical_address, 24],
+ [segment.file_size, 32],
+ [segment.memory_size, 40],
+ [segment.alignment, 48],
+ ] as const
+ ).every(
+ ([field, relative]) => BigInt(field) === raw.readBigUInt64LE(relative),
+ )
+ );
+};
+
+/** Bind coverage-bearing ELF and note headers to the same snapshot as their payloads. */
+export function validateRecordedCrashStructure(
+ report: RecordedCrash,
+ snapshot: Buffer,
+ diagnostics: RecordedCrash["decoder_diagnostics"],
+): void {
+ const fail = () => {
+ throw new AnalysisOutputError(
+ "inspect_recorded_crash",
+ "Reported core structure differs from its original ELF/note headers.",
+ { capturedOutput: diagnostics },
+ );
+ };
+ const bytesAt = (offset: bigint, bytes: number): Buffer => {
+ if (offset < 0n || offset > BigInt(snapshot.length - bytes)) return fail();
+ return snapshot.subarray(Number(offset), Number(offset) + bytes);
+ };
+ const header = bytesAt(0n, 64);
+ if (
+ !header
+ .subarray(0, 6)
+ .equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46, 2, 1])) ||
+ ![0, 3].includes(header[7] ?? -1) ||
+ header[8] !== 0 ||
+ header.readUInt16LE(16) !== 4 ||
+ header.readUInt16LE(18) !== 62
+ )
+ fail();
+ const table = header.readBigUInt64LE(32);
+ const entryBytes = header.readUInt16LE(54);
+ let count = header.readUInt16LE(56);
+ if (count === 0xffff) {
+ if (header.readUInt16LE(58) < 64) return fail();
+ count = bytesAt(header.readBigUInt64LE(40), 64).readUInt32LE(44);
+ }
+ if (count !== report.segments.length || (count > 0 && entryBytes < 56))
+ fail();
+ for (const segment of report.segments) {
+ const offset = table + BigInt(segment.index) * BigInt(entryBytes);
+ if (
+ BigInt(segment.header_location.offset) !== offset ||
+ BigInt(segment.header_location.bytes) !== BigInt(entryBytes)
+ )
+ fail();
+ const raw = bytesAt(offset, entryBytes);
+ if (!segmentMatches(segment, raw)) fail();
+ }
+ for (const note of report.notes) {
+ const raw = bytesAt(BigInt(note.location.offset), 12);
+ if (
+ BigInt(note.owner_location.bytes) !== BigInt(raw.readUInt32LE(0)) ||
+ BigInt(note.descriptor_location.bytes) !== BigInt(raw.readUInt32LE(4)) ||
+ !matchesKind(raw.readUInt32LE(8), note.type, noteKinds)
+ )
+ fail();
+ const owner = bytesAt(
+ BigInt(note.owner_location.offset),
+ raw.readUInt32LE(0),
+ );
+ const terminator = owner.indexOf(0);
+ const display =
+ owner.length === 0
+ ? null
+ : owner.subarray(0, terminator).toString("utf8");
+ if ((owner.length > 0 && terminator < 0) || note.owner_display !== display)
+ fail();
+ }
+}
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index e0291e09f..d011e5ac8 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -1,6 +1,9 @@
import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
import { createEvmInterfaceService } from "../composition/evm.js";
import { registerEvmTools } from "./registerEvmTools.js";
+import { registerRecordedCrashTools } from "./registerRecordedCrashTools.js";
+import { createRecordedCrashService } from "../composition/binaryDiagnostics.js";
+import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
@@ -68,6 +71,7 @@ export interface CreateServerOptions {
readonly evmInterface?: EvmInterfaceService;
readonly logger?: Logger;
readonly binaryLayout?: BinaryLayoutService;
+ readonly recordedCrash?: RecordedCrashService;
readonly firmwareAnalysis?: FirmwareAnalysisPort;
readonly javascriptRecovery?: JavaScriptRecoveryPort;
readonly webModuleTrace?: WebModuleTraceService;
@@ -99,6 +103,11 @@ const installSessionToolAvailability = (
webModuleResolutionEnabled:
options.webModuleTrace !== undefined ||
isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""),
+ recordedCrashEnabled:
+ options.recordedCrash !== undefined ||
+ (process.platform === "linux" &&
+ process.arch === "x64" &&
+ isAbsolute(process.env.REA_PWNTOOLS_PYTHON ?? "")),
binaryLayoutEnabled:
options.binaryLayout !== undefined ||
(process.platform === "linux" &&
@@ -218,6 +227,12 @@ export const createServer = (
toolLogger,
recordEvidence,
);
+ registerRecordedCrashTools(
+ server,
+ options.recordedCrash ?? createRecordedCrashService(),
+ toolLogger,
+ recordEvidence,
+ );
registerFirmwareTools(
server,
new FirmwareAnalysisService(
diff --git a/src/server/registerRecordedCrashTools.ts b/src/server/registerRecordedCrashTools.ts
new file mode 100644
index 000000000..0381689a0
--- /dev/null
+++ b/src/server/registerRecordedCrashTools.ts
@@ -0,0 +1,32 @@
+import type { McpServer } from "@modelcontextprotocol/server";
+import type { RecordedCrashService } from "../application/binaryDiagnostics/RecordedCrashService.js";
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
+import { toolContract } from "../contracts/toolContracts.js";
+import type { Logger } from "../logger.js";
+import { logToolExecution } from "./toolLogging.js";
+import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
+import { toCallToolResult } from "./toolResult.js";
+
+/** Bind recorded crash inspection to its named contract and session Evidence owner. */
+export const registerRecordedCrashTools = (
+ server: McpServer,
+ service: RecordedCrashService,
+ logger: Logger,
+ recordEvidence?: EvidenceWriter["recordEvidence"],
+): void => {
+ const contract = toolContract("inspect_recorded_crash");
+ server.registerTool(
+ contract.name,
+ toolRegistrationOptions(contract),
+ async (input, context) => {
+ const result = await logToolExecution(logger, contract.name, () =>
+ service.inspect(input, { signal: context.mcpReq.signal }),
+ );
+ if (!result.ok) return toCallToolResult(result, contract);
+ const recorded = recordEvidence?.(result.value);
+ return recorded !== undefined && !recorded.ok
+ ? toCallToolResult(recorded, contract)
+ : toCallToolResult(result, contract);
+ },
+ );
+};
diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts
index 733929647..829102d64 100644
--- a/src/server/sessionAvailabilityPolicy.ts
+++ b/src/server/sessionAvailabilityPolicy.ts
@@ -20,6 +20,7 @@ export interface SessionAvailabilityDefaults {
| "javascriptRecoveryEnabled"
| "webModuleResolutionEnabled"
| "binaryLayoutEnabled"
+ | "recordedCrashEnabled"
| "firmwareInspectionEnabled"
| "firmwareExtractionEnabled"
>;
@@ -36,6 +37,8 @@ export const sessionAvailabilityPolicy = (
evmInterfaceEnabled:
defaults.optionalFeatures?.evmInterfaceEnabled ?? false,
processCaptureEnabled: platform !== "win32",
+ recordedCrashEnabled:
+ defaults.optionalFeatures?.recordedCrashEnabled ?? false,
binaryLayoutEnabled:
defaults.optionalFeatures?.binaryLayoutEnabled ?? false,
firmwareInspectionEnabled:
diff --git a/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts
new file mode 100644
index 000000000..5b3ca3b7b
--- /dev/null
+++ b/tests/boundary/binaryDiagnostics/recordedCrashProvider.test.ts
@@ -0,0 +1,408 @@
+import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
+import { access, mkdir, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it, onTestFinished } from "vitest";
+import { z } from "zod";
+import {
+ createTestWorkspace,
+ removeTestWorkspace,
+} from "../../support/workspace/workspaceFixture.js";
+import { PwntoolsRecordedCrashProvider } from "../../../src/native/pwntools/PwntoolsRecordedCrashProvider.js";
+import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js";
+import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
+import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
+import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
+import {
+ recordedCrashFixture,
+ recordedCrashDebuggerFixture,
+ recordedCrashFixtureBytes,
+ recordedCrashSignalFixture,
+} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
+
+const requestSchema = z.object({
+ snapshot_path: z.string(),
+ reply_path: z.string(),
+});
+const host = process.platform === "linux" && process.arch === "x64";
+
+it
+ .runIf(host)
+ .each([
+ "success",
+ "wrong-register",
+ "wrong-register-source",
+ "missing-thread",
+ "duplicate-thread",
+ "missing-signal",
+ "duplicate-signal",
+ "missing-register",
+ "duplicated-physical-thread",
+ "duplicated-physical-signal",
+ "omitted-physical-note",
+ "hidden-segment",
+ "wrong-header-range",
+ "wrong-note-kind",
+ "wrong-owner-display",
+ "wrong-pid",
+ "wrong-current-signal",
+ "wrong-signal-number",
+ "wrong-signal-code",
+ "wrong-signal-errno",
+ "wrong-signal-address",
+ "wrong-raw-note",
+ "malformed-range",
+ ] as const)(
+ "validates recorded source bytes and cleanup: %s",
+ async (scenario) => {
+ const { path, value, bytes } = await fixture(
+ true,
+ scenario === "wrong-owner-display",
+ );
+ let owned = "";
+ const provider = new PwntoolsRecordedCrashProvider(
+ { REA_PWNTOOLS_PYTHON: process.execPath },
+ async (spawn) => {
+ owned = spawn.cwd ?? "";
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")),
+ );
+ expect(await readFile(request.snapshot_path)).toEqual(bytes);
+ const payload = structuredClone(value);
+ alterRecordedCrashReply(payload, scenario);
+ const {
+ artifact: _artifact,
+ diagnostics: _diagnostics,
+ debugger: _debugger,
+ decoder_diagnostics: _decoderDiagnostics,
+ ...raw
+ } = payload;
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ profile: PWNTOOLS_PROVIDER_IDENTITY.version,
+ value: raw,
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", "process.stdout.write('recorded warning')"],
+ });
+ },
+ );
+ const result = await provider.inspect({
+ path,
+ include_debugger_context: false,
+ });
+ if (scenario === "success") {
+ if (!result.ok) throw result.error;
+ expect(result.value.threads).toEqual(value.threads);
+ expect(result.value.diagnostics.stdout).toBe("recorded warning");
+ } else
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisOutputError",
+ capturedOutput: { stdout: "recorded warning" },
+ },
+ });
+ expect(await readFile(path)).toEqual(bytes);
+ await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it
+ .runIf(host)
+ .each([
+ "unconfigured",
+ "success",
+ "process-failure",
+ "invalid-context",
+ "cancelled",
+ "cleanup-failure",
+ ] as const)(
+ "owns optional context from the same snapshot: %s",
+ async (scenario) => {
+ const { path, value, bytes, root } = await fixture();
+ const controller = new AbortController();
+ let owned = "";
+ let snapshotPath = "";
+ const venv = join(root, "venv");
+ const plugin = join(root, "gdbinit.py");
+ await mkdir(venv);
+ await writeFile(plugin, "");
+ const provider = new PwntoolsRecordedCrashProvider(
+ {
+ REA_PWNTOOLS_PYTHON: process.execPath,
+ ...(scenario === "unconfigured"
+ ? {}
+ : {
+ REA_PWNDBG_GDB: process.execPath,
+ REA_PWNDBG_GDBINIT: plugin,
+ REA_PWNDBG_VENV_PATH: venv,
+ }),
+ },
+ async (spawn) => {
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(spawn.arguments.at(-1) ?? "", "utf8")),
+ );
+ expect(await readFile(request.snapshot_path)).toEqual(bytes);
+ if (snapshotPath === "") {
+ snapshotPath = request.snapshot_path;
+ owned = spawn.cwd ?? "";
+ const {
+ artifact: _artifact,
+ diagnostics: _diagnostics,
+ debugger: _debugger,
+ decoder_diagnostics: _decoderDiagnostics,
+ ...raw
+ } = value;
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ profile: PWNTOOLS_PROVIDER_IDENTITY.version,
+ value: raw,
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", "process.stdout.write('basic stage diagnostic')"],
+ });
+ }
+ expect(request.snapshot_path).toBe(snapshotPath);
+ const context = recordedCrashDebuggerFixture();
+ if (scenario === "invalid-context")
+ context.maps = context.maps.map((map) => ({ ...map, end: "0x0" }));
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({ ok: true, value: context }),
+ );
+ const launched = await spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ scenario === "process-failure"
+ ? "process.stderr.write('debugger failed',()=>{process.exitCode=9;})"
+ : scenario === "cancelled"
+ ? "process.stdout.write('ready\\n'); setInterval(() => {}, 1000)"
+ : "process.stdout.write('debugger stage diagnostic')",
+ ],
+ });
+ if (scenario === "cancelled") {
+ await waitForProviderProcessReady(launched.process);
+ setImmediate(() => controller.abort());
+ }
+ if (scenario === "cleanup-failure")
+ return {
+ ...launched,
+ cleanup: async () => {
+ await cleanupOwnedProcessGroup(launched.ownership);
+ throw new Error("source-owned post-cleanup reporting failure");
+ },
+ };
+ return launched;
+ },
+ );
+ const result = await provider.inspect(
+ {
+ path,
+ include_debugger_context: true,
+ },
+ { signal: controller.signal },
+ );
+ if (scenario === "success") {
+ if (!result.ok) throw result.error;
+ expect(result.value.debugger).toMatchObject({
+ status: "available",
+ maps: [{ permissions: null }],
+ diagnostics: { stdout: "debugger stage diagnostic" },
+ });
+ } else {
+ if (result.ok) throw new Error("Expected optional debugger failure");
+ const projected = projectAnalysisError(result.error);
+ if (scenario === "unconfigured")
+ expect(projected).toMatchObject({
+ details: { captured_output: { stdout: "basic stage diagnostic" } },
+ });
+ else if (
+ scenario === "invalid-context" ||
+ scenario === "cancelled" ||
+ scenario === "cleanup-failure"
+ ) {
+ const serialized = JSON.stringify(projected);
+ expect(serialized).toContain("basic stage diagnostic");
+ expect(serialized).toContain(
+ scenario === "cancelled" ? "ready" : "debugger stage diagnostic",
+ );
+ expect(result.error._tag).toBe(
+ scenario === "invalid-context"
+ ? "AnalysisOutputError"
+ : scenario === "cancelled"
+ ? "AnalysisCancelledError"
+ : "ProviderAdapterError",
+ );
+ if (scenario === "cleanup-failure")
+ expect(result.error).toBeInstanceOf(ProviderCleanupError);
+ } else
+ expect(projected).toMatchObject({
+ category: "execution_failure",
+ details: {
+ diagnostics: {
+ preceding_output: { stdout: "basic stage diagnostic" },
+ stderr: "debugger failed",
+ },
+ },
+ });
+ }
+ expect(await readFile(path)).toEqual(bytes);
+ await expect(access(owned)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+const fixture = async (includeSignal = false, opaqueOwner = false) => {
+ const workspace = await createTestWorkspace("rea-recorded-core-seam-");
+ onTestFinished(() => removeTestWorkspace(workspace.root));
+ const path = join(workspace.root, "recorded.core");
+ const value = includeSignal
+ ? recordedCrashSignalFixture(path)
+ : recordedCrashFixture(path);
+ if (opaqueOwner) {
+ value.notes = value.notes.map((note) =>
+ note.index === 0
+ ? {
+ ...note,
+ owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
+ owner_display: "EVIL",
+ }
+ : note,
+ );
+ value.threads = [];
+ }
+ const bytes = recordedCrashFixtureBytes(value);
+ await writeFile(path, bytes);
+ return { path, value, bytes, root: workspace.root };
+};
+
+const alterRecordedCrashReply = (
+ payload: RecordedCrash,
+ scenario: string,
+): void => {
+ if (scenario === "wrong-owner-display")
+ payload.notes = payload.notes.map((note) =>
+ note.index === 0 ? { ...note, owner_display: "FORGED" } : note,
+ );
+ if (
+ scenario === "duplicated-physical-thread" ||
+ scenario === "duplicated-physical-signal"
+ ) {
+ const noteIndex = scenario === "duplicated-physical-thread" ? 0 : 1;
+ const note = payload.notes[noteIndex];
+ if (note === undefined) throw new Error("missing fixture note");
+ payload.notes.push({ ...note, index: 2 });
+ if (noteIndex === 0)
+ payload.threads.push(
+ ...payload.threads.map((thread) => ({
+ ...thread,
+ note_index: 2,
+ })),
+ );
+ else
+ payload.signals.push(
+ ...payload.signals.map((signal) => ({
+ ...signal,
+ note_index: 2,
+ })),
+ );
+ }
+ if (scenario === "omitted-physical-note") {
+ payload.notes = payload.notes.filter((note) => note.index === 0);
+ payload.signals = [];
+ }
+ if (scenario === "hidden-segment") {
+ payload.segments = [];
+ payload.notes = [];
+ payload.threads = [];
+ payload.signals = [];
+ }
+ if (scenario === "wrong-header-range")
+ payload.segments = payload.segments.map((segment) => ({
+ ...segment,
+ header_location: { offset: "0x0", bytes: "0x38" },
+ }));
+ if (scenario === "wrong-note-kind") {
+ payload.notes = payload.notes.map((note) =>
+ note.index === 0 ? { ...note, type: "NT_AUXV" } : note,
+ );
+ payload.threads = [];
+ }
+ if (scenario === "wrong-register-source")
+ payload.threads = payload.threads.map((thread) => ({
+ ...thread,
+ registers: thread.registers.map((register) =>
+ register.name === "rdi"
+ ? {
+ ...register,
+ value: "0x0",
+ location: { offset: "0x104", bytes: "0x8" },
+ }
+ : register,
+ ),
+ }));
+ if (scenario === "missing-thread") payload.threads = [];
+ if (scenario === "duplicate-thread")
+ payload.threads = [...payload.threads, ...payload.threads];
+ if (scenario === "missing-signal") payload.signals = [];
+ if (scenario === "duplicate-signal")
+ payload.signals = [...payload.signals, ...payload.signals];
+ if (scenario === "missing-register")
+ payload.threads = payload.threads.map((thread) => ({
+ ...thread,
+ registers: thread.registers.slice(1),
+ }));
+ if (scenario === "wrong-register")
+ payload.threads = payload.threads.map((t) => ({
+ ...t,
+ registers: t.registers.map((r) => ({ ...r, value: "0x1" })),
+ }));
+ if (scenario === "wrong-pid")
+ payload.threads = payload.threads.map((thread) => ({
+ ...thread,
+ historical_pid: -2,
+ }));
+ if (scenario === "wrong-current-signal")
+ payload.threads = payload.threads.map((thread) => ({
+ ...thread,
+ recorded_current_signal: -1,
+ }));
+ if (scenario.startsWith("wrong-signal"))
+ payload.signals = payload.signals.map((signal) => ({
+ ...signal,
+ ...(scenario === "wrong-signal-number"
+ ? {
+ number: 10,
+ fault_address: null,
+ fault_address_meaning: "unknown" as const,
+ }
+ : scenario === "wrong-signal-code"
+ ? { code: 2 }
+ : scenario === "wrong-signal-errno"
+ ? { errno: 0 }
+ : { fault_address: "0x1" }),
+ }));
+ if (scenario === "wrong-raw-note")
+ payload.notes = payload.notes.map((n) => ({
+ ...n,
+ descriptor_bytes_base64: Buffer.alloc(336, 1).toString("base64"),
+ }));
+ if (scenario === "malformed-range")
+ payload.notes = payload.notes.map((n) => ({
+ ...n,
+ descriptor_location: { offset: "0x200", bytes: "0x150" },
+ }));
+};
diff --git a/tests/boundary/mcp/recordedCrashMcp.test.ts b/tests/boundary/mcp/recordedCrashMcp.test.ts
new file mode 100644
index 000000000..364c280d1
--- /dev/null
+++ b/tests/boundary/mcp/recordedCrashMcp.test.ts
@@ -0,0 +1,76 @@
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it, onTestFinished } from "vitest";
+import { RecordedCrashService } from "../../../src/application/binaryDiagnostics/RecordedCrashService.js";
+import { toolContract } from "../../../src/contracts/toolContracts.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { ok } from "../../../src/domain/result.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import {
+ recordedCrashFixture,
+ RECORDED_CRASH_TEST_PROVIDER,
+} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
+
+it("advertises exact valid schemas and records inline recorded-core evidence without a binary target", async () => {
+ const value = recordedCrashFixture();
+ const service = new RecordedCrashService({
+ identity: RECORDED_CRASH_TEST_PROVIDER,
+ inspect: () => Promise.resolve(ok(value)),
+ });
+ const session = createTestBinarySession(() => {
+ throw new Error("deep provider must not start");
+ });
+ const server = createServer(session, session, { recordedCrash: service });
+ const client = new Client({ name: "recorded-crash-contract", version: "1" });
+ onTestFinished(async () => {
+ await client.close();
+ await server.close();
+ await session.close();
+ });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ const advertised = (await client.listTools()).tools.find(
+ (tool) => tool.name === "inspect_recorded_crash",
+ );
+ if (advertised?.outputSchema === undefined)
+ throw new Error("recorded crash must publish both schemas");
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ const inputSchema: Record = advertised.inputSchema;
+ const outputSchema: Record = advertised.outputSchema;
+ expect(ajv.validateSchema(inputSchema)).toBe(true);
+ expect(ajv.validateSchema(outputSchema)).toBe(true);
+ expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
+ expect(
+ ajv.validate(inputSchema, {
+ path: value.artifact.path,
+ approval: true,
+ }),
+ ).toBe(false);
+ const response = await client.callTool({
+ name: "inspect_recorded_crash",
+ arguments: { path: value.artifact.path },
+ });
+ expect(response.isError).not.toBe(true);
+ expect(
+ ajv.validate(outputSchema, response.structuredContent),
+ JSON.stringify(ajv.errors),
+ ).toBe(true);
+ const parsed = toolContract("inspect_recorded_crash").outputSchema.parse(
+ response.structuredContent,
+ );
+ const evidence = parseEvidence(parsed.evidence);
+ expect(parsed.result).toEqual(value);
+ expect(parsed.result).toEqual(evidence.normalized_result);
+ expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
+ expect(
+ (
+ await client.callTool({
+ name: "inspect_recorded_crash",
+ arguments: { path: "relative.elf" },
+ })
+ ).isError,
+ ).toBe(true);
+});
diff --git a/tests/fixtures/binaryDiagnostics/recordedCrash.ts b/tests/fixtures/binaryDiagnostics/recordedCrash.ts
new file mode 100644
index 000000000..636a20c30
--- /dev/null
+++ b/tests/fixtures/binaryDiagnostics/recordedCrash.ts
@@ -0,0 +1,220 @@
+import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
+
+/** Source-owned observation seam; these bytes do not simulate a verified ELF engine. */
+export const recordedCrashFixture = (
+ path = "/artifacts/source-owned.core",
+): RecordedCrash => {
+ const descriptor = Buffer.alloc(336);
+ descriptor.writeInt32LE(123, 32);
+ descriptor.writeInt16LE(11, 12);
+ descriptor.writeBigUInt64LE(0x1122334455667788n, 224);
+ return {
+ artifact: { path, sha256: "a".repeat(64), bytes: 512 },
+ format: "elf-core",
+ architecture: "x86_64-little-endian",
+ target_execution: "not-performed",
+ live_process_identity: "unknown",
+ segments: [
+ {
+ index: 0,
+ type: "PT_NOTE",
+ header_location: { offset: "0x40", bytes: "0x38" },
+ offset: "0x80",
+ file_size: "0x164",
+ memory_size: "0x0",
+ virtual_address: "0x0",
+ physical_address: "0x0",
+ alignment: "0x4",
+ flags: "0x0",
+ file_backing: "file",
+ },
+ ],
+ notes: [
+ {
+ index: 0,
+ segment_index: 0,
+ type: "NT_PRSTATUS",
+ location: { offset: "0x80", bytes: "0x164" },
+ owner_location: { offset: "0x8c", bytes: "0x5" },
+ owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
+ owner_display: "CORE",
+ descriptor_location: { offset: "0x94", bytes: "0x150" },
+ descriptor_bytes_base64: descriptor.toString("base64"),
+ },
+ ],
+ note_padding: [],
+ threads: [
+ {
+ note_index: 0,
+ historical_pid: 123,
+ recorded_current_signal: 11,
+ registers: [
+ "r15",
+ "r14",
+ "r13",
+ "r12",
+ "rbp",
+ "rbx",
+ "r11",
+ "r10",
+ "r9",
+ "r8",
+ "rax",
+ "rcx",
+ "rdx",
+ "rsi",
+ "rdi",
+ "orig_rax",
+ "rip",
+ "cs",
+ "eflags",
+ "rsp",
+ "ss",
+ "fs_base",
+ "gs_base",
+ "ds",
+ "es",
+ "fs",
+ "gs",
+ ].map((name, index) => ({
+ name,
+ value: name === "rdi" ? "0x1122334455667788" : "0x0",
+ location: {
+ offset: `0x${(148 + 112 + index * 8).toString(16)}`,
+ bytes: "0x8",
+ },
+ })),
+ },
+ ],
+ signals: [],
+ debugger: { status: "not-requested" },
+ note_interpretation_completeness: "unknown",
+ decoder_diagnostics: {
+ stdout: "decoder diagnostic",
+ stderr: "",
+ truncated: false,
+ },
+ diagnostics: { stdout: "decoder diagnostic", stderr: "", truncated: false },
+ limitations: ["Historical PID is recorded metadata."],
+ };
+};
+
+/** Source-owned SIGSEGV descriptor for scalar/source binding regressions. */
+export const recordedCrashSignalFixture = (path?: string): RecordedCrash => {
+ const value = recordedCrashFixture(path);
+ const descriptor = Buffer.alloc(32);
+ descriptor.writeInt32LE(11, 0);
+ descriptor.writeInt32LE(-2, 4);
+ descriptor.writeInt32LE(1, 8);
+ descriptor.writeBigUInt64LE(0x1122334455667788n, 16);
+ value.artifact.bytes = 640;
+ value.segments = value.segments.map((segment) => ({
+ ...segment,
+ file_size: "0x198",
+ }));
+ value.notes.push({
+ index: 1,
+ segment_index: 0,
+ type: "NT_SIGINFO",
+ location: { offset: "0x1e4", bytes: "0x34" },
+ owner_location: { offset: "0x1f0", bytes: "0x5" },
+ owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
+ owner_display: "CORE",
+ descriptor_location: { offset: "0x1f8", bytes: "0x20" },
+ descriptor_bytes_base64: descriptor.toString("base64"),
+ });
+ value.signals.push({
+ note_index: 1,
+ number: 11,
+ code: 1,
+ errno: -2,
+ fault_address: "0x1122334455667788",
+ fault_address_meaning: "recorded-sigsegv-address",
+ thread_association: "unknown",
+ });
+ return value;
+};
+
+/** Construct only the original byte ranges consumed by the provider protocol tests. */
+export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
+ const bytes = Buffer.alloc(value.artifact.bytes);
+ bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
+ bytes.writeUInt16LE(4, 16);
+ bytes.writeUInt16LE(62, 18);
+ bytes.writeBigUInt64LE(64n, 32);
+ bytes.writeUInt16LE(56, 54);
+ bytes.writeUInt16LE(value.segments.length, 56);
+ for (const segment of value.segments) {
+ const start = Number(BigInt(segment.header_location.offset));
+ bytes.writeUInt32LE(segment.type === "PT_NOTE" ? 4 : 0, start);
+ bytes.writeUInt32LE(Number(BigInt(segment.flags)), start + 4);
+ for (const [field, offset] of [
+ [segment.offset, 8],
+ [segment.virtual_address, 16],
+ [segment.physical_address, 24],
+ [segment.file_size, 32],
+ [segment.memory_size, 40],
+ [segment.alignment, 48],
+ ] as const)
+ bytes.writeBigUInt64LE(BigInt(field), start + offset);
+ }
+ for (const note of value.notes) {
+ const start = Number(BigInt(note.location.offset));
+ bytes.writeUInt32LE(Number(BigInt(note.owner_location.bytes)), start);
+ bytes.writeUInt32LE(
+ Number(BigInt(note.descriptor_location.bytes)),
+ start + 4,
+ );
+ bytes.writeUInt32LE(
+ note.type === "NT_PRSTATUS" ? 1 : 0x53494749,
+ start + 8,
+ );
+ Buffer.from(note.owner_bytes_base64, "base64").copy(
+ bytes,
+ Number(BigInt(note.owner_location.offset)),
+ );
+ Buffer.from(note.descriptor_bytes_base64, "base64").copy(
+ bytes,
+ Number(BigInt(note.descriptor_location.offset)),
+ );
+ }
+ return bytes;
+};
+
+export const RECORDED_CRASH_TEST_PROVIDER = {
+ id: "fixture-recorded-core",
+ name: "Source-owned recorded-core seam",
+ version: "1",
+} as const;
+
+/** Source-owned debugger representation with deliberately unreported permission flags. */
+export const recordedCrashDebuggerFixture = (): Extract<
+ RecordedCrash["debugger"],
+ { status: "available" }
+> => ({
+ status: "available",
+ gdb_version: "source seam",
+ pwndbg_version: "source seam",
+ connection: "core",
+ executable: null,
+ historical_pid: 123,
+ reported_limits: {
+ address_space_bytes: 3221225472,
+ cpu_seconds: 30,
+ file_size_bytes: 67108864,
+ },
+ maps: [
+ {
+ start: "0x1000",
+ end: "0x2000",
+ offset: "0x0",
+ reported_flags: 0,
+ permissions: null,
+ pathname_display: "recorded/path",
+ current_file_identity: "unknown",
+ },
+ ],
+ confidence: "derived",
+ completeness: "unknown",
+ diagnostics: { stdout: "", stderr: "", truncated: false },
+});
diff --git a/third_party/pwndbg/LICENSE.md b/third_party/pwndbg/LICENSE.md
new file mode 100644
index 000000000..bbc1e5d14
--- /dev/null
+++ b/third_party/pwndbg/LICENSE.md
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2026 Dominik 'Disconnect3d' Czarnota
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third_party/pwndbg/README.md b/third_party/pwndbg/README.md
new file mode 100644
index 000000000..e0dff52bc
--- /dev/null
+++ b/third_party/pwndbg/README.md
@@ -0,0 +1,23 @@
+# pwndbg core context adapter
+
+REA uses caller-supplied unchanged [pwndbg](https://github.com/pwndbg/pwndbg)
+2026.09.15, commit `55596aa676a2756efb8d67034f31675170eabe48`, and GDB.
+The original MIT license is retained in `LICENSE.md`. Engine source and its
+dependencies are not vendored or acquired by REA.
+
+The adapter executes the selected upstream `gdbinit.py` with its original
+`__file__`, an explicitly selected existing virtual environment, and
+`PWNDBG_NO_AUTOUPDATE=1`. The upstream entrypoint and dependencies are trusted
+caller-supplied executable code. Reported versions establish observed behavior,
+not source authenticity. The real verification lane checks out the exact commit
+and uses its frozen `uv.lock` in isolated runner storage.
+
+Only `pwndbg.aglib.vmmap.get()` is queried, after establishing a core-only GDB
+connection without an executable. All original core notes and recorded register
+values come from the independent pwntools/pyelftools decoder. Mapping candidates
+are derived; original pathnames do not identify current host files, and reported
+flags zero leave permissions unknown. REA does not expose upstream command
+execution, live attach, target launch, implicit executable pairing or updates.
+
+Local actual verification uses Linux x64, GDB 13.1 and the exact upstream tag/build.
+Other GDB versions require their corresponding real workflow before being claimed.