mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(ghidra): verify DOS load images and expose memory evidence (#697)
* feat(ghidra): verify DOS load images and expose memory evidence Verify measured source bytes, mapped regions, fixups and entries against the immutable MZ snapshot, retaining independent checks and raw evidence. Expose loaded byte reads and authoritative original file offsets through both CLI and MCP, with focused and actual Ghidra DOS verification. Assisted-by: Nitori (gensokyo-skills:nitori-reverse-engineering) * fix(cli): describe memory inspection arguments * test(ghidra): align bridge fixtures and CLI capability accounting
This commit is contained in:
1 parent
132a3a46be
commit
a6a8f67649
35 files changed
+1513
-28
No files matched your search
@@ -307,7 +307,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 39 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Native inspection | 40 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ rea setup
|
||||
|
||||
| عائلة الأدوات | العدد | الاستخدام |
|
||||
| --------------------- | ----: | -------------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 39 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
|
||||
| فحص الملفات التنفيذية | 40 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
|
||||
| التحليل المركب | 14 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
|
||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
|
||||
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | --------------------------------------------------------------------------- |
|
||||
| バイナリ検査 | 39 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
|
||||
| バイナリ検査 | 40 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
|
||||
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
|
||||
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
||||
|
||||
+1
-1
@@ -194,7 +194,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | -------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 39 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
|
||||
| 바이너리 검사 | 40 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
|
||||
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
|
||||
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
||||
|
||||
+1
-1
@@ -193,7 +193,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | --------------------------------------------------------- |
|
||||
| 二进制检查 | 39 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
|
||||
| 二进制检查 | 40 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
|
||||
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
||||
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
||||
|
||||
@@ -73,6 +73,11 @@ import ghidra.program.model.listing.Instruction;
|
||||
import ghidra.program.model.listing.InstructionIterator;
|
||||
import ghidra.program.model.listing.Variable;
|
||||
import ghidra.program.model.mem.MemoryBlock;
|
||||
import ghidra.program.model.mem.MemoryBlockSourceInfo;
|
||||
import ghidra.program.database.mem.FileBytes;
|
||||
import ghidra.program.model.address.SegmentedAddress;
|
||||
import ghidra.program.model.reloc.Relocation;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import ghidra.program.model.symbol.RefType;
|
||||
import ghidra.program.model.symbol.Reference;
|
||||
import ghidra.program.model.symbol.ReferenceIterator;
|
||||
@@ -112,6 +117,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
"params"
|
||||
);
|
||||
private static final String[] CAPABILITIES = {
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
"ping",
|
||||
"shutdown",
|
||||
"address_name",
|
||||
@@ -309,6 +317,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
);
|
||||
}
|
||||
return switch (request.method) {
|
||||
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
|
||||
case "read_bytes" -> readMemoryBytes(request.params);
|
||||
case "address_to_file_offset" -> addressToFileOffset(request.params);
|
||||
case "address_name" -> addressName(request.params);
|
||||
case "list_documents" -> listDocuments(request.params);
|
||||
case "list_names" -> listNames(request.params);
|
||||
@@ -371,6 +382,185 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String bytesHex(byte[] bytes) {
|
||||
return java.util.HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private String hashFileBytes(FileBytes source, boolean modified) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] chunk = new byte[65536];
|
||||
for (long offset = 0; offset < source.getSize();) {
|
||||
monitor.checkCancelled();
|
||||
int length = (int) Math.min(chunk.length, source.getSize() - offset);
|
||||
int read = modified
|
||||
? source.getModifiedBytes(offset, chunk, 0, length)
|
||||
: source.getOriginalBytes(offset, chunk, 0, length);
|
||||
if (read != length) throw new IOException("Incomplete FileBytes read at " + offset);
|
||||
digest.update(chunk, 0, read);
|
||||
offset += read;
|
||||
}
|
||||
return bytesHex(digest.digest());
|
||||
}
|
||||
|
||||
private String hashMemory(Address start, long length) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] chunk = new byte[65536];
|
||||
for (long offset = 0; offset < length;) {
|
||||
monitor.checkCancelled();
|
||||
int count = (int) Math.min(chunk.length, length - offset);
|
||||
int read = currentProgram.getMemory().getBytes(start.add(offset), chunk, 0, count);
|
||||
if (read != count) throw new IOException("Incomplete memory read at " + start.add(offset));
|
||||
digest.update(chunk, 0, read);
|
||||
offset += read;
|
||||
}
|
||||
return bytesHex(digest.digest());
|
||||
}
|
||||
|
||||
private JsonObject inspectNativeLoadImage(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of());
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("executable_format", currentProgram.getExecutableFormat());
|
||||
result.addProperty("language_id", currentProgram.getLanguageID().getIdAsString());
|
||||
result.addProperty("compiler_spec_id", currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
|
||||
result.addProperty("image_base", canonicalAddress(currentProgram.getImageBase()));
|
||||
result.addProperty("default_address_space", sessionDefaultAddressSpace.getName());
|
||||
List<FileBytes> files = currentProgram.getMemory().getAllFileBytes();
|
||||
JsonArray sources = new JsonArray();
|
||||
for (FileBytes file : files) {
|
||||
JsonObject source = new JsonObject();
|
||||
source.addProperty("name", file.getFilename());
|
||||
source.addProperty("size", file.getSize());
|
||||
source.addProperty("original_sha256", hashFileBytes(file, false));
|
||||
source.addProperty("modified_sha256", hashFileBytes(file, true));
|
||||
sources.add(source);
|
||||
}
|
||||
result.add("source_files", sources);
|
||||
JsonArray mappings = new JsonArray();
|
||||
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
monitor.checkCancelled();
|
||||
JsonObject mapping = new JsonObject();
|
||||
mapping.addProperty("block", block.getName());
|
||||
mapping.addProperty("start", canonicalAddress(source.getMinAddress()));
|
||||
mapping.addProperty("end", canonicalAddress(source.getMaxAddress()));
|
||||
mapping.addProperty("address_space", source.getMinAddress().getAddressSpace().getName());
|
||||
mapping.addProperty("initialized", block.isInitialized());
|
||||
mapping.addProperty("loaded", block.isLoaded());
|
||||
mapping.addProperty("overlay", block.isOverlay());
|
||||
mapping.addProperty("length", source.getLength());
|
||||
FileBytes backing = source.getFileBytes().orElse(null);
|
||||
int sourceIndex = backing == null ? -1 : files.indexOf(backing);
|
||||
if (sourceIndex < 0) mapping.add("source_file_index", JsonNull.INSTANCE);
|
||||
else mapping.addProperty("source_file_index", sourceIndex);
|
||||
long offset = source.getFileBytesOffset();
|
||||
if (offset < 0) mapping.add("file_offset", JsonNull.INSTANCE);
|
||||
else mapping.addProperty("file_offset", offset);
|
||||
if (block.isInitialized()) mapping.addProperty("sha256", hashMemory(source.getMinAddress(), source.getLength()));
|
||||
else mapping.add("sha256", JsonNull.INSTANCE);
|
||||
mappings.add(mapping);
|
||||
}
|
||||
}
|
||||
result.add("mappings", mappings);
|
||||
JsonArray relocations = new JsonArray();
|
||||
Iterator<Relocation> iterator = currentProgram.getRelocationTable().getRelocations();
|
||||
while (iterator.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
Relocation relocation = iterator.next();
|
||||
Address location = relocation.getAddress();
|
||||
JsonObject row = new JsonObject();
|
||||
row.addProperty("address", canonicalAddress(location));
|
||||
if (location instanceof SegmentedAddress segmented) {
|
||||
row.addProperty("segment", segmented.getSegment());
|
||||
row.addProperty("offset", segmented.getSegmentOffset());
|
||||
} else {
|
||||
row.add("segment", JsonNull.INSTANCE);
|
||||
row.add("offset", JsonNull.INSTANCE);
|
||||
}
|
||||
row.addProperty("status", relocation.getStatus().toString());
|
||||
row.addProperty("type", relocation.getType());
|
||||
row.add("values", GSON.toJsonTree(relocation.getValues() == null ? new long[0] : relocation.getValues()));
|
||||
byte[] original = relocation.getBytes();
|
||||
row.add("original_bytes_hex", original == null ? JsonNull.INSTANCE : GSON.toJsonTree(bytesHex(original)));
|
||||
if (original != null && original.length > 0) {
|
||||
byte[] memory = new byte[original.length];
|
||||
int read = currentProgram.getMemory().getBytes(location, memory);
|
||||
row.add("memory_bytes_hex", read == memory.length ? GSON.toJsonTree(bytesHex(memory)) : JsonNull.INSTANCE);
|
||||
} else row.add("memory_bytes_hex", JsonNull.INSTANCE);
|
||||
relocations.add(row);
|
||||
}
|
||||
result.add("relocations", relocations);
|
||||
List<Address> entries = new ArrayList<>();
|
||||
var entryIterator = currentProgram.getSymbolTable().getExternalEntryPointIterator();
|
||||
while (entryIterator.hasNext()) entries.add(entryIterator.next());
|
||||
entries.sort(Address::compareTo);
|
||||
JsonArray entryPoints = new JsonArray();
|
||||
for (Address entry : entries) entryPoints.add(canonicalAddress(entry));
|
||||
result.add("entry_points", entryPoints);
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject readMemoryBytes(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of("document", "address", "length"));
|
||||
requireDocument(params);
|
||||
Address start = requireAddress(params, "address");
|
||||
JsonElement requested = params.get("length");
|
||||
long length;
|
||||
try {
|
||||
if (requested == null || !requested.isJsonPrimitive() || !requested.getAsJsonPrimitive().isNumber())
|
||||
throw new IllegalArgumentException();
|
||||
length = requested.getAsBigDecimal().longValueExact();
|
||||
if (length < 1 || length > 9007199254740991L) throw new IllegalArgumentException();
|
||||
} catch (RuntimeException failure) {
|
||||
throw new RequestFailure("invalid_request", "Byte length must be a positive safe integer");
|
||||
}
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
byte[] chunk = new byte[65536];
|
||||
for (long offset = 0; offset < length;) {
|
||||
monitor.checkCancelled();
|
||||
Address at;
|
||||
try { at = start.addNoWrap(offset); }
|
||||
catch (Exception endOfSpace) { break; }
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(at);
|
||||
if (block == null || !block.isInitialized()) break;
|
||||
int count = (int) Math.min(chunk.length, Math.min(length - offset, block.getEnd().subtract(at) + 1));
|
||||
int read = currentProgram.getMemory().getBytes(at, chunk, 0, count);
|
||||
bytes.write(chunk, 0, read);
|
||||
if (read != count) break;
|
||||
offset += read;
|
||||
}
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(start));
|
||||
result.addProperty("requested_bytes", length);
|
||||
result.addProperty("returned_bytes", bytes.size());
|
||||
result.addProperty("bytes_hex", bytesHex(bytes.toByteArray()));
|
||||
result.addProperty("complete", bytes.size() == length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonObject addressToFileOffset(JsonObject params) {
|
||||
requireKeys(params, Set.of("document", "address"));
|
||||
requireDocument(params);
|
||||
Address address = requireAddress(params, "address");
|
||||
MemoryBlock block = currentProgram.getMemory().getBlock(address);
|
||||
if (block == null || !block.isInitialized())
|
||||
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
|
||||
Long fileOffset = null;
|
||||
int mappings = 0;
|
||||
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
|
||||
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue;
|
||||
long offset = source.getFileBytesOffset(address);
|
||||
if (offset < 0) continue;
|
||||
fileOffset = offset;
|
||||
mappings++;
|
||||
}
|
||||
if (mappings != 1)
|
||||
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address));
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", canonicalAddress(address));
|
||||
result.addProperty("file_offset", fileOffset);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void initializeDecompiler() {
|
||||
DecompileOptions options = new DecompileOptions();
|
||||
options.setMaxInstructions(Integer.MAX_VALUE);
|
||||
|
||||
@@ -13,6 +13,9 @@ rea inspect /absolute/path/to/legacy.exe --provider ghidra --json
|
||||
rea search /absolute/path/to/legacy.exe entry --kind procedures --provider ghidra --json
|
||||
rea instructions /absolute/path/to/legacy.exe entry --provider ghidra --json
|
||||
rea function /absolute/path/to/legacy.exe entry --provider ghidra --json
|
||||
rea inspect-native-load-image /absolute/path/to/legacy.exe --provider ghidra --json
|
||||
rea read-bytes /absolute/path/to/legacy.exe 0x10000 --length 16 --provider ghidra --json
|
||||
rea address-to-file-offset /absolute/path/to/legacy.exe 0x10000 --provider ghidra --json
|
||||
```
|
||||
|
||||
Use an observed procedure name or address from discovery when the target does
|
||||
@@ -71,6 +74,30 @@ Ghidra's database observation, not a proof of original source ownership or
|
||||
exhaustive code discovery. Providers that do not report complete ranges return
|
||||
`available: false` and an explicit reason.
|
||||
|
||||
## Loaded-image evidence
|
||||
|
||||
`inspect_native_load_image` independently checks the immutable session snapshot
|
||||
against the loaded Ghidra Program. It measures complete original and modified
|
||||
FileBytes digests, every source mapping and its memory digest, relocation records
|
||||
(including multiplicity), and external entry points. It verifies MZ header and
|
||||
module partitions, relocation fixups and linear coordinates rather than relying
|
||||
on the imported executable SHA alone.
|
||||
|
||||
The result is `verified`, `mismatch`, or `unsupported`. Every check retains its
|
||||
expected and observed values. A memory-digest mismatch identifies the source
|
||||
range's file offset and analysis address; it does not locate the first differing
|
||||
byte inside that range. Mapping ends are **inclusive**, unlike the exclusive ends
|
||||
from `list_segments`. Appended overlays remain in source-file identity and are
|
||||
excluded from initialized module coverage. Uninitialized allocations are reported;
|
||||
their contents and DOS runtime semantics are not verified. Other formats return
|
||||
`unsupported` with measured observations, not a successful MZ verification.
|
||||
|
||||
`read_bytes` returns initialized provider memory, including loader fixups; an
|
||||
unmapped or uninitialized byte ends the read with an explicit returned length and
|
||||
`complete: false`. `address_to_file_offset` uses the provider's source mapping;
|
||||
unmapped, uninitialized and ambiguous mappings fail explicitly. A relocated word
|
||||
can differ from the original file word even when its source offset is known.
|
||||
|
||||
## Coverage boundaries
|
||||
|
||||
Static decompilation does not execute the target or emulate BIOS, DOS
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
"client": "2.3.1",
|
||||
"core": "2.3.1"
|
||||
},
|
||||
"skill_version": "25"
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 116,
|
||||
"total": 117,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
"surface": "official-proxy",
|
||||
"count": 39,
|
||||
"count": 40,
|
||||
"tools": [
|
||||
"address_name",
|
||||
"address_to_file_offset",
|
||||
@@ -27,6 +27,7 @@
|
||||
"inline_comment",
|
||||
"inspect_native_data_type",
|
||||
"inspect_native_instruction",
|
||||
"inspect_native_load_image",
|
||||
"list_bookmarks",
|
||||
"list_documents",
|
||||
"list_names",
|
||||
@@ -207,9 +208,11 @@
|
||||
"version": null,
|
||||
"capabilities": [
|
||||
"address_name",
|
||||
"address_to_file_offset",
|
||||
"analyze_function",
|
||||
"inspect_native_data_type",
|
||||
"inspect_native_instruction",
|
||||
"inspect_native_load_image",
|
||||
"list_documents",
|
||||
"list_names",
|
||||
"list_procedures",
|
||||
@@ -222,6 +225,7 @@
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"read_bytes",
|
||||
"read_function_instructions",
|
||||
"resolve_containing_procedure",
|
||||
"resolve_native_call_targets",
|
||||
@@ -465,8 +469,9 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 69,
|
||||
"primary_count": 72,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
"analyze-javascript-application",
|
||||
"analyze-web-bundle",
|
||||
@@ -510,6 +515,7 @@
|
||||
"inspect-native-data-type",
|
||||
"inspect-native-dispatch-metadata",
|
||||
"inspect-native-instruction",
|
||||
"inspect-native-load-image",
|
||||
"inspect-plist",
|
||||
"inspect-signature",
|
||||
"inspect-web-page",
|
||||
@@ -523,6 +529,7 @@
|
||||
"observe-web-session",
|
||||
"project-managed-application-graph",
|
||||
"providers",
|
||||
"read-bytes",
|
||||
"reconcile-javascript-runtime",
|
||||
"resolve-native-call-targets",
|
||||
"search",
|
||||
@@ -546,15 +553,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 69,
|
||||
"mcp_tools": 116,
|
||||
"cli_commands": 72,
|
||||
"mcp_tools": 117,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "7849b6f39bed200cfbf5f4d09ba3f9e0661824efb5753ae2edde8ef2fb66535e",
|
||||
"tools_sha256": "29258155c0d2a5e174bf63b777c35689cec70bd016c85046c62ebaad8e8a57a9",
|
||||
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
|
||||
"combined_sha256": "5bb3f994a3c4d0c87a34ff5aab4e28141b5fa6f3a98b5ae7e95686d85d9d983e",
|
||||
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
|
||||
"combined_sha256": "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110",
|
||||
"providers_sha256": "c1a6849b2524347e6cf9e004a17ba1805f86114b2da72eb689a075afbbb1aada"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_6f5a77c85a58f9980ef709b832b5ccb5207c137d40e181b1ea209afa3de60c67",
|
||||
"manifest_id": "ecm_8903d94ebb803743a2028ca1ea3909e2927e05f3d34f9a03eb96388434e4b629",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "08b7039f48babaeda3660d9f62fa1066a6e9f72cf522a3f35201e59634253537"
|
||||
"sha256": "d2bbf020f2865b25daad721a1b05c9aed5f3fe6b16c1efa5c9652f7c3e1b775a"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -106,6 +106,78 @@ try {
|
||||
assert.equal(target.kind, "executable");
|
||||
assert.equal(target.architecture, "x86");
|
||||
assert.equal(target.sha256, sha256);
|
||||
const image = await call("inspect_native_load_image");
|
||||
assert.equal(
|
||||
image.status,
|
||||
"verified",
|
||||
JSON.stringify(image.checks?.filter((check) => !check.matched)),
|
||||
);
|
||||
assert.equal(image.target_sha256, sha256);
|
||||
assert.equal(image.header_bytes, fixture.header_bytes);
|
||||
assert.equal(image.module_bytes, fixture.module_bytes);
|
||||
assert.equal(image.entry.linear_address, expected.entry);
|
||||
assert.ok(image.checks.every((check) => check.matched));
|
||||
const cliImage = await cliEvidence("inspect-native-load-image");
|
||||
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
|
||||
const stableImage = (value) => ({
|
||||
...value,
|
||||
observations: {
|
||||
...value.observations,
|
||||
source_files: value.observations.source_files.map(
|
||||
({ name: _name, ...file }) => file,
|
||||
),
|
||||
},
|
||||
});
|
||||
assert.deepEqual(stableImage(cliImage.normalized_result), stableImage(image));
|
||||
const mapped = await call("address_to_file_offset", {
|
||||
address: expected.entry,
|
||||
});
|
||||
assert.equal(mapped.file_offset, fixture.header_bytes);
|
||||
const headerMapping = await call("address_to_file_offset", {
|
||||
address: "HEADER:0x0",
|
||||
});
|
||||
assert.equal(headerMapping.file_offset, 0);
|
||||
const fileRelocation = await call("address_to_file_offset", {
|
||||
address: address(fixture.relocation_offset),
|
||||
});
|
||||
assert.equal(
|
||||
fileRelocation.file_offset,
|
||||
fixture.header_bytes + fixture.relocation_offset,
|
||||
);
|
||||
const memoryRelocation = await call("read_bytes", {
|
||||
address: address(fixture.relocation_offset),
|
||||
length: 2,
|
||||
});
|
||||
assert.equal(memoryRelocation.bytes_hex, "0410");
|
||||
assert.equal(memoryRelocation.complete, true);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await cliEvidence("read-bytes", address(fixture.relocation_offset), [
|
||||
"--length",
|
||||
"2",
|
||||
])
|
||||
).normalized_result,
|
||||
memoryRelocation,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await cliEvidence("address-to-file-offset", expected.entry))
|
||||
.normalized_result,
|
||||
mapped,
|
||||
);
|
||||
const tail = await call("read_bytes", {
|
||||
address: address(fixture.module_bytes - 2),
|
||||
length: 8,
|
||||
});
|
||||
assert.equal(tail.returned_bytes, 2);
|
||||
assert.equal(tail.complete, false);
|
||||
const unmapped = await call("read_bytes", { address: "0x80000", length: 8 });
|
||||
assert.equal(unmapped.returned_bytes, 0);
|
||||
assert.equal(unmapped.complete, false);
|
||||
const noSource = await client.callTool({
|
||||
name: "address_to_file_offset",
|
||||
arguments: { address: "0x80000" },
|
||||
});
|
||||
assert.equal(noSource.isError, true);
|
||||
const procedures = await call("list_procedures");
|
||||
assert.ok(
|
||||
Array.isArray(procedures),
|
||||
@@ -330,6 +402,22 @@ try {
|
||||
load_segment: profile.parameters.load_segment,
|
||||
},
|
||||
transport: { cli: true, stdio_mcp: true },
|
||||
load_image: {
|
||||
status: image.status,
|
||||
checks: image.checks.length,
|
||||
mappings: image.observations.mappings.length,
|
||||
relocations: image.observations.relocations.length,
|
||||
cli_mcp_parity: true,
|
||||
},
|
||||
memory_evidence: {
|
||||
relocated_word_hex: memoryRelocation.bytes_hex,
|
||||
entry_file_offset: mapped.file_offset,
|
||||
header_file_offset: headerMapping.file_offset,
|
||||
partial_read_bytes: tail.returned_bytes,
|
||||
unmapped_read_bytes: unmapped.returned_bytes,
|
||||
unmapped_file_offset_rejected: true,
|
||||
cli_mcp_parity: true,
|
||||
},
|
||||
functions: facts,
|
||||
instruction_oracles: {
|
||||
entry_bytes: hexBytes(move.bytes),
|
||||
@@ -400,6 +488,10 @@ function hexBytes(bytes) {
|
||||
return bytes.replace(/\s/gu, "").toLowerCase();
|
||||
}
|
||||
function assertProfile(profile) {
|
||||
assert.equal(
|
||||
profile.parameters.load_image_evidence,
|
||||
"independent-mz-mapping-relocations-v1",
|
||||
);
|
||||
assert.equal(
|
||||
profile.parameters.function_body_evidence,
|
||||
"complete-inclusive-ranges-v1",
|
||||
@@ -471,14 +563,15 @@ async function evidenceCall(name, arguments_) {
|
||||
assertProfile(evidence.analysis_profile.parameters.upstream_analysis_profile);
|
||||
return evidence;
|
||||
}
|
||||
async function cliEvidence(command, procedure) {
|
||||
async function cliEvidence(command, procedure, options = []) {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
command,
|
||||
targetPath,
|
||||
procedure,
|
||||
...(procedure === undefined ? [] : [procedure]),
|
||||
...options,
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications 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: "25"
|
||||
tool_count: 116
|
||||
catalog_digest: "5bb3f994a3c4d0c87a34ff5aab4e28141b5fa6f3a98b5ae7e95686d85d9d983e"
|
||||
version: "26"
|
||||
tool_count: 117
|
||||
catalog_digest: "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -15,6 +15,20 @@ complete tool inventory with availability reasons and remediation. When choosing
|
||||
a tool, use its entry in that result; `tools/list` retains the complete catalog
|
||||
when the target or provider state changes.
|
||||
|
||||
## DOS MZ with Ghidra
|
||||
|
||||
For an admitted DOS MZ target, use `inspect_native_load_image` to check measured
|
||||
loaded bytes, file mappings, relocations and the entry against the immutable
|
||||
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
|
||||
reported static image, not runtime DOS or PC-98 hardware behavior.
|
||||
|
||||
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
|
||||
anchor one observed address to original file bytes. Loader fixups can change a
|
||||
word; a source offset does not imply byte equality. Complete function body ranges
|
||||
use inclusive ends and can contain gaps. Do not treat the enclosing span as code.
|
||||
For packed targets, retain the original and separately derived artifact identities;
|
||||
decompiling an unpacking stub does not recover the unpacked program.
|
||||
|
||||
## Managed PE/CLI
|
||||
|
||||
Start with `inspect_managed_artifact`. REA's canonical managed inspection is
|
||||
|
||||
@@ -42,6 +42,9 @@ export {
|
||||
} from "./DirectAnalysisStatus.js";
|
||||
|
||||
type DirectAnalysisTool =
|
||||
| "inspect_native_load_image"
|
||||
| "read_bytes"
|
||||
| "address_to_file_offset"
|
||||
| "binary_overview"
|
||||
| "procedure_pseudo_code"
|
||||
| "read_function_instructions"
|
||||
|
||||
@@ -15,6 +15,63 @@ export const registerCoreAnalysisCommands = (
|
||||
logger: Logger,
|
||||
): void => {
|
||||
registerCoreCommands(cli, logger);
|
||||
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
|
||||
description:
|
||||
"Verify loaded native bytes, source mappings, relocations and entry",
|
||||
args: z.object({ path: z.string().describe("Local executable path") }),
|
||||
options: z.object({ provider: providerSelectionOption }),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"inspect_native_load_image",
|
||||
{},
|
||||
directAnalysisOptions(logger, undefined, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.readBytes, {
|
||||
description: "Read exact provider memory bytes at one analysis address",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
address: z.string().describe("Exact provider memory address"),
|
||||
}),
|
||||
options: z.object({
|
||||
length: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.default(256)
|
||||
.describe("Requested byte count"),
|
||||
provider: providerSelectionOption,
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.readBytes, () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"read_bytes",
|
||||
{ address: args.address, length: options.length },
|
||||
directAnalysisOptions(logger, undefined, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.addressToFileOffset, {
|
||||
description: "Resolve an analysis address to its original file byte offset",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local executable path"),
|
||||
address: z.string().describe("Exact provider memory address"),
|
||||
}),
|
||||
options: z.object({ provider: providerSelectionOption }),
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.addressToFileOffset, () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"address_to_file_offset",
|
||||
{ address: args.address },
|
||||
directAnalysisOptions(logger, undefined, options.provider),
|
||||
),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.traceNativeValues, {
|
||||
description: "Trace bounded native def-use and call dependencies",
|
||||
args: z.object({
|
||||
|
||||
@@ -23,6 +23,9 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
decodeInterfaceBuilder: "decode-interface-builder",
|
||||
inspectKeyedArchive: "inspect-keyed-archive",
|
||||
inspectNativeDataType: "inspect-native-data-type",
|
||||
inspectNativeLoadImage: "inspect-native-load-image",
|
||||
readBytes: "read-bytes",
|
||||
addressToFileOffset: "address-to-file-offset",
|
||||
traceNativeValues: "trace-native-values",
|
||||
inspectNativeInstruction: "inspect-native-instruction",
|
||||
resolveNativeCallTargets: "resolve-native-call-targets",
|
||||
@@ -112,8 +115,6 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"procedure_info",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
"procedure_references",
|
||||
"resolve_containing_procedure",
|
||||
"set_address_name",
|
||||
|
||||
@@ -36,6 +36,11 @@ const official = <Name extends string, Schema extends z.ZodObject>(
|
||||
|
||||
/** Bridge operations exposed without additional application composition. */
|
||||
export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
official(
|
||||
"inspect_native_load_image",
|
||||
"Verify the provider's loaded DOS MZ image against its immutable target snapshot: complete original/modified source-byte digests, header/module file mappings and memory digests, relocations and external entry. Returns measured observations and independent checks with mismatch coordinates. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.",
|
||||
z.strictObject({}),
|
||||
),
|
||||
official(
|
||||
"inspect_native_data_type",
|
||||
"Inspect one recovered type by exact database category path or defined typed data address. Returns observed size, alignment, packing, fields, bitfields, enum values, and child type identities; source-level authority and flexible-tail semantics remain unknown unless substantiated.",
|
||||
|
||||
@@ -41,6 +41,7 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_native_load_image: evidence,
|
||||
inspect_native_dispatch_metadata: evidence,
|
||||
trace_native_ui_action: evidence,
|
||||
trace_native_values: evidence,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { nativeLoadImageSchema } from "../domain/nativeLoadImage.js";
|
||||
import { nativeUiResultSchema } from "../domain/nativeUiObservation.js";
|
||||
import { nativeValueTraceSchema } from "../domain/nativeValueTrace.js";
|
||||
import { nativeDataTypeSchema } from "../domain/nativeDataType.js";
|
||||
@@ -100,6 +101,7 @@ const addressedString = z.object({
|
||||
|
||||
/** Exact structured-content schemas shared by direct analysis providers. */
|
||||
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
inspect_native_load_image: resultOf(nativeLoadImageSchema),
|
||||
inspect_native_data_type: resultOf(nativeDataTypeSchema),
|
||||
inspect_native_instruction: resultOf(nativeInstructionSchema),
|
||||
resolve_native_call_targets: resultOf(nativeCallTargetsSchema),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const hexBytes = z.string().regex(/^(?:[a-f0-9]{2})*$/u);
|
||||
const address = z.string().min(1);
|
||||
|
||||
/** Measured provider image, without exporting the complete executable bytes. */
|
||||
export const nativeLoadImageObservationSchema = z.strictObject({
|
||||
executable_format: z.string().min(1),
|
||||
language_id: z.string().min(1),
|
||||
compiler_spec_id: z.string().min(1),
|
||||
image_base: address,
|
||||
default_address_space: z.string().min(1),
|
||||
source_files: z.array(
|
||||
z.strictObject({
|
||||
name: z.string(),
|
||||
size: integer,
|
||||
original_sha256: digest,
|
||||
modified_sha256: digest,
|
||||
}),
|
||||
),
|
||||
mappings: z.array(
|
||||
z.strictObject({
|
||||
block: z.string(),
|
||||
start: address,
|
||||
end: address,
|
||||
address_space: z.string().min(1),
|
||||
initialized: z.boolean(),
|
||||
loaded: z.boolean(),
|
||||
overlay: z.boolean(),
|
||||
length: integer.min(1),
|
||||
source_file_index: integer.nullable(),
|
||||
file_offset: integer.nullable(),
|
||||
sha256: digest.nullable(),
|
||||
}),
|
||||
),
|
||||
relocations: z.array(
|
||||
z.strictObject({
|
||||
address,
|
||||
segment: integer.max(0xffff).nullable(),
|
||||
offset: integer.max(0xffff).nullable(),
|
||||
status: z.string().min(1),
|
||||
type: z.number().int(),
|
||||
values: z.array(
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(Number.MIN_SAFE_INTEGER)
|
||||
.max(Number.MAX_SAFE_INTEGER),
|
||||
),
|
||||
original_bytes_hex: hexBytes.nullable(),
|
||||
memory_bytes_hex: hexBytes.nullable(),
|
||||
}),
|
||||
),
|
||||
entry_points: z.array(address),
|
||||
});
|
||||
|
||||
/** Independent comparison, preserving both expectations and measured values. */
|
||||
export const nativeLoadImageCheckSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
matched: z.boolean(),
|
||||
expected: jsonValueSchema,
|
||||
observed: jsonValueSchema,
|
||||
file_offset: integer.nullable(),
|
||||
address: address.nullable(),
|
||||
});
|
||||
|
||||
/** Read-only native image verification; verified applies only to declared coverage. */
|
||||
export const nativeLoadImageSchema = z.discriminatedUnion("status", [
|
||||
z.strictObject({
|
||||
status: z.literal("unsupported"),
|
||||
reason: z.string().min(1),
|
||||
observations: nativeLoadImageObservationSchema,
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
z.strictObject({
|
||||
status: z.enum(["verified", "mismatch"]),
|
||||
format: z.literal("dos-mz"),
|
||||
target_sha256: digest,
|
||||
load_segment: integer.max(0xffff),
|
||||
header_bytes: integer,
|
||||
module_bytes: integer,
|
||||
overlay_bytes: integer,
|
||||
entry: z.strictObject({
|
||||
relative_segment: integer.max(0xffff),
|
||||
offset: integer.max(0xffff),
|
||||
linear_address: address,
|
||||
}),
|
||||
observations: nativeLoadImageObservationSchema,
|
||||
checks: z.array(nativeLoadImageCheckSchema),
|
||||
limitations: z.array(z.string().min(1)),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Provider-neutral native load-image result. */
|
||||
export type NativeLoadImage = z.infer<typeof nativeLoadImageSchema>;
|
||||
/** Measured source mappings, bytes, relocations and entries. */
|
||||
export type NativeLoadImageObservation = z.infer<
|
||||
typeof nativeLoadImageObservationSchema
|
||||
>;
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
||||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "25",
|
||||
skillVersion: "26",
|
||||
} as const;
|
||||
@@ -38,6 +38,9 @@ export const resolveGhidraAnalysisProfile = (
|
||||
].sort(),
|
||||
import_mode: "ephemeral-read-only",
|
||||
function_body_evidence: "complete-inclusive-ranges-v1",
|
||||
...(dosMz
|
||||
? { load_image_evidence: "independent-mz-mapping-relocations-v1" }
|
||||
: {}),
|
||||
jump_table_evidence: "typed-case-default-blocks-v1",
|
||||
decompiler_jump_loads: true,
|
||||
loader: dosMz ? "MzLoader" : "auto-from-header",
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type GhidraSessionInfo,
|
||||
} from "./GhidraSessionValues.js";
|
||||
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { connectGhidraSocket } from "./GhidraSocketConnection.js";
|
||||
import type { GhidraEndpoint } from "./GhidraTransport.js";
|
||||
import { GhidraWire } from "./GhidraClientWire.js";
|
||||
@@ -200,6 +201,28 @@ export class GhidraClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the private immutable source for independent import verification. */
|
||||
async readTargetSnapshot(): Promise<Result<Buffer, GhidraSessionError>> {
|
||||
if (this.#snapshotPath === undefined)
|
||||
return err(
|
||||
this.#failure(
|
||||
"protocol",
|
||||
"Ghidra target snapshot is unavailable before startup or after close",
|
||||
),
|
||||
);
|
||||
try {
|
||||
return ok(await readFile(this.#snapshotPath));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
this.#failure(
|
||||
"protocol",
|
||||
"Ghidra target snapshot could not be read for load-image verification",
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the owned process group and remove all project/runtime artifacts. */
|
||||
close(): Promise<void> {
|
||||
return this.#requestClose(false);
|
||||
|
||||
@@ -7,6 +7,57 @@ import {
|
||||
} from "./GhidraInventoryValues.js";
|
||||
|
||||
describe("Ghidra inventory boundary values", () => {
|
||||
it("admits explicit byte reads and source offset mapping without invented caps", () => {
|
||||
expect(
|
||||
parseGhidraInventoryInput("read_bytes", { address: "0x10000" }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { address: "0x10000", document: null, length: 256 },
|
||||
});
|
||||
expect(
|
||||
parseGhidraInventoryInput("read_bytes", {
|
||||
address: "0x10000",
|
||||
length: 4097,
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
parseGhidraInventoryInput("read_bytes", { address: "0x10000", length: 0 })
|
||||
.ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseGhidraInventoryInput("address_to_file_offset", {
|
||||
address: "HEADER:0x1",
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
it("checks byte length and completeness before accepting memory Evidence", () => {
|
||||
const result = {
|
||||
address: "0x10000",
|
||||
requested_bytes: 4,
|
||||
returned_bytes: 2,
|
||||
bytes_hex: "0410",
|
||||
complete: false,
|
||||
};
|
||||
expect(parseGhidraInventoryResult("read_bytes", result)).toEqual({
|
||||
ok: true,
|
||||
value: result,
|
||||
});
|
||||
for (const change of [
|
||||
{ complete: true },
|
||||
{ returned_bytes: 4 },
|
||||
{ bytes_hex: "04" },
|
||||
{ requested_bytes: 1 },
|
||||
])
|
||||
expect(
|
||||
parseGhidraInventoryResult("read_bytes", { ...result, ...change }).ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseGhidraInventoryResult("address_to_file_offset", {
|
||||
address: "0x10000",
|
||||
file_offset: -1,
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
it("accepts complete inventories and searches without page controls", () => {
|
||||
expect(parseGhidraInventoryInput("list_procedures", {})).toEqual({
|
||||
ok: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { nativeLoadImageObservationSchema } from "../domain/nativeLoadImage.js";
|
||||
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -13,6 +14,9 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Read-only direct inventory operations admitted by the Ghidra adapter. */
|
||||
export const GHIDRA_INVENTORY_OPERATIONS = [
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
"address_name",
|
||||
"list_documents",
|
||||
"list_names",
|
||||
@@ -49,6 +53,16 @@ const searchInput = {
|
||||
};
|
||||
|
||||
const inputSchemas = {
|
||||
inspect_native_load_image: z.strictObject({}),
|
||||
read_bytes: z.strictObject({
|
||||
document,
|
||||
address: explicitAddress,
|
||||
length: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(256),
|
||||
}),
|
||||
address_to_file_offset: z.strictObject({
|
||||
document,
|
||||
address: explicitAddress,
|
||||
}),
|
||||
address_name: z.object({ document, address: explicitAddress }).strict(),
|
||||
list_documents: z.object({}).strict(),
|
||||
list_names: z.object({ document, address: filteredAddress }).strict(),
|
||||
@@ -217,6 +231,30 @@ const containingProcedure = z.discriminatedUnion("found", [
|
||||
]);
|
||||
|
||||
const resultSchemas = {
|
||||
inspect_native_load_image: nativeLoadImageObservationSchema,
|
||||
read_bytes: z
|
||||
.strictObject({
|
||||
address: canonicalAddress,
|
||||
requested_bytes: z.number().int().min(1),
|
||||
returned_bytes: z.number().int().min(0),
|
||||
bytes_hex: z.string().regex(/^(?:[a-f0-9]{2})*$/u),
|
||||
complete: z.boolean(),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (
|
||||
value.returned_bytes * 2 !== value.bytes_hex.length ||
|
||||
value.returned_bytes > value.requested_bytes ||
|
||||
value.complete !== (value.returned_bytes === value.requested_bytes)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Read-byte length and completeness disagree",
|
||||
});
|
||||
}),
|
||||
address_to_file_offset: z.strictObject({
|
||||
address: canonicalAddress,
|
||||
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
||||
}),
|
||||
address_name: z.string().nullable(),
|
||||
list_documents: z.array(z.string().min(1)).length(1),
|
||||
list_names: z.array(symbolItem),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { dosMz } from "../domain/binaryTarget.fixture.js";
|
||||
import type { NativeLoadImageObservation } from "../domain/nativeLoadImage.js";
|
||||
|
||||
const hash = (bytes: Buffer): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
/** Source-owned MZ with one relocated word, overlay and uninitialized allocation. */
|
||||
export const fixtureDosLoadImage = (): {
|
||||
bytes: Buffer;
|
||||
sha256: string;
|
||||
observation: NativeLoadImageObservation;
|
||||
} => {
|
||||
const original = dosMz();
|
||||
original.writeUInt16LE(1, 6);
|
||||
original.writeUInt16LE(9, 28);
|
||||
original.writeUInt16LE(0, 30);
|
||||
original.writeUInt16LE(4, 41);
|
||||
const bytes = Buffer.concat([
|
||||
original,
|
||||
Buffer.from("private-overlay-fixture"),
|
||||
]);
|
||||
const modified = Buffer.from(bytes);
|
||||
modified.writeUInt16LE(0x1004, 41);
|
||||
return {
|
||||
bytes,
|
||||
sha256: hash(bytes),
|
||||
observation: {
|
||||
executable_format: "Old-style DOS Executable (MZ)",
|
||||
language_id: "x86:LE:16:Real Mode",
|
||||
compiler_spec_id: "default",
|
||||
image_base: "0x0",
|
||||
default_address_space: "ram",
|
||||
source_files: [
|
||||
{
|
||||
name: "fixture.exe",
|
||||
size: bytes.length,
|
||||
original_sha256: hash(bytes),
|
||||
modified_sha256: hash(modified),
|
||||
},
|
||||
],
|
||||
mappings: [
|
||||
{
|
||||
block: "HEADER",
|
||||
start: "HEADER:0x0",
|
||||
end: "HEADER:0x1f",
|
||||
address_space: "HEADER",
|
||||
initialized: true,
|
||||
loaded: false,
|
||||
overlay: true,
|
||||
length: 32,
|
||||
source_file_index: 0,
|
||||
file_offset: 0,
|
||||
sha256: hash(bytes.subarray(0, 32)),
|
||||
},
|
||||
{
|
||||
block: "CODE_0",
|
||||
start: "0x10000",
|
||||
end: "0x1007f",
|
||||
address_space: "ram",
|
||||
initialized: true,
|
||||
loaded: true,
|
||||
overlay: false,
|
||||
length: 128,
|
||||
source_file_index: 0,
|
||||
file_offset: 32,
|
||||
sha256: hash(modified.subarray(32, 160)),
|
||||
},
|
||||
{
|
||||
block: "DATA",
|
||||
start: "0x10080",
|
||||
end: "0x1008f",
|
||||
address_space: "ram",
|
||||
initialized: false,
|
||||
loaded: true,
|
||||
overlay: false,
|
||||
length: 16,
|
||||
source_file_index: null,
|
||||
file_offset: null,
|
||||
sha256: null,
|
||||
},
|
||||
],
|
||||
relocations: [
|
||||
{
|
||||
address: "0x10009",
|
||||
segment: 0x1000,
|
||||
offset: 9,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [0, 9, 0x1004],
|
||||
original_bytes_hex: "0400",
|
||||
memory_bytes_hex: "0410",
|
||||
},
|
||||
],
|
||||
entry_points: ["0x10000"],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHash } from "node:crypto";
|
||||
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
|
||||
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
|
||||
import { nativeLoadImageObservationSchema } from "../domain/nativeLoadImage.js";
|
||||
import type { NativeLoadImageObservation } from "../domain/nativeLoadImage.js";
|
||||
|
||||
const damage: ReadonlyArray<
|
||||
readonly [string, (observation: NativeLoadImageObservation) => void]
|
||||
> = [
|
||||
[
|
||||
"memory byte",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].sha256 = "f".repeat(64);
|
||||
},
|
||||
],
|
||||
[
|
||||
"header byte",
|
||||
(x) => {
|
||||
if (x.mappings[0]) x.mappings[0].sha256 = "f".repeat(64);
|
||||
},
|
||||
],
|
||||
[
|
||||
"modified source",
|
||||
(x) => {
|
||||
x.source_files[0] && (x.source_files[0].modified_sha256 = "f".repeat(64));
|
||||
},
|
||||
],
|
||||
[
|
||||
"original source",
|
||||
(x) => {
|
||||
x.source_files[0] && (x.source_files[0].original_sha256 = "f".repeat(64));
|
||||
},
|
||||
],
|
||||
[
|
||||
"source length",
|
||||
(x) => {
|
||||
if (x.source_files[0]) x.source_files[0].size--;
|
||||
},
|
||||
],
|
||||
[
|
||||
"source identity",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].source_file_index = 1;
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing mapping",
|
||||
(x) => {
|
||||
x.mappings.splice(1, 1);
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicate mapping",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings.push(structuredClone(x.mappings[1]));
|
||||
},
|
||||
],
|
||||
[
|
||||
"gap",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].file_offset = 33;
|
||||
},
|
||||
],
|
||||
[
|
||||
"overlap",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].file_offset = 31;
|
||||
},
|
||||
],
|
||||
[
|
||||
"alias address",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].start = "0x10001";
|
||||
},
|
||||
],
|
||||
[
|
||||
"end address",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].end = "0x10080";
|
||||
},
|
||||
],
|
||||
[
|
||||
"wrong address space",
|
||||
(x) => {
|
||||
x.default_address_space = "wrong";
|
||||
},
|
||||
],
|
||||
[
|
||||
"header loaded",
|
||||
(x) => {
|
||||
if (x.mappings[0]) x.mappings[0].loaded = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
"module overlay",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].overlay = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
"uninitialized module",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].initialized = false;
|
||||
},
|
||||
],
|
||||
[
|
||||
"complex source mapping",
|
||||
(x) => {
|
||||
if (x.mappings[1]) x.mappings[1].file_offset = null;
|
||||
},
|
||||
],
|
||||
[
|
||||
"missing relocation",
|
||||
(x) => {
|
||||
x.relocations = [];
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicate relocation",
|
||||
(x) => {
|
||||
if (x.relocations[0])
|
||||
x.relocations.push(structuredClone(x.relocations[0]));
|
||||
},
|
||||
],
|
||||
[
|
||||
"relocation word",
|
||||
(x) => {
|
||||
if (x.relocations[0]) x.relocations[0].memory_bytes_hex = "0400";
|
||||
},
|
||||
],
|
||||
[
|
||||
"relocation metadata",
|
||||
(x) => {
|
||||
if (x.relocations[0]) x.relocations[0].values = [0, 9, 4];
|
||||
},
|
||||
],
|
||||
[
|
||||
"relocation status",
|
||||
(x) => {
|
||||
if (x.relocations[0]) x.relocations[0].status = "FAILURE";
|
||||
},
|
||||
],
|
||||
[
|
||||
"relocation segment",
|
||||
(x) => {
|
||||
if (x.relocations[0]) x.relocations[0].segment = 0;
|
||||
},
|
||||
],
|
||||
[
|
||||
"entry",
|
||||
(x) => {
|
||||
x.entry_points = ["0x10001"];
|
||||
},
|
||||
],
|
||||
[
|
||||
"extra entry",
|
||||
(x) => {
|
||||
x.entry_points.push("0x10010");
|
||||
},
|
||||
],
|
||||
[
|
||||
"loader",
|
||||
(x) => {
|
||||
x.executable_format = "Raw Binary";
|
||||
},
|
||||
],
|
||||
[
|
||||
"language",
|
||||
(x) => {
|
||||
x.language_id = "x86:LE:32:default";
|
||||
},
|
||||
],
|
||||
[
|
||||
"compiler",
|
||||
(x) => {
|
||||
x.compiler_spec_id = "windows";
|
||||
},
|
||||
],
|
||||
[
|
||||
"image base",
|
||||
(x) => {
|
||||
x.image_base = "0x10000";
|
||||
},
|
||||
],
|
||||
[
|
||||
"extra source file",
|
||||
(x) => {
|
||||
if (x.source_files[0])
|
||||
x.source_files.push(structuredClone(x.source_files[0]));
|
||||
},
|
||||
],
|
||||
[
|
||||
"initialized unbacked alias",
|
||||
(x) => {
|
||||
if (x.mappings[2]) x.mappings[2].initialized = true;
|
||||
},
|
||||
],
|
||||
[
|
||||
"overlay source mapping",
|
||||
(x) => {
|
||||
const row = x.mappings[1];
|
||||
if (row)
|
||||
x.mappings.push({
|
||||
...row,
|
||||
file_offset: 160,
|
||||
length: 23,
|
||||
start: "0x10080",
|
||||
end: "0x10096",
|
||||
});
|
||||
},
|
||||
],
|
||||
];
|
||||
describe("independent DOS MZ import verification", () => {
|
||||
it("verifies every initialized source byte while retaining overlay and BSS scope", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
const result = attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
status: "verified",
|
||||
module_bytes: 128,
|
||||
header_bytes: 32,
|
||||
overlay_bytes: 23,
|
||||
entry: { linear_address: "0x10000" },
|
||||
},
|
||||
});
|
||||
if (!result.ok || result.value.status === "unsupported")
|
||||
throw new Error("Expected measured MZ checks");
|
||||
expect(result.value.checks.every((check) => check.matched)).toBe(true);
|
||||
expect(result.value.observations).toEqual(fixture.observation);
|
||||
expect(result.value.limitations.join(" ")).toMatch(
|
||||
/runtime behavior.*not verified/u,
|
||||
);
|
||||
});
|
||||
|
||||
it.each(damage)(
|
||||
"rejects a %s despite a matching executable digest",
|
||||
(_name, mutate) => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
mutate(fixture.observation);
|
||||
const result = attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { status: "mismatch", observations: fixture.observation },
|
||||
});
|
||||
if (!result.ok || result.value.status === "unsupported")
|
||||
throw new Error("Expected MZ verification checks");
|
||||
expect(result.value.checks.some((check) => !check.matched)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains source range coordinates when memory evidence disagrees", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
const mapping = fixture.observation.mappings[1];
|
||||
if (mapping === undefined) throw new Error("Missing module mapping");
|
||||
mapping.sha256 = "f".repeat(64);
|
||||
const result = attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
);
|
||||
if (!result.ok || result.value.status === "unsupported")
|
||||
throw new Error("Expected MZ verification checks");
|
||||
expect(result.value.checks.filter((check) => !check.matched)).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "mapping[1].memory_sha256",
|
||||
file_offset: 32,
|
||||
address: "0x10000",
|
||||
observed: "f".repeat(64),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("is independent of mapping iteration order", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
fixture.observation.mappings.reverse();
|
||||
expect(
|
||||
attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
),
|
||||
).toMatchObject({ ok: true, value: { status: "verified" } });
|
||||
});
|
||||
|
||||
it("accepts a different segment alias while preserving the observed coordinates and raw table values", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
const relocation = fixture.observation.relocations[0];
|
||||
if (relocation === undefined)
|
||||
throw new Error("Missing source-owned relocation");
|
||||
relocation.segment = 0x0fff;
|
||||
relocation.offset = 25;
|
||||
expect(
|
||||
attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
status: "verified",
|
||||
observations: {
|
||||
relocations: [
|
||||
{ segment: 0x0fff, offset: 25, values: [0, 9, 0x1004] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a changed snapshot and malformed MZ rather than trusting provider hashes", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
fixture.bytes[0] = 0;
|
||||
expect(
|
||||
attestGhidraDosLoadImage(
|
||||
fixture.bytes,
|
||||
fixture.sha256,
|
||||
fixture.observation,
|
||||
),
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining("digest") });
|
||||
const digest = createHash("sha256").update(fixture.bytes).digest("hex");
|
||||
expect(
|
||||
attestGhidraDosLoadImage(fixture.bytes, digest, fixture.observation),
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining("MZ header") });
|
||||
});
|
||||
|
||||
it("rejects malformed producer digests and unsafe source coordinates", () => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
expect(
|
||||
nativeLoadImageObservationSchema.safeParse({
|
||||
...fixture.observation,
|
||||
source_files: [
|
||||
{ ...fixture.observation.source_files[0], original_sha256: "bad" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
const mapping = fixture.observation.mappings[0];
|
||||
expect(
|
||||
nativeLoadImageObservationSchema.safeParse({
|
||||
...fixture.observation,
|
||||
mappings: [{ ...mapping, file_offset: Number.MAX_SAFE_INTEGER + 1 }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
nativeLoadImageSchema,
|
||||
type NativeLoadImage,
|
||||
type NativeLoadImageObservation,
|
||||
} from "../domain/nativeLoadImage.js";
|
||||
import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const sha256 = (bytes: Buffer): string =>
|
||||
createHash("sha256").update(bytes).digest("hex");
|
||||
const address = (offset: number): string => `0x${offset.toString(16)}`;
|
||||
const limitations = [
|
||||
"Verification covers original/modified FileBytes, every initialized target-backed header/module mapping, relocation records and the external entry point. Mapping ends are inclusive.",
|
||||
"Appended overlay bytes are identified by file extent and included in FileBytes identity; they must not be mapped as initialized header/module memory. Uninitialized allocations are reported but their contents and DOS runtime behavior are not verified.",
|
||||
"The fixed load segment 0x1000 describes Ghidra's analysis image, not a running DOS process. Verified import does not establish original source ownership or recovered program behavior.",
|
||||
"A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.",
|
||||
];
|
||||
|
||||
/** Verify measured MZ import state against the session's immutable snapshot. */
|
||||
export const attestGhidraDosLoadImage = (
|
||||
bytes: Buffer,
|
||||
targetSha256: string,
|
||||
observations: NativeLoadImageObservation,
|
||||
): Result<NativeLoadImage, string> => {
|
||||
if (sha256(bytes) !== targetSha256)
|
||||
return err(
|
||||
"Ghidra load-image snapshot digest does not match the admitted target",
|
||||
);
|
||||
const parsed = parseDosMzHeader(bytes);
|
||||
if (!parsed.ok) return parsed;
|
||||
const header = parsed.value;
|
||||
const loadSegment = 0x1000;
|
||||
const modified = Buffer.from(bytes);
|
||||
const expectedRelocations = [];
|
||||
for (let index = 0; index < header.relocationCount; index += 1) {
|
||||
const table = header.relocationTableOffset + index * 4;
|
||||
const offset = bytes.readUInt16LE(table);
|
||||
const segment = bytes.readUInt16LE(table + 2);
|
||||
const fileOffset = header.headerBytes + segment * 16 + offset;
|
||||
const original = modified
|
||||
.subarray(fileOffset, fileOffset + 2)
|
||||
.toString("hex");
|
||||
const relocated =
|
||||
(modified.readUInt16LE(fileOffset) + loadSegment) & 0xffff;
|
||||
modified.writeUInt16LE(relocated, fileOffset);
|
||||
expectedRelocations.push({
|
||||
address: address(loadSegment * 16 + segment * 16 + offset),
|
||||
segment: (loadSegment + segment) & 0xffff,
|
||||
offset,
|
||||
status: "APPLIED",
|
||||
type: 0,
|
||||
values: [segment, offset, relocated],
|
||||
original_bytes_hex: original,
|
||||
memory_bytes_hex: modified
|
||||
.subarray(fileOffset, fileOffset + 2)
|
||||
.toString("hex"),
|
||||
});
|
||||
}
|
||||
const checks: Extract<NativeLoadImage, { format: "dos-mz" }>["checks"] = [];
|
||||
const check = (
|
||||
name: string,
|
||||
expected: JsonValue,
|
||||
observed: JsonValue,
|
||||
fileOffset: number | null = null,
|
||||
location: string | null = null,
|
||||
): void => {
|
||||
checks.push({
|
||||
name,
|
||||
matched: JSON.stringify(expected) === JSON.stringify(observed),
|
||||
expected,
|
||||
observed,
|
||||
file_offset: fileOffset,
|
||||
address: location,
|
||||
});
|
||||
};
|
||||
check(
|
||||
"executable_format",
|
||||
"Old-style DOS Executable (MZ)",
|
||||
observations.executable_format,
|
||||
);
|
||||
check("language_id", "x86:LE:16:Real Mode", observations.language_id);
|
||||
check("compiler_spec_id", "default", observations.compiler_spec_id);
|
||||
check("image_base", "0x0", observations.image_base);
|
||||
check("default_address_space", "ram", observations.default_address_space);
|
||||
check("source_file_count", 1, observations.source_files.length);
|
||||
const source = observations.source_files[0];
|
||||
check("source_file_size", bytes.length, source?.size ?? null);
|
||||
check("original_file_sha256", targetSha256, source?.original_sha256 ?? null);
|
||||
check(
|
||||
"modified_file_sha256",
|
||||
sha256(modified),
|
||||
source?.modified_sha256 ?? null,
|
||||
);
|
||||
const entryAddress = address(
|
||||
((loadSegment + header.entrySegment) & 0xffff) * 16 + header.entryOffset,
|
||||
);
|
||||
check("entry_points", [entryAddress], [...observations.entry_points].sort());
|
||||
// A multiset preserves duplicate relocations and aliases; iteration order is not semantic.
|
||||
const rows = (values: NativeLoadImageObservation["relocations"]): string[] =>
|
||||
values
|
||||
.map(({ segment: _segment, offset: _offset, ...value }) =>
|
||||
JSON.stringify(value),
|
||||
)
|
||||
.sort();
|
||||
check(
|
||||
"relocation_address_aliases",
|
||||
[],
|
||||
observations.relocations.filter(
|
||||
(row) =>
|
||||
row.segment === null ||
|
||||
row.offset === null ||
|
||||
address(row.segment * 16 + row.offset) !== row.address,
|
||||
),
|
||||
);
|
||||
check(
|
||||
"relocation_records",
|
||||
rows(expectedRelocations),
|
||||
rows(observations.relocations),
|
||||
);
|
||||
|
||||
checkMappings(header, loadSegment, bytes, modified, observations, check);
|
||||
return ok(
|
||||
nativeLoadImageSchema.parse({
|
||||
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
|
||||
format: "dos-mz",
|
||||
target_sha256: targetSha256,
|
||||
load_segment: loadSegment,
|
||||
header_bytes: header.headerBytes,
|
||||
module_bytes: header.moduleBytes,
|
||||
overlay_bytes: header.overlayBytes,
|
||||
entry: {
|
||||
relative_segment: header.entrySegment,
|
||||
offset: header.entryOffset,
|
||||
linear_address: entryAddress,
|
||||
},
|
||||
observations,
|
||||
checks,
|
||||
limitations,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const checkMappings = (
|
||||
header: DosMzHeader,
|
||||
loadSegment: number,
|
||||
bytes: Buffer,
|
||||
modified: Buffer,
|
||||
observations: NativeLoadImageObservation,
|
||||
check: (
|
||||
name: string,
|
||||
expected: JsonValue,
|
||||
observed: JsonValue,
|
||||
fileOffset?: number | null,
|
||||
location?: string | null,
|
||||
) => void,
|
||||
): void => {
|
||||
const backed = observations.mappings.filter(
|
||||
(mapping) => mapping.source_file_index !== null,
|
||||
);
|
||||
const sorted = [...backed].sort(
|
||||
(left, right) => (left.file_offset ?? -1) - (right.file_offset ?? -1),
|
||||
);
|
||||
let covered = 0;
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
const mapping = sorted[index];
|
||||
if (mapping === undefined) continue;
|
||||
const start = mapping.file_offset;
|
||||
const end = start === null ? null : start + mapping.length;
|
||||
const headerMapping =
|
||||
start !== null && end !== null && start >= 0 && end <= header.headerBytes;
|
||||
const moduleMapping =
|
||||
start !== null &&
|
||||
end !== null &&
|
||||
start >= header.headerBytes &&
|
||||
end <= header.imageBytes;
|
||||
const at = `mapping[${index}]`;
|
||||
check(
|
||||
`${at}.source_file_index`,
|
||||
0,
|
||||
mapping.source_file_index,
|
||||
start,
|
||||
mapping.start,
|
||||
);
|
||||
check(`${at}.file_coverage_start`, covered, start, start, mapping.start);
|
||||
check(
|
||||
`${at}.partition`,
|
||||
true,
|
||||
headerMapping || moduleMapping,
|
||||
start,
|
||||
mapping.start,
|
||||
);
|
||||
check(`${at}.initialized`, true, mapping.initialized, start, mapping.start);
|
||||
check(`${at}.loaded`, moduleMapping, mapping.loaded, start, mapping.start);
|
||||
check(
|
||||
`${at}.overlay`,
|
||||
headerMapping,
|
||||
mapping.overlay,
|
||||
start,
|
||||
mapping.start,
|
||||
);
|
||||
check(
|
||||
`${at}.address_space`,
|
||||
headerMapping ? "HEADER" : observations.default_address_space,
|
||||
mapping.address_space,
|
||||
start,
|
||||
mapping.start,
|
||||
);
|
||||
const expectedStart =
|
||||
start === null
|
||||
? null
|
||||
: headerMapping
|
||||
? `HEADER:${address(start)}`
|
||||
: address(loadSegment * 16 + start - header.headerBytes);
|
||||
const expectedEnd =
|
||||
start === null
|
||||
? null
|
||||
: headerMapping
|
||||
? `HEADER:${address(start + mapping.length - 1)}`
|
||||
: address(
|
||||
loadSegment * 16 +
|
||||
start -
|
||||
header.headerBytes +
|
||||
mapping.length -
|
||||
1,
|
||||
);
|
||||
check(`${at}.start`, expectedStart, mapping.start, start, mapping.start);
|
||||
check(`${at}.end`, expectedEnd, mapping.end, start, mapping.end);
|
||||
check(
|
||||
`${at}.memory_sha256`,
|
||||
start !== null && end !== null && end <= bytes.length
|
||||
? sha256(modified.subarray(start, end))
|
||||
: null,
|
||||
mapping.sha256,
|
||||
start,
|
||||
mapping.start,
|
||||
);
|
||||
covered = end ?? covered;
|
||||
}
|
||||
check("initialized_file_coverage_end", header.imageBytes, covered);
|
||||
check(
|
||||
"initialized_unbacked_mappings",
|
||||
[],
|
||||
observations.mappings.filter(
|
||||
(mapping) => mapping.initialized && mapping.source_file_index === null,
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
// Fake-backed provider coverage; real Ghidra verification lives in
|
||||
// `npm run verify:ghidra` and its focused variants.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
import { parseConfig } from "../config.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
@@ -491,6 +493,67 @@ describe("Ghidra result projection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra measured load-image projection", () => {
|
||||
it.each([
|
||||
"verified",
|
||||
"mismatch",
|
||||
"changed-snapshot",
|
||||
"unsupported",
|
||||
"missing-snapshot",
|
||||
] as const)(
|
||||
"preserves %s state and producing observations",
|
||||
async (state) => {
|
||||
const fixture = fixtureDosLoadImage();
|
||||
if (state === "mismatch") fixture.observation.entry_points = ["0x10001"];
|
||||
if (state === "changed-snapshot") fixture.bytes[0] = 0;
|
||||
const factory: GhidraProviderClientFactory = () => ({
|
||||
start: () => Promise.resolve(ok(sessionInfo())),
|
||||
callTool: () =>
|
||||
Promise.resolve(ok(jsonValueSchema.parse(fixture.observation))),
|
||||
close: () => Promise.resolve(),
|
||||
...(state === "missing-snapshot"
|
||||
? {}
|
||||
: {
|
||||
readTargetSnapshot: () => Promise.resolve(ok(fixture.bytes)),
|
||||
}),
|
||||
});
|
||||
const ghidra = provider(installationHost(), factory);
|
||||
const target: BinaryTarget = {
|
||||
path: "/tmp/source-owned-fixture.exe",
|
||||
sha256: fixture.sha256,
|
||||
kind: "executable",
|
||||
format: state === "unsupported" ? "elf" : "dos-mz",
|
||||
architecture: "x86",
|
||||
availableArchitectures: ["x86"],
|
||||
};
|
||||
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!profile.ok || profile.value.profile === null)
|
||||
throw new Error("Expected admitted profile");
|
||||
const result = await ghidra
|
||||
.createClient(target, profile.value.profile)
|
||||
.execute("inspect_native_load_image", {});
|
||||
if (state === "changed-snapshot" || state === "missing-snapshot") {
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { reason: expect.stringContaining("snapshot") },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
result: { status: state, observations: fixture.observation },
|
||||
rawResult: fixture.observation,
|
||||
analysisProfile: profile.value.profile,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const sessionInfo = () => ({
|
||||
name: "REA Ghidra bridge" as const,
|
||||
run_id: "11111111-1111-4111-8111-111111111111",
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
nativeLoadImageObservationSchema,
|
||||
nativeLoadImageSchema,
|
||||
} from "../domain/nativeLoadImage.js";
|
||||
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -71,7 +76,7 @@ const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]);
|
||||
export type GhidraProviderClientFactory = (
|
||||
options: GhidraClientOptions,
|
||||
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
|
||||
Partial<Pick<GhidraClient, "runtimeLineage">>;
|
||||
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
|
||||
|
||||
/** Ghidra candidate backed by an isolated read-only headless import. */
|
||||
export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
@@ -277,8 +282,52 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
? parseGhidraFunctionResult(operation, called.value)
|
||||
: parseGhidraInventoryResult(operation, called.value);
|
||||
if (!result.ok) return result;
|
||||
let normalized = result.value;
|
||||
if (operation === "inspect_native_load_image") {
|
||||
const observations = nativeLoadImageObservationSchema.parse(
|
||||
result.value,
|
||||
);
|
||||
if (target.format !== "dos-mz") {
|
||||
normalized = jsonValueSchema.parse(
|
||||
nativeLoadImageSchema.parse({
|
||||
status: "unsupported",
|
||||
reason:
|
||||
"Independent load-image verification currently supports DOS MZ targets only.",
|
||||
observations,
|
||||
limitations: [
|
||||
"Other formats expose measured mappings and source identities; no format-specific verification was performed.",
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
if (client.readTargetSnapshot === undefined)
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", operation, {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"The Ghidra client does not expose its immutable target snapshot for independent load-image verification.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const snapshot = await client.readTargetSnapshot();
|
||||
if (!snapshot.ok)
|
||||
return err(projectSessionError(operation, snapshot.error));
|
||||
const attested = attestGhidraDosLoadImage(
|
||||
snapshot.value,
|
||||
target.sha256 ?? "",
|
||||
observations,
|
||||
);
|
||||
if (!attested.ok)
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", operation, {
|
||||
diagnostics: { reason: attested.error },
|
||||
}),
|
||||
);
|
||||
normalized = jsonValueSchema.parse(attested.value);
|
||||
}
|
||||
}
|
||||
return ok(
|
||||
createAnalysisExecution(result.value, committedProfile.provider, {
|
||||
createAnalysisExecution(normalized, committedProfile.provider, {
|
||||
rawResult: called.value,
|
||||
analysisProfile: committedProfile,
|
||||
limitations: [
|
||||
|
||||
@@ -54,6 +54,21 @@ export const limitationsFor = (operation: string): readonly string[] => {
|
||||
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
|
||||
];
|
||||
switch (operation) {
|
||||
case "inspect_native_load_image":
|
||||
return [
|
||||
...common,
|
||||
"Independent verification currently supports DOS MZ only. Measured source mappings use inclusive end addresses and complete region digests; no target execution or project mutation occurs.",
|
||||
];
|
||||
case "read_bytes":
|
||||
return [
|
||||
...common,
|
||||
"Bytes reflect Ghidra's initialized memory, including loader fixups. Reads stop at an unmapped or uninitialized byte and report completeness explicitly.",
|
||||
];
|
||||
case "address_to_file_offset":
|
||||
return [
|
||||
...common,
|
||||
"Offsets come from Ghidra source-byte mappings, not virtual-address arithmetic. Uninitialized memory, missing mappings and ambiguous mappings fail explicitly.",
|
||||
];
|
||||
case "list_documents":
|
||||
return [
|
||||
...common,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const HOPPER_PROVIDER_TOOL_CONTRACTS = Object.freeze([
|
||||
...GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
({ kind, name }) =>
|
||||
kind === "official-proxy" &&
|
||||
name !== "inspect_native_load_image" &&
|
||||
name !== "inspect_native_data_type" &&
|
||||
name !== "inspect_native_instruction" &&
|
||||
name !== "resolve_native_call_targets",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createServer } from "../../../src/server/createServer.js";
|
||||
const VALID_INPUTS: Readonly<
|
||||
Record<string, Readonly<Record<string, JsonValue>>>
|
||||
> = {
|
||||
inspect_native_load_image: {},
|
||||
address_name: {},
|
||||
inspect_native_instruction: { address: "0x1000" },
|
||||
resolve_native_call_targets: { address: "0x1000" },
|
||||
@@ -127,6 +128,23 @@ const connect = async (analysis: AnalysisOperationPort) => {
|
||||
const inventory: JsonValue = [];
|
||||
|
||||
const outputFor = (name: string): JsonValue => {
|
||||
if (name === "inspect_native_load_image")
|
||||
return {
|
||||
status: "unsupported",
|
||||
reason: "Fixture provider has no load-image verifier",
|
||||
observations: {
|
||||
executable_format: "ELF",
|
||||
language_id: "x86:LE:64:default",
|
||||
compiler_spec_id: "gcc",
|
||||
image_base: "0x0",
|
||||
default_address_space: "ram",
|
||||
source_files: [],
|
||||
mappings: [],
|
||||
relocations: [],
|
||||
entry_points: [],
|
||||
},
|
||||
limitations: ["Transport fixture only"],
|
||||
};
|
||||
if (name === "inspect_native_instruction")
|
||||
return {
|
||||
address: "0x1000",
|
||||
|
||||
@@ -124,6 +124,23 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("GhidraClient", () => {
|
||||
it("exposes the immutable import snapshot only during the live session", async () => {
|
||||
const client = clientFor(new FixtureLauncher());
|
||||
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "protocol" },
|
||||
});
|
||||
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
||||
const snapshot = await client.readTargetSnapshot();
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (snapshot.ok) expect(snapshot.value).toEqual(readFileSync(fixturePath));
|
||||
await client.close();
|
||||
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "protocol" },
|
||||
});
|
||||
});
|
||||
|
||||
it("completes an exact, fragmented post-analysis handshake", async () => {
|
||||
const launcher = new FixtureLauncher("fragmented");
|
||||
const client = clientFor(launcher);
|
||||
|
||||
Vendored
+3
@@ -196,6 +196,9 @@ const sessionInfo = ({
|
||||
capabilities: [
|
||||
"ping",
|
||||
"shutdown",
|
||||
"inspect_native_load_image",
|
||||
"read_bytes",
|
||||
"address_to_file_offset",
|
||||
"address_name",
|
||||
"list_documents",
|
||||
"list_names",
|
||||
|
||||
Reference in new issue
Block a user