diff --git a/.github/workflows/real-binary-layout.yml b/.github/workflows/real-binary-layout.yml new file mode 100644 index 000000000..95a03e165 --- /dev/null +++ b/.github/workflows/real-binary-layout.yml @@ -0,0 +1,78 @@ +name: Real offline binary layout + +on: + workflow_dispatch: + inputs: + large_mcp: + description: "Also verify the complete 65,281-section result over MCP (slow)" + type: boolean + default: false + pull_request: + paths: + - ".github/workflows/real-binary-layout.yml" + - "bridge/pwntools/**" + - "src/native/pwntools/**" + - "src/domain/native/binaryLayout*" + - "src/application/binaryDiagnostics/**" + - "src/contracts/native/binaryDiagnosticsToolContracts.ts" + - "src/composition/binaryDiagnostics.ts" + - "src/server/registerBinaryDiagnosticsTools.ts" + - "src/server/createServer.ts" + - "src/cli/binaryDiagnosticsCommands.ts" + - "src/cli.ts" + - "src/cliCommandNames.ts" + - "src/contracts/toolContracts.ts" + - "src/contracts/toolEffects.ts" + - "src/process/OwnedCommand.ts" + - "src/process/ProviderProcess.ts" + - "src/process/ProviderDeadline.ts" + - "src/process/ProcessOwnership*.ts" + - "src/process/PrivateRuntimeRoot.ts" + - "src/artifacts/readStableArtifact*.ts" + - "src/artifacts/ArtifactReader.ts" + - "src/domain/analysisError*.ts" + - "scripts/verify-binary-layout.mjs" + - "scripts/lib/elf-section-name-fixtures.mjs" + - "scripts/lib/large-result-mcp.mjs" + - "scripts/fixtures/binary-layout*" + - "package.json" + - "package-lock.json" + +permissions: + contents: read + +concurrency: + group: real-binary-layout-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + NODE_OPTIONS: --max-old-space-size=768 + HUSKY: "0" + REA_VERIFY_LARGE_ELF_MCP: ${{ inputs.large_mcp && '1' || '0' }} + +jobs: + real-binary-layout: + 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: Acquire exact upstream libraries in an isolated runner environment + shell: bash + run: | + layout_python_root="${RUNNER_TEMP}/rea-pwntools-layout" + python3 -m venv "${layout_python_root}" + "${layout_python_root}/bin/python" -m pip install --disable-pip-version-check \ + 'pwntools==4.15.0' 'pyelftools==0.33' 'unicorn==2.1.2' + command -v gcc + command -v ld + command -v strip + command -v strace + echo "REA_PWNTOOLS_PYTHON=${layout_python_root}/bin/python" >> "${GITHUB_ENV}" + echo "REA_VERIFY_STRACE_COMMAND=$(command -v strace)" >> "${GITHUB_ENV}" + - name: Verify source-owned fixtures through public CLI and MCP + run: npm run verify:binary:layout diff --git a/README.md b/README.md index d2ec86629..e0a1090a3 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Additional tools and host support depend on the target: | Target | What REA returns | Requirements and guide | | ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | 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) | | JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) | | Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) | | Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) | diff --git a/README_ar.md b/README_ar.md index 45fa442d8..328c3b716 100644 --- a/README_ar.md +++ b/README_ar.md @@ -213,20 +213,20 @@ rea setup ## منصة أدوات التحقيق -| عائلة الأدوات | العدد | الاستخدام | -| -------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات | -| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C | -| أدوات macOS الأصلية | 8 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift ومراقبة الاستدعاءات عبر LLDB دون تشغيل Hopper | -| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج | -| 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 | +| أدوات الملفات الثنائية | 9 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة على Linux | +| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج | +| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات | +| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح | +| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة | +| مراقبة المتصفح | 12 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط | +| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل | +| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ | +| سير عمل التطبيقات | 13 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء | +| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة | ## خطة العمل diff --git a/README_ja.md b/README_ja.md index e64c367ae..813e870b2 100644 --- a/README_ja.md +++ b/README_ja.md @@ -240,7 +240,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して | -------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット | | 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 | -| macOS ネイティブ | 8 | Hopper を起動せずに扱える Mach-O メタデータ、コード署名、plist、アーキテクチャ、Swift シンボルのデマングル、LLDB による呼び出し観測 | +| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 | | 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 | | マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 | | ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 | diff --git a/README_ko.md b/README_ko.md index 66656e555..ee9a5fa0c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -207,7 +207,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이 | ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- | | 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 | | 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 | -| macOS 네이티브 | 8 | Hopper 실행 없이 처리하는 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원, LLDB 호출 관찰 | +| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 | | 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 | | 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 | | 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 | diff --git a/README_zh.md b/README_zh.md index 29b6052a1..3c0f1d9e8 100644 --- a/README_zh.md +++ b/README_zh.md @@ -206,7 +206,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常 | ----------------- | ---: | ------------------------------------------------------------------------------------------------------ | | 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 | | 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 | -| macOS 原生工具 | 8 | Mach-O 元数据、代码签名、plist、架构、Swift 符号还原与 LLDB 调用观察,无需启动 Hopper | +| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 | | 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 | | 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 | | 固件 | 2 | Linux 固件区域检查与显式提取 | diff --git a/bridge/pwntools/layout.py b/bridge/pwntools/layout.py new file mode 100644 index 000000000..0d39344ea --- /dev/null +++ b/bridge/pwntools/layout.py @@ -0,0 +1,39 @@ +"""Catch typed resource failures while loading or running the owned ELF 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__), "layout_impl.py") + with open(implementation, "rb") as handle: + source = handle.read() + exec(compile(source, implementation, "exec"), { + "__file__": implementation, + "__name__": "__main__", + }) +except MemoryError: + # Reserved status: an observed allocation failure, including imports/compile. + resource_exit(75, b"M") +except OSError as error: + # Linux ENOMEM is an observed allocation failure, independent of error text. + if error.errno == 12: + resource_exit(75, b"M") + # Linux EFBIG is observed directly, including a partial limits/reply write. + if error.errno == 27: + resource_exit(76, b"F") + raise diff --git a/bridge/pwntools/layout_impl.py b/bridge/pwntools/layout_impl.py new file mode 100644 index 000000000..92b3ea9e8 --- /dev/null +++ b/bridge/pwntools/layout_impl.py @@ -0,0 +1,356 @@ +"""Offline adapter to unchanged pwntools 4.15.0; never use libs/maps/libc. + +Owned request/snapshot/reply paths are supplied by REA. No host target-process launch, +user configuration initialization, debugger startup, or dependency installation. +""" +import base64 +import importlib.metadata +import io +import json +import os +from pathlib import Path +import resource +import sys + +PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2" +PACKAGES = {"pwntools": "4.15.0", "pyelftools": "0.33", "unicorn": "2.1.2"} +OUTPUT_BYTES = 64 * 1024 * 1024 + + +class LayoutFailure(Exception): + def __init__(self, reason, message): + super().__init__(message) + self.reason = reason + + +def address(value): + return hex(int(value)) + + +def file_range(offset, size, length): + return 0 <= offset <= length and 0 <= size <= length - offset + + +def location(offset, size, length): + if not file_range(offset, size, length): + raise LayoutFailure("format", "ELF contains a file-backed range outside the selected snapshot.") + return {"offset": address(offset), "bytes": address(size)} + + +def name_reference(display, section, offset, content): + header = getattr(section, "header", None) + if header is None or header.sh_type != "SHT_STRTAB": + return {"display": display, "bytes_base64": None, "location": None, + "unknown_reason": "Referenced string table could not be resolved."} + return name_in_table(display, header.sh_offset, header.sh_size, offset, content) + + +def name_in_table(display, table_start, table_size, offset, content): + location(table_start, table_size, len(content)) + if not 0 <= offset < table_size: + raise LayoutFailure("format", f"ELF name offset {offset} lies outside its declared string table of {table_size} bytes at file offset {table_start}.") + start = table_start + offset + limit = table_start + table_size + end = content.find(b"\0", start, limit) + if end < 0: + raise LayoutFailure("format", "ELF name lacks a terminator within its reported string table.") + raw = content[start:end] + return {"display": raw.decode("utf-8", "replace") if display is None else display, "bytes_base64": base64.b64encode(raw).decode("ascii"), + "location": location(start, len(raw) + 1, len(content)), "unknown_reason": None} + + +def dynamic_name_reference(tags, offset, image, content): + starts = [tag.d_val for tag in tags if tag.d_tag == "DT_STRTAB"] + sizes = [tag.d_val for tag in tags if tag.d_tag == "DT_STRSZ"] + 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})) + if not 0 <= offset < sizes[0]: + raise LayoutFailure("format", f"ELF dependency name offset {offset} lies outside declared DT_STRSZ {sizes[0]}.") + offsets = set() + if len(starts) == 1 and len(sizes) == 1: + for segment in image.iter_segments_by_type("PT_LOAD"): + h = segment.header + 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)})) + return name_in_table(None, offsets.pop(), sizes[0], offset, content) + + +def symbol_value_meaning(raw, image_type, sections): + index = raw.st_shndx + if raw.st_info.type == "STT_FILE": return "no-address" + if index == "SHN_UNDEF": return "undefined" + if index == "SHN_COMMON": return "alignment" + if index == "SHN_ABS": return "absolute-value" + if not isinstance(index, int) or not 0 < index < len(sections) or index >= 0xff00: + return "unknown-section-index" + if sections[index].header.sh_type == "SHT_NULL": + return "unknown-section-index" + if image_type == "ET_REL": return "section-offset" + if raw.st_info.type == "STT_TLS": return "tls-offset" + return "linked-virtual-address" + + +def relocation_target(image_type, section_index, offset, sections): + if image_type != "ET_REL": + return {"kind": "linked-virtual-address", "address": address(offset)} + if section_index == 0 or sections[section_index].header.sh_type == "SHT_NULL": + return {"kind": "unknown-section", "reported_section_index": section_index, + "offset": address(offset), "unknown_reason": "undefined-section-reference" if section_index == 0 else "inactive-section-header"} + return {"kind": "section-offset", "section_index": section_index, "offset": address(offset)} + + +def inspect_elf(path, cache): + for package, expected in PACKAGES.items(): + try: + version = importlib.metadata.version(package) + except importlib.metadata.PackageNotFoundError as error: + raise LayoutFailure("unavailable", "Selected Python lacks " + package + " " + expected + ".") from error + if version != expected: + raise LayoutFailure("unsupported", "Expected " + package + " " + expected + "; selected Python reports " + version + ".") + content = path.read_bytes() + if len(content) < 4 or content[:4] != b"\x7fELF": + raise LayoutFailure("format", "Selected object does not contain an ELF header.") + 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.") + 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.") + 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.") + from pwnlib.context import context + with context.local(cache_dir=str(cache), log_level="warning"): + from elftools.common.exceptions import ELFError + from elftools.elf.elffile import ELFFile + from pwnlib.elf import ELF + try: + headers = ELFFile(io.BytesIO(content)) + section_count = headers.num_sections() + if headers.header.e_shstrndx == 0xffff and section_count == 0: + raise LayoutFailure("format", "ELF extended section-name index requires a declared section-zero header.") + raw_name_index = headers.header.e_shstrndx + if 0xff00 <= raw_name_index < 0xffff: + raise LayoutFailure("format", f"ELF section-name index {raw_name_index} is reserved and cannot directly reference a section.") + section_name_index = headers.get_shstrndx() + if raw_name_index == 0xffff and section_name_index < 0xff00: + raise LayoutFailure("format", f"ELF SHN_XINDEX resolves to ordinary index {section_name_index}; the escape requires an index at least SHN_LORESERVE (0xff00).") + if section_name_index != 0: + if section_name_index >= section_count: + raise LayoutFailure("format", f"ELF section-name table index {section_name_index} lies outside the declared section count {section_count}.") + names_header = headers._get_section_header(section_name_index) + if names_header.sh_type != "SHT_STRTAB": + raise LayoutFailure("format", f"ELF section-name table {section_name_index} is not SHT_STRTAB.") + location(names_header.sh_offset, names_header.sh_size, len(content)) + except ELFError as error: + raise LayoutFailure("format", str(error)) from error + class LayoutELF(ELF): + def _get_section_name(self, header): + # SHN_UNDEF declares no names; upstream would otherwise read + # strings at the inactive section-zero offset. + return "" if section_name_index == 0 else super()._get_section_name(header) + + # pyelftools 0.33 calls these documented filters internally; + # pwntools 4.15.0's cached overrides do not accept the argument. + # Forward the filter on this adapter instance, without modifying + # either upstream package or process-global methods. + def iter_segments(self, type=None): + return (segment for segment in super().iter_segments() + if type is None or segment.header.p_type == type) + + def iter_sections(self, type=None): + return (section for section in super().iter_sections() + if type is None or section.header.sh_type == type) + 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.") + 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.") + 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 + sections, segments, symbols, relocations, packed_relatives = [], [], [], [], [] + needed, interpreters = [], [] + for index, section in enumerate(all_sections): + h = section.header + backing = h.sh_type not in ("SHT_NOBITS", "SHT_NULL") + if backing: location(h.sh_offset, h.sh_size, length) + sections.append({ + "index": index, "name": name_reference(section.name, shstrings, h.sh_name, content) if shstrings is not None else {"display": "", "bytes_base64": None, "location": None, "unknown_reason": "ELF declares no section-name table (SHN_UNDEF index 0)."}, "name_offset": address(h.sh_name), "type": h.sh_type, + "header_location": location(image.header.e_shoff + index * image.header.e_shentsize, image.header.e_shentsize, length), + "address": address(h.sh_addr), "offset": address(h.sh_offset), + "size": address(h.sh_size), "alignment": address(h.sh_addralign), + "flags": address(h.sh_flags), "link": h.sh_link, "info": h.sh_info, + "entry_size": address(h.sh_entsize), "file_backing": "file" if backing else "none", + }) + if h.sh_type in ("SHT_SYMTAB", "SHT_DYNSYM"): + if h.sh_entsize < image.structs.Elf_Sym.sizeof(): + raise LayoutFailure("format", "ELF symbol entry is smaller than the decoded Elf64_Sym structure.") + if h.sh_link >= len(all_sections) or all_sections[h.sh_link].header.sh_type != "SHT_STRTAB": + raise LayoutFailure("format", f"ELF symbol table {index} links to invalid string table {h.sh_link}.") + strings = all_sections[h.sh_link] + for entry, symbol in enumerate(section.iter_symbols()): + raw = symbol.entry + symbols.append({ + "table_index": index, "entry_index": entry, + "name": name_reference(symbol.name, strings, raw.st_name, content), "name_offset": address(raw.st_name), + "location": location(h.sh_offset + entry * h.sh_entsize, h.sh_entsize, length), + "value": address(raw.st_value), "value_meaning": symbol_value_meaning(raw, image.header.e_type, all_sections), + "size": address(raw.st_size), "binding": raw.st_info.bind, "type": raw.st_info.type, + "visibility": raw.st_other.visibility, "section_index": raw.st_shndx, + }) + if h.sh_type in ("SHT_REL", "SHT_RELA"): + if image.header.e_type == "ET_REL" and h.sh_info >= len(all_sections): + raise LayoutFailure("format", f"ELF relocation section {index} references missing target section {h.sh_info}.") + symbol_table = None + if h.sh_link != 0: + if h.sh_link >= len(all_sections) or all_sections[h.sh_link].header.sh_type not in ("SHT_SYMTAB", "SHT_DYNSYM"): + raise LayoutFailure("format", f"ELF relocation section {index} links to invalid symbol table {h.sh_link}.") + symbol_table = all_sections[h.sh_link] + for entry, relocation in enumerate(section.iter_relocations()): + raw = relocation.entry + if raw.r_info_sym != 0 and (symbol_table is None or raw.r_info_sym >= symbol_table.num_symbols()): + raise LayoutFailure("format", f"ELF relocation section {index} entry {entry} references missing symbol {raw.r_info_sym} in table {h.sh_link}.") + relocations.append({ + "section_index": index, "entry_index": entry, "reported_offset": address(raw.r_offset), + "target": relocation_target(image.header.e_type, h.sh_info, raw.r_offset, all_sections), + "location": location(h.sh_offset + entry * h.sh_entsize, h.sh_entsize, length), + "type": raw.r_info_type, "symbol_table_index": h.sh_link, "symbol_index": raw.r_info_sym, + "symbol_reference_meaning": "zero-symbol-value" if raw.r_info_sym == 0 else "symbol-table-entry", + "addend": None if "r_addend" not in raw else str(raw.r_addend), + }) + if h.sh_type == "SHT_RELR": + packed_relatives.append({ + "section_index": index, + "location": location(h.sh_offset, h.sh_size, length), + "encoded_bytes_base64": base64.b64encode(content[h.sh_offset:h.sh_offset + h.sh_size]).decode("ascii"), + "entries": [{"decoded_index": decoded, "reported_offset": address(relocation.entry.r_offset)} for decoded, relocation in enumerate(section.iter_relocations())], + "offset_meaning": "unknown" if image.header.e_type == "ET_REL" else "linked-virtual-address", + "evidence_kind": "derived", "entry_source_locations": None, "addends": None, + }) + for 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, length) + if h.p_type == "PT_LOAD" and h.p_memsz < h.p_filesz: + raise LayoutFailure("format", "ELF load segment memory size is smaller than its file size.") + segments.append({ + "index": index, "type": h.p_type, "offset": address(h.p_offset), + "header_location": location(image.header.e_phoff + index * image.header.e_phentsize, image.header.e_phentsize, length), + "file_size": address(h.p_filesz), "memory_size": address(h.p_memsz), + "virtual_address": address(h.p_vaddr), "physical_address": address(h.p_paddr), + "alignment": address(h.p_align), "flags": address(h.p_flags), + "file_backing": "file" if backing else "none", + "permissions": None if h.p_type == "PT_NULL" else {"read": bool(h.p_flags & 4), "write": bool(h.p_flags & 2), "execute": bool(h.p_flags & 1)}, + }) + if h.p_type == "PT_DYNAMIC": + tag_size = image.structs.Elf_Dyn.sizeof() + tags = [] + for tag_index in range(h.p_filesz // tag_size): + tag = segment._get_tag(tag_index) + tags.append(tag) + if tag.d_tag == "DT_NULL": + break + else: + raise LayoutFailure("format", f"ELF dynamic segment {index} has no complete DT_NULL terminator within its reported file range.") + for tag in tags: + if tag.d_tag == "DT_NEEDED": + needed.append(dynamic_name_reference(tags, tag.d_val, image, content)) + if h.p_type == "PT_INTERP": + raw = content[h.p_offset:h.p_offset + h.p_filesz] + end = raw.find(b"\0") + if end < 0: + raise LayoutFailure("format", "ELF interpreter lacks a reported terminator.") + interpreters.append({"display": raw[:end].decode("utf-8", "replace"), "bytes_base64": base64.b64encode(raw[:end]).decode("ascii"), "location": location(h.p_offset, end + 1, length), "unknown_reason": None}) + return { + "format": "elf", "architecture": {"machine": image.header.e_machine, "bits": image.bits, "byte_order": image.endian}, + "image_type": image.header.e_type, + "entry_point": {"reported_value": address(image.header.e_entry), "meaning": "not-applicable" if image.header.e_type == "ET_REL" else "absent" if image.header.e_entry == 0 else "linked-virtual-address", "execution_status": "unknown"}, + "runtime_load_base": None, "sections": sections, "segments": segments, "symbols": symbols, "relocations": relocations, + "packed_relative_relocations": packed_relatives, "relocation_inventory_completeness": "unknown", + "linkage": {"needed_libraries": needed, "interpreters": interpreters, + "got": [{"display_name": name, "address": address(value)} for name, value in image.got.items()], + "plt": [{"display_name": name, "address": address(value)} for name, value in image.plt.items()], + "convenience_maps_completeness": "unknown", "runtime_library_paths": None}, + "mitigations": {"evidence_kind": "inferred", "position_independent": image.pie, "nx_indicator": image.nx, "executable_stack_indicator": image.execstack, "stack_canary_indicator": image.canary, "relro": image.relro}, + "limitations": [ + "PT_NULL payload fields are unused and retain their reported numbers without file-byte or permission claims. Zero-file-size segments provide no file bytes.", + "SHN_XINDEX symbol values remain unresolved in this upstream representation; an external index table does not establish a resolved index in this report.", + "Reported values preserve linked addresses, section offsets, TLS offsets, alignment and absolute values separately. Runtime load base and library paths are unknown.", + "Names are display strings plus raw bytes and file ranges where resolvable; section/table/entry indices preserve identity and duplicates. Name ranges include their terminating NUL; raw name bytes exclude it.", + "Ordinary relocation rows cover REL/RELA. RELR tables retain complete encoded bytes and upstream-decoded offsets as derived evidence; per-offset packed-word locations and implicit addends remain unknown. Relocation inventory completeness is unknown, including other encodings and missing section tables.", + "Relocation symbol index zero uses a zero symbol value without a table lookup. Positive indices reference validated original symbol-table entries; raw table and symbol indices remain reported.", + "Relocatable relocation targets with SHN_UNDEF or inactive SHT_NULL headers retain their reported section index/offset as unknown; they do not establish an associated target section.", + "GOT/PLT are derived upstream convenience maps; PLT inference can emulate selected instructions in Unicorn. They do not establish target runtime behavior; aliases may collapse and warnings may indicate incomplete coverage. Their completeness is unknown.", + "Mitigations are upstream static heuristics, not runtime protection. ET_DYN does not prove an executable; absent canary symbols do not prove every function unprotected.", + "This profile inspects ELF EXEC/DYN/REL layout only. It does not analyze recorded cores, resolve loaded libraries, launch the target as a host process or start a debugger.", + "Symbol/relocation inventories reflect original section tables. A sectionless image can still report dynamic dependency names; missing tables do not prove absence of dynamic symbols or relocations.", + *(["Absent section-name table: section-dependent RELRO/canary heuristics may be incomplete; protection coverage is unknown."] if all_sections and section_name_index == 0 else []), + *(["Sectionless images lack the .dynamic section and symbol tables used by upstream heuristics. RELRO/canary indicators may be incomplete; their values remain reported static candidates, with protection coverage unknown."] if not all_sections else []), + ], + } + except LayoutFailure: + raise + except (ELFError, ValueError, AssertionError, IndexError) as error: + 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]) diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index b4fc6c5b0..2bad907aa 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -26,6 +26,7 @@ graph TD end subgraph "Provider Layers" + PWNTOOLS["src/native/pwntools/
BYO offline ELF adapter
owned Python process, unchanged upstream tables/heuristics"] HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"] GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"] IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"] @@ -92,6 +93,9 @@ graph TD APP -->|record callbacks| RECORDS SERVER -->|narrow record ports| RECORDS RECORDS --> DOMAIN + COMPOSITION --> PWNTOOLS + PWNTOOLS --> PROCESS + PWNTOOLS --> DOMAIN COMPOSITION --> HOPPER COMPOSITION --> GHIDRA COMPOSITION --> IDA diff --git a/docs/binary-diagnostics.md b/docs/binary-diagnostics.md new file mode 100644 index 000000000..a833edc36 --- /dev/null +++ b/docs/binary-diagnostics.md @@ -0,0 +1,131 @@ +# Offline binary diagnostics + +`inspect_binary_layout` / `inspect-binary-layout` answers what an explicit +file contains: section/segment layout, original symbols/relocations, static +linkage names and mitigation indicators. It works without an active Hopper, +Ghidra or IDA target. CTF is one possible use; crash triage, compatibility and +ordinary binary inspection use the same modular capability. + +## Bring your own engine + +Initial real verification covers **Linux x64, ELF64 x86-64 little-endian +EXEC/DYN/REL**. Provide an absolute Python executable whose environment already +contains unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2: + +```sh +REA_PWNTOOLS_PYTHON=/absolute/isolated-env/bin/python \ + rea inspect-binary-layout ./selected.elf --json +``` + +```json +{ + "name": "inspect_binary_layout", + "arguments": { "path": "/artifacts/selected.elf" } +} +``` + +REA never installs Python, packages or GDB, changes a user init file, launches +the selected object as a host process, or requests runtime library resolution. The Python process +uses isolated mode and an owned cache. Exact upstream profiles are recorded in +[upstream provenance](../third_party/pwntools/README.md). + +## Interpreting results + +- Artifact path, SHA-256 and size identify original selected bytes. All parsing + reads an owned stable snapshot; the original is unchanged. +- Addresses, offsets, lengths and flags are hexadecimal strings. A linked + address is not a runtime address. Runtime load base remains null. A zero + EXEC/DYN entry means absent; a relocatable entry is not applicable. +- Each symbol retains its table and entry index. Its reported value can mean + undefined, alignment, absolute value, no address, unknown section index, + section offset, TLS offset or linked virtual address. Duplicate names remain + separate entries. SHN_XINDEX remains unresolved in the selected upstream + representation; its external index table is not silently treated as resolved. +- Relocatable objects have section-relative relocations and no executable entry + claim. Signed REL/RELA relocation addends are decimal strings. Packed RELR + tables retain full original bytes and upstream-decoded offsets as derived + evidence, with per-offset packed-word locations and implicit addends unknown. + Overall relocation inventory completeness remains unknown. + A relocatable SHN_UNDEF target or inactive SHT_NULL header remains an explicit + unknown with the original reported section index and offset. +- REL/RELA symbol references retain their original table and entry indices. + Positive indices resolve through a validated symbol table; symbol index zero + means a zero symbol value without a table lookup, including when no table is + linked. Malformed references fail with the affected section and symbol index. +- Section-name and symbol tables require declared SHT_STRTAB links. + SHN_UNDEF explicitly means no section names: display is empty, raw name and + location unknown, with the original name offsets retained. Extended section-name + indices resolve through section zero and undergo the same validation. +- Symbol tables require a declared SHT_STRTAB link. Name offsets and terminators + must stay inside the declared string table; malformed references fail before + unrelated bytes can become names. Dynamic dependencies use DT_STRTAB/DT_STRSZ + with a unique file-backed mapping; ambiguous or unbacked mappings are + explicitly unsupported. +- Name display strings use UTF-8 replacement for opaque bytes, including + sectionless dependency names and interpreter paths. Raw name + bytes and string-table ranges retain observed identity where resolvable; + ranges include the terminating NUL and base64 bytes exclude it. +- NOBITS and NULL sections provide no file bytes. Reported file-backed ranges + are checked against snapshot size again at the public boundary. PT_NULL payload + fields are unused: reported numbers remain, file backing is none and interpreted + permissions are null. Segments with zero file size also have no file bytes. +- Sectionless dynamic images still report dependency names with raw bytes and + file locations where uniquely mapped. Missing symbol/relocation section tables + do not establish that those runtime tables are absent. + Upstream RELRO/canary heuristics rely on sections and can be incomplete in + this case; their reported candidates retain an explicit coverage limitation. +- DT_NEEDED/PT_INTERP are reported names, not resolved runtime paths. GOT/PLT + maps are derived convenience views and may collapse aliases. PLT inference + can emulate selected instructions in Unicorn; its outputs are static candidates. + Exec syscall tracing observes host process launches. Map completeness + remains unknown and original upstream warnings are returned inline. +- Canary, PIE, NX, executable-stack and RELRO are static inferences. NX and + executable-stack are separate upstream indicators: on x86-64 an executable + stack can accompany an unknown (`null`) NX indicator. ET_DYN can be a shared + library, and an absent canary symbol does not prove every function unprotected. + +Complete results have a 32 MiB input, 64 MiB reply, 1 MiB combined diagnostics +and 30-second owned command deadline. The reply budget applies to the decoder +record; the CLI/MCP Evidence envelope adds copies and encoding overhead. The +pinned MCP SDK defaults to a 10 MiB receive buffer. Large complete results need +a caller-configured `StdioClientTransport({ maxBufferSize: ... })`; the large +extended-index verification uses 256 MiB and an explicit five-minute request +timeout in its separate opt-in MCP lane. REA does not silently truncate the result. Python lowers resource soft limits to at most 3 GiB virtual address space, +30 CPU seconds and 64 MiB file output. Inherited tighter soft/hard limits are +retained, and the effective values are returned in the result limitations. Virtual address space is not RSS; +Unicorn needs a large virtual map. Limits fail with no partial success, and +owned process/root cleanup completes independently of cancellation. + +Core files, GDB session/state/control, optional pwndbg enrichment and instruction +inspection are separate increments tracked by #969. This increment makes no +real support claim for those features or other operating systems/architectures. + +Typed decoder failures retain bounded `captured_output` (stdout, stderr and an +explicit truncation flag) alongside the original failure category and reason. +Successful `diagnostics` also exposes the supervisor's `truncated` flag. Output +beyond the complete diagnostic budget is rejected, including when the decoder +writes a valid reply; cleanup and late cancellation reuse the observed flag. +Memory allocation failures use `resource_constraint`, with effective resource +limits (or an explicit unknown) and memory-specific recovery guidance. A +MemoryError alone does not establish the exact failed allocation or that a +particular budget was exhausted. Invalid undersized symbol entries are rejected +before REA reports source ranges for them. +The bridge reserves 1 MiB to report allocation failures. If even error reporting +or serialization fails with MemoryError, its reserved exit status preserves the +resource classification with unknown effective limits. +Reserved exits require a matching private marker written by the bridge's actual +failure branch. Bare launcher exits or missing/unwritable markers retain process +diagnostics with the resource cause unverified. +Signal termination alone is never classified as observed memory exhaustion. +The small bootstrap also covers catchable MemoryError during adapter imports, +compilation and initialization. Python interpreter startup failures before the +bootstrap runs retain their observed process diagnostics without guessed causes. +SIGXCPU retains a CPU resource diagnostic and CPU-specific recovery advice. +Observed EFBIG writes or SIGXFSZ termination retain a file-size resource +diagnostic and file-size-specific recovery advice. If a tight file limit also +prevents writing the limit record, effective values remain unknown. +The bridge records actual limits in owned storage before analysis; missing or +malformed limit reports remain unknown with their read failure preserved. A +received signal alone does not establish its exact cause. Dynamic tags require +a complete DT_NULL inside PT_DYNAMIC; interpreter names and ranges end at the +first NUL, with any padding still represented by the original segment range. diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json index 1d5f875cb..11f0fc659 100644 --- a/docs/error-contract.schema.json +++ b/docs/error-contract.schema.json @@ -14,6 +14,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -48,6 +49,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -82,6 +84,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -116,6 +119,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -150,6 +154,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -184,6 +189,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -218,6 +224,42 @@ "truncated", "cancelled", "timeout", + "resource_constraint", + "unavailable", + "execution_failure" + ] + }, + "message": { "type": "string", "minLength": 1 }, + "retryable": { "type": "boolean" }, + "remediation": { + "type": "object", + "properties": { "action": { "type": "string", "minLength": 1 } }, + "required": ["action"], + "additionalProperties": false + }, + "details": { + "type": "object", + "propertyNames": { "type": "string" }, + "additionalProperties": {} + } + }, + "required": ["code", "category", "message", "retryable", "remediation"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { "type": "string", "const": "resource_constraint" }, + "category": { + "type": "string", + "enum": [ + "invalid_input", + "unsupported_provider", + "integrity_mismatch", + "truncated", + "cancelled", + "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -252,6 +294,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -286,6 +329,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -342,6 +386,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -376,6 +421,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -410,6 +456,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -444,6 +491,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -478,6 +526,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -512,6 +561,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -546,6 +596,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -580,6 +631,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] @@ -614,6 +666,7 @@ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure" ] diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 75735d07f..327a8e352 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -7,10 +7,10 @@ "client": "2.3.1", "core": "2.3.1" }, - "skill_version": "29" + "skill_version": "30" }, "tools": { - "total": 135, + "total": 136, "families": [ { "id": "direct", @@ -84,10 +84,11 @@ { "id": "native", "surface": "native-provider", - "count": 8, + "count": 9, "tools": [ "capture_native_ui_scenario", "demangle_swift", + "inspect_binary_layout", "inspect_macho", "inspect_plist", "inspect_signature", @@ -372,6 +373,12 @@ "observe_native_ui" ] }, + { + "id": "pwntools-elf", + "name": "REA pwntools ELF adapter", + "version": "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2", + "capabilities": ["inspect_binary_layout"] + }, { "id": "rea-android-application", "name": "REA Android application workflows", @@ -585,7 +592,7 @@ } ], "cli": { - "primary_count": 90, + "primary_count": 91, "commands": [ "address-to-file-offset", "analyze", @@ -627,6 +634,7 @@ "inspect-android-package", "inspect-artifact", "inspect-asset-catalog", + "inspect-binary-layout", "inspect-electron-page", "inspect-firmware-regions", "inspect-keyed-archive", @@ -687,15 +695,15 @@ }, "runtime_catalog": { "counts": { - "cli_commands": 90, - "mcp_tools": 135, + "cli_commands": 91, + "mcp_tools": 136, "mcp_prompts": 6 }, "digests": { - "tools_sha256": "4266c0019ab7ddda340ceba2da9a62525368af1e6052d08cc0ef4a790be07882", + "tools_sha256": "51992818bb03b1f6136a2674517e3d983037dc474cd0ac31a37fe4096211e5e2", "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", - "combined_sha256": "bfa2ed377826f8e561d1b8a9024d4ab0cf11f09d8b1af86c80d2884c1d72dbf3", - "providers_sha256": "de4729a56a0e22b42f24803efefd625fa607b6af7de02d594b6dfaccf5a91fa0" + "combined_sha256": "fc75e5d817f64311dacc23ef2bde4223fd12d2d5a9c49e93051ac9f7857b2d8c", + "providers_sha256": "6e0080dea56ee7f3ebdf5ce0d74ea18e5d44ebce80b89c7f4de94e9c69f4d2e5" } } } diff --git a/docs/testing.md b/docs/testing.md index c82aeebab..ad3312253 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -617,6 +617,23 @@ compiler. Ordinary unit/static gates acquire no browser. See [website runtime attribution](web-runtime.md) for effects, resource bounds and coverage limits. +### Offline binary layout + +`npm run verify:binary:layout` requires Linux x64, GCC/binutils, absolute +`REA_PWNTOOLS_PYTHON` with pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2 and +absolute `REA_VERIFY_STRACE_COMMAND`. It compiles ephemeral source-owned ELF +fixtures and checks public CLI/MCP, lossless addresses/names, file ranges, +mitigation inferences, malformed/unsupported input, original file hashes and +released process ownership. Exec syscall tracing must identify only the declared +Node/Python launchers; no target binary is executed. Core/debugger claims need +separate verification lanes. Pass an installed package entrypoint as the script's +first argument to verify packaging independently of the checkout. +The valid SHN_XINDEX fixture has 65,281 full section rows; CLI is checked in +the ordinary lane. Its large MCP transfer is opt-in with +`REA_VERIFY_LARGE_ELF_MCP=1` (or the workflow dispatch `large_mcp` input), an +explicit 256 MiB SDK receive buffer and five-minute request timeout. Ordinary +MCP fixtures retain the pinned SDK defaults. + ## Agent evaluation and conformance records Evaluate native, JavaScript, managed and browser investigation tasks through a diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 3092caf24..2d1fa5481 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_d5fdca7e469b71989b98eaec30d26af3ccb3a08b42d558b4d4fe3b1d220c3898", + "manifest_id": "ecm_4308b9eca45ac983c4af2da0e6aafe6180b27569f55b3454b508f7c61a1f3fde", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "3f8e02d3dc409f4fe0ac3a4315cfa4c34de53e504f627b6dc9cf6cb7b0adbedc" + "sha256": "3c5b36ca0beb632f003b66e52afc4cd689ee5fe71c965dfa72374467cac9144e" } ], "claims": [ diff --git a/package.json b/package.json index c1788c9ad..3c70e52a0 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,10 @@ "LICENSE", "native/windows/build/manifest.json", "native/windows/build/rea-windows-x64.node", - "scripts/verify-windows-native-artifact.mjs" + "scripts/verify-windows-native-artifact.mjs", + "bridge/pwntools/layout.py", + "bridge/pwntools/layout_impl.py", + "third_party/pwntools/README.md" ], "scripts": { "deps:check": "node scripts/check-dependency-install.mjs", @@ -195,7 +198,8 @@ "verify:module-boundaries": "node scripts/verify-module-boundaries.mjs", "verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs", "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: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" }, "lint-staged": { "*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [ diff --git a/scripts/fixtures/binary-layout-high.S b/scripts/fixtures/binary-layout-high.S new file mode 100644 index 000000000..5faf3fcb7 --- /dev/null +++ b/scripts/fixtures/binary-layout-high.S @@ -0,0 +1,10 @@ +.globl _start +.globl absolute_value +.set absolute_value, 0x20000000000007 +.bss +.lcomm zero_backing, 8192 +.text +_start: + mov $60, %eax + xor %edi, %edi + syscall diff --git a/scripts/fixtures/binary-layout.c b/scripts/fixtures/binary-layout.c new file mode 100644 index 000000000..0ff882b8e --- /dev/null +++ b/scripts/fixtures/binary-layout.c @@ -0,0 +1,17 @@ +#include +#include + +int stored_value = 7; +char zero_backing[8192]; +__thread int tls_value; +int common_value; + +__attribute__((noinline)) int read_values(const char *text) { + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%s", text); + return buffer[0] + stored_value + tls_value + common_value; +} + +int main(int argc, char **argv) { + return read_values(argc > 1 ? argv[1] : "source-owned"); +} diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs index 3664e28aa..efe58e26c 100644 --- a/scripts/lib/catalog-builders.mjs +++ b/scripts/lib/catalog-builders.mjs @@ -16,7 +16,10 @@ export const toolFamilyCatalog = (sources) => { { id: "native", surface: "native-provider", - contracts: sources.nativeContracts.NATIVE_TOOL_CONTRACTS, + contracts: [ + ...sources.nativeContracts.NATIVE_TOOL_CONTRACTS, + ...sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + ], }, { id: "artifact", @@ -143,6 +146,11 @@ export const providerCatalog = (sources) => { identity: sources.nativeProvider.NATIVE_MACOS_PROVIDER_IDENTITY, contracts: sources.nativeContracts.NATIVE_TOOL_CONTRACTS, }, + { + identity: sources.pwntoolsProvider.PWNTOOLS_PROVIDER_IDENTITY, + contracts: + sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS, + }, { identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER, contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS, diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs index c2b2018f8..178702f1c 100644 --- a/scripts/lib/catalog-core.mjs +++ b/scripts/lib/catalog-core.mjs @@ -40,6 +40,9 @@ const SOURCE_PATHS = { officialContracts: "dist/contracts/officialToolContracts.js", enhancedContracts: "dist/contracts/enhancedToolContracts.js", sessionContracts: "dist/contracts/sessionToolContracts.js", + binaryDiagnosticsContracts: + "dist/contracts/native/binaryDiagnosticsToolContracts.js", + pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js", nativeContracts: "dist/contracts/native/nativeToolContracts.js", artifactContracts: "dist/contracts/artifactToolContracts.js", managedContracts: "dist/contracts/managed/managedToolContracts.js", diff --git a/scripts/lib/elf-section-name-fixtures.mjs b/scripts/lib/elf-section-name-fixtures.mjs new file mode 100644 index 000000000..ad3684c84 --- /dev/null +++ b/scripts/lib/elf-section-name-fixtures.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; + +/** Source-owned ELF64 mutations for declared, absent and escaped name-table references. */ +export const sectionNameFixtures = (original, report) => { + // ELF gABI: https://gabi.xinuos.com/elf/02-eheader.html#e-shstrndx + // SHN_XINDEX must carry an actual index >= SHN_LORESERVE, not a small index. + const sourceTable = report.sections[original.readUInt16LE(62)]; + assert.equal(sourceTable.type, "SHT_STRTAB"); + const sourceHeader = Number(BigInt(sourceTable.header_location.offset)); + const fixtures = []; + for (const problem of [ + "out-of-range", + "wrong-type", + "undeclared-header", + "extended-undeclared-header", + "extended-without-sections", + "extended-absent", + "extended-small", + ]) { + let bytes = Buffer.from(original); + let selected = report.sections.length + 100; + if (problem === "wrong-type") + selected = report.sections.find( + (section) => section.type === "SHT_PROGBITS", + ).index; + else if (problem.includes("undeclared-header")) { + const end = + Number(bytes.readBigUInt64LE(40)) + + report.sections.length * bytes.readUInt16LE(58); + const extended = Buffer.alloc(Math.max(bytes.length, end + 64)); + bytes.copy(extended); + bytes.copy(extended, end, sourceHeader, sourceHeader + 64); + bytes = extended; + selected = report.sections.length; + } + if (problem === "extended-without-sections") { + bytes.writeBigUInt64LE(0n, 40); + bytes.writeUInt16LE(0, 60); + bytes.writeUInt16LE(0xffff, 62); + } else if (problem.startsWith("extended-")) { + bytes.writeUInt16LE(0xffff, 62); + if (problem === "extended-absent") selected = 0; + if (problem === "extended-small") selected = sourceTable.index; + bytes.writeUInt32LE(selected, Number(bytes.readBigUInt64LE(40)) + 40); + } else bytes.writeUInt16LE(selected, 62); + fixtures.push({ name: problem, bytes, expectation: "invalid_input" }); + } + const absent = Buffer.from(original); + absent.writeUInt16LE(0, 62); + fixtures.push({ name: "absent", bytes: absent, expectation: "absent" }); + + // A genuine escape requires a large table. Relocate the original table, keep + // its referenced indices and move only the name table to index SHN_LORESERVE. + const selected = 0xff00, + count = selected + 1; + const start = Math.ceil(original.length / 8) * 8; + const large = Buffer.alloc(start + count * 64); + original.copy(large); + const oldStart = Number(original.readBigUInt64LE(40)); + original.copy(large, start, oldStart, oldStart + report.sections.length * 64); + large.fill( + 0, + start + sourceTable.index * 64, + start + (sourceTable.index + 1) * 64, + ); + original.copy(large, start + selected * 64, sourceHeader, sourceHeader + 64); + large.writeBigUInt64LE(BigInt(start), 40); + large.writeUInt16LE(0, 60); + large.writeUInt16LE(0xffff, 62); + large.writeBigUInt64LE(BigInt(count), start + 32); + large.writeUInt32LE(selected, start + 40); + fixtures.push({ + name: "extended-large", + bytes: large, + expectation: "resolved", + tableIndex: selected, + sectionCount: count, + }); + const reserved = Buffer.from(large); + reserved.writeUInt16LE(selected, 62); + reserved.writeUInt32LE(0, start + 40); + fixtures.push({ + name: "direct-reserved", + bytes: reserved, + expectation: "invalid_input", + }); + return fixtures; +}; diff --git a/scripts/lib/large-result-mcp.mjs b/scripts/lib/large-result-mcp.mjs new file mode 100644 index 000000000..d7a4ab30f --- /dev/null +++ b/scripts/lib/large-result-mcp.mjs @@ -0,0 +1,27 @@ +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +/** Own one SDK client with an explicit receive budget for a large complete result. */ +export const withLargeResultMcp = async ( + { entrypoint, environment, maxBufferSize }, + useClient, +) => { + const client = new Client({ name: "large-result-verifier", version: "1" }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "inherit", + maxBufferSize, + }); + try { + await client.connect(transport); + return await useClient(client); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } +}; diff --git a/scripts/verify-binary-layout.mjs b/scripts/verify-binary-layout.mjs new file mode 100644 index 000000000..feb11b285 --- /dev/null +++ b/scripts/verify-binary-layout.mjs @@ -0,0 +1,1290 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + copyFile, + mkdir, + readFile, + readdir, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { mcpTextValue } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { sectionNameFixtures } from "./lib/elf-section-name-fixtures.mjs"; +import { withLargeResultMcp } from "./lib/large-result-mcp.mjs"; + +const verifyLargeMcp = process.env.REA_VERIFY_LARGE_ELF_MCP === "1"; +const python = process.env.REA_PWNTOOLS_PYTHON; +const strace = process.env.REA_VERIFY_STRACE_COMMAND; +if ( + process.platform !== "linux" || + process.arch !== "x64" || + !isAbsolute(python ?? "") || + !isAbsolute(strace ?? "") +) + throw new Error( + "verify:binary:layout requires Linux x64, absolute REA_PWNTOOLS_PYTHON (pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2) and REA_VERIFY_STRACE_COMMAND. gcc, ld and strip must be available. No target is executed.", + ); +const run = createVerifierRun(); +const execute = promisify(execFile); +for (const command of ["gcc", "ld", "strip", "/usr/bin/prlimit", strace]) { + try { + await execute(command, [command === strace ? "-V" : "--version"], { + timeout: 10_000, + maxBuffer: 1024 * 1024, + }); + } catch (cause) { + throw new Error( + `verify:binary:layout prerequisite unavailable: ${command}`, + { cause }, + ); + } +} +const entrypoint = + process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const root = await PrivateRuntimeRoot.create({ + prefix: "rea-layout-verifier-", +}); +const environment = Object.fromEntries( + Object.entries(process.env).filter(([, value]) => typeof value === "string"), +); +const client = new Client({ name: "binary-layout-verifier", version: "1" }); +const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: environment, + stderr: "pipe", +}); +const source = fileURLToPath( + new URL("./fixtures/binary-layout.c", import.meta.url), +); +const highSource = fileURLToPath( + new URL("./fixtures/binary-layout-high.S", import.meta.url), +); +let cases = 0; +let bootstrapCases = 0; +const failures = []; +try { + const bootstrapRoot = join(root.path, "bootstrap-boundary"); + await mkdir(bootstrapRoot); + const bootstrapPath = join(bootstrapRoot, "layout.py"); + await copyFile( + join(dirname(dirname(entrypoint)), "bridge/pwntools/layout.py"), + bootstrapPath, + ); + for (const [source, status] of [ + ["raise MemoryError('source-owned initialization failure')", 75], + ["raise OSError(12, 'source-owned allocation failure')", 75], + ["raise OSError(27, 'source-owned file write failure')", 76], + [ + "raise OSError(2, 'MemoryError-like text is not an allocation failure')", + 1, + ], + ]) { + await writeFile(join(bootstrapRoot, "layout_impl.py"), source); + const markerPath = join(bootstrapRoot, `failure-${bootstrapCases}`); + let exitCode = 0; + try { + await execute( + python, + ["-I", bootstrapPath, markerPath, "unused-request"], + { + timeout: 10000, + maxBuffer: 1024 * 1024, + }, + ); + } catch (cause) { + assert.equal(typeof cause.code, "number"); + exitCode = cause.code; + } + assert.equal(exitCode, status); + if (status === 75 || status === 76) + assert.equal( + await readFile(markerPath, "ascii"), + status === 75 ? "M" : "F", + ); + else await assert.rejects(readFile(markerPath), { code: "ENOENT" }); + bootstrapCases++; + } + for (const [name, flags] of [ + [ + "protected", + [ + "-fPIE", + "-pie", + "-fstack-protector-all", + "-Wl,-z,relro,-z,now", + "-Wl,-z,noexecstack", + ], + ], + [ + "plain", + [ + "-fno-pie", + "-no-pie", + "-fno-stack-protector", + "-Wl,-z,norelro", + "-Wl,-z,execstack", + ], + ], + ["relocatable", ["-c", "-fcommon", "-fPIC"]], + ["library", ["-shared", "-fPIC", "-fno-stack-protector"]], + ["packed-relative", ["-shared", "-fPIC", "-Wl,-z,pack-relative-relocs"]], + ]) + await execute( + "gcc", + ["-O1", "-g", ...flags, source, "-o", join(root.path, name)], + { timeout: 30_000, maxBuffer: 1024 * 1024 }, + ); + const sectionlessBytes = await readFile(join(root.path, "protected")); + sectionlessBytes.writeBigUInt64LE(0n, 40); + sectionlessBytes.writeUInt16LE(0, 60); + sectionlessBytes.writeUInt16LE(0, 62); + await writeFile(join(root.path, "sectionless"), sectionlessBytes); + const nullBytes = Buffer.from(await readFile(join(root.path, "protected"))); + const phOffset = Number(nullBytes.readBigUInt64LE(32)); + const phSize = nullBytes.readUInt16LE(54); + const phCount = nullBytes.readUInt16LE(56); + const unusedIndex = Array.from({ length: phCount }, (_, index) => index).find( + (index) => nullBytes.readUInt32LE(phOffset + index * phSize) === 0x6474e551, + ); + assert.notEqual( + unusedIndex, + undefined, + "Required stack fixture header absent", + ); + const unusedHeader = phOffset + unusedIndex * phSize; + nullBytes.writeUInt32LE(0, unusedHeader); + for (const field of [8, 32, 40]) + nullBytes.writeBigUInt64LE(0xffffffffffffffffn, unusedHeader + field); + await writeFile(join(root.path, "unused-segment"), nullBytes); + const emptyBytes = Buffer.from(nullBytes); + emptyBytes.writeUInt32LE(1, unusedHeader); + emptyBytes.writeBigUInt64LE(0n, unusedHeader + 32); + emptyBytes.writeBigUInt64LE(0n, unusedHeader + 40); + await writeFile(join(root.path, "empty-segment"), emptyBytes); + await copyFile(join(root.path, "protected"), join(root.path, "stripped")); + await execute("strip", ["--strip-all", join(root.path, "stripped")], { + timeout: 10_000, + }); + await execute("gcc", ["-c", highSource, "-o", join(root.path, "high.o")], { + timeout: 10_000, + }); + await execute( + "ld", + [ + "-Ttext=0x20000000000001", + "-o", + join(root.path, "high"), + join(root.path, "high.o"), + ], + { timeout: 10_000 }, + ); + await client.connect(transport); + const reports = new Map(); + for (const name of [ + "protected", + "plain", + "relocatable", + "library", + "packed-relative", + "stripped", + "sectionless", + "unused-segment", + "empty-segment", + "high", + ]) { + const path = join(root.path, name); + const bytes = await readFile(path); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, path); + assert.equal(value.artifact.sha256, sha256); + assert.equal(value.artifact.bytes, bytes.length); + assert.equal(value.runtime_load_base, null); + assert.equal(value.linkage.runtime_library_paths, null); + assert.equal(value.mitigations.evidence_kind, "inferred"); + if (name === "sectionless") { + assert.equal(value.sections.length, 0); + assert.equal(value.symbols.length, 0); + assert.deepEqual( + value.linkage.needed_libraries, + reports.get("protected").linkage.needed_libraries, + ); + assert.ok( + value.linkage.needed_libraries.every( + (name) => name.location !== null && name.bytes_base64 !== null, + ), + ); + // Preserve the unchanged engine's section-dependent heuristic and its + // explicit coverage limitation, rather than inventing a stronger label. + assert.equal(value.mitigations.relro, "Partial"); + assert.ok( + value.limitations.some((item) => + item.includes("RELRO/canary indicators may be incomplete"), + ), + ); + } else + assert.ok( + value.sections.some((section) => section.file_backing === "none"), + ); + for (const section of value.sections) { + assert.ok(typeof section.address === "string"); + if (section.file_backing === "file") + assert.ok( + BigInt(section.offset) + BigInt(section.size) <= + BigInt(bytes.length), + ); + } + if (name === "unused-segment" || name === "empty-segment") { + const segment = value.segments[unusedIndex]; + assert.equal(segment.file_backing, "none"); + assert.equal(segment.offset, "0xffffffffffffffff"); + if (name === "unused-segment") assert.equal(segment.permissions, null); + else assert.notEqual(segment.permissions, null); + } + assert.equal(value.relocation_inventory_completeness, "unknown"); + if (name === "library" || name === "packed-relative") { + assert.equal(value.entry_point.reported_value, "0x0"); + assert.equal(value.entry_point.meaning, "absent"); + } + if (name === "packed-relative") { + assert.ok(value.packed_relative_relocations.length > 0); + for (const table of value.packed_relative_relocations) { + assert.equal(table.evidence_kind, "derived"); + assert.equal(table.offset_meaning, "linked-virtual-address"); + assert.equal(table.entry_source_locations, null); + assert.equal(table.addends, null); + assert.ok(table.entries.length > 0); + const start = Number(BigInt(table.location.offset)); + const length = Number(BigInt(table.location.bytes)); + assert.deepEqual( + Buffer.from(table.encoded_bytes_base64, "base64"), + bytes.subarray(start, start + length), + ); + } + } + reports.set(name, value); + assert.deepEqual(await readFile(path), bytes); + cases++; + } + } + const protectedReport = reports.get("protected"); + const fileSymbols = protectedReport.symbols.filter( + (symbol) => symbol.type === "STT_FILE", + ); + assert.ok(fileSymbols.length > 0); + assert.ok( + fileSymbols.every((symbol) => symbol.value_meaning === "no-address"), + ); + const plain = reports.get("plain"); + assert.equal(protectedReport.mitigations.position_independent, true); + assert.equal(protectedReport.mitigations.nx_indicator, true); + assert.equal(protectedReport.mitigations.stack_canary_indicator, true); + assert.equal(protectedReport.mitigations.relro, "Full"); + assert.equal(plain.mitigations.position_independent, false); + assert.equal(plain.mitigations.nx_indicator, null); + assert.equal(plain.mitigations.executable_stack_indicator, true); + assert.equal(protectedReport.mitigations.executable_stack_indicator, false); + assert.equal(plain.mitigations.stack_canary_indicator, false); + assert.equal(plain.mitigations.relro, null); + const relocatable = reports.get("relocatable"); + assert.equal(relocatable.entry_point.meaning, "not-applicable"); + assert.ok(relocatable.relocations.length > 0); + assert.ok( + relocatable.relocations.every( + (item) => item.target.kind === "section-offset", + ), + ); + assert.ok( + relocatable.relocations.some((item) => BigInt(item.addend ?? "0") < 0n), + ); + assert.equal( + relocatable.symbols.find((symbol) => symbol.name.display === "common_value") + .value_meaning, + "alignment", + ); + assert.equal( + reports + .get("library") + .symbols.find((symbol) => symbol.name.display === "tls_value") + .value_meaning, + "tls-offset", + ); + assert.equal( + reports.get("high").entry_point.reported_value, + "0x20000000000001", + ); + assert.equal( + reports + .get("high") + .symbols.find((symbol) => symbol.name.display === "absolute_value").value, + "0x20000000000007", + ); + assert.equal( + reports + .get("high") + .symbols.find((symbol) => symbol.name.display === "absolute_value") + .value_meaning, + "absolute-value", + ); + assert.ok( + reports.get("stripped").symbols.length < protectedReport.symbols.length, + ); + const object = await readFile(join(root.path, "relocatable")); + const name = relocatable.symbols.find( + (symbol) => symbol.name.display === "read_values", + ).name; + const changed = Buffer.from(object); + changed[Number(BigInt(name.location.offset))] = 0xff; + const opaque = join(root.path, "opaque-name.o"); + await writeFile(opaque, changed); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, opaque); + const symbol = value.symbols.find((item) => + item.name.display.endsWith("ead_values"), + ); + assert.equal(Buffer.from(symbol.name.bytes_base64, "base64")[0], 0xff); + assert.equal(symbol.name.location.offset, name.location.offset); + assert.deepEqual(await readFile(opaque), changed); + cases++; + } + const sectionBearingBytes = await readFile(join(root.path, "protected")); + const dynamicSegment = protectedReport.segments.find( + (segment) => segment.type === "PT_DYNAMIC", + ); + const interpreterSegment = protectedReport.segments.find( + (segment) => segment.type === "PT_INTERP", + ); + assert.notEqual(dynamicSegment, undefined); + assert.notEqual(interpreterSegment, undefined); + const dependency = protectedReport.linkage.needed_libraries[0]; + assert.notEqual(dependency, undefined); + assert.notEqual(dependency.location, null); + for (const [profile, original] of [ + ["section-bearing", sectionBearingBytes], + ["sectionless", sectionlessBytes], + ]) { + for (const facet of ["dependency", "interpreter"]) { + const bytes = Buffer.from(original); + const reference = + facet === "dependency" + ? dependency + : protectedReport.linkage.interpreters[0]; + assert.notEqual(reference, undefined); + assert.notEqual(reference.location, null); + const raw = Buffer.from(reference.bytes_base64, "base64"); + const changedIndex = facet === "dependency" ? 0 : 1; + raw[changedIndex] = 0xff; + bytes[Number(BigInt(reference.location.offset)) + changedIndex] = 0xff; + const path = join(root.path, `opaque-${profile}-${facet}`); + await writeFile(path, bytes); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, path); + const actual = + facet === "dependency" + ? value.linkage.needed_libraries[0] + : value.linkage.interpreters[0]; + assert.deepEqual(actual, { + ...reference, + display: raw.toString("utf8"), + bytes_base64: raw.toString("base64"), + }); + assert.equal( + value.artifact.sha256, + createHash("sha256").update(bytes).digest("hex"), + ); + assert.deepEqual(await readFile(path), bytes); + cases++; + } + } + } + for (const fixture of sectionNameFixtures( + sectionBearingBytes, + protectedReport, + )) { + const path = join(root.path, `section-names-${fixture.name}`); + await writeFile(path, fixture.bytes); + for (const mode of ["cli", "mcp"]) { + if ( + fixture.expectation === "resolved" && + mode === "mcp" && + !verifyLargeMcp + ) + continue; + // Ordinary cases retain the SDK default. Only this complete 65,281-row + // case selects a larger receive budget; production data is not truncated. + const value = + fixture.expectation === "resolved" && mode === "mcp" + ? await withLargeResultMcp( + { entrypoint, environment, maxBufferSize: 256 * 1024 * 1024 }, + (largeClient) => + inspect(mode, path, undefined, environment, largeClient), + ) + : await inspect( + mode, + path, + fixture.expectation === "invalid_input" + ? "invalid_input" + : undefined, + ); + if (fixture.expectation === "absent") { + assert.deepEqual(value.symbols, protectedReport.symbols); + assert.deepEqual( + value.sections.map((section) => section.name), + protectedReport.sections.map(() => ({ + display: "", + bytes_base64: null, + location: null, + unknown_reason: + "ELF declares no section-name table (SHN_UNDEF index 0).", + })), + ); + assert.deepEqual( + value.sections.map((section) => section.name_offset), + protectedReport.sections.map((section) => section.name_offset), + ); + } else if (fixture.expectation === "resolved") { + assert.equal(value.sections.length, fixture.sectionCount); + assert.deepEqual( + value.sections[fixture.tableIndex].name, + protectedReport.sections[sectionBearingBytes.readUInt16LE(62)].name, + ); + assert.deepEqual(value.symbols, protectedReport.symbols); + } + cases++; + } + assert.deepEqual(await readFile(path), fixture.bytes); + } + // Malformed references must fail before the decoder reads unrelated bytes. + const neededTagOffsets = []; + let stringSizeTagOffset; + for ( + let offset = Number(BigInt(dynamicSegment.offset)); + offset < + Number(BigInt(dynamicSegment.offset) + BigInt(dynamicSegment.file_size)); + offset += 16 + ) { + const tag = sectionBearingBytes.readBigUInt64LE(offset); + if (tag === 0n) break; + if (tag === 1n) neededTagOffsets.push(offset + 8); + if (tag === 10n) stringSizeTagOffset = offset + 8; + } + assert.ok(neededTagOffsets.length > 0); + assert.notEqual(stringSizeTagOffset, undefined); + for (const [profile, original] of [ + ["section-bearing", sectionBearingBytes], + ["sectionless", sectionlessBytes], + ]) { + for (const problem of ["offset-at-end", "truncated-table"]) { + const bytes = Buffer.from(original); + if (problem === "offset-at-end") + bytes.writeBigUInt64LE( + bytes.readBigUInt64LE(stringSizeTagOffset), + neededTagOffsets[0], + ); + else { + assert.ok(bytes.readBigUInt64LE(neededTagOffsets[0]) > 0n); + bytes.writeBigUInt64LE(1n, stringSizeTagOffset); + } + const path = join(root.path, `invalid-dependency-${profile}-${problem}`); + await writeFile(path, bytes); + for (const mode of ["cli", "mcp"]) { + await inspect(mode, path, "invalid_input"); + cases++; + } + assert.deepEqual(await readFile(path), bytes); + } + } + for (const type of ["SHT_SYMTAB", "SHT_DYNSYM"]) { + const table = protectedReport.sections.find( + (section) => section.type === type, + ); + const other = protectedReport.sections.find( + (section) => section.type === "SHT_PROGBITS", + ); + assert.notEqual(table, undefined); + assert.notEqual(other, undefined); + const header = Number(BigInt(table.header_location.offset)); + const strings = protectedReport.sections[table.link]; + assert.equal(strings.type, "SHT_STRTAB"); + for (const problem of [ + "out-of-range", + "null", + "wrong-type", + "undeclared-header", + ]) { + let bytes = Buffer.from(sectionBearingBytes); + let link = + problem === "null" + ? 0 + : problem === "wrong-type" + ? other.index + : protectedReport.sections.length + 100; + if (problem === "undeclared-header") { + // Upstream can read an otherwise valid header beyond declared e_shnum. + const end = + Number(bytes.readBigUInt64LE(40)) + + protectedReport.sections.length * bytes.readUInt16LE(58); + const extended = Buffer.alloc(Math.max(bytes.length, end + 64)); + bytes.copy(extended); + const start = Number(BigInt(strings.header_location.offset)); + bytes.copy(extended, end, start, start + 64); + bytes = extended; + link = protectedReport.sections.length; + } + bytes.writeUInt32LE(link, header + 40); + const path = join(root.path, `invalid-symbol-link-${type}-${problem}`); + await writeFile(path, bytes); + for (const mode of ["cli", "mcp"]) { + await inspect(mode, path, "invalid_input"); + cases++; + } + assert.deepEqual(await readFile(path), bytes); + } + } + const shortDynamic = Buffer.from(sectionBearingBytes); + shortDynamic.writeBigUInt64LE( + 16n, + Number(BigInt(dynamicSegment.header_location.offset)) + 32, + ); + assert.notEqual( + shortDynamic.readBigUInt64LE(Number(BigInt(dynamicSegment.offset))), + 0n, + ); + const paddedInterpreter = Buffer.from(sectionBearingBytes); + const interpreterStart = Number(BigInt(interpreterSegment.offset)); + paddedInterpreter[interpreterStart + 2] = 0; + const paddedInterpreterPath = join(root.path, "padded-interpreter"); + await writeFile(paddedInterpreterPath, paddedInterpreter); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, paddedInterpreterPath); + assert.deepEqual(value.linkage.interpreters, [ + { + display: "/l", + bytes_base64: Buffer.from("/l").toString("base64"), + location: { offset: interpreterSegment.offset, bytes: "0x3" }, + unknown_reason: null, + }, + ]); + assert.equal( + value.segments[interpreterSegment.index].file_size, + interpreterSegment.file_size, + ); + assert.deepEqual(await readFile(paddedInterpreterPath), paddedInterpreter); + cases++; + } + const unterminatedInterpreter = Buffer.from(sectionBearingBytes); + unterminatedInterpreter.fill( + 0x58, + interpreterStart, + interpreterStart + Number(BigInt(interpreterSegment.file_size)), + ); + const zeroSymbolEntries = ["SHT_SYMTAB", "SHT_DYNSYM"].map((type) => { + const section = protectedReport.sections.find((item) => item.type === type); + assert.notEqual( + section, + undefined, + `Required fixture table absent: ${type}`, + ); + const bytes = Buffer.from(sectionBearingBytes); + bytes.writeBigUInt64LE( + 0n, + Number(BigInt(section.header_location.offset)) + 56, + ); + return [`zero-entry-${type}`, bytes, "invalid_input"]; + }); + const undersizedSymbolEntries = ["SHT_SYMTAB", "SHT_DYNSYM"].map((type) => { + const section = protectedReport.sections.find((item) => item.type === type); + assert.notEqual(section, undefined); + const bytes = Buffer.from(sectionBearingBytes); + const header = Number(BigInt(section.header_location.offset)); + bytes.writeBigUInt64LE(8n, header + 32); + bytes.writeBigUInt64LE(8n, header + 56); + return [`undersized-entry-${type}`, bytes, "invalid_input"]; + }); + const relocationSection = relocatable.sections.find( + (item) => item.type === "SHT_RELA", + ); + assert.notEqual(relocationSection, undefined); + const undersizedRelocationEntries = [ + ["REL", 9], + ["RELA", 4], + ].map(([type, sectionType]) => { + const bytes = Buffer.from(object); + const header = Number(BigInt(relocationSection.header_location.offset)); + bytes.writeUInt32LE(sectionType, header + 4); + bytes.writeBigUInt64LE(8n, header + 32); + bytes.writeBigUInt64LE(8n, header + 56); + return [`undersized-entry-${type}`, bytes, "invalid_input"]; + }); + const invalidRelocationReferences = [ + ["missing-symbol-table", relocatable.sections.length + 100], + ["wrong-symbol-table-kind", 1], + ["positive-symbol-without-table", 0], + ["missing-relocation-symbol", null], + ].map(([name, link]) => { + const bytes = Buffer.from(object); + const header = Number(BigInt(relocationSection.header_location.offset)); + if (link === null) { + const offset = Number(BigInt(relocationSection.offset)) + 8; + const type = bytes.readBigUInt64LE(offset) & 0xffffffffn; + bytes.writeBigUInt64LE((0xffffffffn << 32n) | type, offset); + } else bytes.writeUInt32LE(link, header + 40); + return [name, bytes, "invalid_input"]; + }); + const inactiveTarget = relocatable.sections.find( + (section) => section.name.display === ".comment", + ); + assert.notEqual( + inactiveTarget, + undefined, + "Required inactive-target fixture absent", + ); + for (const [name, targetIndex, reason] of [ + ["undefined-relocation-target", 0, "undefined-section-reference"], + [ + "inactive-relocation-target", + inactiveTarget.index, + "inactive-section-header", + ], + ]) { + const bytes = Buffer.from(object); + bytes.writeUInt32LE( + targetIndex, + Number(BigInt(relocationSection.header_location.offset)) + 44, + ); + if (targetIndex !== 0) + bytes.writeUInt32LE( + 0, + Number(BigInt(inactiveTarget.header_location.offset)) + 4, + ); + const inactiveSymbol = relocatable.symbols.find( + (symbol) => symbol.name.display === "read_values", + ); + assert.notEqual(inactiveSymbol, undefined); + if (targetIndex !== 0) + bytes.writeUInt16LE( + targetIndex, + Number(BigInt(inactiveSymbol.location.offset)) + 6, + ); + const path = join(root.path, name); + await writeFile(path, bytes); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, path); + const rows = value.relocations.filter( + (item) => item.section_index === relocationSection.index, + ); + assert.ok(rows.length > 0); + for (const row of rows) + assert.deepEqual(row.target, { + kind: "unknown-section", + reported_section_index: targetIndex, + offset: row.reported_offset, + unknown_reason: reason, + }); + if (targetIndex !== 0) + assert.equal( + value.symbols.find((symbol) => symbol.name.display === "read_values") + .value_meaning, + "unknown-section-index", + ); + assert.deepEqual(await readFile(path), bytes); + cases++; + } + } + const relative = protectedReport.relocations.find( + (item) => item.type === 8 && item.symbol_index === 0, + ); + assert.notEqual(relative, undefined, "Required relative relocation absent"); + const relativeOwner = protectedReport.sections[relative.section_index]; + const noSymbolTable = Buffer.from(sectionBearingBytes); + const relativeHeader = Number(BigInt(relativeOwner.header_location.offset)); + noSymbolTable.writeBigUInt64LE( + BigInt(relative.location.offset), + relativeHeader + 24, + ); + noSymbolTable.writeBigUInt64LE( + BigInt(relative.location.bytes), + relativeHeader + 32, + ); + noSymbolTable.writeUInt32LE(0, relativeHeader + 40); + const zeroSymbolPath = join(root.path, "relative-without-symbol-table"); + await writeFile(zeroSymbolPath, noSymbolTable); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, zeroSymbolPath); + const reported = value.relocations.find( + (item) => item.section_index === relative.section_index, + ); + assert.equal(reported.symbol_table_index, 0); + assert.equal(reported.symbol_index, 0); + assert.equal(reported.symbol_reference_meaning, "zero-symbol-value"); + assert.deepEqual(await readFile(zeroSymbolPath), noSymbolTable); + cases++; + } + const undersizedProgramHeader = Buffer.from(sectionBearingBytes); + const missingTargetSection = Buffer.from(object); + missingTargetSection.writeUInt32LE( + relocatable.sections.length + 100, + Number(BigInt(relocationSection.header_location.offset)) + 44, + ); + undersizedProgramHeader.writeUInt16LE(8, 54); + undersizedProgramHeader.writeUInt16LE(1, 56); + const symbolTable = relocatable.sections.find( + (section) => section.type === "SHT_SYMTAB", + ); + assert.notEqual(symbolTable, undefined); + const symbol = relocatable.symbols.find( + (item) => item.name.display === "read_values", + ); + assert.notEqual(symbol, undefined); + const extendedWords = Buffer.alloc( + Number(BigInt(symbolTable.size) / BigInt(symbolTable.entry_size)) * 4, + ); + extendedWords.writeUInt32LE(symbol.section_index, symbol.entry_index * 4); + const headers = Buffer.from( + object.subarray( + Number(BigInt(relocatable.sections[0].header_location.offset)), + Number( + BigInt(relocatable.sections.at(-1).header_location.offset) + + BigInt(relocatable.sections.at(-1).header_location.bytes), + ), + ), + ); + const extendedHeader = Buffer.alloc(64); + extendedHeader.writeUInt32LE(18, 4); // SHT_SYMTAB_SHNDX + extendedHeader.writeBigUInt64LE(BigInt(object.length), 24); + extendedHeader.writeBigUInt64LE(BigInt(extendedWords.length), 32); + extendedHeader.writeUInt32LE(symbolTable.index, 40); + extendedHeader.writeBigUInt64LE(4n, 48); + extendedHeader.writeBigUInt64LE(4n, 56); + const extendedObject = Buffer.concat([ + object, + extendedWords, + headers, + extendedHeader, + ]); + extendedObject.writeBigUInt64LE( + BigInt(object.length + extendedWords.length), + 40, + ); + extendedObject.writeUInt16LE(relocatable.sections.length + 1, 60); + extendedObject.writeUInt16LE( + 0xffff, + Number(BigInt(symbol.location.offset)) + 6, + ); + const extendedPath = join(root.path, "external-symbol-index.o"); + await writeFile(extendedPath, extendedObject); + for (const mode of ["cli", "mcp"]) { + const value = await inspect(mode, extendedPath); + const reported = value.symbols.find( + (item) => item.name.display === "read_values", + ); + assert.equal(reported.section_index, 0xffff); + assert.equal(reported.value_meaning, "unknown-section-index"); + assert.ok( + value.sections.some((section) => section.type === "SHT_SYMTAB_SHNDX"), + ); + assert.deepEqual(await readFile(extendedPath), extendedObject); + cases++; + } + for (const [name, option, expected] of [ + ["address-space", "--as=2147483648", '"address_space_bytes": 2147483648'], + ["cpu", "--cpu=20", '"cpu_seconds": 20'], + ["file-size", "--fsize=33554432", '"file_size_bytes": 33554432'], + ]) { + const wrapper = join(root.path, `python-limit-${name}`); + const quotedPython = "'" + python.replaceAll("'", "'\"'\"'") + "'"; + await writeFile( + wrapper, + `#!/bin/sh\nexec /usr/bin/prlimit ${option} -- ${quotedPython} "$@"\n`, + { mode: 0o700 }, + ); + const limitedEnvironment = { ...environment, REA_PWNTOOLS_PYTHON: wrapper }; + const limitedClient = new Client({ + name: "limited-layout-verifier", + version: "1", + }); + const limitedTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: limitedEnvironment, + stderr: "pipe", + }); + try { + await limitedClient.connect(limitedTransport); + for (const mode of ["cli", "mcp"]) { + const value = await inspect( + mode, + join(root.path, "protected"), + undefined, + limitedEnvironment, + limitedClient, + ); + assert.ok(value.limitations.some((item) => item.includes(expected))); + cases++; + } + } finally { + try { + await limitedClient.close(); + } finally { + await limitedTransport.close(); + } + } + } + const unsupported = Buffer.from(object); + const originalSectionOffset = Number(object.readBigUInt64LE(40)); + const sectionSize = object.readUInt16LE(58); + const originalSectionCount = object.readUInt16LE(60); + const sectionCount = 30000; + const sectionOffset = Math.ceil(object.length / 8) * 8; + const sectionHeavy = Buffer.alloc(sectionOffset + sectionCount * sectionSize); + object.copy(sectionHeavy); + object.copy( + sectionHeavy, + sectionOffset, + originalSectionOffset, + originalSectionOffset + originalSectionCount * sectionSize, + ); + for (let index = originalSectionCount; index < sectionCount; index++) { + sectionHeavy.writeUInt32LE(1, sectionOffset + index * sectionSize + 4); + sectionHeavy.writeBigUInt64LE(1n, sectionOffset + index * sectionSize + 48); + } + sectionHeavy.writeBigUInt64LE(BigInt(sectionOffset), 40); + sectionHeavy.writeUInt16LE(sectionCount, 60); + const sectionHeavyPath = join(root.path, "section-heavy.o"); + await writeFile(sectionHeavyPath, sectionHeavy); + const memoryWrapper = join(root.path, "python-memory-constraint"); + const quotedPython = "'" + python.replaceAll("'", "'\"'\"'") + "'"; + await writeFile( + memoryWrapper, + `#!/bin/sh\nexec /usr/bin/prlimit --as=100663296 -- ${quotedPython} "$@"\n`, + { mode: 0o700 }, + ); + const memoryEnvironment = { + ...environment, + REA_PWNTOOLS_PYTHON: memoryWrapper, + }; + const memoryClient = new Client({ + name: "memory-layout-verifier", + version: "1", + }); + const memoryTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: memoryEnvironment, + stderr: "pipe", + }); + try { + await memoryClient.connect(memoryTransport); + for (const mode of ["cli", "mcp"]) { + const error = await inspect( + mode, + sectionHeavyPath, + "resource_constraint", + memoryEnvironment, + memoryClient, + ); + assert.equal(error.code, "resource_constraint"); + assert.equal(error.details.resource, "memory"); + assert.ok( + error.details.reported_limits === null || + error.details.reported_limits.address_space_bytes === 100663296, + ); + assert.equal(error.details.captured_output.truncated, false); + assert.ok(error.remediation.action.includes("memory")); + cases++; + } + assert.deepEqual(await readFile(sectionHeavyPath), sectionHeavy); + } finally { + try { + await memoryClient.close(); + } finally { + await memoryTransport.close(); + } + } + // Extended section numbering keeps this valid while providing enough work + // to reach the one-second CPU limit even on faster CI runners. + const cpuSectionCount = 300000; + const cpuHeavy = Buffer.alloc(sectionOffset + cpuSectionCount * sectionSize); + sectionHeavy.copy(cpuHeavy); + for (let index = sectionCount; index < cpuSectionCount; index++) { + cpuHeavy.writeUInt32LE(1, sectionOffset + index * sectionSize + 4); + cpuHeavy.writeBigUInt64LE(1n, sectionOffset + index * sectionSize + 48); + } + cpuHeavy.writeUInt16LE(0, 60); + cpuHeavy.writeBigUInt64LE(BigInt(cpuSectionCount), sectionOffset + 32); + const cpuHeavyPath = join(root.path, "cpu-section-heavy.o"); + await writeFile(cpuHeavyPath, cpuHeavy); + const cpuWrapper = join(root.path, "python-cpu-constraint"); + await writeFile( + cpuWrapper, + `#!/bin/sh\nexec /usr/bin/prlimit --cpu=1: --core=0: -- ${quotedPython} "$@"\n`, + { mode: 0o700 }, + ); + const cpuEnvironment = { ...environment, REA_PWNTOOLS_PYTHON: cpuWrapper }; + const cpuClient = new Client({ name: "cpu-layout-verifier", version: "1" }); + const cpuTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: cpuEnvironment, + stderr: "pipe", + }); + try { + await cpuClient.connect(cpuTransport); + for (const mode of ["cli", "mcp"]) { + const error = await inspect( + mode, + cpuHeavyPath, + "resource_constraint", + cpuEnvironment, + cpuClient, + ); + assert.equal(error.details.resource, "cpu"); + assert.equal(error.details.reported_limits.cpu_seconds, 1); + assert.ok(JSON.stringify(error).includes("SIGXCPU")); + assert.ok(error.remediation.action.includes("CPU")); + cases++; + } + assert.deepEqual(await readFile(cpuHeavyPath), cpuHeavy); + } finally { + try { + await cpuClient.close(); + } finally { + await cpuTransport.close(); + } + } + for (const limit of [1024, 1]) { + const wrapper = join(root.path, `python-file-size-${limit}`); + await writeFile( + wrapper, + `#!/bin/sh\nexec /usr/bin/prlimit --fsize=${limit}: --core=0: -- ${quotedPython} "$@"\n`, + { mode: 0o700 }, + ); + const fileEnvironment = { ...environment, REA_PWNTOOLS_PYTHON: wrapper }; + const fileClient = new Client({ + name: "file-size-layout-verifier", + version: "1", + }); + const fileTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: fileEnvironment, + stderr: "pipe", + }); + try { + await fileClient.connect(fileTransport); + for (const mode of ["cli", "mcp"]) { + const error = await inspect( + mode, + sectionHeavyPath, + "resource_constraint", + fileEnvironment, + fileClient, + ); + assert.equal(error.details.resource, "file-size"); + if (limit === 1024) + assert.equal(error.details.reported_limits.file_size_bytes, limit); + else { + assert.equal(error.details.reported_limits, null); + assert.ok(JSON.stringify(error).includes("limit report unavailable")); + } + assert.ok(error.remediation.action.includes("file-size")); + cases++; + } + assert.deepEqual(await readFile(sectionHeavyPath), sectionHeavy); + } finally { + try { + await fileClient.close(); + } finally { + await fileTransport.close(); + } + } + } + unsupported.writeUInt16LE(183, 18); + const core = Buffer.from(object); + core.writeUInt16LE(4, 16); + for (const [name, bytes, category] of [ + ["not-elf", Buffer.from("ordinary local text"), "invalid_input"], + ["truncated", object.subarray(0, 32), "invalid_input"], + [ + "truncated-tables", + object.subarray(0, object.length - 1), + "invalid_input", + ], + ...zeroSymbolEntries, + ...undersizedSymbolEntries, + ...undersizedRelocationEntries, + ...invalidRelocationReferences, + ["unterminated-dynamic-segment", shortDynamic, "invalid_input"], + ["unterminated-interpreter", unterminatedInterpreter, "invalid_input"], + [ + "missing-relocation-target-section", + missingTargetSection, + "invalid_input", + ], + ["undersized-program-header", undersizedProgramHeader, "invalid_input"], + ["arm64", unsupported, "unsupported_provider"], + ["core", core, "unsupported_provider"], + ]) { + const path = join(root.path, name); + await writeFile(path, bytes); + for (const mode of ["cli", "mcp"]) { + await inspect(mode, path, category); + assert.deepEqual(await readFile(path), bytes); + cases++; + } + } + const packedFile = await readFile(join(root.path, "packed-relative")); + const packedTable = + reports.get("packed-relative").packed_relative_relocations[0]; + assert.notEqual(packedTable, undefined); + packedFile.writeBigUInt64LE(1n, Number(BigInt(packedTable.location.offset))); + const badPackedPath = join(root.path, "relr-bitmap-without-anchor"); + await writeFile(badPackedPath, packedFile); + for (const mode of ["cli", "mcp"]) { + await inspect(mode, badPackedPath, "invalid_input"); + assert.deepEqual(await readFile(badPackedPath), packedFile); + cases++; + } + const brokenPython = join(root.path, "broken-configured-python"); + await writeFile( + brokenPython, + "#!/rea-missing-configured-python-interpreter\n", + { mode: 0o700 }, + ); + const unmarkedLaunchers = []; + for (const status of [75, 76]) { + const path = join(root.path, `unmarked-launcher-${status}`); + await writeFile( + path, + `#!/bin/sh\nprintf 'launcher exited ${status}\\n' >&2\nexit ${status}\n`, + { mode: 0o700 }, + ); + unmarkedLaunchers.push(path); + } + for (const [configuredPython, category] of [ + [root.path, "unavailable"], + [brokenPython, "unavailable"], + ...unmarkedLaunchers.map((path) => [path, "execution_failure"]), + ]) { + const selectedEnvironment = { + ...environment, + REA_PWNTOOLS_PYTHON: configuredPython, + }; + const selectedClient = new Client({ + name: "unavailable-layout-verifier", + version: "1", + }); + const selectedTransport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env: selectedEnvironment, + stderr: "pipe", + }); + try { + await selectedClient.connect(selectedTransport); + for (const mode of ["cli", "mcp"]) { + const error = await inspect( + mode, + join(root.path, "protected"), + category, + selectedEnvironment, + selectedClient, + ); + if (category === "unavailable") { + assert.equal(error.code, "provider_unavailable"); + assert.equal( + error.details.rejections[0].diagnostics.executable_path, + configuredPython, + ); + } else { + assert.equal(error.code, "execution_failure"); + assert.ok(JSON.stringify(error).includes("launcher exited")); + assert.ok(JSON.stringify(error).includes("resource_failure_marker")); + } + cases++; + } + } finally { + try { + await selectedClient.close(); + } finally { + await selectedTransport.close(); + } + } + } + for (const mode of ["cli", "mcp"]) { + await inspect(mode, join(root.path, "absent"), "invalid_input"); + cases++; + } + for (const name of ["protected", "sectionless"]) { + const trace = join(root.path, `exec.trace.${name}`); + await execute( + strace, + [ + "-ff", + "-e", + "trace=execve,execveat", + "-s", + "4096", + "-o", + trace, + process.execPath, + entrypoint, + "inspect-binary-layout", + join(root.path, name), + "--json", + ], + { + env: { ...environment, PATH: "/usr/bin:/bin" }, + timeout: 45_000, + maxBuffer: 256 * 1024 * 1024, + }, + ); + } + const executions = []; + for (const file of await readdir(root.path)) { + if (!file.startsWith("exec.trace.")) continue; + for (const line of (await readFile(join(root.path, file), "utf8")).split( + "\n", + )) { + if (!/execve(?:at)?\(/.test(line)) continue; + const match = /^execve\("([^\"]+)"/.exec(line); + assert.notEqual( + match, + null, + `Unresolved executable identity in syscall trace: ${line}`, + ); + assert.ok( + line.endsWith(" = 0") || / = -1 [A-Z]+/.test(line), + `Incomplete exec observation: ${line}`, + ); + const ownershipInspection = + ["/usr/bin/ps", "/bin/ps"].includes(match[1]) && + line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]'); + assert.ok( + [process.execPath, python].includes(match[1]) || ownershipInspection, + `Unexpected attempted host execution: ${line}`, + ); + executions.push(match[1]); + } + } + assert.ok(executions.includes(process.execPath)); + assert.ok(executions.includes(python)); + cases += 2; +} catch (cause) { + failures.push(cause); +} finally { + for (const close of [ + () => client.close(), + () => transport.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); +} +if (failures.length > 0) { + console.error( + JSON.stringify( + { status: "failed", public_cases: cases, verifier }, + null, + 2, + ), + ); + throw new AggregateError( + failures, + "Offline binary layout verification failed; cleanup failures are retained.", + ); +} +console.log( + JSON.stringify( + { + status: "passed", + public_cases: cases, + bootstrap_boundary_cases: bootstrapCases, + large_index_mcp: verifyLargeMcp ? "verified" : "not-requested", + profile: "pwntools4.15.0/pyelftools0.33/Unicorn2.1.2", + target_execution: "exec-syscalls-verified-absent", + verifier, + }, + null, + 2, + ), +); + +async function inspect( + mode, + path, + category, + selectedEnvironment = environment, + selectedClient = client, +) { + let envelope; + if (mode === "mcp") { + const response = await selectedClient.callTool( + { + name: "inspect_binary_layout", + arguments: { path }, + }, + { timeout: 300_000 }, + ); + const value = JSON.parse(mcpTextValue(response)); + if (category !== undefined) { + assert.equal(response.isError, true); + assert.equal(value.error.category, category, JSON.stringify(value.error)); + return value.error; + } + assert.notEqual(response.isError, true, mcpTextValue(response)); + envelope = value.evidence; + assert.deepEqual(value.result, envelope.normalized_result); + } else { + let response; + try { + response = await execute( + process.execPath, + [entrypoint, "inspect-binary-layout", path, "--json"], + { + env: selectedEnvironment, + timeout: 40_000, + maxBuffer: 256 * 1024 * 1024, + }, + ); + } catch (cause) { + if (category === undefined) throw cause; + if (typeof cause.code !== "number") throw cause; + const error = JSON.parse(cause.stdout); + assert.equal(error.category, category, JSON.stringify(error)); + return error; + } + assert.equal(category, undefined, "Expected selected input to fail"); + envelope = JSON.parse(response.stdout); + } + const evidence = parseEvidence(envelope); + assert.equal(evidence.subject.local_path, path); + assert.equal( + evidence.provider.version, + "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2", + ); + assert.deepEqual(evidence.normalized_result, evidence.raw_result); + assert.equal(evidence.normalized_result.diagnostics.truncated, false); + return evidence.normalized_result; +} diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index 3d4dac785..0657e88eb 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -2,9 +2,9 @@ name: reverse-engineer-anything description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis. metadata: - version: "29" - tool_count: 135 - catalog_digest: "bfa2ed377826f8e561d1b8a9024d4ab0cf11f09d8b1af86c80d2884c1d72dbf3" + version: "30" + tool_count: 136 + catalog_digest: "fc75e5d817f64311dacc23ef2bde4223fd12d2d5a9c49e93051ac9f7857b2d8c" --- # REA @@ -99,6 +99,11 @@ explicit path or endpoint and do not need it. [historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream profile and credential exclusions. - User-owned Electron runtime already open: `list_electron_targets`. +- Explicit Linux ELF file for offline layout, symbols, relocations or static + mitigation evidence: `inspect_binary_layout`. This target-free operation uses + 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). - Native executable, library, or analysis database: `open_binary`, then use focused analysis tools directly; call `binary_overview` when metadata or inventory context is useful and available from the selected provider. diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index a2dcad5c3..ab2c25d94 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -33,6 +33,7 @@ type ProviderDescriptor = ProviderCapability; export type AvailabilityPolicy = { readonly processCaptureEnabled: boolean; readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures; + readonly binaryLayoutEnabled?: boolean; readonly firmwareInspectionEnabled?: boolean; readonly firmwareExtractionEnabled?: boolean; readonly androidAnalysisEnabled?: boolean; @@ -280,6 +281,14 @@ const workflowAvailabilityFor = ({ remediation: "On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru 1.13.0 and util-linux prlimit. No binary target is required.", }; + if (name === "inspect_binary_layout") + return policy.binaryLayoutEnabled === true + ? { reason: "available", remediation: null } + : { + reason: "provider_missing", + remediation: + "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", + }; if (kind === "firmware-provider") { const enabled = name === "inspect_firmware_regions" diff --git a/src/application/binaryDiagnostics/BinaryLayoutPort.ts b/src/application/binaryDiagnostics/BinaryLayoutPort.ts new file mode 100644 index 000000000..a6936a60f --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryLayoutPort.ts @@ -0,0 +1,17 @@ +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { AnalysisError } from "../../domain/analysisErrorBase.js"; +import type { + BinaryLayout, + InspectBinaryLayoutInput, +} from "../../domain/native/binaryLayout.js"; +import type { Result } from "../../domain/result.js"; +import type { ProviderIdentity } from "../AnalysisProvider.js"; + +/** Replaceable offline binary-layout provider; owns its parser and process protocol. */ +export interface BinaryLayoutPort { + readonly identity: ProviderIdentity; + inspect( + input: InspectBinaryLayoutInput, + options?: ExecutionOptions, + ): Promise>; +} diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts new file mode 100644 index 000000000..dcfad2311 --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -0,0 +1,131 @@ +import { expect, it } from "vitest"; +import { BinaryLayoutService } from "./BinaryLayoutService.js"; +import { + binaryLayoutFixture, + BINARY_LAYOUT_TEST_PROVIDER, +} from "../../../tests/fixtures/binaryDiagnostics/layout.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 = binaryLayoutFixture(); + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_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(BINARY_LAYOUT_TEST_PROVIDER); + expect(evidence.confidence).toBe("observed"); +}); + +it.each([ + { path: "relative.elf" }, + { path: "/selected.elf", approval: true }, + { path: 123 }, + {}, +])( + "rejects malformed selection before starting the provider: %j", + async (input) => { + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_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-layout", + "inspect_binary_layout", + "ELF core belongs to recorded-crash analysis.", + ); + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_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 BinaryLayoutService({ + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => { + controller.abort(); + return Promise.resolve(ok(binaryLayoutFixture())); + }, + }); + expect( + await service.inspect( + { path: "/artifacts/source-owned.elf" }, + { signal: controller.signal }, + ), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + capturedOutput: binaryLayoutFixture().diagnostics, + }, + }); + const untouched = new BinaryLayoutService({ + identity: BINARY_LAYOUT_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 = binaryLayoutFixture(); + 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: "0x80", + 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", + permissions: { read: true, write: false, execute: true }, + }); + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => Promise.resolve(ok(fixture)), + }); + expect( + await service.inspect({ path: "/artifacts/source-owned.elf" }), + ).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisOutputError", + capturedOutput: fixture.diagnostics, + }, + }); + }, +); diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts new file mode 100644 index 000000000..7fb1d308e --- /dev/null +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -0,0 +1,91 @@ +import { isAbsolute } from "node:path"; +import type { ExecutionOptions } from "../AnalysisProvider.js"; +import type { BinaryLayoutPort } from "./BinaryLayoutPort.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 { + inspectBinaryLayoutInputSchema, + binaryLayoutSchema, +} from "../../domain/native/binaryLayout.js"; +import { projectInputIssues } from "../../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../../domain/result.js"; + +const OPERATION = "inspect_binary_layout"; + +/** Shared CLI/MCP validation and Evidence composition for offline layout inspection. */ +export class BinaryLayoutService { + constructor(readonly provider: BinaryLayoutPort) {} + + /** Bind complete observations to the caller-selected artifact before creating Evidence. */ + async inspect( + rawInput: unknown, + options?: ExecutionOptions, + ): Promise> { + if (options?.signal?.aborted) + return err(new AnalysisCancelledError(OPERATION)); + const input = inspectBinaryLayoutInputSchema.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 = binaryLayoutSchema.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 = binaryLayoutSchema.safeParse(inspected.value); + if (!report.success || report.data.artifact.path !== input.data.path) + return err( + new AnalysisOutputError( + OPERATION, + "Binary layout 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: "observed", + limitations: value.limitations, + locations: [{ kind: "artifact-path", path: value.artifact.path }], + }, + ), + ); + } +} diff --git a/src/cli.ts b/src/cli.ts index 10f1a0949..6fa48dd33 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,3 +1,4 @@ +import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js"; import { Cli } from "incur"; import { createLogger, parseLogLevel } from "./logger.js"; @@ -59,6 +60,7 @@ export const createCli = ( registerManagedCommands(cli, logger); registerAndroidCommands(cli, logger, environment); registerFirmwareCommands(cli, logger, environment); + registerBinaryDiagnosticsCommands(cli, logger, environment); registerEvidenceCommands(cli, logger); registerProcessCommands(cli, logger, environment); registerBrowserCommands(cli, logger); diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts new file mode 100644 index 000000000..e6f63f7ef --- /dev/null +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -0,0 +1,35 @@ +import { z } from "incur"; +import { resolve } from "node:path"; +import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "../logger.js"; +import type { CliInstance } from "./types.js"; +import { withCommandCancellation } from "./commandCancellation.js"; + +/** Inspect operator-selected files through the same shared workflow as MCP. */ +export const registerBinaryDiagnosticsCommands = ( + cli: CliInstance, + logger: Logger, + environment: Readonly, +): void => { + const service = createBinaryLayoutService(environment); + cli.command(CLI_COMMANDS.inspectBinaryLayout, { + description: + "Inspect binary file/linked layout without executing the selected object", + args: z.object({ + path: z.string().describe("Local ELF binary or relocatable object"), + }), + run: ({ args }) => + withCommandCancellation((signal) => + logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => { + const result = await service.inspect( + { path: resolve(args.path) }, + { signal }, + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + ), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 4006827ea..021ff04fc 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -37,6 +37,7 @@ export const CLI_COMMANDS = Object.freeze({ resolveNativeCallTargets: "resolve-native-call-targets", inspectAssetCatalog: "inspect-asset-catalog", inspectManagedArtifact: "inspect-managed-artifact", + inspectBinaryLayout: "inspect-binary-layout", inspectFirmwareRegions: "inspect-firmware-regions", extractFirmware: "extract-firmware", inspectAndroidPackage: "inspect-android-package", diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts new file mode 100644 index 000000000..cece28a5e --- /dev/null +++ b/src/composition/binaryDiagnostics.ts @@ -0,0 +1,8 @@ +import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; +import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js"; + +/** Compose the offline adapter without import-time I/O or dependency acquisition. */ +export const createBinaryLayoutService = ( + environment: Readonly = process.env, +): BinaryLayoutService => + new BinaryLayoutService(new PwntoolsLayoutProvider(environment)); diff --git a/src/contracts/contractSnapshot.test.ts b/src/contracts/contractSnapshot.test.ts index f923cc3e9..82ee4acd9 100644 --- a/src/contracts/contractSnapshot.test.ts +++ b/src/contracts/contractSnapshot.test.ts @@ -1,3 +1,4 @@ +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; import { z } from "zod"; @@ -69,6 +70,7 @@ describe("tool contract surface", () => { ...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS, ...NATIVE_TOOL_CONTRACTS, + ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, ...FIRMWARE_TOOL_CONTRACTS, diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts index 3ea7f5c76..da7dd924e 100644 --- a/src/contracts/errorSchemas.ts +++ b/src/contracts/errorSchemas.ts @@ -9,6 +9,7 @@ const categorySchema = z.enum([ "truncated", "cancelled", "timeout", + "resource_constraint", "unavailable", "execution_failure", ]); @@ -38,6 +39,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [ generic("capability_unavailable"), generic("provider_unavailable"), generic("provider_timeout"), + generic("resource_constraint"), generic("cancelled"), z .object({ diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts new file mode 100644 index 000000000..c49780c03 --- /dev/null +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -0,0 +1,26 @@ +import { + inspectBinaryLayoutInputSchema, + binaryLayoutSchema, +} from "../../domain/native/binaryLayout.js"; +import type { ToolContract } from "../toolContractTypes.js"; +import { toolContractMetadata } from "../toolEffects.js"; +import { evidenceResultOf } from "../toolOutputSchemas.js"; + +/** Offline binary diagnostics are independent of macOS process/UI providers. */ +export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [ + { + name: "inspect_binary_layout", + ...toolContractMetadata("inspect_binary_layout"), + kind: "native-provider", + description: + "Inspect an explicit local binary without launching it as a host process. Returns file-backed section/segment ranges, linked addresses, original symbol/REL/RELA table identities, complete encoded RELR tables and upstream-derived offsets with unknown per-word locations/addends, raw name bytes, dependency/interpreter names and static mitigation inferences inline with artifact SHA-256 Evidence. Initial profile: ELF64 x86-64 little-endian EXEC/DYN/REL on Linux x64 via caller-supplied Python with unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2. Runtime addresses/library paths, overall relocation inventory completeness and complete derived GOT/PLT coverage remain unknown. Zero executable entry values mean absence; relocatable entries are not applicable. Upstream can emulate PLT instructions with Unicorn for derived static maps. Uses bounded complete output and owned temporary files/processes; malformed, unsupported or oversized objects return no partial success.", + inputSchema: inspectBinaryLayoutInputSchema, + outputSchema: evidenceResultOf(binaryLayoutSchema), + examples: [ + { + title: "Inspect ELF linked and file layout", + input: { path: "/artifacts/application.elf" }, + }, + ], + }, +] as const satisfies readonly ToolContract[]; diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts index 449e5f785..44c1e9abd 100644 --- a/src/contracts/toolContracts.test.ts +++ b/src/contracts/toolContracts.test.ts @@ -1,3 +1,4 @@ +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { describe, expect, it } from "vitest"; import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js"; @@ -23,6 +24,7 @@ const GROUPS = { official: OFFICIAL_TOOL_CONTRACTS, enhanced: ENHANCED_TOOL_CONTRACTS, native: NATIVE_TOOL_CONTRACTS, + binary_diagnostics: BINARY_DIAGNOSTICS_TOOL_CONTRACTS, artifact: ARTIFACT_TOOL_CONTRACTS, managed: MANAGED_TOOL_CONTRACTS, android: ANDROID_TOOL_CONTRACTS, diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts index 40ed7a6db..370c83efc 100644 --- a/src/contracts/toolContracts.ts +++ b/src/contracts/toolContracts.ts @@ -1,3 +1,4 @@ +import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js"; import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js"; import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js"; import { MANAGED_TOOL_CONTRACTS } from "./managed/managedToolContracts.js"; @@ -23,6 +24,7 @@ export const TOOL_CONTRACTS = [ ...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS, ...NATIVE_TOOL_CONTRACTS, + ...BINARY_DIAGNOSTICS_TOOL_CONTRACTS, ...ARTIFACT_TOOL_CONTRACTS, ...MANAGED_TOOL_CONTRACTS, ...ANDROID_TOOL_CONTRACTS, diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index a5c30788d..5db06293e 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -65,6 +65,11 @@ export const TOOL_EFFECTS: Readonly> = { launchesProcess: true, accessesNetwork: true, }), + inspect_binary_layout: effects({ + mutatesSession: true, + writesFilesystem: true, + launchesProcess: true, + }), inspect_firmware_regions: effects({ mutatesSession: true, launchesProcess: true, diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 33db95a58..90843234b 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -8,6 +8,7 @@ const ANALYSIS_ERROR_TAGS = [ "AnalysisCapabilityUnavailableError", "AnalysisCancelledError", "AnalysisTimeoutError", + "AnalysisResourceConstraintError", "ProviderSelectionError", "ProviderAdapterError", "BrowserObservationError", @@ -30,8 +31,28 @@ const ANALYSIS_ERROR_TAGS = [ /** Stable tag for an expected analysis failure. */ export type AnalysisErrorTag = (typeof ANALYSIS_ERROR_TAGS)[number]; +/** Retained command output associated with a typed failure; truncation stays explicit. */ +export interface AnalysisCapturedOutput { + readonly stdout: string; + readonly stderr: string; + readonly truncated: boolean; +} + +/** Optional provider-neutral context that must survive typed error projection. */ +export interface AnalysisErrorOptions extends ErrorOptions { + readonly capturedOutput?: AnalysisCapturedOutput; +} + /** Base class for expected analysis, provider, and session failures. */ export abstract class AnalysisError extends Error { + readonly capturedOutput: AnalysisCapturedOutput | undefined; + constructor(message: string, options?: AnalysisErrorOptions) { + super(message, options); + this.capturedOutput = + options?.capturedOutput === undefined + ? undefined + : { ...options.capturedOutput }; + } abstract readonly _tag: AnalysisErrorTag; readonly userMessage: string | undefined = undefined; readonly userCategory: "cancelled" | undefined = undefined; diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index dd89321f0..691347fac 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -1,5 +1,23 @@ import type { JsonValue } from "./jsonValue.js"; -import { AnalysisError } from "./analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "./analysisErrorBase.js"; + +/** Provider reported a resource failure, distinct from malformed input or unsupported coverage. */ +export class AnalysisResourceConstraintError extends AnalysisError { + readonly _tag = "AnalysisResourceConstraintError"; + + constructor( + readonly operation: string, + readonly resource: "memory" | "cpu" | "file-size", + readonly reason: string, + readonly reportedLimits: Readonly> | null, + options?: AnalysisErrorOptions, + ) { + super(`Resource constraint during ${operation}: ${reason}`, options); + } +} /** Provider-neutral invalid analysis input or output at an application boundary. */ export class AnalysisProtocolError extends AnalysisError { @@ -12,7 +30,7 @@ export class AnalysisInputError extends AnalysisError { constructor( readonly operation: string, - options?: ErrorOptions, + options?: AnalysisErrorOptions, readonly issues: readonly AnalysisInputIssue[] = [], ) { super(`Invalid analysis input for ${operation}`, options); @@ -43,7 +61,7 @@ export class AnalysisOutputError extends AnalysisError { constructor( readonly operation: string, readonly reason: string, - options?: ErrorOptions, + options?: AnalysisErrorOptions, ) { super(`Invalid analysis output for ${operation}: ${reason}`, options); } @@ -58,7 +76,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { readonly providerId: string, readonly operation: string, readonly reason: string, - options?: ErrorOptions & { readonly userMessage?: string }, + options?: AnalysisErrorOptions & { readonly userMessage?: string }, ) { super( `Provider ${providerId} cannot execute ${operation}: ${reason}`, @@ -72,8 +90,11 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError { export class AnalysisCancelledError extends AnalysisError { readonly _tag = "AnalysisCancelledError"; - constructor(readonly operation: string) { - super(`Analysis operation was cancelled: ${operation}`); + constructor( + readonly operation: string, + options?: AnalysisErrorOptions, + ) { + super(`Analysis operation was cancelled: ${operation}`, options); } } @@ -84,9 +105,11 @@ export class AnalysisTimeoutError extends AnalysisError { constructor( readonly operation: string, readonly timeoutMs: number, + options?: AnalysisErrorOptions, ) { super( `Analysis operation timed out after ${String(timeoutMs)}ms: ${operation}`, + options, ); } } diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index f4baf9f18..af486b87b 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -2,6 +2,7 @@ import { AnalysisAccessDeniedError, AnalysisArtifactChangedError, AnalysisInputError, + AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { ArtifactOperationError } from "./artifactOperationError.js"; import { @@ -32,6 +33,12 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js"; export const analysisErrorRemediationAction = ( error: AnalysisError, ): string => { + if (error instanceof AnalysisResourceConstraintError) + return error.resource === "cpu" + ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." + : error.resource === "file-size" + ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." + : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits."; if (error instanceof HopperTimeoutError) return error.providerState === "busy" ? "Check binary_session.analysis_activity, wait for the active Hopper request to finish, then retry." @@ -123,12 +130,14 @@ const STATIC_ERROR_CATEGORIES: Readonly< AnalysisCancelledError: "cancelled", HopperCancelledError: "cancelled", AnalysisTimeoutError: "timeout", + AnalysisResourceConstraintError: "resource_constraint", HopperTimeoutError: "timeout", NoBinaryOpenError: "unavailable", BinaryTargetError: "unavailable", }; export const analysisErrorUserMessage = (error: AnalysisError): string => { + if (error instanceof AnalysisResourceConstraintError) return error.reason; if (error instanceof AnalysisAccessDeniedError) return "Host filesystem permissions denied read access to the selected path."; if (error instanceof AnalysisArtifactChangedError) @@ -263,6 +272,7 @@ const KNOWN_ERROR_TAGS = { AnalysisCapabilityUnavailableError: true, AnalysisCancelledError: true, AnalysisTimeoutError: true, + AnalysisResourceConstraintError: true, ProviderSelectionError: true, ProviderAdapterError: true, BrowserObservationError: true, diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 6b83265a2..e0952a7f3 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -3,6 +3,9 @@ import { AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, + AnalysisCancelledError, + AnalysisTimeoutError, + AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { ArtifactOperationError } from "./artifactOperationError.js"; import { BinaryTargetError } from "./configurationErrors.js"; @@ -16,6 +19,87 @@ import { import { ProviderAdapterError } from "./providerAdapterError.js"; import { UnknownRegistryError } from "./unknownRegistryError.js"; import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { ProviderSelectionError } from "./providerSelectionError.js"; +import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; + +const retainedOutput = { + stdout: "selected output\u0000", + stderr: "upstream warning", + truncated: true, +}; + +it.each([ + ["memory", null], + ["memory", { address_space_bytes: 67108864 }], + ["cpu", null], + ["cpu", { cpu_seconds: 1 }], + ["file-size", null], + ["file-size", { file_size_bytes: 1024 }], +] as const)( + "projects reported %s constraints with observed or unknown limits: %j", + (resource, limits) => { + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_binary_layout", + resource, + resource === "memory" + ? "Memory allocation failed; exact cause unknown." + : "Observed SIGXCPU; exact signal cause unknown.", + limits, + { capturedOutput: retainedOutput }, + ), + ); + expect(projected).toMatchObject({ + code: "resource_constraint", + category: "resource_constraint", + retryable: false, + details: { + resource, + reported_limits: limits, + captured_output: retainedOutput, + }, + }); + expect(projected.remediation.action).toContain( + resource === "cpu" + ? "CPU" + : resource === "file-size" + ? "file-size" + : "memory", + ); + expect(projected.remediation.action).not.toContain("doctor"); + expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe( + true, + ); + }, +); +it.each([ + new AnalysisInputError("inspect", { capturedOutput: retainedOutput }), + new AnalysisOutputError("inspect", "invalid reply", { + capturedOutput: retainedOutput, + }), + new AnalysisCapabilityUnavailableError( + "provider", + "inspect", + "unsupported profile", + { capturedOutput: retainedOutput }, + ), + new AnalysisCancelledError("inspect", { capturedOutput: retainedOutput }), + new AnalysisTimeoutError("inspect", 30, { capturedOutput: retainedOutput }), + new ProviderSelectionError({ + operation: "inspect", + reason: "provider_unavailable", + requestedProviderId: "provider", + candidateIds: [], + capturedOutput: retainedOutput, + }), +])( + "retains captured output without changing the typed primary failure: $._tag", + (failure) => { + expect(projectAnalysisError(failure).details?.captured_output).toEqual( + retainedOutput, + ); + }, +); describe("analysis error projection: provider failures", () => { it("projects the primary browser failure alongside incomplete cleanup", () => { diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 2f61a932f..848b10275 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -14,6 +14,7 @@ import { AnalysisInputError, AnalysisOutputError, AnalysisTimeoutError, + AnalysisResourceConstraintError, } from "./analysisErrorCore.js"; import { ArtifactOperationError } from "./artifactOperationError.js"; import { BinaryTargetError } from "./configurationErrors.js"; @@ -39,7 +40,11 @@ export const projectAnalysisError = ( ): AnalysisErrorProjection => { assertKnownAnalysisErrorTag(error._tag); const code = errorCode(error); - const details = errorDetails(error); + const primaryDetails = errorDetails(error); + const details = + error.capturedOutput === undefined + ? primaryDetails + : { ...primaryDetails, captured_output: { ...error.capturedOutput } }; return { code, category: analysisErrorCategory(error), @@ -138,6 +143,7 @@ const STATIC_ERROR_CODES = { AnalysisCancelledError: "cancelled", HopperCancelledError: "cancelled", AnalysisTimeoutError: "provider_timeout", + AnalysisResourceConstraintError: "resource_constraint", HopperTimeoutError: "provider_timeout", HopperProcessError: "provider_unavailable", HopperStartError: "provider_unavailable", @@ -181,6 +187,13 @@ const errorDetails = ( const requestErrorDetails = ( error: AnalysisError, ): Readonly> | undefined => { + if (error instanceof AnalysisResourceConstraintError) + return { + operation: error.operation, + resource: error.resource, + reason: error.reason, + reported_limits: error.reportedLimits, + }; if (error instanceof AnalysisArtifactChangedError) return { operation: error.operation, @@ -409,6 +422,7 @@ export interface AnalysisErrorProjection extends Readonly< | "capability_unavailable" | "provider_unavailable" | "provider_timeout" + | "resource_constraint" | "cancelled" | "artifact_integrity_mismatch" | "artifact_operation_failed" @@ -427,6 +441,7 @@ export interface AnalysisErrorProjection extends Readonly< | "truncated" | "cancelled" | "timeout" + | "resource_constraint" | "unavailable" | "execution_failure"; readonly message: string; diff --git a/src/domain/native/binaryLayout.test.ts b/src/domain/native/binaryLayout.test.ts new file mode 100644 index 000000000..9f8f970e0 --- /dev/null +++ b/src/domain/native/binaryLayout.test.ts @@ -0,0 +1,362 @@ +import { expect, it } from "vitest"; +import { binaryLayoutSchema, type BinaryLayout } from "./binaryLayout.js"; + +const report = () => ({ + artifact: { path: "/fixture.elf", sha256: "a".repeat(64), bytes: 128 }, + format: "elf", + architecture: { machine: "EM_X86_64", bits: 64, byte_order: "little" }, + image_type: "ET_EXEC", + entry_point: { + reported_value: "0x20000000000001", + meaning: "linked-virtual-address", + execution_status: "unknown", + }, + runtime_load_base: null, + sections: [ + { + index: 0, + name: { + display: "opaque", + bytes_base64: "b3BhcXVl", + location: { offset: "0x40", bytes: "0x7" }, + unknown_reason: null, + }, + name_offset: "0x0", + type: "SHT_NOBITS", + header_location: { offset: "0x0", bytes: "0x40" }, + address: "0x20000000000001", + offset: "0xffffffffffffffff", + size: "0x2000", + alignment: "0x1", + flags: "0x3", + link: 0, + info: 0, + entry_size: "0x0", + file_backing: "none", + }, + ], + segments: [], + symbols: [], + relocations: [], + packed_relative_relocations: [], + relocation_inventory_completeness: "unknown", + linkage: { + needed_libraries: [], + interpreters: [], + got: [], + plt: [], + convenience_maps_completeness: "unknown", + runtime_library_paths: null, + }, + mitigations: { + evidence_kind: "inferred", + position_independent: false, + nx_indicator: null, + executable_stack_indicator: false, + stack_canary_indicator: false, + relro: null, + }, + diagnostics: { stdout: "", stderr: "", truncated: false }, + limitations: [], +}); + +it("retains high linked values and NOBITS without inventing file backing", () => { + const value = binaryLayoutSchema.parse(report()); + expect(value.entry_point.reported_value).toBe("0x20000000000001"); + expect(value.sections[0]?.offset).toBe("0xffffffffffffffff"); + expect(value.runtime_load_base).toBeNull(); +}); + +it.each([ + "file-backed-outside", + "header-outside", + "wrong-index", + "noncanonical-base64", + "wrong-machine", + "outside-elf64", +])("rejects a malformed source representation: %s", (problem) => { + const value = report(); + const section = value.sections[0]; + if (section === undefined) throw new Error("section missing"); + if (problem === "file-backed-outside") section.file_backing = "file"; + if (problem === "header-outside") section.header_location.offset = "0x80"; + if (problem === "wrong-index") section.index = 1; + if (problem === "wrong-machine") value.architecture.machine = "EM_AARCH64"; + if (problem === "outside-elf64") + value.entry_point.reported_value = "0x10000000000000000"; + if (problem === "noncanonical-base64") section.name.bytes_base64 = "AR=="; + expect(binaryLayoutSchema.safeParse(value).success).toBe(false); +}); + +it.each([ + ["PT_NULL", "0xffffffffffffffff", "none", null, true], + ["PT_LOAD", "0x0", "none", { read: true, write: true, execute: false }, true], + [ + "PT_LOAD", + "0x1", + "file", + { read: true, write: false, execute: false }, + false, + ], + ["PT_NULL", "0x1", "file", null, false], + [ + "PT_NULL", + "0x1", + "none", + { read: false, write: false, execute: false }, + false, + ], + [ + "PT_LOAD", + "0x1", + "none", + { read: true, write: false, execute: false }, + false, + ], +])( + "validates actual segment backing/meaning for %s size %s", + (type, fileSize, backing, permissions, valid) => { + const value = { + ...report(), + segments: [ + { + index: 0, + type, + header_location: { offset: "0x0", bytes: "0x38" }, + offset: "0xffffffffffffffff", + file_size: fileSize, + memory_size: fileSize, + virtual_address: "0x0", + physical_address: "0x0", + alignment: "0x0", + flags: "0x0", + file_backing: backing, + permissions, + }, + ], + }; + expect(binaryLayoutSchema.safeParse(value).success).toBe(valid); + }, +); + +it.each([ + ["ET_EXEC", "0x0", "absent", true], + ["ET_DYN", "0x0", "linked-virtual-address", false], + ["ET_REL", "0x0", "not-applicable", true], + ["ET_EXEC", "0x1", "absent", false], +])( + "validates actual entry meaning: %s %s %s", + (imageType, entry, meaning, valid) => { + expect( + binaryLayoutSchema.safeParse({ + ...report(), + image_type: imageType, + entry_point: { + reported_value: entry, + meaning, + execution_status: "unknown", + }, + }).success, + ).toBe(valid); + }, +); + +it.each([ + "valid", + "byte-length", + "section-identity", + "decoded-order", + "noncanonical-bytes", +])( + "retains original packed bytes and derived offset provenance: %s", + (problem) => { + const value = report(); + const section = value.sections[0]; + if (section === undefined) throw new Error("section missing"); + section.type = "SHT_RELR"; + section.file_backing = "file"; + section.offset = "0x60"; + section.size = "0x8"; + const table = { + section_index: 0, + location: { offset: "0x60", bytes: "0x8" }, + encoded_bytes_base64: "AAAAAAAAAAA=", + entries: [{ decoded_index: 0, reported_offset: "0x20000000000001" }], + offset_meaning: "linked-virtual-address", + evidence_kind: "derived", + entry_source_locations: null, + addends: null, + }; + if (problem === "byte-length") table.location.bytes = "0x7"; + if (problem === "section-identity") table.section_index = 1; + if (problem === "decoded-order") + table.entries[0] = { decoded_index: 1, reported_offset: "0x0" }; + if (problem === "noncanonical-bytes") table.encoded_bytes_base64 = "AB=="; + expect( + binaryLayoutSchema.safeParse({ + ...value, + packed_relative_relocations: [table], + }).success, + ).toBe(problem === "valid"); + }, +); + +it.each([ + "valid", + "zero-symbol", + "zero-without-table", + "missing-table", + "wrong-table-kind", + "missing-symbol", + "symbol-outside-table", + "positive-without-table", + "owner-link-mismatch", + "wrong-meaning", + "valid-section-target", + "missing-target", + "wrong-target-owner", + "wrong-target-offset", + "undefined-target", + "inactive-target", + "false-resolved-zero-target", + "wrong-unknown-target", + "missing-string-table", + "wrong-string-table-kind", +])("validates relocation symbol reference semantics: %s", (problem) => { + const value = binaryLayoutSchema.parse(report()); + value.artifact.bytes = 512; + const base = value.sections[0]; + if (base === undefined) throw new Error("section missing"); + value.sections.push( + { + ...base, + index: 1, + type: "SHT_SYMTAB", + link: 3, + header_location: { offset: "0x40", bytes: "0x40" }, + offset: "0x100", + size: "0x30", + entry_size: "0x18", + file_backing: "file", + }, + { + ...base, + index: 2, + type: "SHT_RELA", + header_location: { offset: "0x80", bytes: "0x40" }, + offset: "0x130", + size: "0x18", + entry_size: "0x18", + link: 1, + file_backing: "file", + }, + { + ...base, + index: 3, + type: "SHT_STRTAB", + offset: "0x40", + size: "0x7", + file_backing: "file", + }, + ); + value.symbols.push({ + table_index: 1, + entry_index: 1, + name: base.name, + name_offset: "0x0", + location: { offset: "0x118", bytes: "0x18" }, + value: "0x0", + value_meaning: "undefined", + size: "0x0", + binding: "STB_GLOBAL", + type: "STT_NOTYPE", + visibility: "STV_DEFAULT", + section_index: "SHN_UNDEF", + }); + const relocation: BinaryLayout["relocations"][number] = { + section_index: 2, + entry_index: 0, + reported_offset: "0x0", + target: { kind: "linked-virtual-address", address: "0x0" }, + location: { offset: "0x130", bytes: "0x18" }, + type: 1, + symbol_table_index: 1, + symbol_index: 1, + symbol_reference_meaning: "symbol-table-entry", + addend: "0", + }; + const table = value.sections[1]; + const owner = value.sections[2]; + if (table === undefined || owner === undefined) + throw new Error("table or relocation owner missing"); + if (problem.startsWith("zero")) { + relocation.symbol_index = 0; + relocation.symbol_reference_meaning = "zero-symbol-value"; + } + if (problem === "zero-without-table" || problem === "positive-without-table") + owner.link = relocation.symbol_table_index = 0; + if (problem === "missing-table") + owner.link = relocation.symbol_table_index = 99; + if (problem === "wrong-table-kind") table.type = "SHT_PROGBITS"; + if (problem === "missing-string-table") table.link = 99; + if (problem === "wrong-string-table-kind") table.link = 0; + if (problem === "missing-symbol") value.symbols = []; + if (problem === "symbol-outside-table") table.size = "0x18"; + if (problem === "owner-link-mismatch") owner.link = 0; + if (problem === "wrong-meaning") + relocation.symbol_reference_meaning = "zero-symbol-value"; + if (problem.includes("target")) { + value.image_type = "ET_REL"; + value.entry_point.meaning = "not-applicable"; + owner.info = 1; + relocation.target = { + kind: "section-offset", + section_index: + problem === "missing-target" + ? 99 + : problem === "wrong-target-owner" + ? 2 + : 1, + offset: problem === "wrong-target-offset" ? "0x1" : "0x0", + }; + if (problem === "missing-target") owner.info = 99; + if (problem === "false-resolved-zero-target") { + owner.info = 0; + relocation.target.section_index = 0; + } + if ( + ["undefined-target", "inactive-target", "wrong-unknown-target"].includes( + problem, + ) + ) { + if (problem === "inactive-target") + value.sections.push({ ...base, index: 4, type: "SHT_NULL" }); + owner.info = + problem === "undefined-target" + ? 0 + : problem === "inactive-target" + ? 4 + : 1; + relocation.target = { + kind: "unknown-section", + reported_section_index: owner.info, + offset: "0x0", + unknown_reason: + problem === "undefined-target" + ? "undefined-section-reference" + : "inactive-section-header", + }; + } + } + value.relocations.push(relocation); + expect(binaryLayoutSchema.safeParse(value).success).toBe( + [ + "valid", + "zero-symbol", + "zero-without-table", + "valid-section-target", + "undefined-target", + "inactive-target", + ].includes(problem), + ); +}); diff --git a/src/domain/native/binaryLayout.ts b/src/domain/native/binaryLayout.ts new file mode 100644 index 000000000..95a55b866 --- /dev/null +++ b/src/domain/native/binaryLayout.ts @@ -0,0 +1,443 @@ +import { z } from "zod"; + +const unsignedHex = z.string().regex(/^0x(?:0|[1-9a-f][0-9a-f]{0,15})$/); +const index = z.number().int().nonnegative(); +const scalar = z.union([z.string(), z.number().int()]); +const range = z.strictObject({ offset: unsignedHex, bytes: unsignedHex }); +const canonicalBase64 = z + .string() + .regex( + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?$/, + ); +const name = z.strictObject({ + display: z.string(), + bytes_base64: canonicalBase64.nullable(), + location: range.nullable(), + unknown_reason: z.string().nullable(), +}); + +/** Explicit local object selection, independent of an active disassembler target. */ +export const inspectBinaryLayoutInputSchema = z.strictObject({ + path: z + .string() + .min(1) + .describe("Absolute filesystem path to the selected binary"), +}); + +/** File and linked-image evidence; numeric ELF values never pass through unsafe JSON numbers. */ +const binaryLayoutObjectSchema = z.strictObject({ + artifact: z.strictObject({ + path: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + bytes: index, + }), + format: z.literal("elf"), + architecture: z.strictObject({ + machine: z.literal("EM_X86_64"), + bits: z.literal(64), + byte_order: z.literal("little"), + }), + image_type: z.enum(["ET_EXEC", "ET_DYN", "ET_REL"]), + entry_point: z.strictObject({ + reported_value: unsignedHex, + meaning: z.enum(["linked-virtual-address", "not-applicable", "absent"]), + execution_status: z.literal("unknown"), + }), + runtime_load_base: z.null(), + sections: z.array( + z.strictObject({ + index, + name, + name_offset: unsignedHex, + type: scalar, + header_location: range, + address: unsignedHex, + offset: unsignedHex, + size: unsignedHex, + alignment: unsignedHex, + flags: unsignedHex, + link: index, + info: index, + entry_size: unsignedHex, + file_backing: z.enum(["file", "none"]), + }), + ), + segments: z.array( + z.strictObject({ + index, + type: scalar, + header_location: range, + offset: unsignedHex, + file_size: unsignedHex, + memory_size: unsignedHex, + virtual_address: unsignedHex, + physical_address: unsignedHex, + alignment: unsignedHex, + flags: unsignedHex, + file_backing: z.enum(["file", "none"]), + permissions: z + .strictObject({ + read: z.boolean(), + write: z.boolean(), + execute: z.boolean(), + }) + .nullable(), + }), + ), + symbols: z.array( + z.strictObject({ + table_index: index, + entry_index: index, + name, + name_offset: unsignedHex, + location: range, + value: unsignedHex, + value_meaning: z.enum([ + "undefined", + "alignment", + "absolute-value", + "no-address", + "unknown-section-index", + "section-offset", + "tls-offset", + "linked-virtual-address", + ]), + size: unsignedHex, + binding: scalar, + type: scalar, + visibility: scalar, + section_index: scalar, + }), + ), + relocations: z.array( + z.strictObject({ + section_index: index, + entry_index: index, + reported_offset: unsignedHex, + target: z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("section-offset"), + section_index: index, + offset: unsignedHex, + }), + z.strictObject({ + kind: z.literal("linked-virtual-address"), + address: unsignedHex, + }), + z.strictObject({ + kind: z.literal("unknown-section"), + reported_section_index: index, + offset: unsignedHex, + unknown_reason: z.enum([ + "undefined-section-reference", + "inactive-section-header", + ]), + }), + ]), + location: range, + type: index, + symbol_table_index: index, + symbol_index: index, + symbol_reference_meaning: z.enum([ + "zero-symbol-value", + "symbol-table-entry", + ]), + addend: z + .string() + .regex(/^-?(?:0|[1-9][0-9]{0,18})$/) + .nullable(), + }), + ), + packed_relative_relocations: z.array( + z.strictObject({ + section_index: index, + location: range, + encoded_bytes_base64: canonicalBase64, + entries: z.array( + z.strictObject({ decoded_index: index, reported_offset: unsignedHex }), + ), + offset_meaning: z.enum(["linked-virtual-address", "unknown"]), + evidence_kind: z.literal("derived"), + entry_source_locations: z.null(), + addends: z.null(), + }), + ), + relocation_inventory_completeness: z.literal("unknown"), + linkage: z.strictObject({ + needed_libraries: z.array(name), + interpreters: z.array(name), + got: z.array( + z.strictObject({ display_name: z.string(), address: unsignedHex }), + ), + plt: z.array( + z.strictObject({ display_name: z.string(), address: unsignedHex }), + ), + convenience_maps_completeness: z.literal("unknown"), + runtime_library_paths: z.null(), + }), + mitigations: z.strictObject({ + evidence_kind: z.literal("inferred"), + position_independent: z.boolean(), + nx_indicator: z.boolean().nullable(), + executable_stack_indicator: z.boolean(), + stack_canary_indicator: z.boolean(), + relro: z.enum(["Partial", "Full"]).nullable(), + }), + diagnostics: z.strictObject({ + stdout: z.string(), + stderr: z.string(), + truncated: z.boolean(), + }), + limitations: z.array(z.string()), +}); + +/** Decoder payload excludes the artifact identity observed by the owning process boundary. */ +export const binaryLayoutPayloadSchema = binaryLayoutObjectSchema.omit({ + artifact: true, + diagnostics: true, +}); + +/** Check source ranges again at the public boundary, independently of upstream parsing. */ +export const binaryLayoutSchema = binaryLayoutObjectSchema.superRefine( + (value, context) => { + const size = BigInt(value.artifact.bytes); + const check = ( + offset: string, + length: string, + path: (string | number)[], + ): void => { + const start = BigInt(offset); + const bytes = BigInt(length); + if (start > size || bytes > size - start) + context.addIssue({ + code: "custom", + path, + message: + "Reported file-backed range lies outside the selected artifact.", + }); + }; + const checkLocation = ( + where: z.infer, + path: (string | number)[], + ): void => check(where.offset, where.bytes, path); + const checkName = ( + reported: z.infer, + path: (string | number)[], + ): void => { + if (reported.location !== null) + checkLocation(reported.location, [...path, "location"]); + }; + const expectedEntryMeaning = + value.image_type === "ET_REL" + ? "not-applicable" + : value.entry_point.reported_value === "0x0" + ? "absent" + : "linked-virtual-address"; + if (value.entry_point.meaning !== expectedEntryMeaning) + context.addIssue({ + code: "custom", + path: ["entry_point", "meaning"], + message: + "Entry meaning must distinguish zero/unused fields from an actual linked address.", + }); + for (const [index, section] of value.sections.entries()) { + if ( + (section.type === "SHT_SYMTAB" || section.type === "SHT_DYNSYM") && + value.sections[section.link]?.type !== "SHT_STRTAB" + ) + context.addIssue({ + code: "custom", + path: ["sections", index, "link"], + message: + "Symbol tables must link to an existing string-table section.", + }); + if (section.index !== index) + context.addIssue({ + code: "custom", + path: ["sections", index, "index"], + message: + "Complete section order must preserve original producer indices.", + }); + checkLocation(section.header_location, [ + "sections", + index, + "header_location", + ]); + checkName(section.name, ["sections", index, "name"]); + if (section.file_backing === "file") + check(section.offset, section.size, ["sections", index]); + } + for (const [index, segment] of value.segments.entries()) { + if (segment.index !== index) + context.addIssue({ + code: "custom", + path: ["segments", index, "index"], + message: + "Complete segment order must preserve original producer indices.", + }); + checkLocation(segment.header_location, [ + "segments", + index, + "header_location", + ]); + const hasFileBytes = + segment.type !== "PT_NULL" && BigInt(segment.file_size) !== 0n; + if (segment.file_backing !== (hasFileBytes ? "file" : "none")) + context.addIssue({ + code: "custom", + path: ["segments", index, "file_backing"], + message: + "File backing must follow the reported segment type and file size.", + }); + if ((segment.type === "PT_NULL") !== (segment.permissions === null)) + context.addIssue({ + code: "custom", + path: ["segments", index, "permissions"], + message: + "Unused PT_NULL flags have no permission meaning; other segment flags retain their interpretation.", + }); + if (hasFileBytes) + check(segment.offset, segment.file_size, ["segments", index]); + } + for (const [index, symbol] of value.symbols.entries()) { + checkLocation(symbol.location, ["symbols", index, "location"]); + checkName(symbol.name, ["symbols", index, "name"]); + } + for (const [index, relocation] of value.relocations.entries()) + checkLocation(relocation.location, ["relocations", index, "location"]); + validateRelocationReferences(value, context); + validatePackedRelativeTables(value, context, checkLocation); + for (const facet of ["needed_libraries", "interpreters"] as const) + for (const [index, reported] of value.linkage[facet].entries()) + checkName(reported, ["linkage", facet, index]); + }, +); + +export type InspectBinaryLayoutInput = z.infer< + typeof inspectBinaryLayoutInputSchema +>; +export type BinaryLayout = z.infer; + +const validateRelocationReferences = ( + value: z.output, + context: z.RefinementCtx, +): void => { + const symbols = new Set( + value.symbols.map( + (symbol) => `${symbol.table_index}:${symbol.entry_index}`, + ), + ); + for (const [index, relocation] of value.relocations.entries()) { + const owner = value.sections[relocation.section_index]; + const target = relocation.target; + const targetMatches = + value.image_type === "ET_REL" + ? target.kind === "section-offset" + ? target.section_index !== 0 && + target.section_index === owner?.info && + value.sections[target.section_index] !== undefined && + value.sections[target.section_index]?.type !== "SHT_NULL" && + target.offset === relocation.reported_offset + : target.kind === "unknown-section" && + target.reported_section_index === owner?.info && + target.offset === relocation.reported_offset && + (target.reported_section_index === 0 + ? target.unknown_reason === "undefined-section-reference" + : value.sections[target.reported_section_index]?.type === + "SHT_NULL" && + target.unknown_reason === "inactive-section-header") + : target.kind === "linked-virtual-address" && + target.address === relocation.reported_offset; + if (!targetMatches) + context.addIssue({ + code: "custom", + path: ["relocations", index, "target"], + message: + "Relocation target must preserve its reported offset and owner reference; SHN_UNDEF and inactive headers remain unknown rather than resolved sections.", + }); + const table = value.sections[relocation.symbol_table_index]; + const tableExists = + relocation.symbol_table_index !== 0 && + (table?.type === "SHT_SYMTAB" || table?.type === "SHT_DYNSYM"); + const positiveReferenceExists = + tableExists && + BigInt(table.entry_size) >= 24n && + BigInt(relocation.symbol_index) < + BigInt(table.size) / BigInt(table.entry_size) && + symbols.has( + `${relocation.symbol_table_index}:${relocation.symbol_index}`, + ); + if ( + (owner?.type !== "SHT_REL" && owner?.type !== "SHT_RELA") || + owner.link !== relocation.symbol_table_index || + (relocation.symbol_table_index !== 0 && !tableExists) || + (relocation.symbol_index !== 0 && !positiveReferenceExists) || + relocation.symbol_reference_meaning !== + (relocation.symbol_index === 0 + ? "zero-symbol-value" + : "symbol-table-entry") + ) + context.addIssue({ + code: "custom", + path: ["relocations", index], + message: + "Relocation must preserve its REL/RELA owner and resolve positive symbol indices through the linked symbol table; index zero uses a zero symbol value.", + }); + } +}; + +const validatePackedRelativeTables = ( + value: z.output, + context: z.RefinementCtx, + checkLocation: ( + location: z.output, + path: (string | number)[], + ) => void, +): void => { + for (const [ + tableIndex, + table, + ] of value.packed_relative_relocations.entries()) { + checkLocation(table.location, [ + "packed_relative_relocations", + tableIndex, + "location", + ]); + const encodedBytes = + (table.encoded_bytes_base64.length * 3) / 4 - + (table.encoded_bytes_base64.endsWith("==") + ? 2 + : table.encoded_bytes_base64.endsWith("=") + ? 1 + : 0); + if ( + !Number.isInteger(encodedBytes) || + BigInt(encodedBytes) !== BigInt(table.location.bytes) + ) + context.addIssue({ + code: "custom", + path: ["packed_relative_relocations", tableIndex], + message: + "Complete packed table bytes must match its original file range.", + }); + const section = value.sections[table.section_index]; + if ( + section?.type !== "SHT_RELR" || + section.offset !== table.location.offset || + section.size !== table.location.bytes + ) + context.addIssue({ + code: "custom", + path: ["packed_relative_relocations", tableIndex, "section_index"], + message: + "Packed relative evidence must retain its original RELR section identity and range.", + }); + for (const [decoded, entry] of table.entries.entries()) + if (entry.decoded_index !== decoded) + context.addIssue({ + code: "custom", + path: ["packed_relative_relocations", tableIndex, "entries", decoded], + message: "Decoded offsets must preserve upstream iterator order.", + }); + } +}; diff --git a/src/domain/providerSelectionError.ts b/src/domain/providerSelectionError.ts index 2dbb182fa..e6520848f 100644 --- a/src/domain/providerSelectionError.ts +++ b/src/domain/providerSelectionError.ts @@ -1,5 +1,8 @@ import type { JsonValue } from "./jsonValue.js"; -import { AnalysisError } from "./analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "./analysisErrorBase.js"; /** Stable failure classes for target-bound deep-provider selection. */ export type ProviderSelectionFailureReason = @@ -19,6 +22,7 @@ export interface ProviderSelectionRejection { /** Complete typed context for one failed deep-provider selection. */ export interface ProviderSelectionErrorOptions { + readonly capturedOutput?: AnalysisCapturedOutput; readonly operation?: string; readonly reason: ProviderSelectionFailureReason; readonly requestedProviderId: string; @@ -38,7 +42,12 @@ export class ProviderSelectionError extends AnalysisError { constructor(options: ProviderSelectionErrorOptions) { const operation = options.operation ?? "open_binary"; - super(providerSelectionDiagnostic(options)); + super( + providerSelectionDiagnostic(options), + options.capturedOutput === undefined + ? undefined + : { capturedOutput: options.capturedOutput }, + ); this.operation = operation; this.reason = options.reason; this.requestedProviderId = options.requestedProviderId; diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts index 531f3ddb8..3c4aff2ed 100644 --- a/src/generatedPackageMetadata.ts +++ b/src/generatedPackageMetadata.ts @@ -5,5 +5,5 @@ export const PACKAGE_METADATA = { serverSdkVersion: "2.3.1", clientSdkVersion: "2.3.1", coreSdkVersion: "2.3.1", - skillVersion: "29", + skillVersion: "30", } as const; diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts new file mode 100644 index 000000000..5e595f6de --- /dev/null +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -0,0 +1,245 @@ +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { + AnalysisError, + type AnalysisCapturedOutput, +} from "../../domain/analysisErrorBase.js"; +import type { ProviderProcessSnapshot } from "../../process/ProviderProcess.js"; +import { + AnalysisAccessDeniedError, + AnalysisArtifactChangedError, + AnalysisCancelledError, + AnalysisInputError, + AnalysisOutputError, + AnalysisTimeoutError, + AnalysisResourceConstraintError, +} from "../../domain/analysisErrorCore.js"; +import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { ProviderSelectionError } from "../../domain/providerSelectionError.js"; +import { OwnedCommandFailure } from "../../process/OwnedCommand.js"; +import type { PwntoolsFailureEvidence } from "./PwntoolsResourceLimits.js"; +import { + PWNTOOLS_PROVIDER_IDENTITY, + PWNTOOLS_LIMITS, + PWNTOOLS_MEMORY_FAILURE_EXIT, + PWNTOOLS_FILE_SIZE_FAILURE_EXIT, +} from "./PwntoolsRelease.js"; + +const OPERATION = "inspect_binary_layout"; + +/** Keep read, engine, output and owned-lifecycle failure reasons distinct. */ +export const pwntoolsLayoutFailure = ( + cause: unknown, + phase: string, + path: string, + executablePath = path, + failureEvidence: PwntoolsFailureEvidence = {}, +): AnalysisError => { + const limitReport = failureEvidence.limits; + if (cause instanceof AnalysisError) return cause; + if (cause instanceof OwnedCommandFailure) { + const outputOptions = + cause.snapshot === null + ? undefined + : { capturedOutput: capturedPwntoolsOutput(cause.snapshot) }; + if (cause.cleanupFailure !== null) + return new ProviderCleanupError( + PWNTOOLS_PROVIDER_IDENTITY.id, + cause.resources, + { + reason: cause.cleanupFailure, + ...(cause.snapshot === null + ? {} + : { + captured_output: { ...capturedPwntoolsOutput(cause.snapshot) }, + }), + previous_error: { + failure_kind: cause.reason, + message: cause.message, + exit_code: cause.snapshot?.exitCode ?? null, + signal: cause.snapshot?.signal ?? null, + stdout: cause.snapshot?.stdout.text ?? null, + stderr: cause.snapshot?.stderr.text ?? null, + }, + }, + { operation: OPERATION, cause }, + ); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, outputOptions); + if (cause.reason === "timeout") + return new AnalysisTimeoutError( + OPERATION, + PWNTOOLS_LIMITS.timeoutMs, + outputOptions, + ); + if (cause.reason === "output-limit") + return new AnalysisOutputError(OPERATION, cause.message, outputOptions); + if ( + cause.reason === "process" && + (cause.snapshot?.signal === "SIGXFSZ" || + (failureEvidence.marker?.resource === "file-size" && + cause.snapshot?.exitCode === PWNTOOLS_FILE_SIZE_FAILURE_EXIT && + cause.snapshot.signal === null)) + ) + return new AnalysisResourceConstraintError( + OPERATION, + "file-size", + (cause.snapshot.signal === "SIGXFSZ" + ? "Owned Python terminated with SIGXFSZ; the exact signal cause is unknown." + : "The owned Python bridge reported EFBIG while writing a file; the exact write failure cause is unknown.") + + (limitReport?.failure === null || limitReport === undefined + ? "" + : ` Effective limit report unavailable: ${limitReport.failure}`), + limitReport?.limits ?? null, + outputOptions, + ); + if (cause.reason === "process" && cause.snapshot?.signal === "SIGXCPU") + return new AnalysisResourceConstraintError( + OPERATION, + "cpu", + "Owned Python terminated with SIGXCPU; the exact signal cause is unknown." + + (limitReport?.failure === null || limitReport === undefined + ? "" + : ` Effective limit report unavailable: ${limitReport.failure}`), + limitReport?.limits ?? null, + outputOptions, + ); + if ( + cause.reason === "process" && + failureEvidence.marker?.resource === "memory" && + cause.snapshot?.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT && + cause.snapshot.signal === null + ) + return new AnalysisResourceConstraintError( + 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 + ? "" + : ` Effective limit report unavailable: ${limitReport.failure}`), + limitReport?.limits ?? null, + outputOptions, + ); + } + if ( + cause instanceof Error && + "code" in cause && + "syscall" in cause && + typeof cause.syscall === "string" && + cause.syscall.startsWith("spawn ") + ) + return pwntoolsUnavailable( + `Selected Python could not launch (${String(cause.code)}): ${executablePath}. Check the configured executable, its interpreter and host execute access.`, + executablePath, + String(cause.code), + ); + if (cause instanceof ArtifactReaderFailure) { + if (cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION); + if ( + cause.reason === "limit" || + cause.reason === "path" || + cause.reason === "format" + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: cause.message, + }, + ]); + } + if (cause instanceof Error && "code" in cause) { + if (phase === "configuration") + return pwntoolsUnavailable( + `Selected Python executable is unavailable (${String(cause.code)}): ${path}. Check the caller-selected path and execute access.`, + path, + String(cause.code), + ); + if ( + phase === "artifact-read" && + (cause.code === "EACCES" || cause.code === "EPERM") + ) + return new AnalysisAccessDeniedError(OPERATION, path, cause.code, { + cause, + }); + if ( + phase === "artifact-read" && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + return new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected object could not be read (${String(cause.code)}): ${path}.`, + }, + ]); + } + return new ProviderAdapterError(PWNTOOLS_PROVIDER_IDENTITY.id, OPERATION, { + cause, + diagnostics: { + phase, + path, + reason: cause instanceof Error ? cause.message : String(cause), + ...(failureEvidence.marker === undefined + ? {} + : { resource_failure_marker: { ...failureEvidence.marker } }), + ...(cause instanceof OwnedCommandFailure + ? { + failure_kind: cause.reason, + exit_code: cause.snapshot?.exitCode ?? null, + signal: cause.snapshot?.signal ?? null, + stdout: cause.snapshot?.stdout.text ?? null, + stderr: cause.snapshot?.stderr.text ?? null, + ...(cause.snapshot === null + ? {} + : { + captured_output: { + ...capturedPwntoolsOutput(cause.snapshot), + }, + }), + } + : {}), + }, + }); +}; + +/** Report caller-selected engine absence with its actual configuration and host constraint. */ +export const pwntoolsUnavailable = ( + reason: string, + path: string, + systemCode?: string, + capturedOutput?: AnalysisCapturedOutput, +): ProviderSelectionError => + new ProviderSelectionError({ + ...(capturedOutput === undefined ? {} : { capturedOutput }), + operation: OPERATION, + reason: "provider_unavailable", + requestedProviderId: PWNTOOLS_PROVIDER_IDENTITY.id, + candidateIds: [PWNTOOLS_PROVIDER_IDENTITY.id], + rejections: [ + { + providerId: PWNTOOLS_PROVIDER_IDENTITY.id, + code: "provider_unavailable", + reason, + diagnostics: { + configuration_key: "REA_PWNTOOLS_PYTHON", + executable_path: path, + ...(systemCode === undefined ? {} : { system_code: systemCode }), + }, + }, + ], + }); + +/** Retain bounded text output and the shared supervisor's observed truncation flag. */ +export const capturedPwntoolsOutput = ( + snapshot: ProviderProcessSnapshot, +): AnalysisCapturedOutput => ({ + stdout: snapshot.stdout.text, + stderr: snapshot.stderr.text, + truncated: snapshot.diagnosticTruncated === true, +}); diff --git a/src/native/pwntools/PwntoolsLayoutProvider.ts b/src/native/pwntools/PwntoolsLayoutProvider.ts new file mode 100644 index 000000000..4e543d105 --- /dev/null +++ b/src/native/pwntools/PwntoolsLayoutProvider.ts @@ -0,0 +1,381 @@ +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 { PrivateRuntimeRoot } from "../../process/PrivateRuntimeRoot.js"; +import { + PWNTOOLS_PROVIDER_IDENTITY, + PWNTOOLS_LIMITS, + PWNTOOLS_FILE_SIZE_FAILURE_EXIT, + PWNTOOLS_MEMORY_FAILURE_EXIT, +} from "./PwntoolsRelease.js"; + +const OPERATION = "inspect_binary_layout"; +const decoded = binaryLayoutPayloadSchema; +const replySchema = z.discriminatedUnion("ok", [ + z.strictObject({ + ok: z.literal(true), + profile: z.literal(PWNTOOLS_PROVIDER_IDENTITY.version), + value: decoded, + }), + z.strictObject({ + ok: z.literal(false), + reason: z.enum([ + "format", + "unsupported", + "unavailable", + "resource-limit", + "output-limit", + "decoder", + ]), + message: z.string(), + reported_limits: pwntoolsResourceLimitsSchema.nullable().optional(), + }), +]); + +type Launcher = NonNullable[2]>["launcher"]; + +/** Thin owned-process adapter; all ELF parsing and heuristics come from unchanged upstream code. */ +export class PwntoolsLayoutProvider implements BinaryLayoutPort { + readonly identity = PWNTOOLS_PROVIDER_IDENTITY; + constructor( + readonly environment: Readonly, + readonly launcher?: Launcher, + readonly createRuntime: () => Promise< + Pick + > = () => PrivateRuntimeRoot.create({ prefix: "rea-elf-layout-" }), + ) {} + + /** Snapshot one explicit artifact and return complete static observations without launching the selected object as a host process. */ + async inspect( + input: InspectBinaryLayoutInput, + options?: ExecutionOptions, + ): Promise> { + let root: Pick | undefined; + let result: Result; + let phase: "configuration" | "artifact-read" | "decoder" = "configuration"; + const executablePath = this.environment.REA_PWNTOOLS_PYTHON ?? ""; + let selectedPath = executablePath; + try { + if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); + if (process.platform !== "linux" || process.arch !== "x64") + throw new AnalysisCapabilityUnavailableError( + this.identity.id, + OPERATION, + "The real-verified pwntools layout profile currently supports Linux x64 only.", + ); + if (!isAbsolute(selectedPath)) + throw pwntoolsUnavailable( + "Set REA_PWNTOOLS_PYTHON to an absolute caller-supplied Python executable with pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2; REA never installs Python or packages.", + selectedPath, + ); + await access(selectedPath, constants.X_OK); + if (!(await stat(selectedPath)).isFile()) + throw pwntoolsUnavailable( + "Selected Python executable must be a regular executable file, not a directory: " + + selectedPath, + selectedPath, + ); + phase = "artifact-read"; + selectedPath = input.path; + const snapshot = await readStableArtifact( + input.path, + PWNTOOLS_LIMITS.inputBytes, + options?.signal, + ); + phase = "decoder"; + root = await this.createRuntime(); + const requestPath = join(root.path, "request.json"); + const snapshotPath = join(root.path, "object.snapshot"); + const replyPath = join(root.path, "reply.json"); + await writeFile(snapshotPath, snapshot.bytes, { + flag: "wx", + mode: 0o600, + ...(options?.signal === undefined ? {} : { signal: options.signal }), + }); + await writeFile( + requestPath, + JSON.stringify({ snapshot_path: snapshotPath, reply_path: replyPath }), + { flag: "wx", mode: 0o600 }, + ); + const execution = await runOwnedCommand( + { + command: this.environment.REA_PWNTOOLS_PYTHON ?? "", + arguments: [ + "-I", + fileURLToPath( + new URL("../../../bridge/pwntools/layout.py", import.meta.url), + ), + join(root.path, "resource.failure"), + requestPath, + ], + cwd: root.path, + runId: `rea-elf-layout-${randomUUID()}`, + hostEnvironment: { + ...this.environment, + PWNLIB_NOTERM: "1", + OPENBLAS_NUM_THREADS: "1", + OMP_NUM_THREADS: "1", + }, + }, + { + timeoutMs: PWNTOOLS_LIMITS.timeoutMs, + diagnosticBytes: PWNTOOLS_LIMITS.diagnosticBytes, + }, + { + ...(options?.signal === undefined ? {} : { signal: options.signal }), + ...(this.launcher === undefined ? {} : { launcher: this.launcher }), + }, + ); + const capturedOutput = capturedPwntoolsOutput(execution); + let reply: z.output; + try { + const file = await readStableArtifact( + replyPath, + PWNTOOLS_LIMITS.outputBytes, + options?.signal, + ); + reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); + } catch (cause: unknown) { + if (options?.signal?.aborted) + throw new AnalysisCancelledError(OPERATION, { capturedOutput }); + throw new AnalysisOutputError( + OPERATION, + `Owned ELF decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`, + { cause, capturedOutput }, + ); + } + if (!reply.ok) { + if (reply.reason === "resource-limit") + throw new AnalysisResourceConstraintError( + OPERATION, + "memory", + reply.message, + reply.reported_limits ?? null, + { capturedOutput }, + ); + if (reply.reason === "format") + throw new AnalysisInputError(OPERATION, { capturedOutput }, [ + { + path: ["path"], + reason: "invalid_format", + message: reply.message, + }, + ]); + 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, + }); + 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.", + ); + 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; + } +} diff --git a/src/native/pwntools/PwntoolsRelease.ts b/src/native/pwntools/PwntoolsRelease.ts new file mode 100644 index 000000000..f041304f1 --- /dev/null +++ b/src/native/pwntools/PwntoolsRelease.ts @@ -0,0 +1,19 @@ +/** Verified unchanged upstream profile for the Linux ELF adapter. */ +export const PWNTOOLS_PROVIDER_IDENTITY = { + id: "pwntools-elf", + name: "REA pwntools ELF adapter", + version: "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2", +} as const; +/** Bridge-reserved status for MemoryError when no structured reply can be written. */ +export const PWNTOOLS_MEMORY_FAILURE_EXIT = 75; +/** Bridge-reserved status for an observed EFBIG failure without a complete reply. */ +export const PWNTOOLS_FILE_SIZE_FAILURE_EXIT = 76; +/** Complete evidence budgets; address-space is separate from resident memory. */ +export const PWNTOOLS_LIMITS = { + inputBytes: 32 * 1024 * 1024, + outputBytes: 64 * 1024 * 1024, + diagnosticBytes: 1024 * 1024, + timeoutMs: 30_000, + addressSpaceBytes: 3 * 1024 * 1024 * 1024, + cpuSeconds: 30, +} as const; diff --git a/src/native/pwntools/PwntoolsResourceLimits.ts b/src/native/pwntools/PwntoolsResourceLimits.ts new file mode 100644 index 000000000..ad00d92b4 --- /dev/null +++ b/src/native/pwntools/PwntoolsResourceLimits.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; + +/** Actual limits reported by the owned bridge after lowering inherited soft limits. */ +export const pwntoolsResourceLimitsSchema = z.strictObject({ + address_space_bytes: z + .number() + .int() + .nonnegative() + .max(PWNTOOLS_LIMITS.addressSpaceBytes), + cpu_seconds: z.number().int().nonnegative().max(PWNTOOLS_LIMITS.cpuSeconds), + file_size_bytes: z + .number() + .int() + .nonnegative() + .max(PWNTOOLS_LIMITS.outputBytes), +}); + +/** A missing or invalid report leaves limits unknown and retains its read failure. */ +export interface PwntoolsLimitReport { + readonly limits: z.output | null; + readonly failure: string | null; +} + +/** Private failure-branch evidence; a bare launcher exit code is insufficient. */ +export interface PwntoolsFailureEvidence { + readonly limits?: PwntoolsLimitReport; + readonly marker?: { + readonly resource: "memory" | "file-size" | null; + readonly failure: string | null; + }; +} diff --git a/src/server/createServer.ts b/src/server/createServer.ts index acc5dca05..1a69fecac 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -1,3 +1,6 @@ +import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js"; +import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js"; +import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import { McpServer } from "@modelcontextprotocol/server"; import { isAbsolute } from "node:path"; @@ -60,6 +63,7 @@ const ACTIVE_TARGET_INSTRUCTIONS = export interface CreateServerOptions { readonly logger?: Logger; + readonly binaryLayout?: BinaryLayoutService; readonly firmwareAnalysis?: FirmwareAnalysisPort; readonly javascriptRecovery?: JavaScriptRecoveryPort; readonly webModuleTrace?: WebModuleTraceService; @@ -88,6 +92,11 @@ const installSessionToolAvailability = ( webModuleResolutionEnabled: options.webModuleTrace !== undefined || isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""), + binaryLayoutEnabled: + options.binaryLayout !== undefined || + (process.platform === "linux" && + process.arch === "x64" && + isAbsolute(process.env.REA_PWNTOOLS_PYTHON ?? "")), firmwareInspectionEnabled: options.firmwareAnalysis !== undefined || (process.platform === "linux" && @@ -190,6 +199,12 @@ export const createServer = ( toolLogger, recordEvidence, ); + registerBinaryDiagnosticsTools( + server, + options.binaryLayout ?? createBinaryLayoutService(), + toolLogger, + recordEvidence, + ); registerFirmwareTools( server, new FirmwareAnalysisService( diff --git a/src/server/registerBinaryDiagnosticsTools.ts b/src/server/registerBinaryDiagnosticsTools.ts new file mode 100644 index 000000000..949e24624 --- /dev/null +++ b/src/server/registerBinaryDiagnosticsTools.ts @@ -0,0 +1,32 @@ +import type { McpServer } from "@modelcontextprotocol/server"; +import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.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 offline layout inspection to its named contract and session Evidence owner. */ +export const registerBinaryDiagnosticsTools = ( + server: McpServer, + service: BinaryLayoutService, + logger: Logger, + recordEvidence?: EvidenceWriter["recordEvidence"], +): void => { + const contract = toolContract("inspect_binary_layout"); + server.registerTool( + contract.name, + toolRegistrationOptions(contract), + async (input, context) => { + const result = await logToolExecution(logger, contract.name, () => + service.inspect(input, { signal: context.mcpReq.signal }), + ); + if (!result.ok) return toCallToolResult(result, contract); + const recorded = recordEvidence?.(result.value); + return recorded !== undefined && !recorded.ok + ? toCallToolResult(recorded, contract) + : toCallToolResult(result, contract); + }, + ); +}; diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 904594c46..0303828e5 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -18,6 +18,7 @@ export interface SessionAvailabilityDefaults { | "androidAnalysisEnabled" | "javascriptRecoveryEnabled" | "webModuleResolutionEnabled" + | "binaryLayoutEnabled" | "firmwareInspectionEnabled" | "firmwareExtractionEnabled" >; @@ -32,6 +33,8 @@ export const sessionAvailabilityPolicy = ( configured ?? (() => ({ processCaptureEnabled: platform !== "win32", + binaryLayoutEnabled: + defaults.optionalFeatures?.binaryLayoutEnabled ?? false, firmwareInspectionEnabled: defaults.optionalFeatures?.firmwareInspectionEnabled ?? false, firmwareExtractionEnabled: diff --git a/src/server/toolResult.ts b/src/server/toolResult.ts index f74dfe32d..ca2319bdd 100644 --- a/src/server/toolResult.ts +++ b/src/server/toolResult.ts @@ -9,7 +9,7 @@ import type { Result } from "../domain/result.js"; /** * Serialize an application result as MCP text and structured content. * Shared error projection preserves actionable local diagnostics while omitting - * raw causes and captured process output. + * raw cause objects; explicitly retained bounded command output stays visible. */ export const toCallToolResult = ( result: Result, diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts new file mode 100644 index 000000000..702458224 --- /dev/null +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -0,0 +1,691 @@ +import { + createTestWorkspace, + removeTestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import { expect, it, onTestFinished } from "vitest"; +import { PwntoolsLayoutProvider } from "../../../src/native/pwntools/PwntoolsLayoutProvider.js"; +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; +import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js"; +import { binaryLayoutFixture } from "../../fixtures/binaryDiagnostics/layout.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; + +const unsupportedHost = process.platform !== "linux" || process.arch !== "x64"; +const requestSchema = z.strictObject({ + snapshot_path: z.string(), + reply_path: z.string(), +}); + +it.runIf(!unsupportedHost).each([ + ["SIGXCPU", "reported", "cpu"], + ["SIGXCPU", "missing", "cpu"], + ["SIGXCPU", "malformed", "cpu"], + ["SIGXFSZ", "reported", "file-size"], + ["EFBIG", "reported", "file-size"], + ["EFBIG", "malformed", "file-size"], +] as const)( + "preserves observed %s with %s limit evidence", + async (failure, scenario, resource) => { + const { path } = await fixture(); + let ownedPath = ""; + const limits = { + address_space_bytes: 3221225472, + cpu_seconds: 1, + file_size_bytes: 1024, + }; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + if (failure === "EFBIG") + await writeFile(join(ownedPath, "resource.failure"), "F"); + if (scenario !== "missing") + await writeFile( + join(ownedPath, "limits.json"), + scenario === "reported" + ? JSON.stringify(limits) + : '{"cpu_seconds":999}', + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + failure === "EFBIG" + ? 'process.stdout.write("resource diagnostic", () => { process.exitCode = 76; })' + : failure === "SIGXFSZ" + ? 'process.on("SIGXFSZ", () => { process.removeAllListeners("SIGXFSZ"); process.kill(process.pid, "SIGXFSZ"); }); process.stdout.write("resource diagnostic", () => process.kill(process.pid, "SIGXFSZ")); setTimeout(() => {}, 1000);' + : `process.stdout.write("resource diagnostic", () => process.kill(process.pid, "${failure}"))`, + ], + }); + }, + ); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected observed resource failure"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "resource_constraint", + details: { + resource, + reported_limits: scenario === "reported" ? limits : null, + captured_output: { stdout: "resource diagnostic", truncated: false }, + }, + }); + expect(result.error.message).toContain( + failure === "EFBIG" + ? "exact write failure cause is unknown" + : "exact signal cause is unknown", + ); + if (scenario !== "reported") + expect(result.error.message).toContain("limit report unavailable"); + expect(projected.remediation.action).toContain( + resource === "cpu" ? "CPU" : "file-size", + ); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost)( + "retains actual diagnostic truncation when output overflow and process cleanup both fail", + async () => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'], + }); + return { + ...launched, + cleanup: async () => { + await launched.cleanup?.(); + throw new Error("injected post-cleanup reporting failure"); + }, + }; + }, + ); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected compounded lifecycle failure"); + expect(result.error).toMatchObject({ + cleanupIncomplete: true, + diagnostics: { + reason: "injected post-cleanup reporting failure", + previous_error: { failure_kind: "output-limit" }, + captured_output: { truncated: true, stderr: "" }, + }, + }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it + .runIf(!unsupportedHost) + .each([ + "reserved-memory-status", + "unmarked-memory-status", + "unmarked-file-status", + "wrong-marker", + "ordinary-exit", + "signal", + ])( + "requires matching private bridge failure evidence, without guessing from stderr: %s", + async (scenario) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + if ( + scenario === "reserved-memory-status" || + scenario === "wrong-marker" + ) + await writeFile( + join(ownedPath, "resource.failure"), + scenario === "wrong-marker" ? Buffer.from([0xcd]) : "M", + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + 'process.stderr.write("MemoryError-like diagnostic\\n");' + + (scenario === "signal" + ? 'process.kill(process.pid,"SIGKILL")' + : `process.exit(${scenario === "unmarked-file-status" ? "76" : ["reserved-memory-status", "unmarked-memory-status", "wrong-marker"].includes(scenario) ? "75" : "7"})`), + ], + }); + }, + ); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected explicit process failure"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe( + scenario === "reserved-memory-status" + ? "resource_constraint" + : "execution_failure", + ); + expect(JSON.stringify(projected)).toContain("MemoryError-like diagnostic"); + if (scenario === "reserved-memory-status") + expect(projected.details).toMatchObject({ + resource: "memory", + reported_limits: null, + captured_output: { truncated: false }, + }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each([1048576, 1048577])( + "keeps complete diagnostics or fails explicitly when a valid reply emits %s bytes", + async (bytes) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("Request path missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + const { + artifact: _artifact, + diagnostics: _diagnostics, + ...value + } = binaryLayoutFixture(path); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value, + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write("x".repeat(${String(bytes)}))`, + ], + }); + }, + ); + const result = await provider.inspect({ path }); + if (bytes === 1048576) { + if (!result.ok) throw result.error; + expect(result.value.diagnostics.stdout).toHaveLength(bytes); + expect(result.value.diagnostics.truncated).toBe(false); + } else { + if (result.ok) + throw new Error("Excess diagnostics must not return partial success"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "unreadable_output", + details: { captured_output: { truncated: true } }, + }); + } + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each([ + null, + { + address_space_bytes: 67108864, + cpu_seconds: 20, + file_size_bytes: 33554432, + }, +])( + "preserves reported memory failure, budgets, diagnostics and cleanup: %j", + async (limits) => { + const { path } = await fixture(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("Request path missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: false, + reason: "resource-limit", + message: "Memory allocation failed; exact cause unknown.", + reported_limits: limits, + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + "process.stderr.write('upstream allocation diagnostic')", + ], + }); + }, + ); + const result = await provider.inspect({ path }); + if (result.ok) throw new Error("Expected resource failure"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "resource_constraint", + category: "resource_constraint", + details: { + resource: "memory", + reported_limits: limits, + captured_output: { + stderr: "upstream allocation diagnostic", + truncated: false, + }, + }, + }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path, "utf8")).toBe("source-owned-seam-bytes"); + }, +); + +it.runIf(!unsupportedHost).each(["cancelled", "output-limit", "process"])( + "releases an acquired decoder and its snapshot after %s", + async (scenario) => { + const { path } = await fixture(); + const controller = new AbortController(); + let ownedPath = ""; + let pid: number | undefined; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + ownedPath = spawn.cwd ?? ""; + const launched = await spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: [ + "-e", + scenario === "cancelled" + ? 'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)' + : scenario === "output-limit" + ? 'process.stdout.write("x".repeat(1048577))' + : 'process.stderr.write("decoder exited unexpectedly"); process.exit(7)', + ], + }); + pid = launched.process.pid; + if (scenario === "cancelled") { + await waitForProviderProcessReady(launched.process); + setImmediate(() => controller.abort()); + } + return launched; + }, + ); + const result = await provider.inspect( + { path }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + _tag: + scenario === "cancelled" + ? "AnalysisCancelledError" + : scenario === "output-limit" + ? "AnalysisOutputError" + : "ProviderAdapterError", + ...(scenario === "process" + ? { + diagnostics: { + exit_code: 7, + stderr: "decoder exited unexpectedly", + }, + } + : {}), + }, + }); + if (pid === undefined) throw new Error("Decoder was not acquired"); + const acquiredPid = pid; + expect(() => process.kill(acquiredPid, 0)).toThrow(); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(path, "utf8")).toBe("source-owned-seam-bytes"); + }, +); + +it.runIf(!unsupportedHost)( + "reports private storage denial as an adapter failure rather than selected-input read denial", + async () => { + const { path } = await fixture(); + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + undefined, + () => + Promise.reject( + Object.assign(new Error("private root denied"), { code: "EACCES" }), + ), + ); + expect(await provider.inspect({ path })).toMatchObject({ + ok: false, + error: { + _tag: "ProviderAdapterError", + diagnostics: { phase: "decoder", reason: "private root denied" }, + }, + }); + }, +); + +it.runIf(!unsupportedHost)( + "preserves a decoder failure when private-root cleanup also fails", + async () => { + const { path, root } = await fixture(); + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("Request path missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: false, + reason: "format", + message: "Malformed original ELF table.", + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + arguments: ["-e", "process.exit(0)"], + }); + }, + () => + Promise.resolve({ + path: root, + close: () => Promise.reject(new Error("root cleanup failed")), + }), + ); + expect(await provider.inspect({ path })).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [root], + diagnostics: { + reason: "root cleanup failed", + previous_error: { category: "invalid_input" }, + }, + }, + }); + }, +); +const fixture = async () => { + const workspace = await createTestWorkspace("rea-layout-boundary-"); + const root = workspace.root; + onTestFinished(() => removeTestWorkspace(root)); + const path = join(root, "selected.elf"); + await writeFile(path, "source-owned-seam-bytes"); + return { root, path }; +}; + +it.runIf(!unsupportedHost)( + "preserves cancellation during the actual signal-aware snapshot write and closes its acquired root", + async () => { + const { path } = await fixture(); + const controller = new AbortController(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + () => { + throw new Error("Decoder must not launch"); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-layout-cancel-", + }); + ownedPath = root.path; + controller.abort(); + return root; + }, + ); + expect( + await provider.inspect({ path }, { signal: controller.signal }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it + .runIf(!unsupportedHost) + .each([ + "success", + "format", + "unavailable", + "unsupported", + "output-limit", + "wrong-profile", + "malformed-reply", + ])( + "preserves process/reply boundaries and cleans private snapshots: %s", + async (scenario) => { + const { root, path } = await fixture(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("request path missing"); + ownedPath = spawn.cwd ?? ""; + expect(spawn.arguments[0]).toBe("-I"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + expect(await readFile(request.snapshot_path, "utf8")).toBe( + "source-owned-seam-bytes", + ); + const { + artifact: _artifact, + diagnostics: _diagnostics, + ...value + } = binaryLayoutFixture(path); + await writeFile( + request.reply_path, + scenario === "malformed-reply" + ? "broken-json" + : JSON.stringify( + [ + "format", + "unavailable", + "unsupported", + "output-limit", + ].includes(scenario) + ? { + ok: false, + reason: scenario, + message: "ELF table is truncated.", + } + : { + ok: true, + profile: + scenario === "wrong-profile" + ? "pwntools@other" + : PWNTOOLS_PROVIDER_IDENTITY.version, + value, + }, + ), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", "process.stderr.write('upstream warning')"], + }); + }, + ); + const result = await provider.inspect({ path }); + if (scenario === "success") { + if (!result.ok) throw result.error; + expect(result.value.artifact).toMatchObject({ + path, + bytes: Buffer.byteLength("source-owned-seam-bytes"), + }); + expect(result.value.artifact.sha256).toMatch(/^[a-f0-9]{64}$/); + expect(result.value.diagnostics.stderr).toBe("upstream warning"); + } else + expect(result).toMatchObject({ + ok: false, + error: { + _tag: + scenario === "format" + ? "AnalysisInputError" + : scenario === "unavailable" + ? "ProviderSelectionError" + : scenario === "unsupported" + ? "AnalysisCapabilityUnavailableError" + : "AnalysisOutputError", + }, + }); + if (!result.ok) + expect(projectAnalysisError(result.error).details).toMatchObject({ + captured_output: { + stdout: "", + stderr: "upstream warning", + truncated: false, + }, + }); + expect(await readFile(path, "utf8")).toBe("source-owned-seam-bytes"); + expect(ownedPath.startsWith(root)).toBe(false); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost)( + "rejects missing configuration, absent input and prelaunch cancellation distinctly", + async () => { + const { path } = await fixture(); + expect( + await new PwntoolsLayoutProvider({}).inspect({ path }), + ).toMatchObject({ + ok: false, + error: { _tag: "ProviderSelectionError", reason: "provider_unavailable" }, + }); + const provider = new PwntoolsLayoutProvider({ + REA_PWNTOOLS_PYTHON: process.execPath, + }); + expect(await provider.inspect({ path: path + ".absent" })).toMatchObject({ + ok: false, + error: { _tag: "AnalysisInputError" }, + }); + const controller = new AbortController(); + controller.abort(); + expect( + await provider.inspect({ path }, { signal: controller.signal }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + }, +); +it.runIf(unsupportedHost)( + "reports unverified host coverage before acquiring a provider", + async () => { + expect( + await new PwntoolsLayoutProvider({}).inspect({ path: "/selected.elf" }), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCapabilityUnavailableError" }, + }); + }, +); + +it.runIf(!unsupportedHost)( + "retains decoder warnings when cancellation arrives during successful root cleanup", + async () => { + const { path } = await fixture(); + const controller = new AbortController(); + let ownedPath = ""; + const provider = new PwntoolsLayoutProvider( + { REA_PWNTOOLS_PYTHON: process.execPath }, + async (spawn) => { + const requestPath = spawn.arguments.at(-1); + if (requestPath === undefined) throw new Error("Request path missing"); + const request = requestSchema.parse( + JSON.parse(await readFile(requestPath, "utf8")), + ); + const { + artifact: _artifact, + diagnostics: _diagnostics, + ...value + } = binaryLayoutFixture(path); + await writeFile( + request.reply_path, + JSON.stringify({ + ok: true, + profile: PWNTOOLS_PROVIDER_IDENTITY.version, + value, + }), + ); + return spawnOwnedProviderProcess({ + ...spawn, + command: process.execPath, + arguments: ["-e", 'process.stderr.write("retained late warning")'], + }); + }, + async () => { + const root = await PrivateRuntimeRoot.create({ + prefix: "rea-layout-late-cancel-", + }); + ownedPath = root.path; + return { + path: root.path, + close: async () => { + await root.close(); + controller.abort(); + }, + }; + }, + ); + const result = await provider.inspect( + { path }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + if (result.ok) throw new Error("Late cancellation must be preserved"); + expect(projectAnalysisError(result.error).details?.captured_output).toEqual( + { stdout: "", stderr: "retained late warning", truncated: false }, + ); + await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])( + "identifies the configured executable on actual launch/preflight failure: %s", + async (scenario) => { + const { path, root } = await fixture(); + const executable = + scenario === "directory" ? root : join(root, "broken-python"); + if (scenario === "missing-interpreter") + await writeFile( + executable, + "#!/rea-missing-interpreter-for-boundary-test\n", + { mode: 0o700 }, + ); + const result = await new PwntoolsLayoutProvider({ + REA_PWNTOOLS_PYTHON: executable, + }).inspect({ path }); + if (result.ok) throw new Error("Expected unavailable executable"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "provider_unavailable", + details: { + rejections: [{ diagnostics: { executable_path: executable } }], + }, + }); + expect(result.error._tag).toBe("ProviderSelectionError"); + expect(await readFile(path, "utf8")).toBe("source-owned-seam-bytes"); + }, +); diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts index 3e8c98b5d..c804c93e2 100644 --- a/tests/boundary/cli/productCatalog.test.ts +++ b/tests/boundary/cli/productCatalog.test.ts @@ -1,3 +1,4 @@ +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -115,6 +116,7 @@ describe("canonical product catalog", () => { GHIDRA_PROVIDER_IDENTITY, IDA_PROVIDER_IDENTITY, NATIVE_MACOS_PROVIDER_IDENTITY, + PWNTOOLS_PROVIDER_IDENTITY, ARTIFACT_GRAPH_PROVIDER, ANDROID_APPLICATION_PROVIDER, APPLE_APPLICATION_PROVIDER, diff --git a/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts new file mode 100644 index 000000000..12ed431d9 --- /dev/null +++ b/tests/boundary/mcp/binaryDiagnosticsMcp.test.ts @@ -0,0 +1,76 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it, onTestFinished } from "vitest"; +import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.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 { + binaryLayoutFixture, + BINARY_LAYOUT_TEST_PROVIDER, +} from "../../fixtures/binaryDiagnostics/layout.js"; + +it("advertises exact valid schemas and records inline layout without a binary target", async () => { + const value = binaryLayoutFixture(); + const service = new BinaryLayoutService({ + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => Promise.resolve(ok(value)), + }); + const session = createTestBinarySession(() => { + throw new Error("deep provider must not start"); + }); + const server = createServer(session, session, { binaryLayout: service }); + const client = new Client({ name: "binary-layout-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_binary_layout", + ); + if (advertised?.outputSchema === undefined) + throw new Error("layout must publish both schemas"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const inputSchema: Record = advertised.inputSchema; + const outputSchema: Record = advertised.outputSchema; + expect(ajv.validateSchema(inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true); + expect( + ajv.validate(inputSchema, { + path: value.artifact.path, + approval: true, + }), + ).toBe(false); + const response = await client.callTool({ + name: "inspect_binary_layout", + 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_binary_layout").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_binary_layout", + arguments: { path: "relative.elf" }, + }) + ).isError, + ).toBe(true); +}); diff --git a/tests/fixtures/binaryDiagnostics/layout.ts b/tests/fixtures/binaryDiagnostics/layout.ts new file mode 100644 index 000000000..28cbf03c1 --- /dev/null +++ b/tests/fixtures/binaryDiagnostics/layout.ts @@ -0,0 +1,48 @@ +import type { BinaryLayout } from "../../../src/domain/native/binaryLayout.js"; + +/** Small source-owned provider observation for validation and transport tests. */ +export const binaryLayoutFixture = ( + path = "/artifacts/source-owned.elf", +): BinaryLayout => ({ + artifact: { path, sha256: "a".repeat(64), bytes: 128 }, + format: "elf", + architecture: { machine: "EM_X86_64", bits: 64, byte_order: "little" }, + image_type: "ET_REL", + entry_point: { + reported_value: "0x0", + meaning: "not-applicable", + execution_status: "unknown", + }, + runtime_load_base: null, + sections: [], + segments: [], + symbols: [], + relocations: [], + packed_relative_relocations: [], + relocation_inventory_completeness: "unknown", + linkage: { + needed_libraries: [], + interpreters: [], + got: [], + plt: [], + convenience_maps_completeness: "unknown", + runtime_library_paths: null, + }, + mitigations: { + evidence_kind: "inferred", + position_independent: false, + nx_indicator: null, + executable_stack_indicator: false, + stack_canary_indicator: false, + relro: null, + }, + diagnostics: { stdout: "producer warning", stderr: "", truncated: false }, + limitations: ["Runtime addresses remain unknown."], +}); + +/** Producer identity belongs to the test seam, not a simulated installed engine claim. */ +export const BINARY_LAYOUT_TEST_PROVIDER = { + id: "fixture-layout", + name: "Source-owned layout seam", + version: "1", +} as const; diff --git a/third_party/pwntools/README.md b/third_party/pwntools/README.md new file mode 100644 index 000000000..ed9690851 --- /dev/null +++ b/third_party/pwntools/README.md @@ -0,0 +1,40 @@ +# Offline ELF upstream profile + +REA uses caller-supplied, unchanged Python packages. It vendors no pwntools, +pyelftools or Unicorn implementation and never installs these dependencies. + +| Upstream | Exact verified profile | License | Role | +| --- | --- | --- | --- | +| [pwntools](https://github.com/Gallopsled/pwntools) | 4.15.0; tag commit `3ad86ec5adab1abf9781ca7552a6f5d7cff7a33c` | MIT | ELF analysis and static mitigation/GOT/PLT heuristics | +| [pyelftools](https://github.com/eliben/pyelftools) | 0.33 | Public domain | Original ELF sections, segments, symbols and relocations | +| [Unicorn](https://github.com/unicorn-engine/unicorn) | 2.1.2 | GPL-2.0 | Upstream PLT emulation; distinct from executing the target as a host process | + +The adapter checks all three runtime versions before importing the ELF engine. +Original upstream code/licenses remain in the caller-selected environment. +No target or core binaries are committed. Fixtures are compiled in owned +temporary storage from repository source and are never executed. + +`bridge/pwntools/layout.py` is an owned allocation-failure bootstrap; +`layout_impl.py` is the adapter. It reads original tables through +unchanged upstream APIs, preserves raw name bytes/ranges, and serializes uint64 +values as strings. It never accesses `ELF.libs`, `ELF.maps`, `ELF.libc` or +`process.corefile`, which have materially different execution authority. + +The real verification lane traces host exec syscalls, checks original artifact +hashes and observes released owned processes. No negative claim relies only on +an unmodified target's sentinel: upstream can execute a patched copy when its +runtime-library accessors are used. + +The instance-local adapter subclass forwards the documented pyelftools 0.33 +`iter_segments(type=...)` / `iter_sections(type=...)` filters missing from +pwntools 4.15.0 cached overrides. No upstream file or global method is changed. +Sectionless dynamic string tables use unique DT_STRTAB/DT_STRSZ mappings to +file-backed PT_LOAD bytes; ambiguous or non-file-backed mappings are explicitly +unsupported. Section-name table escapes follow the [ELF ABI](https://gabi.xinuos.com/elf/02-eheader.html): +SHN_XINDEX represents actual indices at least 0xff00; ordinary absence uses index +zero. Raw name offsets remain reported when the name table is absent. + +PLT convenience inference uses the unchanged upstream Unicorn instruction +emulator. REA labels those maps as derived static evidence. The no-execution +verification claim concerns launching the selected object as a host process, +not the absence of static instruction emulation inside an analysis engine.