mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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}": [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+23
@@ -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.
|
||||
Reference in New Issue
Block a user