feat(native): inspect recorded crashes through pwntools and pwndbg (#1054)

* feat(native): inspect recorded crashes through pwntools and pwndbg

* fix(native): bind core note semantics to raw owner bytes

* fix(native): validate recorded core ABI and evidence scalars

* fix(native): require complete recorded note interpretations

* fix(native): bind physical core notes to original headers
This commit is contained in:
Ysopwnable
2026-10-08 16:50:31 +08:00
committed by GitHub
parent b9fb438835
commit 163ee09a74
58 changed files with 4623 additions and 490 deletions
+99
View File
@@ -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"
+1
View File
@@ -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) |
+1 -1
View File
@@ -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 واستخراجها الصريح |
+1 -1
View File
@@ -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 ファームウェア領域の検査と明示的な展開 |
+1 -1
View File
@@ -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 펌웨어 영역 검사 및 명시적 추출 |
+14 -14
View File
@@ -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 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+101
View File
@@ -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()
+39
View File
@@ -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
+34
View File
@@ -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")
+59
View File
@@ -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)
+11 -58
View File
@@ -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)
+39
View File
@@ -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
+143
View File
@@ -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 = "[email protected];[email protected];[email protected]"
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)
+1 -1
View File
@@ -26,7 +26,7 @@ graph TD
end
subgraph "Provider Layers"
PWNTOOLS["src/native/pwntools/<br/>BYO offline ELF adapter<br/>owned Python process, unchanged upstream tables/heuristics"]
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/<br/>BYO offline ELF/core adapters<br/>owned snapshots/Python and optional core-only GDB<br/>unchanged upstream parsers, ABI structures and mapping API"]
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
+12 -10
View File
@@ -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": "[email protected];[email protected];[email protected]",
"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"
}
}
}
+88
View File
@@ -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.
+22
View File
@@ -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
@@ -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": [
+11 -2
View File
@@ -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}": [
+24
View File
@@ -0,0 +1,24 @@
#include <stdint.h>
#include <pthread.h>
#include <stdatomic.h>
#include <unistd.h>
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;
}
+94
View File
@@ -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))
+2 -2
View File
@@ -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);
+318
View File
@@ -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.",
);
+142
View File
@@ -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();
}
},
};
}
+133
View File
@@ -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,
"[email protected];[email protected];[email protected]",
);
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();
}
},
};
}
@@ -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();
}
+173
View File
@@ -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;
}
+8 -3
View File
@@ -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.
@@ -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.
+9
View File
@@ -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"
@@ -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<Result<RecordedCrash, AnalysisError>>;
}
@@ -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,
},
});
},
);
@@ -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<Result<Evidence, AnalysisError>> {
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 }],
},
),
);
}
}
+29 -1
View File
@@ -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);
}),
),
});
};
+1
View File
@@ -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",
+8
View File
@@ -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<NodeJS.ProcessEnv> = process.env,
): BinaryLayoutService =>
new BinaryLayoutService(new PwntoolsLayoutProvider(environment));
/** Compose recorded-core inspection without acquiring a debugger or toolchain. */
export const createRecordedCrashService = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
): RecordedCrashService =>
new RecordedCrashService(new PwntoolsRecordedCrashProvider(environment));
@@ -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[];
+5
View File
@@ -70,6 +70,11 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
launchesProcess: true,
accessesNetwork: true,
}),
inspect_recorded_crash: effects({
mutatesSession: true,
writesFilesystem: true,
launchesProcess: true,
}),
inspect_binary_layout: effects({
mutatesSession: true,
writesFilesystem: true,
+284
View File
@@ -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<typeof recordedCrashFixture>) => ({
...v,
threads: [
{
...v.threads[0],
registers: [
{
name: "rdi",
value: 9007199254740992,
location: { offset: "0x104", bytes: "0x8" },
},
],
},
],
}),
],
[
"source outside artifact",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
artifact: { ...v.artifact, bytes: 120 },
}),
],
[
"invalid note reference",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
threads: [{ ...v.threads[0], note_index: 1 }],
}),
],
[
"register outside descriptor",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
threads: [
{
...v.threads[0],
registers: [
{
name: "rdi",
value: "0x0",
location: { offset: "0x1e4", bytes: "0x8" },
},
],
},
],
}),
],
[
"raw descriptor length",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
notes: v.notes.map((n) => ({ ...n, descriptor_bytes_base64: "AA==" })),
}),
],
[
"different owner ABI",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
notes: v.notes.map((n) => ({ ...n, owner_display: "OTHER" })),
}),
],
[
"raw owner ABI hidden by a CORE display label",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...v,
notes: v.notes.map((n) => ({
...n,
owner_bytes_base64: Buffer.from("EVIL\0").toString("base64"),
})),
}),
],
[
"duplicate register",
(v: ReturnType<typeof recordedCrashFixture>) => ({
...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);
});
+362
View File
@@ -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<typeof range>,
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<typeof recordedCrashSchema>;
@@ -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<number, Range[]>();
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;
}
+1 -1
View File
@@ -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;
+232
View File
@@ -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<NodeJS.ProcessEnv>;
readonly rootPath: string;
readonly diagnostics: AnalysisCapturedOutput;
readonly options: ExecutionOptions | undefined;
readonly launcher?: PwntoolsLauncher;
}): Promise<RecordedCrash["debugger"]> => {
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<typeof replySchema>;
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,
};
+333
View File
@@ -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<typeof runOwnedCommand>[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<Payload>;
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<Report>;
}
/** 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<Input, Payload, Report>,
readonly environment: Readonly<NodeJS.ProcessEnv>,
readonly launcher?: PwntoolsLauncher,
readonly createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => 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<Result<Report, AnalysisError>> {
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<PrivateRuntimeRoot, "path" | "close"> | undefined;
let result: Result<Report, AnalysisError>;
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<typeof replySchema>;
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;
}
}
+75 -28
View File
@@ -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: {
+58 -365
View File
@@ -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<Parameters<typeof runOwnedCommand>[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<typeof binaryLayoutPayloadSchema>,
BinaryLayout
>
implements BinaryLayoutPort
{
constructor(
readonly environment: Readonly<NodeJS.ProcessEnv>,
readonly launcher?: Launcher,
readonly createRuntime: () => Promise<
environment: Readonly<NodeJS.ProcessEnv>,
launcher?: PwntoolsLauncher,
createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => 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<Result<BinaryLayout, AnalysisError>> {
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
let result: Result<BinaryLayout, AnalysisError>;
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<typeof replySchema>;
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,
);
}
}
@@ -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<typeof recordedCrashPayloadSchema>,
RecordedCrash
>
implements RecordedCrashPort
{
constructor(
environment: Readonly<NodeJS.ProcessEnv>,
launcher?: PwntoolsLauncher,
createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => 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,
);
}
}
@@ -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<PwntoolsFailureEvidence> => {
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 }),
};
};
@@ -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<string, number>(
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();
}
}
@@ -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<number, string>,
): 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();
}
}
+15
View File
@@ -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(
+32
View File
@@ -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);
},
);
};
+3
View File
@@ -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:
@@ -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" },
}));
};
@@ -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<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = 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);
});
+220
View File
@@ -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 },
});
+21
View File
@@ -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.
+23
View File
@@ -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.