mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat(ghidra): integrate optional NativeAOT metadata recovery (#748)
* feat(ghidra): integrate optional NativeAOT metadata recovery * fix(ghidra): bind extension snapshots to configured artifacts * test(ghidra): verify lazy NativeAOT startup failures * fix(ghidra): preserve NativeAOT failure reasons and coverage * fix(ghidra): preserve NativeAOT x64 calling conventions * test(ghidra): guard recovered NativeAOT method conventions * fix(ghidra): reject blocking extension file inputs * fix(ghidra): diagnose incompatible NativeAOT extension classes * test(ghidra): verify real extension initialization failures * fix(ghidra): keep extension descriptor fields optional
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
name: Source-built NativeAOT fixtures
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "bridge/ghidra/extensions/nativeaot/**"
|
||||
- "scripts/generate-nativeaot-fixtures.mjs"
|
||||
- "tests/fixtures/nativeaot/**"
|
||||
- ".github/workflows/nativeaot-fixtures.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-fixture:
|
||||
name: Build Windows x64 NativeAOT fixture
|
||||
# .NET 8.0.22's bundled linker discovery supports VS 2019/2022.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_PROCESSOR_COUNT: "1"
|
||||
COMPlus_GCHeapHardLimit: "30000000"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "8.0.416"
|
||||
- name: Build benign fixture and independent byte oracle
|
||||
run: node scripts/generate-nativeaot-fixtures.mjs
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: nativeaot-win-x64-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
path: |
|
||||
_reference/nativeaot-integration/generated/win-x64/symbols/NativeAotFixture.exe
|
||||
_reference/nativeaot-integration/generated/win-x64/NativeAotFixture.map
|
||||
_reference/nativeaot-integration/generated/win-x64/symbols.txt
|
||||
_reference/nativeaot-integration/generated/win-x64/oracle.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -7,3 +7,6 @@
|
||||
[submodule "third_party/unblob"]
|
||||
path = third_party/unblob
|
||||
url = https://github.com/onekey-sec/unblob.git
|
||||
[submodule "third_party/ghidra-nativeaot"]
|
||||
path = third_party/ghidra-nativeaot
|
||||
url = https://github.com/Washi1337/ghidra-nativeaot.git
|
||||
|
||||
@@ -234,6 +234,8 @@ Opening a Ghidra target selects its provider; the first analysis query starts im
|
||||
REA analyzes a temporary copy of the target and removes the temporary project when the session closes. Results identify what Ghidra observed and what it could not resolve. Decompilation produces pseudocode rather than the original source.
|
||||
|
||||
Ghidra also imports DOS MZ executables with an explicit 16-bit x86 real-mode profile. Function results include complete observed body ranges, distinguishing owned bytes from the enclosing span. See the [DOS analysis guide](docs/ghidra-dos.md) for addresses, packing, and verification boundaries.
|
||||
See [optional NativeAOT metadata recovery](docs/ghidra-nativeaot.md) for the pinned
|
||||
headless adapter, supported layout and existing native-tool workflow.
|
||||
|
||||
Windows Ghidra P0 uses bundled native controls for its read-only native x86-64 PE boundary on local NTFS; see the [Windows Ghidra P0 guide](docs/windows-ghidra-p0.md). See [Ghidra installation](docs/installation.md#ghidra), [provider evaluation](docs/provider-evaluation.md), and [testing](docs/testing.md) for configuration details, coverage, and real-provider verification.
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private List<FunctionEntry> procedureInventory;
|
||||
private List<InventoryItem> stringInventory;
|
||||
private DecompInterface decompiler;
|
||||
private final ReaGhidraExtensions analysisExtensions = new ReaGhidraExtensions();
|
||||
private static AddressSpace sessionDefaultAddressSpace;
|
||||
|
||||
@Override
|
||||
@@ -196,6 +197,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
// each edit owns a real outer transaction and can roll back immediately.
|
||||
end(true);
|
||||
try {
|
||||
if (!descriptor.transport.equals("unix-socket") && descriptor.analysisExtensions.size() > 0)
|
||||
throw new IOException("Windows Ghidra P0 does not admit metadata recovery or database mutation.");
|
||||
analysisExtensions.analyze(descriptor.analysisExtensions, currentProgram, monitor);
|
||||
initializeDecompiler();
|
||||
serve(descriptor);
|
||||
}
|
||||
@@ -204,6 +208,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
decompiler.dispose();
|
||||
decompiler = null;
|
||||
}
|
||||
analysisExtensions.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +419,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
.toArray(String[]::new)
|
||||
));
|
||||
result.add("target", target);
|
||||
JsonArray extensions = analysisExtensions.reports();
|
||||
if (extensions.size() > 0) result.add("analysis_extensions", extensions);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -454,6 +461,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private JsonObject inspectNativeLoadImage(JsonObject params) throws Exception {
|
||||
requireKeys(params, Set.of());
|
||||
JsonObject result = new JsonObject();
|
||||
JsonArray recovery = analysisExtensions.inspectImage();
|
||||
if (recovery.size() > 0) result.add("metadata_recovery", recovery);
|
||||
result.addProperty("executable_format", currentProgram.getExecutableFormat());
|
||||
result.addProperty("language_id", currentProgram.getLanguageID().getIdAsString());
|
||||
result.addProperty("compiler_spec_id", currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
|
||||
@@ -822,6 +831,10 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.addProperty("total_fields", totalFields);
|
||||
result.addProperty("truncated", totalFields > 20_000);
|
||||
result.add("limitations", GSON.toJsonTree(List.of("Layouts are Ghidra data-type database observations; original debug/source authority and flexible-tail semantics are not inferred. Nested and recursive types retain category-path identities for separate inspection.", "At most 20000 fields or enum members are serialized per type to bound provider output. total_fields and truncated report omissions. Enum values preserve Ghidra's signed long representation.")));
|
||||
if (type != null) {
|
||||
JsonObject metadata = analysisExtensions.inspectDataType(currentProgram, type, address, monitor);
|
||||
if (metadata != null) result.add("metadata_recovery", metadata);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -3051,10 +3064,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IllegalArgumentException("REA session descriptor is invalid");
|
||||
}
|
||||
JsonObject object = requireObject(
|
||||
JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8)),
|
||||
DESCRIPTOR_KEYS
|
||||
);
|
||||
JsonElement parsed = JsonParser.parseString(Files.readString(path, StandardCharsets.UTF_8));
|
||||
Set<String> keys = new HashSet<>(DESCRIPTOR_KEYS);
|
||||
if (parsed.isJsonObject() && parsed.getAsJsonObject().has("analysis_extensions"))
|
||||
keys.add("analysis_extensions");
|
||||
JsonObject object = requireObject(parsed, keys);
|
||||
return new SessionDescriptor(
|
||||
requireString(object, "transport"),
|
||||
requireString(object, "endpoint_path"),
|
||||
@@ -3062,7 +3076,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
requireString(object, "run_id"),
|
||||
requireSha256(object, "target_sha256"),
|
||||
requireString(object, "provider_version"),
|
||||
requireString(object, "profile_digest")
|
||||
requireString(object, "profile_digest"),
|
||||
object.has("analysis_extensions") ? object.getAsJsonArray("analysis_extensions") : new JsonArray()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3246,7 +3261,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
String runId,
|
||||
String targetSha256,
|
||||
String providerVersion,
|
||||
String profileDigest
|
||||
String profileDigest,
|
||||
JsonArray analysisExtensions
|
||||
) {}
|
||||
private record Request(int id, String method, JsonObject params) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.data.DataType;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Session-owned extension loading; analyzer algorithms stay behind each adapter ABI. */
|
||||
final class ReaGhidraExtensions implements AutoCloseable {
|
||||
private record Loaded(URLClassLoader loader, Object instance, String sha256) {}
|
||||
private final List<Loaded> loaded = new ArrayList<>();
|
||||
private final JsonArray reports = new JsonArray();
|
||||
|
||||
void analyze(JsonArray descriptors, Program program, TaskMonitor monitor) throws Exception {
|
||||
for (var item : descriptors) {
|
||||
monitor.checkCancelled();
|
||||
JsonObject descriptor = item.getAsJsonObject();
|
||||
String id = descriptor.get("id").getAsString();
|
||||
String entry = descriptor.get("entry_class").getAsString();
|
||||
String digest = descriptor.get("sha256").getAsString();
|
||||
JsonObject report = new JsonObject();
|
||||
report.addProperty("id", id);
|
||||
report.addProperty("sha256", digest);
|
||||
URLClassLoader loader = null;
|
||||
try {
|
||||
if (!id.equals("nativeaot") || !entry.equals("rea.extensions.nativeaot.NativeAotExtension") ||
|
||||
descriptor.get("integration_api").getAsInt() != 1)
|
||||
throw new IllegalArgumentException("Unregistered Ghidra extension ABI: " + id);
|
||||
Path path = Path.of(descriptor.get("path").getAsString());
|
||||
if (!path.isAbsolute() || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.size(path) > 8 * 1024 * 1024)
|
||||
throw new IllegalArgumentException("Invalid private extension artifact: " + path);
|
||||
String measured = java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(path)));
|
||||
if (!measured.equals(digest)) throw new IllegalArgumentException("Private extension digest mismatch: " + id);
|
||||
loader = new URLClassLoader(new java.net.URL[] { path.toUri().toURL() }, getClass().getClassLoader());
|
||||
Class<?> type = Class.forName(entry, true, loader);
|
||||
Object instance = type.getConstructor().newInstance();
|
||||
Object response = type.getMethod("analyze", Program.class, TaskMonitor.class).invoke(instance, program, monitor);
|
||||
if (!(response instanceof JsonObject result)) throw new IllegalArgumentException("Invalid Ghidra extension result: " + id);
|
||||
report.addProperty("status", result.get("status").getAsString());
|
||||
report.add("reason", result.get("reason").deepCopy());
|
||||
report.add("result", result);
|
||||
loaded.add(new Loaded(loader, instance, digest));
|
||||
loader = null;
|
||||
} catch (InvocationTargetException ex) {
|
||||
if (ex.getCause() instanceof CancelledException cancelled) throw cancelled;
|
||||
failure(report, ex.getCause());
|
||||
} catch (Exception | LinkageError ex) {
|
||||
failure(report, ex);
|
||||
} finally {
|
||||
if (loader != null) loader.close();
|
||||
}
|
||||
reports.add(report);
|
||||
}
|
||||
}
|
||||
|
||||
JsonArray reports() { return reports.deepCopy(); }
|
||||
|
||||
JsonArray inspectImage() throws Exception {
|
||||
JsonArray summaries = new JsonArray();
|
||||
for (Loaded extension : loaded) {
|
||||
Object response = extension.instance.getClass().getMethod("inspectImage").invoke(extension.instance);
|
||||
if (!(response instanceof JsonObject summary)) throw new IllegalArgumentException("Invalid extension image summary");
|
||||
summary.addProperty("analysis_artifact_sha256", extension.sha256);
|
||||
summaries.add(summary);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
JsonObject inspectDataType(Program program, DataType type, Address address, TaskMonitor monitor) throws Exception {
|
||||
for (Loaded extension : loaded) {
|
||||
try {
|
||||
Object response = extension.instance.getClass().getMethod("inspectDataType", Program.class, DataType.class, Address.class, TaskMonitor.class)
|
||||
.invoke(extension.instance, program, type, address, monitor);
|
||||
if (response instanceof JsonObject result) return result;
|
||||
if (response != null) throw new IllegalArgumentException("Invalid extension type metadata result");
|
||||
} catch (InvocationTargetException ex) {
|
||||
if (ex.getCause() instanceof CancelledException cancelled) throw cancelled;
|
||||
throw new IllegalStateException("Ghidra extension type inspection failed: " + ex.getCause(), ex.getCause());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void failure(JsonObject report, Throwable ex) {
|
||||
String reason = "Extension loading/analysis failed: " + ex;
|
||||
if (ex.getCause() != null) reason += "; caused by " + ex.getCause();
|
||||
report.addProperty("status", "failed");
|
||||
report.addProperty("reason", reason);
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("loader_failure", reason);
|
||||
report.add("result", result);
|
||||
}
|
||||
|
||||
@Override public void close() throws Exception {
|
||||
Exception failure = null;
|
||||
for (Loaded extension : loaded) {
|
||||
try { extension.loader.close(); }
|
||||
catch (Exception ex) { if (failure == null) failure = ex; else failure.addSuppressed(ex); }
|
||||
}
|
||||
loaded.clear();
|
||||
if (failure != null) throw failure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rea.extensions.nativeaot;
|
||||
|
||||
import ghidra.app.util.importer.MessageLog;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import java.util.LinkedHashSet;
|
||||
import nativeaot.rtr.ReadyToRunDirectory;
|
||||
import nativeaot.rtr.ReadyToRunSection;
|
||||
import nativeaot.rtr.SignatureReadyToRunLocator;
|
||||
import nativeaot.rtr.SymbolReadyToRunLocator;
|
||||
|
||||
/** Validate the producer's NativeAOT row layout before database mutation. */
|
||||
final class NativeAotDirectory {
|
||||
record Located(Address address, ReadyToRunDirectory directory, String discovery) {}
|
||||
|
||||
static Located locate(Program program, TaskMonitor monitor, MessageLog log) throws Exception {
|
||||
Address[] symbols = SymbolReadyToRunLocator.instance.locateModules(program, monitor, log);
|
||||
String discovery = symbols.length == 0 ? "signature-heuristic" : "symbol";
|
||||
Address[] found = symbols.length == 0
|
||||
? SignatureReadyToRunLocator.instance.locateModules(program, monitor, log) : symbols;
|
||||
var candidates = new LinkedHashSet<Address>();
|
||||
for (Address address : found) if (address.getOffset() != 0) candidates.add(address);
|
||||
if (candidates.isEmpty()) return null;
|
||||
if (candidates.size() != 1)
|
||||
throw new UnsupportedOperationException("Ambiguous NativeAOT directory candidates: " + candidates);
|
||||
Address address = candidates.iterator().next();
|
||||
var memory = program.getMemory();
|
||||
byte[] header = new byte[16];
|
||||
if (memory.getBytes(address, header) != header.length)
|
||||
throw new IllegalArgumentException("Incomplete NativeAOT header at " + address);
|
||||
int count = Short.toUnsignedInt(memory.getShort(address.add(12)));
|
||||
if (count < 1 || count > 80 || Byte.toUnsignedInt(header[14]) != 24 || header[15] != 1)
|
||||
throw new UnsupportedOperationException("Unsupported NativeAOT row layout at " + address +
|
||||
": sections=" + count + ", entry_size=" + Byte.toUnsignedInt(header[14]) + ", entry_type=" + Byte.toUnsignedInt(header[15]));
|
||||
byte[] rows = new byte[count * 24];
|
||||
if (memory.getBytes(address.add(16), rows) != rows.length)
|
||||
throw new IllegalArgumentException("Incomplete NativeAOT directory rows at " + address);
|
||||
ReadyToRunDirectory directory = ReadyToRunDirectory.readAtAddress(program, address);
|
||||
if (directory.getMajorVersion() != 9 || directory.getMinorVersion() != 1)
|
||||
throw new UnsupportedOperationException("Unsupported NativeAOT RTR format " +
|
||||
directory.getMajorVersion() + "." + directory.getMinorVersion() + " at " + address + "; verified format is 9.1 (.NET 8.0.22 Linux x64 fixture).");
|
||||
var types = new LinkedHashSet<Integer>();
|
||||
for (ReadyToRunSection section : directory.getSections()) {
|
||||
monitor.checkCancelled();
|
||||
if (!types.add(section.getType())) throw new IllegalArgumentException("Duplicate NativeAOT section " + section.getType() + " at " + address);
|
||||
// ModuleInfoRow flags bit 0 declares an end pointer. ThreadStaticIndex
|
||||
// (204) is a single-address entry with flags=0 and an absent end.
|
||||
if (section.getFlags() != 0 && section.getFlags() != 1)
|
||||
throw new UnsupportedOperationException("Unsupported NativeAOT section flags " + section.getFlags() + " for section " + section.getType());
|
||||
boolean hasEnd = section.getFlags() == 1;
|
||||
if (!hasEnd && section.getEnd() != 0)
|
||||
throw new IllegalArgumentException("NativeAOT single-address section " + section.getType() + " reports an unexpected end");
|
||||
if (hasEnd && Long.compareUnsigned(section.getStart(), section.getEnd()) > 0)
|
||||
throw new IllegalArgumentException("Reversed NativeAOT section " + section.getType() + " at " + address);
|
||||
if (section.getStart() != 0 && (!hasEnd || section.getStart() != section.getEnd()) &&
|
||||
(!memory.contains(address.getNewAddress(section.getStart())) ||
|
||||
(hasEnd && !memory.contains(address.getNewAddress(section.getEnd() - 1)))))
|
||||
throw new IllegalArgumentException("Unmapped NativeAOT section " + section.getType() + " at " + address);
|
||||
}
|
||||
if (directory.getSectionByType(ReadyToRunSection.DEHYDRATED_DATA) == null)
|
||||
throw new UnsupportedOperationException("NativeAOT directory at " + address + " has no DEHYDRATED_DATA; non-dehydrated layouts are not verified.");
|
||||
for (int type : new int[] { ReadyToRunSection.DEHYDRATED_DATA, ReadyToRunSection.FROZEN_OBJECT_REGION }) {
|
||||
ReadyToRunSection section = directory.getSectionByType(type);
|
||||
if (section != null && section.getFlags() != 1)
|
||||
throw new IllegalArgumentException("NativeAOT section " + type + " requires an end pointer");
|
||||
}
|
||||
return new Located(address, directory, discovery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package rea.extensions.nativeaot;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import ghidra.app.util.importer.MessageLog;
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.address.AddressSet;
|
||||
import ghidra.program.model.data.DataType;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.util.exception.CancelledException;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import nativeaot.objectmodel.FrozenObjectAnnotator;
|
||||
import nativeaot.objectmodel.MethodTableCrawler;
|
||||
import nativeaot.objectmodel.MethodTableManager;
|
||||
import nativeaot.rehydration.MetadataRehydratorNet80;
|
||||
import nativeaot.rtr.ReadyToRunSection;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Optional headless facade over the unchanged upstream analysis primitives. */
|
||||
public final class NativeAotExtension {
|
||||
private MethodTableManager manager;
|
||||
private NativeAotDirectory.Located located;
|
||||
private JsonObject report;
|
||||
private final List<String> diagnostics = new ArrayList<>();
|
||||
private JsonObject derivedRange;
|
||||
|
||||
public JsonObject analyze(Program program, TaskMonitor monitor) throws Exception {
|
||||
report = new JsonObject();
|
||||
report.addProperty("id", "nativeaot");
|
||||
report.addProperty("integration_api", 1);
|
||||
report.addProperty("source_revision", "effeb734fc570c32650f88b159608979dc7b423e");
|
||||
report.addProperty("source_revision_authority", "build-reported-unattested");
|
||||
report.addProperty("status", "not_applicable");
|
||||
report.addProperty("reason", "No NativeAOT directory with the supported row layout was found.");
|
||||
report.addProperty("method_tables", 0);
|
||||
if (!program.getLanguageID().getIdAsString().startsWith("x86:LE:64"))
|
||||
return failure("unsupported", "NativeAOT adapter supports x86-64 only.");
|
||||
MessageLog log = new MessageLog();
|
||||
int transaction = program.startTransaction("REA NativeAOT metadata recovery");
|
||||
boolean commit = false;
|
||||
String stage = "directory-discovery";
|
||||
try {
|
||||
located = NativeAotDirectory.locate(program, monitor, log);
|
||||
if (located == null) return finish(log);
|
||||
report.addProperty("header_address", address(located.address()));
|
||||
report.addProperty("discovery", located.discovery());
|
||||
report.addProperty("format_major", located.directory().getMajorVersion());
|
||||
report.addProperty("format_minor", located.directory().getMinorVersion());
|
||||
stage = "metadata-rehydration";
|
||||
var section = located.directory().getSectionByType(ReadyToRunSection.DEHYDRATED_DATA);
|
||||
Address start = located.address().getNewAddress(section.getStart());
|
||||
Address end = located.address().getNewAddress(section.getEnd());
|
||||
if (start.compareTo(end) >= 0) throw new IllegalArgumentException("Empty dehydrated section at " + start);
|
||||
var scan = new MetadataRehydratorNet80().rehydrate(program, new AddressSet(start, end).getFirstRange(), monitor, log);
|
||||
// Upstream reports an inclusive end at base + written length.
|
||||
long writtenLength = scan.getScanningRange().getLength() - 1;
|
||||
derivedRange = new JsonObject();
|
||||
derivedRange.addProperty("address", address(scan.getScanningRange().getMinAddress()));
|
||||
derivedRange.addProperty("size_bytes", writtenLength);
|
||||
derivedRange.addProperty("sha256", hashMemory(program, scan.getScanningRange().getMinAddress(), writtenLength, monitor));
|
||||
derivedRange.add("file_offset", JsonNull.INSTANCE);
|
||||
stage = "method-table-recovery";
|
||||
manager = MethodTableManager.createForDirectory(program, located.directory());
|
||||
var conventions = NativeAotMethodConventions.capture(program, monitor);
|
||||
new MethodTableCrawler(manager, program, scan).analyze(monitor, log);
|
||||
stage = "calling-convention-preservation";
|
||||
NativeAotMethodConventions.restore(program, manager, conventions, monitor);
|
||||
log.appendMsg("REA", "Preserved pre-recovery calling conventions; new functions use the loaded compiler specification default. Original method signatures remain unknown.");
|
||||
if (manager.getObjectMT() == null || manager.getMethodTableCount() == 0)
|
||||
throw new UnsupportedOperationException("No unambiguous System.Object method table was recovered at header " + located.address());
|
||||
stage = "frozen-object-annotation";
|
||||
if (manager.getStringMT() == null || scan.getPointerLocations().length == 0) {
|
||||
diagnostics.add("Frozen-object annotation unavailable: System.String table or pointer locations were not recovered.");
|
||||
} else {
|
||||
new FrozenObjectAnnotator(program, manager).analyze(located.directory(), scan.getPointerLocations(), monitor, log);
|
||||
}
|
||||
monitor.checkCancelled();
|
||||
report.addProperty("method_tables", manager.getMethodTableCount());
|
||||
JsonObject coverage = auditFrozenObjects(program, scan.getPointerLocations(), monitor);
|
||||
report.add("coverage", coverage);
|
||||
if (coverage.get("frozen_objects_annotated").getAsInt() != coverage.get("frozen_object_candidates").getAsInt())
|
||||
diagnostics.add("Some frozen-object candidates lack committed instance annotations; inspect coverage counts and Ghidra diagnostics.");
|
||||
report.addProperty("status", diagnostics.isEmpty() ? "complete" : "partial");
|
||||
report.add("reason", JsonNull.INSTANCE);
|
||||
report.add("derived_memory", derivedRange.deepCopy());
|
||||
JsonArray tables = new JsonArray();
|
||||
for (var mt : manager.getMethodTables()) {
|
||||
monitor.checkCancelled();
|
||||
JsonObject identity = new JsonObject();
|
||||
identity.addProperty("address", address(mt.getAddress()));
|
||||
identity.addProperty("type", mt.getMTType().getPathName());
|
||||
tables.add(identity);
|
||||
}
|
||||
report.add("types", tables);
|
||||
commit = true;
|
||||
return finish(log);
|
||||
} catch (CancelledException ex) {
|
||||
manager = null;
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
manager = null;
|
||||
diagnostics.add(stage + ": " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
|
||||
report.addProperty("status", ex instanceof UnsupportedOperationException ? "unsupported" : "failed");
|
||||
report.addProperty("reason", stage + ": " + ex.getMessage());
|
||||
return finish(log);
|
||||
} finally {
|
||||
program.endTransaction(transaction, commit);
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline discovery for the existing loaded-image inspection. */
|
||||
public JsonObject inspectImage() {
|
||||
JsonObject summary = new JsonObject();
|
||||
summary.addProperty("format", "dotnet-nativeaot");
|
||||
for (String key : new String[] { "status", "reason", "header_address", "discovery", "format_major", "format_minor", "method_tables", "derived_memory", "coverage", "diagnostics" })
|
||||
summary.add(key, report.has(key) ? report.get(key).deepCopy() : JsonNull.INSTANCE);
|
||||
summary.add("types", report.has("types") ? report.get("types").deepCopy() : new JsonArray());
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add("Recovery covers the reported metadata candidates; it does not reconstruct original C# source, custom field layouts, every runtime object, or execution behavior.");
|
||||
limitations.add("Method prototypes and parameter names remain Ghidra inferences; verify them against instructions and call sites. Pre-recovery conventions are retained rather than universally forcing __thiscall.");
|
||||
limitations.add("NativeAOT type identities and relationships are inferred; generated names are not original names. Derived memory has no original file offsets.");
|
||||
summary.add("limitations", limitations);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Read recovered relationships without restoring or mutating database state. */
|
||||
public JsonObject inspectDataType(Program program, DataType type, Address selected, TaskMonitor monitor) throws Exception {
|
||||
if (manager == null) return null;
|
||||
return NativeAotTypeMetadata.describe(program, manager, located, type, report, derivedRange, monitor);
|
||||
}
|
||||
|
||||
private JsonObject auditFrozenObjects(Program program, Address[] pointerLocations, TaskMonitor monitor) throws Exception {
|
||||
JsonObject coverage = new JsonObject();
|
||||
int candidates = 0, annotated = 0;
|
||||
var section = located.directory().getSectionByType(ReadyToRunSection.FROZEN_OBJECT_REGION);
|
||||
if (section != null) for (Address location : section.getPointersInSection(pointerLocations)) {
|
||||
monitor.checkCancelled();
|
||||
var table = manager.getMethodTable(program.getMemory().getLong(location));
|
||||
if (table == null) continue;
|
||||
candidates++;
|
||||
var expected = table.getInstanceType();
|
||||
var actual = program.getListing().getDataAt(location);
|
||||
if (expected != null && actual != null && actual.getDataType().getPathName().equals(expected.getPathName())) annotated++;
|
||||
}
|
||||
coverage.addProperty("frozen_object_candidates", candidates);
|
||||
coverage.addProperty("frozen_objects_annotated", annotated);
|
||||
coverage.addProperty("basis", "rehydrated-pointer-candidates-and-committed-instance-types");
|
||||
return coverage;
|
||||
}
|
||||
|
||||
private JsonObject finish(MessageLog log) {
|
||||
if (!log.toString().isBlank()) diagnostics.add(log.toString());
|
||||
JsonArray messages = new JsonArray();
|
||||
for (String message : diagnostics) messages.add(message);
|
||||
report.add("diagnostics", messages);
|
||||
return report;
|
||||
}
|
||||
|
||||
private JsonObject failure(String status, String reason) {
|
||||
report.addProperty("status", status);
|
||||
report.addProperty("reason", reason);
|
||||
return finish(new MessageLog());
|
||||
}
|
||||
|
||||
static String address(Address value) { return "0x" + Long.toUnsignedString(value.getOffset(), 16); }
|
||||
|
||||
private static String hashMemory(Program program, Address start, long length, TaskMonitor monitor) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] buffer = new byte[65536];
|
||||
for (long offset = 0; offset < length;) {
|
||||
monitor.checkCancelled();
|
||||
int count = (int)Math.min(buffer.length, length - offset);
|
||||
if (program.getMemory().getBytes(start.add(offset), buffer, 0, count) != count)
|
||||
throw new IllegalStateException("Incomplete derived-memory read at " + start.add(offset));
|
||||
digest.update(buffer, 0, count);
|
||||
offset += count;
|
||||
}
|
||||
return java.util.HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package rea.extensions.nativeaot;
|
||||
|
||||
import ghidra.program.model.address.Address;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import nativeaot.objectmodel.MethodTableManager;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
|
||||
/** Keep upstream's unconditional __thiscall assignment out of x64 ABI evidence. */
|
||||
final class NativeAotMethodConventions {
|
||||
static Map<Address, String> capture(Program program, TaskMonitor monitor) throws Exception {
|
||||
Map<Address, String> result = new HashMap<>();
|
||||
var functions = program.getFunctionManager().getFunctions(true);
|
||||
while (functions.hasNext()) {
|
||||
monitor.checkCancelled();
|
||||
var function = functions.next();
|
||||
result.put(function.getEntryPoint(), function.getCallingConventionName());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void restore(Program program, MethodTableManager manager, Map<Address, String> previous, TaskMonitor monitor) throws Exception {
|
||||
var visited = new HashSet<Address>();
|
||||
String defaultConvention = program.getCompilerSpec().getDefaultCallingConvention().getName();
|
||||
for (var table : manager.getMethodTables()) for (long target : table.getVTable()) {
|
||||
monitor.checkCancelled();
|
||||
Address address = table.getAddress().getNewAddress(target);
|
||||
if (!visited.add(address)) continue;
|
||||
var function = program.getFunctionManager().getFunctionAt(address);
|
||||
if (function == null) continue;
|
||||
String convention = previous.containsKey(address) ? previous.get(address) : defaultConvention;
|
||||
if (!java.util.Objects.equals(function.getCallingConventionName(), convention))
|
||||
function.setCallingConvention(convention);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rea.extensions.nativeaot;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import ghidra.program.model.data.DataType;
|
||||
import ghidra.program.model.listing.Program;
|
||||
import ghidra.util.task.TaskMonitor;
|
||||
import nativeaot.objectmodel.MethodTable;
|
||||
import nativeaot.objectmodel.MethodTableManager;
|
||||
|
||||
/** Translate a selected recovered type into provider-neutral metadata evidence. */
|
||||
final class NativeAotTypeMetadata {
|
||||
static JsonObject describe(Program program, MethodTableManager manager, NativeAotDirectory.Located located,
|
||||
DataType type, JsonObject report, JsonObject derivedRange, TaskMonitor monitor) throws Exception {
|
||||
for (MethodTable table : manager.getMethodTables()) {
|
||||
monitor.checkCancelled();
|
||||
boolean sameType = type.getPathName().equals(table.getMTType().getPathName()) ||
|
||||
(table.getInstanceType() != null && type.getPathName().equals(table.getInstanceType().getPathName()));
|
||||
if (!sameType) continue;
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("format", "dotnet-nativeaot");
|
||||
result.addProperty("status", report.get("status").getAsString());
|
||||
result.addProperty("header_address", NativeAotExtension.address(located.address()));
|
||||
result.addProperty("format_major", located.directory().getMajorVersion());
|
||||
result.addProperty("format_minor", located.directory().getMinorVersion());
|
||||
result.addProperty("method_table_address", NativeAotExtension.address(table.getAddress()));
|
||||
result.addProperty("name_origin", table == manager.getObjectMT() || table == manager.getStringMT() ? "inferred" : "generated");
|
||||
result.add("original_name", JsonNull.INSTANCE);
|
||||
result.addProperty("base_size_bytes", table.getBaseSize());
|
||||
result.add("related_type", identity(manager, table.getRelatedTypeAddress()));
|
||||
JsonArray interfaces = new JsonArray();
|
||||
for (long value : table.getInterfaceAddresses()) if (value != 0) interfaces.add(identity(manager, value));
|
||||
result.add("interfaces", interfaces);
|
||||
JsonArray slots = new JsonArray();
|
||||
for (int slot = 0; slot < table.getVTableSlotCount(); slot++) {
|
||||
monitor.checkCancelled();
|
||||
long value = table.getVTableSlot(slot);
|
||||
JsonObject target = new JsonObject();
|
||||
target.addProperty("slot", slot);
|
||||
target.addProperty("slot_address", NativeAotExtension.address(table.getAddress().add(24 + slot * 8L)));
|
||||
target.add("target_address", value == 0 ? JsonNull.INSTANCE : new com.google.gson.JsonPrimitive("0x" + Long.toUnsignedString(value, 16)));
|
||||
var function = value == 0 ? null : program.getFunctionManager().getFunctionAt(table.getAddress().getNewAddress(value));
|
||||
target.add("procedure_name", function == null ? JsonNull.INSTANCE : new com.google.gson.JsonPrimitive(function.getName(true)));
|
||||
target.addProperty("basis", "method-table-pointer");
|
||||
slots.add(target);
|
||||
}
|
||||
result.add("virtual_slots", slots);
|
||||
result.add("derived_memory", derivedRange.deepCopy());
|
||||
result.add("diagnostics", report.get("diagnostics").deepCopy());
|
||||
JsonArray limitations = new JsonArray();
|
||||
limitations.add("Type identities and relationships are recovered by upstream heuristics. Original source names and custom field layouts are not recovered.");
|
||||
limitations.add("Method prototypes are inferred, not original signatures. REA preserves pre-recovery calling conventions and the loaded compiler default for new functions.");
|
||||
limitations.add("Rehydrated metadata bytes are derived analysis-memory content; their original file offsets and runtime behavior remain unknown.");
|
||||
limitations.add("Verified workflow: .NET 8.0.22 NativeAOT RTR 9.1, Linux x64 ELF / Windows x64 PE on Linux; other runtime layouts and hosts are unverified.");
|
||||
result.add("limitations", limitations);
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static com.google.gson.JsonElement identity(MethodTableManager manager, long value) throws Exception {
|
||||
if (value == 0) return JsonNull.INSTANCE;
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("address", "0x" + Long.toUnsignedString(value, 16));
|
||||
MethodTable table = manager.getMethodTable(value);
|
||||
result.add("type", table == null ? JsonNull.INSTANCE : new com.google.gson.JsonPrimitive(table.getMTType().getPathName()));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# Optional NativeAOT metadata recovery
|
||||
|
||||
REA integrates [Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot)
|
||||
through an optional headless adapter. The original source is an opt-in submodule,
|
||||
pinned to `effeb734fc570c32650f88b159608979dc7b423e`; its MIT license and existing
|
||||
source notices are retained. No upstream source or algorithms are rewritten.
|
||||
|
||||
The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**,
|
||||
x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using
|
||||
Ghidra **12.1.4** and JDK **21**. Other layouts/architectures/hosts are unsupported.
|
||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||
|
||||
## Build and configure
|
||||
|
||||
Bring your own supported Ghidra/JDK installation. This developer build needs
|
||||
existing `git`, `javac` and `jar`; it does not invoke Gradle, download toolchains,
|
||||
or change host configuration:
|
||||
|
||||
```sh
|
||||
git submodule update --init third_party/ghidra-nativeaot
|
||||
npm run build:ghidra:nativeaot
|
||||
export REA_GHIDRA_NATIVEAOT_JAR="$PWD/_reference/nativeaot-integration/extension/rea-ghidra-nativeaot.jar"
|
||||
```
|
||||
|
||||
The build requires a clean pinned upstream checkout, compiles unchanged upstream
|
||||
Java plus the REA facade, uses a fresh classes directory, and writes the JAR and
|
||||
build manifest under ignored `_reference/`. `REA_NATIVEAOT_BUILD_ROOT` can select
|
||||
another local output directory; `REA_NATIVEAOT_SOURCE` can select an equivalent
|
||||
clean pinned source checkout. Neither output nor compiled fixture belongs in Git
|
||||
or the npm package. An installed REA accepts an existing explicitly supplied JAR.
|
||||
|
||||
REA commits the actual JAR SHA-256 into the analysis profile, copies identical
|
||||
bytes into the owned runtime, rechecks the digest before loading, and closes the
|
||||
classloader/project on session close. Profiles cannot enable code absent from
|
||||
explicit local configuration. JARs execute within Ghidra; supply a build you trust.
|
||||
The producer's reported source revision is build metadata, not an attestation of
|
||||
arbitrary caller-supplied bytes. The bounded loader accepts regular JARs up to
|
||||
8 MiB to limit startup artifact memory; this does not truncate analysis results.
|
||||
|
||||
## Use existing tools
|
||||
|
||||
Open the executable with provider `ghidra`. `inspect_native_load_image` returns
|
||||
optional `observations.metadata_recovery` inline, including the detected header,
|
||||
layout, recovery status, type category paths/addresses, diagnostics, annotation
|
||||
coverage and derived-memory SHA. The enclosing `unsupported` load-image status
|
||||
for PE/ELF concerns independent DOS loader verification; metadata recovery has
|
||||
its own status. It is not a failed import.
|
||||
|
||||
Pass one returned type path or its typed address to `inspect_native_data_type`.
|
||||
Its optional `metadata_recovery` includes related/base type, implemented
|
||||
interfaces and virtual slots with targets and known procedure names. Follow
|
||||
these with `analyze_function`, string searches and ordinary cross-references.
|
||||
For CLI use:
|
||||
|
||||
```sh
|
||||
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
|
||||
rea inspect-native-data-type ./NativeAotFixture --type /NativeAOT/MethodTables/Class_ADDRESS_MT --provider ghidra --json
|
||||
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
|
||||
```
|
||||
|
||||
No new MCP workflow or approval flag is required. Configuration opts into the
|
||||
adapter; without it ordinary Ghidra profiles/results remain unchanged.
|
||||
|
||||
## Evidence boundaries
|
||||
|
||||
Discovery prefers an imported RTR symbol, otherwise the upstream initialized,
|
||||
non-executable-memory signature heuristic. REA rejects ambiguous candidates,
|
||||
unknown layout versions, malformed directory rows, unsupported flags and
|
||||
unmapped ranges before recovery. It requires the verified dehydrated layout.
|
||||
|
||||
The adapter performs rehydration, method-table recovery and frozen-object
|
||||
annotation in one ephemeral Program transaction. Fatal failure/cancellation
|
||||
rolls it back. Recovery may change analysis-memory bytes, never the original
|
||||
executable file. Derived ranges have a digest and **no original file offset**;
|
||||
they are not captured runtime state. Candidate/committed instance annotation
|
||||
counts distinguish partial frozen-object coverage. Coverage concerns the
|
||||
recovered candidate set, not every possible object or every runtime type.
|
||||
|
||||
REA preserves pre-recovery calling conventions and uses the loaded compiler
|
||||
specification default for newly created methods. The upstream universal
|
||||
`__thiscall` assignment is not treated as x64 ABI authority. Parameters and
|
||||
method prototypes remain inferences; verify them against instructions/call sites.
|
||||
|
||||
Type relationships and System.Object/System.String identification use upstream
|
||||
heuristics. Generated names are explicitly marked; original class/member names
|
||||
and custom field layouts remain unknown. Pseudocode is recovered native logic,
|
||||
not original C# source. Unsupported recovery reports its stage and reason; it
|
||||
does not imply that ordinary native disassembly/decompilation is impossible.
|
||||
|
||||
## Verification
|
||||
|
||||
See [the dedicated lane](testing.md#optional-nativeaot-ghidra-analysis).
|
||||
Fixtures are source-built benign .NET programs with inheritance, an interface,
|
||||
virtual dispatch, representative arithmetic and frozen strings. Independent
|
||||
compiler/linker symbols and raw directory bytes provide the oracle. Stripped
|
||||
fixtures exercise heuristic detection. Tests do not execute the resulting
|
||||
NativeAOT binaries. Fixture generation and adapter compilation are optional and
|
||||
never prerequisites of ordinary native analysis or setup.
|
||||
@@ -610,9 +610,9 @@
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "45a8aea00dba2a8ad8563b4943cd177d07b14889bc9b823bc39b89b6ddb26d2c",
|
||||
"tools_sha256": "eec3abb8fa5b25c6e5d6e361b7ebd157e3599f69b68defda85680dbfe061b3d3",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "297aced29e7ce5e845c505d5f2e9e2dee42b7ed3e628f088f424fe8088c9e8dc",
|
||||
"combined_sha256": "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b",
|
||||
"providers_sha256": "dfba0ee59f12c82cdfb5320e7e0d70a114e533b2cb88afd15db9b9a4a175c9b0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,36 @@ Synthetic producer regressions run independently:
|
||||
npm run test:focused -- tests/boundary/android/jadxIntegration.test.ts tests/boundary/mcp/androidAnalysisMcp.test.ts
|
||||
```
|
||||
|
||||
## Optional NativeAOT Ghidra analysis
|
||||
|
||||
This lane is separate from the default native lane. `build:fixtures:nativeaot`
|
||||
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
|
||||
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
|
||||
records independent symbol/directory/SHA oracles; it never executes the target.
|
||||
Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout
|
||||
negative inputs. The optional Windows fixture workflow builds a PE on a Windows
|
||||
runner; analyzing that PE on Linux does not verify a Windows Ghidra host.
|
||||
|
||||
Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set
|
||||
`REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`,
|
||||
`-- ordinary`, `-- unsupported`, `-- malformed`, `-- ambiguous`, and
|
||||
`-- loader-failure`, and `-- default-native` separately. The loader-failure mode source-builds
|
||||
a deliberately failing JDK 21 initializer and checks the actual loader cause and
|
||||
cleanup. The default-native mode verifies ordinary analysis with the optional
|
||||
extension disabled.
|
||||
The real MCP lane checks source identity, inline format discovery, metadata
|
||||
relationships/slots against independent compiler symbols, frozen strings,
|
||||
pseudocode and owned cleanup. Set `REA_NATIVEAOT_PROOF_CLI=1` for one equivalent
|
||||
CLI type inspection; this costs an additional full import. Select an unpacked
|
||||
installed package with `REA_NATIVEAOT_PROOF_PACKAGE_ROOT`, a fixture directory
|
||||
with `REA_NATIVEAOT_PROOF_FIXTURE_ROOT`, and optional evidence capture directory
|
||||
with `REA_NATIVEAOT_PROOF_CAPTURE_DIR` (absolute paths).
|
||||
|
||||
Keep builds/imports sequential on small hosts; scope `GHIDRA_HEADLESS_MAXMEM`
|
||||
(e.g. `768M`) to this command and use CPU affinity if needed. REA does not install
|
||||
or upgrade Java, Ghidra, .NET or native toolchains. See
|
||||
[the supported layout and provenance](ghidra-nativeaot.md).
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_167568fa66903a8f654d2673326fe628791d29d08aa1436fa73cc02080357180",
|
||||
"manifest_id": "ecm_ffc0fa54e6d99b09a87fb90cb629015b0f7dfc62bcd44c3ffdef562ed2658426",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "12f60e9ee66325b3918d093aa8d3e081e44004875d0cce55b5c644d0cb3a06f5"
|
||||
"sha256": "14e42a5e3de32cc288709f3ecf92c5acf90e59a798f47313062c8f2dcf39e7ba"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
+5
-1
@@ -44,6 +44,7 @@
|
||||
"bridge/hopper_bridge.py",
|
||||
"bridge/native/ReaNativeUI.swift",
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"bridge/ghidra/ReaGhidraExtensions.java",
|
||||
"bridge/ghidra/ReaGhidraPrepareCom.java",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/electron-active-hook.cjs",
|
||||
@@ -168,7 +169,10 @@
|
||||
"verify:windows-native:artifact": "node scripts/verify-windows-native-artifact.mjs",
|
||||
"prepublishOnly": "npm run verify:windows-native:artifact",
|
||||
"verify:windows-native": "node scripts/verify-windows-native.mjs",
|
||||
"verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs"
|
||||
"verify:ghidra:windows:package": "node scripts/verify-packaged-ghidra-windows.mjs",
|
||||
"build:ghidra:nativeaot": "node scripts/build-ghidra-nativeaot.mjs",
|
||||
"build:fixtures:nativeaot": "node scripts/generate-nativeaot-fixtures.mjs",
|
||||
"verify:ghidra:nativeaot": "npm run build:cached && node scripts/verify-real-ghidra-nativeaot.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const source = resolve(
|
||||
process.env.REA_NATIVEAOT_SOURCE ??
|
||||
join(root, "third_party/ghidra-nativeaot"),
|
||||
);
|
||||
const install = process.env.GHIDRA_INSTALL_DIR;
|
||||
if (!install)
|
||||
throw new Error(
|
||||
"NativeAOT build lane requires existing GHIDRA_INSTALL_DIR (Ghidra 12.1.4).",
|
||||
);
|
||||
const properties = await readFile(
|
||||
join(install, "Ghidra/application.properties"),
|
||||
"utf8",
|
||||
);
|
||||
if (!/^application.version=12\.1\.4\s*$/m.test(properties))
|
||||
throw new Error("NativeAOT build lane requires Ghidra 12.1.4.");
|
||||
const output = resolve(
|
||||
process.env.REA_NATIVEAOT_BUILD_ROOT ??
|
||||
join(root, "_reference/nativeaot-integration/extension"),
|
||||
);
|
||||
const java = process.env.JAVA_HOME ? join(process.env.JAVA_HOME, "bin") : null;
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0)
|
||||
throw new Error(
|
||||
`NativeAOT build prerequisite/action ${command} failed: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
);
|
||||
return result.stdout.trim();
|
||||
};
|
||||
const javac = java ? join(java, "javac") : "javac";
|
||||
if (!/^javac 21(?:\.|$)/u.test(run(javac, ["-version"])))
|
||||
throw new Error(
|
||||
"NativeAOT build lane requires an existing JDK 21 javac; REA does not install or upgrade Java.",
|
||||
);
|
||||
const revision = run("git", ["-C", source, "rev-parse", "HEAD"]);
|
||||
if (
|
||||
revision !== "effeb734fc570c32650f88b159608979dc7b423e" ||
|
||||
run("git", ["-C", source, "status", "--porcelain"])
|
||||
)
|
||||
throw new Error(
|
||||
"NativeAOT build requires the clean pinned upstream source; initialize third_party/ghidra-nativeaot.",
|
||||
);
|
||||
const files = async (path, extension) => {
|
||||
const result = [];
|
||||
for (const entry of await readdir(path, { withFileTypes: true })) {
|
||||
const child = join(path, entry.name);
|
||||
if (entry.isDirectory()) result.push(...(await files(child, extension)));
|
||||
else if (entry.isFile() && child.endsWith(extension)) result.push(child);
|
||||
}
|
||||
return result.sort();
|
||||
};
|
||||
await mkdir(output, { recursive: true });
|
||||
const classes = await mkdtemp(join(output, "classes-"));
|
||||
try {
|
||||
const sources = [
|
||||
...(await files(join(source, "src/main/java"), ".java")),
|
||||
...(await files(join(root, "bridge/ghidra/extensions/nativeaot"), ".java")),
|
||||
];
|
||||
const jars = await files(join(install, "Ghidra"), ".jar");
|
||||
const args = [
|
||||
"-proc:none",
|
||||
"-cp",
|
||||
jars.join(process.platform === "win32" ? ";" : ":"),
|
||||
"-d",
|
||||
classes,
|
||||
...sources,
|
||||
];
|
||||
await writeFile(
|
||||
join(output, "javac.args"),
|
||||
args
|
||||
.map((s) => `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
|
||||
.join("\n"),
|
||||
);
|
||||
run(javac, [
|
||||
"-J-Xmx512m",
|
||||
"-J-XX:ActiveProcessorCount=1",
|
||||
`@${join(output, "javac.args")}`,
|
||||
]);
|
||||
await writeFile(
|
||||
join(classes, "LICENSE-ghidra-nativeaot.md"),
|
||||
await readFile(join(source, "LICENSE.md")),
|
||||
);
|
||||
const jar = join(output, "rea-ghidra-nativeaot.jar");
|
||||
run(java ? join(java, "jar") : "jar", [
|
||||
"--create",
|
||||
"--file",
|
||||
jar,
|
||||
"--date=2025-01-01T00:00:00Z",
|
||||
"-C",
|
||||
classes,
|
||||
".",
|
||||
]);
|
||||
const sha256 = createHash("sha256")
|
||||
.update(await readFile(jar))
|
||||
.digest("hex");
|
||||
await writeFile(
|
||||
join(output, "build.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
jar,
|
||||
sha256,
|
||||
upstream_revision: revision,
|
||||
ghidra_version: "12.1.4",
|
||||
integration_api: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
jar,
|
||||
sha256,
|
||||
upstream_revision: revision,
|
||||
ghidra_version: "12.1.4",
|
||||
integration_api: 1,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await rm(classes, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
if (process.arch !== "x64" || !["linux", "win32"].includes(process.platform))
|
||||
throw new Error(
|
||||
"NativeAOT fixture lane requires Linux or Windows x64; it does not cross-compile NativeAOT.",
|
||||
);
|
||||
const runtime = process.platform === "win32" ? "win-x64" : "linux-x64";
|
||||
const directory = resolve(
|
||||
process.env.REA_NATIVEAOT_FIXTURE_ROOT ??
|
||||
join(root, "_reference/nativeaot-integration/generated"),
|
||||
);
|
||||
const output = join(directory, runtime);
|
||||
await mkdir(output, { recursive: true });
|
||||
const env = {
|
||||
...process.env,
|
||||
DOTNET_CLI_HOME: join(output, "cli-home"),
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1",
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1",
|
||||
DOTNET_PROCESSOR_COUNT: "1",
|
||||
COMPlus_GCHeapHardLimit: "30000000",
|
||||
NUGET_PACKAGES: process.env.NUGET_PACKAGES ?? join(directory, "nuget"),
|
||||
};
|
||||
await writeFile(
|
||||
join(output, "global.json"),
|
||||
JSON.stringify({ sdk: { version: "8.0.416", rollForward: "disable" } }) +
|
||||
"\n",
|
||||
);
|
||||
const run = (command, args) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: output,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
timeout: 300_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0)
|
||||
throw new Error(
|
||||
`NativeAOT fixture lane requires/action failed ${command}: ${result.error?.message ?? ""}\n${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
);
|
||||
return result.stdout;
|
||||
};
|
||||
const dotnet = process.env.REA_NATIVEAOT_DOTNET ?? "dotnet";
|
||||
if (run(dotnet, ["--version"]).trim() !== "8.0.416")
|
||||
throw new Error(
|
||||
"NativeAOT fixtures require existing .NET SDK 8.0.416; REA does not install SDKs.",
|
||||
);
|
||||
if (process.platform === "linux") {
|
||||
run(process.env.REA_NATIVEAOT_STRIP ?? "strip", ["--version"]);
|
||||
run(process.env.REA_NATIVEAOT_NM ?? "nm", ["--version"]);
|
||||
run(process.env.REA_NATIVEAOT_CC ?? "cc", ["--version"]);
|
||||
run("clang", ["--version"]);
|
||||
}
|
||||
const map = join(output, "NativeAotFixture.map");
|
||||
run(dotnet, [
|
||||
"publish",
|
||||
join(root, "tests/fixtures/nativeaot/NativeAotFixture.csproj"),
|
||||
"-r",
|
||||
runtime,
|
||||
"-c",
|
||||
"Release",
|
||||
"-o",
|
||||
join(output, "symbols"),
|
||||
"--maxcpucount:1",
|
||||
`-p:BaseIntermediateOutputPath=${join(output, "obj")}/`,
|
||||
`-p:BaseOutputPath=${join(output, "bin")}/`,
|
||||
`-p:NativeAotMapPath=${map}`,
|
||||
]);
|
||||
const name =
|
||||
process.platform === "win32" ? "NativeAotFixture.exe" : "NativeAotFixture";
|
||||
const binary = join(output, "symbols", name);
|
||||
let symbols = "";
|
||||
if (process.platform === "linux") {
|
||||
// Preserve the symbol table as an independent oracle while avoiding a full
|
||||
// runtime DWARF import in this metadata-specific lane.
|
||||
run(process.env.REA_NATIVEAOT_STRIP ?? "strip", ["--strip-debug", binary]);
|
||||
symbols = run(process.env.REA_NATIVEAOT_NM ?? "nm", ["-n", binary]);
|
||||
await mkdir(join(output, "stripped"), { recursive: true });
|
||||
const stripped = join(output, "stripped", name);
|
||||
await copyFile(binary, stripped);
|
||||
run(process.env.REA_NATIVEAOT_STRIP ?? "strip", ["--strip-all", stripped]);
|
||||
} else {
|
||||
symbols = await readFile(map, "utf8");
|
||||
}
|
||||
const bytes = await readFile(binary);
|
||||
const candidates = [];
|
||||
for (let offset = 0; offset <= bytes.length - 16; offset++) {
|
||||
if (
|
||||
bytes.readUInt32LE(offset) !== 0x00525452 ||
|
||||
bytes[offset + 14] !== 24 ||
|
||||
bytes[offset + 15] !== 1
|
||||
)
|
||||
continue;
|
||||
const count = bytes.readUInt16LE(offset + 12);
|
||||
if (count === 0 || count > 80 || offset + 16 + count * 24 > bytes.length)
|
||||
continue;
|
||||
candidates.push({
|
||||
offset,
|
||||
major: bytes.readUInt16LE(offset + 4),
|
||||
minor: bytes.readUInt16LE(offset + 6),
|
||||
sections: Array.from({ length: count }, (_, index) => {
|
||||
const row = offset + 16 + index * 24;
|
||||
return {
|
||||
type: bytes.readUInt32LE(row),
|
||||
flags: bytes.readUInt32LE(row + 4),
|
||||
start: `0x${bytes.readBigUInt64LE(row + 8).toString(16)}`,
|
||||
end: `0x${bytes.readBigUInt64LE(row + 16).toString(16)}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
candidates.length !== 1 ||
|
||||
candidates[0].major !== 9 ||
|
||||
candidates[0].minor !== 1
|
||||
)
|
||||
throw new Error(
|
||||
"NativeAOT fixture did not produce exactly one independent RTR 9.1 directory.",
|
||||
);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
await writeFile(join(output, "symbols.txt"), symbols);
|
||||
await writeFile(
|
||||
join(output, "oracle.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
sdk: "8.0.416",
|
||||
runtime_version: "8.0.22",
|
||||
runtime,
|
||||
binary,
|
||||
sha256,
|
||||
headers: candidates,
|
||||
expected_strings: [
|
||||
"REA_NATIVEAOT_FROZEN",
|
||||
"REA_NATIVEAOT_BASE",
|
||||
"REA_NATIVEAOT_DERIVED",
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
// A source-built negative input, not a downloaded third-party target.
|
||||
if (process.platform === "linux") {
|
||||
const source = join(output, "ordinary.c");
|
||||
await writeFile(
|
||||
source,
|
||||
"int ordinary_probe(int x) { return x * 3 + 1; }\nint main(void) { return ordinary_probe(1); }\n",
|
||||
);
|
||||
run(process.env.REA_NATIVEAOT_CC ?? "cc", [
|
||||
"-g",
|
||||
"-O0",
|
||||
source,
|
||||
"-o",
|
||||
join(output, "ordinary"),
|
||||
]);
|
||||
// Small source-built producer regressions run in seconds: no executable
|
||||
// patching, large runtime import, or downloaded opaque negative inputs.
|
||||
const row = "struct Row { unsigned type, flags; const char *start, *end; };";
|
||||
const header =
|
||||
"struct Header { unsigned signature; unsigned short major, minor; unsigned flags; unsigned short count; unsigned char size, type; struct Row row; };";
|
||||
for (const kind of ["unsupported", "malformed", "ambiguous"]) {
|
||||
const major = kind === "unsupported" ? 99 : 9;
|
||||
const declaration = `const struct Header __attribute__((aligned(8))) probe = {0x00525452, ${major}, 1, 0, 1, 24, 1, {207, 1, data + 8, data}};`;
|
||||
const extra =
|
||||
kind === "ambiguous" ? declaration.replace("probe", "second_probe") : "";
|
||||
const negative = join(output, `${kind}.c`);
|
||||
await writeFile(
|
||||
negative,
|
||||
`${row}\n${header}\nconst char data[16] = {0};\n${declaration}\n${extra}\nint main(void) { return 0; }\n`,
|
||||
);
|
||||
run(process.env.REA_NATIVEAOT_CC ?? "cc", [
|
||||
"-O0",
|
||||
"-fno-pie",
|
||||
"-no-pie",
|
||||
negative,
|
||||
"-o",
|
||||
join(output, kind),
|
||||
]);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`PASS source-built ${runtime} NativeAOT fixture, independent RTR 9.1 oracle and SHA-256 ${sha256}; no target execution`,
|
||||
);
|
||||
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, isAbsolute, join, resolve } 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 { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { nativeDataTypeSchema } from "../dist/domain/nativeDataType.js";
|
||||
import { nativeLoadImageSchema } from "../dist/domain/nativeLoadImage.js";
|
||||
|
||||
const mode = process.argv[2] ?? "symbols";
|
||||
if (
|
||||
![
|
||||
"symbols",
|
||||
"stripped",
|
||||
"ordinary",
|
||||
"unsupported",
|
||||
"malformed",
|
||||
"ambiguous",
|
||||
"loader-failure",
|
||||
"default-native",
|
||||
].includes(mode) ||
|
||||
process.argv.length > 3
|
||||
)
|
||||
throw new Error(
|
||||
"Usage: verify-real-ghidra-nativeaot.mjs [symbols|stripped|ordinary|unsupported|malformed|ambiguous|loader-failure|default-native]",
|
||||
);
|
||||
if (process.platform !== "linux" || process.arch !== "x64")
|
||||
throw new Error(
|
||||
"verify:ghidra:nativeaot requires a Linux x64 host; Windows host mutation is not supported.",
|
||||
);
|
||||
for (const key of ["loader-failure", "default-native"].includes(mode)
|
||||
? ["GHIDRA_INSTALL_DIR"]
|
||||
: ["GHIDRA_INSTALL_DIR", "REA_GHIDRA_NATIVEAOT_JAR"])
|
||||
if (!process.env[key] || !isAbsolute(process.env[key]))
|
||||
throw new Error(
|
||||
`verify:ghidra:nativeaot prerequisite missing: absolute ${key}`,
|
||||
);
|
||||
const fixtureRoot = resolve(
|
||||
process.env.REA_NATIVEAOT_PROOF_FIXTURE_ROOT ??
|
||||
"_reference/nativeaot-integration/generated/linux-x64",
|
||||
);
|
||||
const oracle = JSON.parse(
|
||||
await readFile(join(fixtureRoot, "oracle.json"), "utf8"),
|
||||
);
|
||||
const symbolTarget = join(
|
||||
fixtureRoot,
|
||||
"symbols",
|
||||
oracle.runtime === "win-x64" ? "NativeAotFixture.exe" : "NativeAotFixture",
|
||||
);
|
||||
const target =
|
||||
mode === "stripped"
|
||||
? join(fixtureRoot, "stripped", basename(symbolTarget))
|
||||
: [
|
||||
"ordinary",
|
||||
"unsupported",
|
||||
"malformed",
|
||||
"ambiguous",
|
||||
"loader-failure",
|
||||
"default-native",
|
||||
].includes(mode)
|
||||
? join(
|
||||
fixtureRoot,
|
||||
["loader-failure", "default-native"].includes(mode)
|
||||
? "ordinary"
|
||||
: mode,
|
||||
)
|
||||
: symbolTarget;
|
||||
const bytes = await readFile(target);
|
||||
const sha256 = hash(bytes);
|
||||
const workspace = await mkdtemp(join(tmpdir(), "rea-nativeaot-proof-"));
|
||||
const runtime = join(workspace, "runtime");
|
||||
await mkdir(runtime);
|
||||
const packageRoot = process.env.REA_NATIVEAOT_PROOF_PACKAGE_ROOT;
|
||||
if (packageRoot !== undefined && !isAbsolute(packageRoot))
|
||||
throw new Error("REA_NATIVEAOT_PROOF_PACKAGE_ROOT must be absolute");
|
||||
const entrypoint =
|
||||
packageRoot === undefined
|
||||
? fileURLToPath(new URL("./rea.mjs", import.meta.url))
|
||||
: join(packageRoot, "scripts/rea.mjs");
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
||||
TMPDIR: runtime,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
REA_ANALYSIS_PROVIDER: "ghidra",
|
||||
GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR,
|
||||
REA_GHIDRA_NATIVEAOT_JAR: process.env.REA_GHIDRA_NATIVEAOT_JAR,
|
||||
GHIDRA_HEADLESS_MAXMEM: process.env.GHIDRA_HEADLESS_MAXMEM ?? "768M",
|
||||
...(process.env.JAVA_HOME === undefined
|
||||
? {}
|
||||
: { JAVA_HOME: process.env.JAVA_HOME }),
|
||||
};
|
||||
if (mode === "default-native") delete env.REA_GHIDRA_NATIVEAOT_JAR;
|
||||
// Source-built linkage failure exercises the actual JVM loader boundary, not
|
||||
// a simulated producer report. Its class/JAR exist only in the owned workspace.
|
||||
if (mode === "loader-failure")
|
||||
env.REA_GHIDRA_NATIVEAOT_JAR = join(workspace, "bad-extension.jar");
|
||||
async function prepareLoaderFailure() {
|
||||
const source = join(workspace, "NativeAotExtension.java");
|
||||
const classes = join(workspace, "classes");
|
||||
await mkdir(classes);
|
||||
await writeFile(
|
||||
source,
|
||||
`package rea.extensions.nativeaot;
|
||||
public final class NativeAotExtension {
|
||||
static { fail(); }
|
||||
private static void fail() { throw new IllegalStateException("REA_INITIALIZER_FIXTURE"); }
|
||||
public NativeAotExtension() {}
|
||||
}
|
||||
`,
|
||||
);
|
||||
const command = (name) =>
|
||||
process.env.JAVA_HOME === undefined
|
||||
? name
|
||||
: join(process.env.JAVA_HOME, "bin", name);
|
||||
await promisify(execFile)(
|
||||
command("javac"),
|
||||
["-J-Xmx512m", "-J-XX:ActiveProcessorCount=1", "-d", classes, source],
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
await promisify(execFile)(
|
||||
command("jar"),
|
||||
["--create", "--file", env.REA_GHIDRA_NATIVEAOT_JAR, "-C", classes, "."],
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
env,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client({ name: "rea-nativeaot-proof", version: "1" });
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = (stderr + chunk.toString()).slice(-65536);
|
||||
});
|
||||
let opened = false;
|
||||
let report;
|
||||
try {
|
||||
if (mode === "loader-failure") await prepareLoaderFailure();
|
||||
await client.connect(transport);
|
||||
const response = await client.callTool(
|
||||
{ name: "open_binary", arguments: { path: target, provider_id: "ghidra" } },
|
||||
{ timeout: 360000 },
|
||||
);
|
||||
if (
|
||||
["unsupported", "malformed", "ambiguous", "loader-failure"].includes(mode)
|
||||
) {
|
||||
// MCP open binds a lazy session; the first inspection starts import.
|
||||
let failure = response;
|
||||
if (failure.isError !== true) {
|
||||
opened = true;
|
||||
failure = await client.callTool(
|
||||
{ name: "inspect_native_load_image", arguments: {} },
|
||||
{ timeout: 360000 },
|
||||
);
|
||||
}
|
||||
assert.equal(failure.isError, true, JSON.stringify(failure));
|
||||
const diagnostic = JSON.stringify(failure);
|
||||
assert.match(
|
||||
diagnostic,
|
||||
mode === "unsupported"
|
||||
? /Unsupported NativeAOT RTR format/
|
||||
: mode === "malformed"
|
||||
? /Reversed NativeAOT section/
|
||||
: mode === "loader-failure"
|
||||
? /ExceptionInInitializerError.*REA_INITIALIZER_FIXTURE/
|
||||
: /Ambiguous NativeAOT directory candidates/,
|
||||
);
|
||||
report = { mode, target, sha256, typed_failure: failure };
|
||||
} else if (mode === "default-native") {
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
opened = true;
|
||||
const image = nativeLoadImageSchema.parse(
|
||||
await call("inspect_native_load_image"),
|
||||
);
|
||||
assert.equal(image.observations.metadata_recovery, undefined);
|
||||
report = { mode, target, sha256, image };
|
||||
} else {
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
opened = true;
|
||||
const image = nativeLoadImageSchema.parse(
|
||||
await call("inspect_native_load_image"),
|
||||
);
|
||||
const summary = image.observations.metadata_recovery?.[0];
|
||||
assert.ok(
|
||||
summary,
|
||||
"Loaded-image inspection omitted inline runtime-format discovery",
|
||||
);
|
||||
assert.equal(summary.format, "dotnet-nativeaot");
|
||||
assert.equal(
|
||||
summary.analysis_artifact_sha256,
|
||||
hash(await readFile(env.REA_GHIDRA_NATIVEAOT_JAR)),
|
||||
);
|
||||
if (mode === "ordinary") {
|
||||
assert.equal(summary.status, "not_applicable");
|
||||
assert.equal(summary.method_tables, 0);
|
||||
assert.deepEqual(summary.types, []);
|
||||
report = { mode, target, sha256, summary };
|
||||
} else {
|
||||
assert.ok(["complete", "partial"].includes(summary.status));
|
||||
assert.equal(summary.format_major, 9);
|
||||
assert.equal(summary.format_minor, 1);
|
||||
assert.equal(summary.types.length, summary.method_tables);
|
||||
assert.ok(summary.method_tables > 0);
|
||||
assert.equal(
|
||||
summary.discovery,
|
||||
mode === "stripped" || oracle.runtime === "win-x64"
|
||||
? "signature-heuristic"
|
||||
: "symbol",
|
||||
);
|
||||
assert.equal(summary.derived_memory.file_offset, null);
|
||||
assert.ok(summary.coverage.frozen_objects_annotated > 0);
|
||||
// Independent compiler/linker symbols identify fixture classes. Recovered
|
||||
// Class_address names are never treated as the original source identities.
|
||||
const symbols = await readFile(join(fixtureRoot, "symbols.txt"), "utf8");
|
||||
const bias =
|
||||
oracle.runtime === "linux-x64"
|
||||
? BigInt(image.observations.image_base)
|
||||
: 0n;
|
||||
const addressOf = (name) => {
|
||||
const line = symbols
|
||||
.split(/\r?\n/u)
|
||||
.find((line) => line.split(/\s+/u).includes(name));
|
||||
assert.ok(line, `Independent symbol missing: ${name}`);
|
||||
const match =
|
||||
oracle.runtime === "linux-x64"
|
||||
? line.match(/^([a-f0-9]+)\s/iu)
|
||||
: line.match(/\s([a-f0-9]{16})\s/iu);
|
||||
assert.ok(match, `Unrecognized independent symbol line: ${line}`);
|
||||
return `0x${(BigInt(`0x${match[1]}`) + bias).toString(16)}`;
|
||||
};
|
||||
const fixtureSymbols =
|
||||
oracle.symbols ??
|
||||
(oracle.runtime === "win-x64"
|
||||
? {
|
||||
base: "??_7NativeAotFixture_BaseProbe@@6B@",
|
||||
derived: "??_7NativeAotFixture_DerivedProbe@@6B@",
|
||||
interface: "??_7NativeAotFixture_IProbe@@6B@",
|
||||
compute: "NativeAotFixture_DerivedProbe__Compute",
|
||||
dispatch: "NativeAotFixture_Program__Dispatch",
|
||||
}
|
||||
: {
|
||||
base: "_ZTV26NativeAotFixture_BaseProbe",
|
||||
derived: "_ZTV29NativeAotFixture_DerivedProbe",
|
||||
interface: "_ZTV23NativeAotFixture_IProbe",
|
||||
compute: "NativeAotFixture_DerivedProbe__Compute",
|
||||
dispatch: "NativeAotFixture_Program__Dispatch",
|
||||
});
|
||||
const lookup = (address) => {
|
||||
const identity = summary.types.find((type) => type.address === address);
|
||||
assert.ok(identity?.type, `Recovered identity missing at ${address}`);
|
||||
return identity.type;
|
||||
};
|
||||
const derivedAddress = addressOf(fixtureSymbols.derived);
|
||||
const baseAddress = addressOf(fixtureSymbols.base);
|
||||
const interfaceAddress = addressOf(fixtureSymbols.interface);
|
||||
const derived = nativeDataTypeSchema.parse(
|
||||
await call("inspect_native_data_type", {
|
||||
type: lookup(derivedAddress),
|
||||
}),
|
||||
);
|
||||
const metadata = derived.metadata_recovery;
|
||||
assert.ok(metadata);
|
||||
assert.equal(metadata.original_name, null);
|
||||
assert.equal(metadata.name_origin, "generated");
|
||||
assert.equal(metadata.method_table_address, derivedAddress);
|
||||
assert.equal(metadata.related_type.address, baseAddress);
|
||||
assert.ok(
|
||||
metadata.interfaces.some((type) => type.address === interfaceAddress),
|
||||
);
|
||||
assert.ok(
|
||||
metadata.virtual_slots.some(
|
||||
(slot) => slot.target_address === addressOf(fixtureSymbols.compute),
|
||||
),
|
||||
);
|
||||
const compute = await call("analyze_function", {
|
||||
procedure: addressOf(fixtureSymbols.compute),
|
||||
});
|
||||
assert.ok(compute.pseudocode.trim());
|
||||
assert.doesNotMatch(
|
||||
compute.pseudocode,
|
||||
/\b__thiscall\b/u,
|
||||
"Upstream x64 calling-convention rewrite leaked into recovered pseudocode",
|
||||
);
|
||||
assert.match(compute.pseudocode, /(?:0xb|\b11\b|<<)/u);
|
||||
const dispatch = await call("analyze_function", {
|
||||
procedure: addressOf(fixtureSymbols.dispatch),
|
||||
});
|
||||
assert.ok(dispatch.pseudocode.trim());
|
||||
for (const literal of oracle.expected_strings)
|
||||
assert.ok(
|
||||
(await call("search_strings", { pattern: literal })).length > 0,
|
||||
`Frozen string missing: ${literal}`,
|
||||
);
|
||||
const observed = await call("read_bytes", {
|
||||
address: metadata.virtual_slots[0].slot_address,
|
||||
length: 8,
|
||||
});
|
||||
assert.equal(observed.complete, true);
|
||||
assert.equal(
|
||||
`0x${Buffer.from(observed.bytes_hex, "hex").readBigUInt64LE().toString(16)}`,
|
||||
metadata.virtual_slots[0].target_address,
|
||||
);
|
||||
report = { mode, target, sha256, summary, derived, compute, dispatch };
|
||||
await call("close_binary");
|
||||
opened = false;
|
||||
// One representative CLI boundary, selected explicitly to avoid repeated
|
||||
// full NativeAOT auto-analysis during each focused iteration.
|
||||
if (process.env.REA_NATIVEAOT_PROOF_CLI === "1") {
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entrypoint,
|
||||
"inspect-native-data-type",
|
||||
target,
|
||||
"--type",
|
||||
lookup(derivedAddress),
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{ env, timeout: 360000, maxBuffer: 16 * 1024 * 1024 },
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(stdout));
|
||||
assert.equal(evidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result.metadata_recovery,
|
||||
metadata,
|
||||
);
|
||||
report.cli_metadata = evidence.normalized_result.metadata_recovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
hash(await readFile(target)),
|
||||
sha256,
|
||||
"Input executable changed",
|
||||
);
|
||||
if (opened) {
|
||||
await call("close_binary");
|
||||
opened = false;
|
||||
}
|
||||
await client.close();
|
||||
assert.deepEqual(
|
||||
await readdir(runtime),
|
||||
[],
|
||||
"Owned runtime/project/extension files leaked after close",
|
||||
);
|
||||
const capture = process.env.REA_NATIVEAOT_PROOF_CAPTURE_DIR;
|
||||
if (capture !== undefined) {
|
||||
assert.ok(isAbsolute(capture));
|
||||
await mkdir(capture, { recursive: true });
|
||||
await writeFile(
|
||||
join(capture, `${oracle.runtime}-${mode}.json`),
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`PASS real Ghidra NativeAOT ${oracle.runtime}/${mode}: MCP metadata, source identity and owned cleanup${report?.compute ? ", independent type/slot oracle and pseudocode" : ""}${report?.cli_metadata ? ", equivalent CLI" : ""}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(stderr);
|
||||
throw error;
|
||||
} finally {
|
||||
if (opened)
|
||||
await client
|
||||
.callTool({ name: "close_binary", arguments: {} })
|
||||
.catch(() => {});
|
||||
await client.close().catch(() => {});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
}
|
||||
function hash(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
async function call(name, args = {}) {
|
||||
const response = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: 360000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
if (name === "close_binary") return response.structuredContent;
|
||||
const evidence = parseEvidence(response.structuredContent.evidence);
|
||||
assert.equal(evidence.subject.digest.sha256, sha256);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
response.structuredContent.result,
|
||||
);
|
||||
return response.structuredContent.result;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
|
||||
metadata:
|
||||
version: "25"
|
||||
tool_count: 125
|
||||
catalog_digest: "297aced29e7ce5e845c505d5f2e9e2dee42b7ed3e628f088f424fe8088c9e8dc"
|
||||
catalog_digest: "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b"
|
||||
---
|
||||
|
||||
# REA
|
||||
@@ -89,6 +89,9 @@ explicit path or endpoint and do not need it.
|
||||
`extract_firmware` when extraction is requested, with a caller-selected new
|
||||
absolute output directory. These tools use caller-supplied Binwalk/Unblob on
|
||||
Linux; see the [firmware guide](https://github.com/morluto/rea/blob/main/docs/firmware-analysis.md).
|
||||
- .NET NativeAOT PE/ELF: native Ghidra analysis. Native code can yield pseudocode;
|
||||
consult the NativeAOT workflow in `references/native-and-artifacts.md` for
|
||||
optional metadata recovery and its supported host/layout boundary.
|
||||
- User-owned browser page already open: `list_browser_targets`.
|
||||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Native executable, library, or analysis database: `open_binary`, then
|
||||
|
||||
@@ -37,6 +37,30 @@ assembly. Keep managed/native boundaries and unavailable reconstruction facts
|
||||
explicit. A bring-your-own reconstruction oracle is separate from the canonical
|
||||
parser and must not become an implicit setup dependency.
|
||||
|
||||
## .NET NativeAOT
|
||||
|
||||
A NativeAOT PE/ELF contains native code. Absence of PE/CLI metadata does not mean
|
||||
analysis must stop at assembly. Use native function analysis for recovered
|
||||
pseudocode; it is not reconstructed original C#.
|
||||
|
||||
With an explicitly configured NativeAOT Ghidra adapter, call
|
||||
`inspect_native_load_image` after opening the target. Its optional
|
||||
`observations.metadata_recovery` returns the format, positive header evidence,
|
||||
coverage, derived-memory identity and type addresses/category paths inline.
|
||||
Then inspect a reported type with `inspect_native_data_type`, follow its
|
||||
`metadata_recovery.related_type`, interfaces and virtual slot addresses, and use
|
||||
`analyze_function` plus string/xref searches for implementation evidence. Keep
|
||||
`not_applicable`, partial recovery and unsupported layouts explicit. Generated
|
||||
`Class_address` names and inferred System.Object/String identities are not
|
||||
original names; custom field layouts remain unknown.
|
||||
|
||||
Recovery modifies an ephemeral analysis database and may replace loaded metadata
|
||||
bytes with derived content. Original executable files remain unchanged. Distinguish
|
||||
original file offsets from derived analysis memory. Windows x64 PE **targets**
|
||||
can be analyzed on a supported Linux host; Windows **host** P0 does not admit
|
||||
this database recovery. See the [NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
|
||||
for the tested layout and optional bring-your-own adapter build.
|
||||
|
||||
## Packages and extraction
|
||||
|
||||
Use `inspect_artifact` for application bundles, archives, ZIP/APK/IPA/MSIX/AppX,
|
||||
|
||||
@@ -17,6 +17,11 @@ const environmentSchema = z.object({
|
||||
.min(1)
|
||||
.refine(isAbsolute, "JAVA_HOME must be absolute")
|
||||
.optional(),
|
||||
REA_GHIDRA_NATIVEAOT_JAR: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
|
||||
.optional(),
|
||||
REA_ILSPY_CMD_PATH: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
||||
@@ -27,6 +27,9 @@ export const parseConfig = (
|
||||
analysisProvider: env.REA_ANALYSIS_PROVIDER,
|
||||
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
|
||||
ghidraJavaHome: env.JAVA_HOME,
|
||||
...(env.REA_GHIDRA_NATIVEAOT_JAR === undefined
|
||||
? {}
|
||||
: { ghidraNativeAotJar: env.REA_GHIDRA_NATIVEAOT_JAR }),
|
||||
ilspyCmdPath: env.REA_ILSPY_CMD_PATH,
|
||||
hopperLauncherPath: env.HOPPER_LAUNCHER_PATH ?? defaultHopperLauncherPath(),
|
||||
hopperTargetPath: env.HOPPER_TARGET_PATH,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AppConfig {
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
readonly ghidraInstallDir: string | undefined;
|
||||
readonly ghidraJavaHome: string | undefined;
|
||||
readonly ghidraNativeAotJar?: string;
|
||||
readonly ilspyCmdPath: string | undefined;
|
||||
readonly hopperLauncherPath: string;
|
||||
readonly hopperTargetPath: string | undefined;
|
||||
|
||||
@@ -199,7 +199,14 @@ describe("analysis error projection: caller contract", () => {
|
||||
"capability_unavailable",
|
||||
"target_unavailable",
|
||||
]);
|
||||
expect(projected[1]).toMatchObject({ category: "unsupported_provider" });
|
||||
expect(projected[1]).toMatchObject({
|
||||
category: "unsupported_provider",
|
||||
details: {
|
||||
provider_id: "fixture",
|
||||
operation: "overview",
|
||||
reason: "absent",
|
||||
},
|
||||
});
|
||||
expect(projected[2]).toMatchObject({
|
||||
details: { path: "/local/targets/app" },
|
||||
});
|
||||
|
||||
@@ -227,7 +227,11 @@ const providerErrorDetails = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, JsonValue>> | undefined => {
|
||||
if (error instanceof AnalysisCapabilityUnavailableError)
|
||||
return { provider_id: error.providerId, operation: error.operation };
|
||||
return {
|
||||
provider_id: error.providerId,
|
||||
operation: error.operation,
|
||||
reason: error.reason,
|
||||
};
|
||||
if (error instanceof ProviderSelectionError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { nativeMetadataRecoverySchema } from "./nativeMetadataRecovery.js";
|
||||
|
||||
/** Select a database type by exact category path or a defined typed data address. */
|
||||
export const nativeDataTypeInputSchema = z
|
||||
@@ -54,4 +55,5 @@ export const nativeDataTypeSchema = z.strictObject({
|
||||
total_fields: z.number().int().nonnegative(),
|
||||
truncated: z.boolean(),
|
||||
limitations: z.array(z.string()),
|
||||
metadata_recovery: nativeMetadataRecoverySchema.optional(),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
|
||||
@@ -8,6 +9,7 @@ const address = z.string().min(1);
|
||||
|
||||
/** Measured provider image, without exporting the complete executable bytes. */
|
||||
export const nativeLoadImageObservationSchema = z.strictObject({
|
||||
metadata_recovery: z.array(nativeMetadataRecoverySummarySchema).optional(),
|
||||
executable_format: z.string().min(1),
|
||||
language_id: z.string().min(1),
|
||||
compiler_spec_id: z.string().min(1),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
nativeMetadataRecoverySchema,
|
||||
nativeMetadataRecoverySummarySchema,
|
||||
} from "./nativeMetadataRecovery.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const metadata = {
|
||||
format: "dotnet-nativeaot",
|
||||
status: "partial",
|
||||
header_address: "0x401000",
|
||||
format_major: 9,
|
||||
format_minor: 1,
|
||||
method_table_address: "0x402000",
|
||||
name_origin: "generated",
|
||||
original_name: null,
|
||||
base_size_bytes: 24,
|
||||
related_type: { address: "0x403000", type: null },
|
||||
interfaces: [],
|
||||
virtual_slots: [
|
||||
{
|
||||
slot: 0,
|
||||
slot_address: "0x402018",
|
||||
target_address: null,
|
||||
procedure_name: null,
|
||||
basis: "method-table-pointer",
|
||||
},
|
||||
],
|
||||
derived_memory: {
|
||||
address: "0x402000",
|
||||
size_bytes: 128,
|
||||
sha256: "a".repeat(64),
|
||||
file_offset: null,
|
||||
},
|
||||
diagnostics: ["candidate annotation missing"],
|
||||
limitations: ["Names are generated; original fields unknown"],
|
||||
};
|
||||
describe("NativeAOT recovery evidence", () => {
|
||||
it("preserves generated identities, unresolved relationships and null virtual targets", () => {
|
||||
expect(
|
||||
jsonValueSchema.parse(nativeMetadataRecoverySchema.parse(metadata)),
|
||||
).toEqual(metadata);
|
||||
});
|
||||
it("rejects invented source authority and original file offsets for derived memory", () => {
|
||||
expect(
|
||||
nativeMetadataRecoverySchema.safeParse({
|
||||
...metadata,
|
||||
original_name: "InventedClass",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
nativeMetadataRecoverySchema.safeParse({
|
||||
...metadata,
|
||||
derived_memory: { ...metadata.derived_memory, file_offset: 128 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
it("keeps a negative discovery explicit without fabricating a header", () => {
|
||||
const summary = {
|
||||
format: "dotnet-nativeaot",
|
||||
status: "not_applicable",
|
||||
reason: "No supported row layout found",
|
||||
analysis_artifact_sha256: "b".repeat(64),
|
||||
header_address: null,
|
||||
discovery: null,
|
||||
format_major: null,
|
||||
format_minor: null,
|
||||
method_tables: 0,
|
||||
types: [],
|
||||
derived_memory: null,
|
||||
coverage: null,
|
||||
diagnostics: [],
|
||||
limitations: [],
|
||||
};
|
||||
expect(nativeMetadataRecoverySummarySchema.parse(summary)).toEqual(summary);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const address = z.string().regex(/^0x[0-9a-f]+$/u);
|
||||
const relatedType = z.strictObject({ address, type: z.string().nullable() });
|
||||
|
||||
/** Runtime-format metadata recovered in an analysis image, with explicit inference boundaries. */
|
||||
export const nativeMetadataRecoverySchema = z.strictObject({
|
||||
format: z.literal("dotnet-nativeaot"),
|
||||
status: z.enum(["complete", "partial"]),
|
||||
header_address: address,
|
||||
format_major: z.number().int().nonnegative(),
|
||||
format_minor: z.number().int().nonnegative(),
|
||||
method_table_address: address,
|
||||
name_origin: z.enum(["generated", "inferred"]),
|
||||
original_name: z.null(),
|
||||
base_size_bytes: z.number().int().nonnegative(),
|
||||
related_type: relatedType.nullable(),
|
||||
interfaces: z.array(relatedType),
|
||||
virtual_slots: z.array(
|
||||
z.strictObject({
|
||||
slot: z.number().int().nonnegative(),
|
||||
slot_address: address,
|
||||
target_address: address.nullable(),
|
||||
procedure_name: z.string().nullable(),
|
||||
basis: z.literal("method-table-pointer"),
|
||||
}),
|
||||
),
|
||||
derived_memory: z.strictObject({
|
||||
address,
|
||||
size_bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
file_offset: z.null(),
|
||||
}),
|
||||
diagnostics: z.array(z.string()),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Discover a recovered runtime format and reusable type identities from the loaded image. */
|
||||
export const nativeMetadataRecoverySummarySchema = z.strictObject({
|
||||
format: z.literal("dotnet-nativeaot"),
|
||||
status: z.enum(["complete", "partial", "not_applicable"]),
|
||||
reason: z.string().nullable(),
|
||||
analysis_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
header_address: address.nullable(),
|
||||
discovery: z.enum(["symbol", "signature-heuristic"]).nullable(),
|
||||
format_major: z.number().int().nonnegative().nullable(),
|
||||
format_minor: z.number().int().nonnegative().nullable(),
|
||||
method_tables: z.number().int().nonnegative(),
|
||||
types: z.array(relatedType),
|
||||
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory.nullable(),
|
||||
coverage: z
|
||||
.strictObject({
|
||||
frozen_object_candidates: z.number().int().nonnegative(),
|
||||
frozen_objects_annotated: z.number().int().nonnegative(),
|
||||
basis: z.literal(
|
||||
"rehydrated-pointer-candidates-and-committed-instance-types",
|
||||
),
|
||||
})
|
||||
.nullable(),
|
||||
diagnostics: z.array(z.string()),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -19,7 +19,11 @@ import {
|
||||
functionDossierSchema,
|
||||
type FunctionDossier,
|
||||
} from "../domain/hopperValues.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "../domain/nativeValueFlow.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -386,7 +390,7 @@ export const parseGhidraFunctionResult = (
|
||||
): Result<JsonValue, AnalysisOutputError> => {
|
||||
const parsed = resultSchemas[operation].safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
? ok(jsonValueSchema.parse(parsed.data))
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
functionBodySchema,
|
||||
functionBodyEntryAgrees,
|
||||
} from "../domain/hopperValues.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Read-only direct inventory operations admitted by the Ghidra adapter. */
|
||||
@@ -274,7 +274,7 @@ export const parseGhidraInventoryResult = (
|
||||
): Result<JsonValue, AnalysisOutputError> => {
|
||||
const parsed = resultSchemas[operation].safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
? ok(jsonValueSchema.parse(parsed.data))
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { ghidraJavaEnvironment } from "./GhidraInstallation.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
import {
|
||||
snapshotGhidraExtensions,
|
||||
type GhidraExtension,
|
||||
} from "./extensions/GhidraExtensions.js";
|
||||
|
||||
/** Private paths and identity material for one headless Ghidra import. */
|
||||
export interface GhidraLaunchSession {
|
||||
@@ -66,6 +70,7 @@ export interface GhidraHeadlessLauncherOptions {
|
||||
/** Select the admitted 16-bit real-mode MZ import instead of auto-detection. */
|
||||
readonly dosMz?: true;
|
||||
readonly dosCom?: true;
|
||||
readonly analysisExtensions?: readonly GhidraExtension[];
|
||||
}
|
||||
|
||||
/** Launch Ghidra without copying scripts into or modifying its installation. */
|
||||
@@ -83,6 +88,10 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
let started: SpawnedOwnedProviderProcess | undefined;
|
||||
try {
|
||||
await createGhidraRuntimeDirectories(paths, platform);
|
||||
const extensions = await snapshotGhidraExtensions(
|
||||
this.options.analysisExtensions ?? [],
|
||||
session.runtimeRoot,
|
||||
);
|
||||
await writeGhidraRuntimeFile(
|
||||
paths.descriptorPath,
|
||||
`${JSON.stringify({
|
||||
@@ -93,6 +102,9 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
target_sha256: session.targetSha256,
|
||||
provider_version: session.providerVersion,
|
||||
profile_digest: session.profileDigest,
|
||||
...(extensions.length === 0
|
||||
? {}
|
||||
: { analysis_extensions: extensions }),
|
||||
})}\n`,
|
||||
platform,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
// Fake-backed provider coverage; real Ghidra verification lives in
|
||||
// `npm run verify:ghidra` and its focused variants.
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
@@ -688,3 +692,102 @@ const peTarget = (
|
||||
executableRole: "application",
|
||||
managed: false,
|
||||
});
|
||||
|
||||
describe("Ghidra extension failures", () => {
|
||||
it.each(["unsupported", "failed", "malformed"] as const)(
|
||||
"preserves %s recovery diagnostics and closes without restarting",
|
||||
async (status) => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "rea-provider-extension-"),
|
||||
);
|
||||
try {
|
||||
const jar = join(directory, "addon.jar");
|
||||
await writeFile(jar, Buffer.from([0x50, 0x4b, 3, 4]));
|
||||
const config = parseConfig({
|
||||
GHIDRA_INSTALL_DIR: INSTALL,
|
||||
REA_GHIDRA_NATIVEAOT_JAR: jar,
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
let starts = 0,
|
||||
calls = 0,
|
||||
closes = 0;
|
||||
const ghidra = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
installationHost(),
|
||||
() => ({
|
||||
start: () => {
|
||||
starts++;
|
||||
return Promise.resolve(
|
||||
ok({
|
||||
...sessionInfo(),
|
||||
analysis_extensions: [
|
||||
{
|
||||
id: "nativeaot",
|
||||
sha256:
|
||||
status === "malformed"
|
||||
? "0".repeat(64)
|
||||
: createHash("sha256")
|
||||
.update(Buffer.from([0x50, 0x4b, 3, 4]))
|
||||
.digest("hex"),
|
||||
status: status === "malformed" ? "failed" : status,
|
||||
reason:
|
||||
"directory-discovery: Unsupported layout at 0x401000",
|
||||
result: {
|
||||
id: "nativeaot",
|
||||
integration_api: 1,
|
||||
source_revision:
|
||||
"effeb734fc570c32650f88b159608979dc7b423e",
|
||||
source_revision_authority: "build-reported-unattested",
|
||||
status: status === "malformed" ? "failed" : status,
|
||||
reason:
|
||||
"directory-discovery: Unsupported layout at 0x401000",
|
||||
method_tables: 0,
|
||||
diagnostics: ["exact producer diagnostic"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
callTool: () => {
|
||||
calls++;
|
||||
return Promise.resolve(ok(null));
|
||||
},
|
||||
close: () => {
|
||||
closes++;
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const target = executableTarget("elf", "x86_64");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("expected extension profile");
|
||||
const client = ghidra.createClient(target, resolved.value.profile);
|
||||
const failed = await client.execute("health", {});
|
||||
expect(failed.ok).toBe(false);
|
||||
if (!failed.ok) {
|
||||
expect(failed.error._tag).toBe(
|
||||
status === "unsupported"
|
||||
? "AnalysisCapabilityUnavailableError"
|
||||
: "ProviderAdapterError",
|
||||
);
|
||||
expect(JSON.stringify(failed.error)).toContain(
|
||||
status === "malformed"
|
||||
? "identity mismatch"
|
||||
: "Unsupported layout at 0x401000",
|
||||
);
|
||||
}
|
||||
expect(await client.execute("list_procedures", {})).toEqual(failed);
|
||||
expect({ starts, calls, closes }).toEqual({
|
||||
starts: 1,
|
||||
calls: 0,
|
||||
closes: 1,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ghidraExtensionFailure } from "./extensions/GhidraExtensionFailures.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
@@ -60,6 +62,13 @@ import {
|
||||
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
||||
import { resolveGhidraAnalysisProfile } from "./GhidraAnalysisProfile.js";
|
||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import type { GhidraSessionInfo } from "./GhidraSessionValues.js";
|
||||
import {
|
||||
ghidraExtensionSchema,
|
||||
resolveGhidraExtensions,
|
||||
validateGhidraExtensionProfile,
|
||||
ghidraExtensionLimitations,
|
||||
} from "./extensions/GhidraExtensions.js";
|
||||
import {
|
||||
CAPABILITIES,
|
||||
windowsP0Capabilities,
|
||||
@@ -188,16 +197,33 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
};
|
||||
}
|
||||
|
||||
resolveAnalysisProfile(
|
||||
async resolveAnalysisProfile(
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
return resolveGhidraAnalysisProfile(
|
||||
const installation = this.#inspectInstallation();
|
||||
const resolved = await resolveGhidraAnalysisProfile(
|
||||
target,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
this.#inspectInstallation(),
|
||||
installation,
|
||||
options?.signal,
|
||||
);
|
||||
if (!resolved.ok || resolved.value.profile === null) return resolved;
|
||||
const extensions = await resolveGhidraExtensions(
|
||||
this.config,
|
||||
target,
|
||||
installation.platform,
|
||||
options?.signal,
|
||||
);
|
||||
if (!extensions.ok) return extensions;
|
||||
if (extensions.value.length === 0) return resolved;
|
||||
return ok({
|
||||
...resolved.value,
|
||||
profile: createAnalysisProfile(resolved.value.profile.provider, {
|
||||
...resolved.value.profile.parameters,
|
||||
analysis_extensions: jsonValueSchema.parse(extensions.value),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
createClient(
|
||||
@@ -213,6 +239,36 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
);
|
||||
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
|
||||
const committedProfile = prerequisites.value.profile;
|
||||
const extensionProfile = ghidraExtensionSchema
|
||||
.array()
|
||||
.safeParse(committedProfile.parameters.analysis_extensions ?? []);
|
||||
if (
|
||||
!extensionProfile.success ||
|
||||
(this.config.ghidraNativeAotJar !== undefined &&
|
||||
extensionProfile.data.length === 0)
|
||||
)
|
||||
return unavailableClient(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"Resolve the configured Ghidra extensions into an analysis profile before opening the session.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const extensions = extensionProfile.data;
|
||||
const invalidProfile = validateGhidraExtensionProfile(
|
||||
extensions,
|
||||
this.config,
|
||||
target,
|
||||
installation.platform,
|
||||
);
|
||||
if (invalidProfile !== null)
|
||||
return unavailableClient(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: { reason: invalidProfile },
|
||||
}),
|
||||
);
|
||||
let extensionFailure: AnalysisError | undefined;
|
||||
const targetLimitations =
|
||||
target.format === "dos-mz"
|
||||
? [
|
||||
@@ -241,6 +297,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
...(target.format === "dos-mz" ? { dosMz: true } : {}),
|
||||
...(target.format === "dos-com" ? { dosCom: true } : {}),
|
||||
platform: installation.platform,
|
||||
...(extensions.length === 0 ? {} : { analysisExtensions: extensions }),
|
||||
}),
|
||||
targetPath:
|
||||
installation.platform === "win32"
|
||||
@@ -262,8 +319,22 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
...(context === undefined ? {} : { runId: context.runId }),
|
||||
logger: this.logger.child({ layer: "ghidra-bridge" }),
|
||||
});
|
||||
const checkExtensions = async (
|
||||
operation: AnalysisOperation,
|
||||
info: GhidraSessionInfo,
|
||||
): Promise<AnalysisError | undefined> => {
|
||||
extensionFailure = ghidraExtensionFailure(
|
||||
extensions,
|
||||
info.analysis_extensions ?? [],
|
||||
operation,
|
||||
);
|
||||
if (extensionFailure === undefined) return undefined;
|
||||
await client.close();
|
||||
return extensionFailure;
|
||||
};
|
||||
return {
|
||||
execute: async (operation, parameters, options) => {
|
||||
if (extensionFailure !== undefined) return err(extensionFailure);
|
||||
if (
|
||||
operation !== "health" &&
|
||||
!isGhidraInventoryOperation(operation) &&
|
||||
@@ -280,6 +351,8 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
const started = await client.start(options?.signal);
|
||||
if (!started.ok)
|
||||
return err(projectSessionError(operation, started.error));
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
return ok(
|
||||
createAnalysisExecution(started.value, committedProfile.provider, {
|
||||
analysisProfile: committedProfile,
|
||||
@@ -287,6 +360,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
...healthLimitations,
|
||||
...providerLimitations,
|
||||
...targetLimitations,
|
||||
...ghidraExtensionLimitations(extensions),
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -295,6 +369,13 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
? parseGhidraFunctionInput(operation, parameters)
|
||||
: parseGhidraInventoryInput(operation, parameters);
|
||||
if (!input.ok) return input;
|
||||
if (extensions.length > 0) {
|
||||
const started = await client.start(options?.signal);
|
||||
if (!started.ok)
|
||||
return err(projectSessionError(operation, started.error));
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
}
|
||||
const called = await client.callTool(
|
||||
operation,
|
||||
input.value,
|
||||
@@ -358,6 +439,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
...limitationsFor(operation),
|
||||
...providerLimitations,
|
||||
...targetLimitations,
|
||||
...ghidraExtensionLimitations(extensions),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
|
||||
import { GHIDRA_FUNCTION_OPERATIONS } from "./GhidraFunctionValues.js";
|
||||
import { ghidraExtensionResultSchema } from "./extensions/GhidraExtensions.js";
|
||||
|
||||
/** Exact methods proved by the bridge handshake after auto-analysis. */
|
||||
export const GHIDRA_SESSION_CAPABILITIES = [
|
||||
@@ -29,6 +30,7 @@ const sessionInfoSchema = z
|
||||
read_only: z.boolean(),
|
||||
analysis_complete: z.boolean(),
|
||||
analysis_timed_out: z.boolean(),
|
||||
analysis_extensions: z.array(ghidraExtensionResultSchema).optional(),
|
||||
capabilities: z.array(capabilitySchema),
|
||||
target: z
|
||||
.object({
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import {
|
||||
validateGhidraExtensionResults,
|
||||
type GhidraExtension,
|
||||
type GhidraExtensionResult,
|
||||
} from "./GhidraExtensions.js";
|
||||
|
||||
/** Preserve unsupported recovery separately from malformed or failed producers. */
|
||||
export const ghidraExtensionFailure = (
|
||||
extensions: readonly GhidraExtension[],
|
||||
results: readonly GhidraExtensionResult[],
|
||||
operation: string,
|
||||
): AnalysisError | undefined => {
|
||||
const reports = results;
|
||||
const invalid = validateGhidraExtensionResults(extensions, reports);
|
||||
const failed = reports.find(
|
||||
(value) => value.status === "unsupported" || value.status === "failed",
|
||||
);
|
||||
if (invalid === null && failed === undefined) return undefined;
|
||||
return invalid === null && failed?.status === "unsupported"
|
||||
? new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
operation,
|
||||
`${failed.id}: ${failed.reason}. Omit REA_GHIDRA_NATIVEAOT_JAR to continue ordinary native analysis.`,
|
||||
)
|
||||
: new ProviderAdapterError("ghidra", operation, {
|
||||
diagnostics: {
|
||||
reason: invalid ?? failed?.reason ?? "Ghidra extension failed",
|
||||
analysis_extensions: jsonValueSchema.parse(reports),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,414 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { constants } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, open, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parseConfig } from "../../config.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import {
|
||||
ghidraExtensionSchema,
|
||||
resolveGhidraExtensions,
|
||||
snapshotGhidraExtensions,
|
||||
validateGhidraExtensionResults,
|
||||
validateGhidraExtensionProfile,
|
||||
type GhidraExtensionResult,
|
||||
} from "./GhidraExtensions.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((path) => rm(path, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
const root = async () => {
|
||||
const path = await mkdtemp(join(tmpdir(), "rea-extension-test-"));
|
||||
roots.push(path);
|
||||
return path;
|
||||
};
|
||||
const target: BinaryTarget = {
|
||||
path: "/tmp/native",
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
const config = (path?: string) => {
|
||||
const parsed = parseConfig(
|
||||
path === undefined ? {} : { REA_GHIDRA_NATIVEAOT_JAR: path },
|
||||
);
|
||||
if (!parsed.ok) throw parsed.error;
|
||||
return parsed.value;
|
||||
};
|
||||
const artifact = async () => {
|
||||
const directory = await root();
|
||||
const path = join(directory, "fixture.jar");
|
||||
const bytes = Buffer.from([0x50, 0x4b, 3, 4, 0, 1, 2, 3]);
|
||||
await writeFile(path, bytes);
|
||||
const resolved = await resolveGhidraExtensions(config(path), target, "linux");
|
||||
if (!resolved.ok || resolved.value[0] === undefined)
|
||||
throw new Error("expected fixture artifact");
|
||||
return { extension: resolved.value[0], bytes, path };
|
||||
};
|
||||
const result = (sha256: string): GhidraExtensionResult => ({
|
||||
id: "nativeaot",
|
||||
sha256,
|
||||
status: "not_applicable",
|
||||
reason: "No matching directory",
|
||||
result: {
|
||||
id: "nativeaot",
|
||||
integration_api: 1,
|
||||
source_revision: "effeb734fc570c32650f88b159608979dc7b423e",
|
||||
source_revision_authority: "build-reported-unattested",
|
||||
status: "not_applicable",
|
||||
reason: "No matching directory",
|
||||
method_tables: 0,
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
describe("optional Ghidra extension boundary", () => {
|
||||
it("keeps ordinary profiles free of unconfigured extensions", async () => {
|
||||
expect(await resolveGhidraExtensions(config(), target, "linux")).toEqual({
|
||||
ok: true,
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
it("validates configuration paths rather than ignoring an unsupported value", () => {
|
||||
expect(parseConfig({ REA_GHIDRA_NATIVEAOT_JAR: "relative.jar" }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("commits the actual regular artifact bytes and caller path", async () => {
|
||||
const { extension, bytes, path } = await artifact();
|
||||
expect(extension).toMatchObject({
|
||||
configured_path: path,
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
integration_api: 1,
|
||||
});
|
||||
});
|
||||
it("snapshots identical bytes exclusively into the private runtime", async () => {
|
||||
const { extension, bytes } = await artifact();
|
||||
const runtime = await root();
|
||||
const snapshots = await snapshotGhidraExtensions([extension], runtime);
|
||||
expect(snapshots[0]?.path).toBe(join(runtime, "extensions/nativeaot.jar"));
|
||||
expect(await readFile(join(runtime, "extensions/nativeaot.jar"))).toEqual(
|
||||
bytes,
|
||||
);
|
||||
await expect(
|
||||
snapshotGhidraExtensions([extension], runtime),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
it("rejects artifact changes after the profile commitment", async () => {
|
||||
const { extension, path } = await artifact();
|
||||
await writeFile(path, Buffer.from([0x50, 0x4b, 3, 4, 9, 9, 9, 9]));
|
||||
await expect(
|
||||
snapshotGhidraExtensions([extension], await root()),
|
||||
).rejects.toThrow("digest differs");
|
||||
});
|
||||
it("rejects nonexistent, empty and non-JAR artifacts with useful reasons", async () => {
|
||||
const directory = await root();
|
||||
for (const bytes of [Buffer.alloc(0), Buffer.from("invalid jar")]) {
|
||||
const path = join(directory, "invalid.jar");
|
||||
await writeFile(path, bytes);
|
||||
const resolved = await resolveGhidraExtensions(
|
||||
config(path),
|
||||
target,
|
||||
"linux",
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toMatchObject({
|
||||
diagnostics: { path, extension: "nativeaot" },
|
||||
});
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await resolveGhidraExtensions(
|
||||
config(join(directory, "absent.jar")),
|
||||
target,
|
||||
"linux",
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
it("rejects unsupported hosts and target architectures before opening files", async () => {
|
||||
expect(
|
||||
(await resolveGhidraExtensions(config("/absent.jar"), target, "win32"))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await resolveGhidraExtensions(
|
||||
config("/absent.jar"),
|
||||
{
|
||||
...target,
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
},
|
||||
"linux",
|
||||
)
|
||||
).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects a FIFO without requiring another process to open its writer",
|
||||
async () => {
|
||||
const path = join(await root(), "not-a-jar.fifo");
|
||||
await promisify(execFile)("mkfifo", [path]);
|
||||
let writerRequired = false;
|
||||
let rescueTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Release a broken blocking-open implementation so regression failures
|
||||
// do not strand a libuv filesystem worker. Success needs no FIFO peer.
|
||||
const rescue = new Promise<void>((resolve) => {
|
||||
rescueTimer = setTimeout(() => {
|
||||
writerRequired = true;
|
||||
void open(path, constants.O_WRONLY | constants.O_NONBLOCK)
|
||||
.then((file) => file.close())
|
||||
.then(resolve, () => resolve());
|
||||
}, 1000);
|
||||
});
|
||||
try {
|
||||
const resolved = await resolveGhidraExtensions(
|
||||
config(path),
|
||||
target,
|
||||
"linux",
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toMatchObject({
|
||||
diagnostics: { reason: expect.stringContaining("regular JAR") },
|
||||
});
|
||||
expect(writerRequired).toBe(false);
|
||||
} finally {
|
||||
clearTimeout(rescueTimer);
|
||||
if (writerRequired) await rescue;
|
||||
}
|
||||
},
|
||||
);
|
||||
it("honors cancellation before artifact I/O", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const resolved = await resolveGhidraExtensions(
|
||||
config("/absent.jar"),
|
||||
target,
|
||||
"linux",
|
||||
controller.signal,
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error._tag).toBe("AnalysisCancelledError");
|
||||
});
|
||||
it("refuses traversal identities in an imported extension profile", () => {
|
||||
expect(
|
||||
ghidraExtensionSchema.safeParse({
|
||||
id: "../escape",
|
||||
path: "/fixture.jar",
|
||||
configured_path: "/fixture.jar",
|
||||
sha256: "a".repeat(64),
|
||||
integration_api: 1,
|
||||
entry_class: "ignored",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra extension profile and producer validation", () => {
|
||||
it("requires exact inventory, artifact digest and consistent producer status", async () => {
|
||||
const { extension } = await artifact();
|
||||
const valid = result(extension.sha256);
|
||||
expect(validateGhidraExtensionResults([extension], [valid])).toBeNull();
|
||||
expect(validateGhidraExtensionResults([extension], [])).not.toBeNull();
|
||||
expect(validateGhidraExtensionResults([], [valid])).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionResults([extension], [valid, valid]),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[{ ...valid, sha256: "b".repeat(64) }],
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[{ ...valid, status: "complete" }],
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
it("requires matching explicit configuration for imported executable profiles", async () => {
|
||||
const { extension, path } = await artifact();
|
||||
expect(
|
||||
validateGhidraExtensionProfile(
|
||||
[extension],
|
||||
config(path),
|
||||
target,
|
||||
"linux",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionProfile([extension], config(), target, "linux"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionProfile(
|
||||
[extension],
|
||||
config(path),
|
||||
target,
|
||||
"darwin",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionProfile(
|
||||
[{ ...extension, entry_class: "arbitrary.Code" }],
|
||||
config(path),
|
||||
target,
|
||||
"linux",
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionProfile(
|
||||
[{ ...extension, configured_path: "/another.jar" }],
|
||||
config(path),
|
||||
target,
|
||||
"linux",
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
it("does not classify PE/CLI or ReadyToRun as NativeAOT", async () => {
|
||||
const resolved = await resolveGhidraExtensions(
|
||||
config("/absent.jar"),
|
||||
{ ...target, format: "pe", managed: true, executableRole: "application" },
|
||||
"linux",
|
||||
);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok)
|
||||
expect(resolved.error).toMatchObject({
|
||||
diagnostics: {
|
||||
reason: expect.stringContaining("inspect_managed_artifact"),
|
||||
},
|
||||
});
|
||||
});
|
||||
it("does not substitute another artifact under an unchanged configured path", async () => {
|
||||
const first = await artifact();
|
||||
const second = await artifact();
|
||||
await expect(
|
||||
snapshotGhidraExtensions(
|
||||
[{ ...second.extension, configured_path: first.path }],
|
||||
await root(),
|
||||
),
|
||||
).rejects.toThrow("resolved path differs");
|
||||
});
|
||||
it("rejects unregistered descriptors before snapshot filesystem writes", async () => {
|
||||
const { extension } = await artifact();
|
||||
const runtime = await root();
|
||||
await expect(
|
||||
snapshotGhidraExtensions([{ ...extension, id: "unknown" }], runtime),
|
||||
).rejects.toThrow("Unregistered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra extension recovery coverage", () => {
|
||||
it("validates positive recovery, measured partial coverage and distinct table identities", async () => {
|
||||
const { extension } = await artifact();
|
||||
const baseline = result(extension.sha256);
|
||||
const positive: GhidraExtensionResult = {
|
||||
...baseline,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
result: {
|
||||
...baseline.result,
|
||||
status: "complete",
|
||||
reason: null,
|
||||
method_tables: 1,
|
||||
header_address: "0x401000",
|
||||
discovery: "signature-heuristic",
|
||||
format_major: 9,
|
||||
format_minor: 1,
|
||||
types: [
|
||||
{
|
||||
address: "0x402000",
|
||||
type: "/NativeAOT/MethodTables/Class_00402000_MT",
|
||||
},
|
||||
],
|
||||
derived_memory: {
|
||||
address: "0x402000",
|
||||
size_bytes: 64,
|
||||
sha256: "a".repeat(64),
|
||||
file_offset: null,
|
||||
},
|
||||
coverage: {
|
||||
frozen_object_candidates: 1,
|
||||
frozen_objects_annotated: 1,
|
||||
basis: "rehydrated-pointer-candidates-and-committed-instance-types",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validateGhidraExtensionResults([extension], [positive])).toBeNull();
|
||||
const incomplete = {
|
||||
...positive,
|
||||
result: {
|
||||
...positive.result,
|
||||
coverage: {
|
||||
frozen_object_candidates: 2,
|
||||
frozen_objects_annotated: 1,
|
||||
basis: "rehydrated-pointer-candidates-and-committed-instance-types",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(
|
||||
validateGhidraExtensionResults([extension], [incomplete]),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[
|
||||
{
|
||||
...incomplete,
|
||||
status: "partial",
|
||||
result: { ...incomplete.result, status: "partial" },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[{ ...positive, result: { ...positive.result, discovery: null } }],
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
it("preserves real loader failures without requiring a successful analyzer report", async () => {
|
||||
const { extension } = await artifact();
|
||||
const reason = "Extension loading/analysis failed: ClassNotFoundException";
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[
|
||||
{
|
||||
id: "nativeaot",
|
||||
sha256: extension.sha256,
|
||||
status: "failed",
|
||||
reason,
|
||||
result: { loader_failure: reason },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
it("does not accept complete recovery without format and byte identities", async () => {
|
||||
const { extension } = await artifact();
|
||||
const incomplete = result(extension.sha256);
|
||||
incomplete.result.status = "complete";
|
||||
incomplete.result.reason = null;
|
||||
expect(
|
||||
validateGhidraExtensionResults(
|
||||
[extension],
|
||||
[{ ...incomplete, status: "complete", reason: null }],
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { open, realpath, writeFile, mkdir } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { AppConfig } from "../../config.js";
|
||||
import type { BinaryTarget } from "../../domain/binaryTarget.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { nativeAotAdapter } from "./nativeaot/NativeAotAdapter.js";
|
||||
|
||||
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
/** Exact executable extension artifact committed before the session starts. */
|
||||
export const ghidraExtensionSchema = z.strictObject({
|
||||
id: z.string().regex(/^[a-z][a-z0-9-]*$/u),
|
||||
path: z.string().refine(isAbsolute, "Extension path must be absolute"),
|
||||
configured_path: z
|
||||
.string()
|
||||
.refine(isAbsolute, "Extension path must be absolute"),
|
||||
sha256: digest,
|
||||
entry_class: z.string().min(1),
|
||||
integration_api: z.literal(1),
|
||||
});
|
||||
export type GhidraExtension = z.infer<typeof ghidraExtensionSchema>;
|
||||
|
||||
/** Shared extension envelope; each adapter validates its own producer result. */
|
||||
export const ghidraExtensionResultSchema = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
sha256: digest,
|
||||
status: z.enum([
|
||||
"complete",
|
||||
"partial",
|
||||
"not_applicable",
|
||||
"unsupported",
|
||||
"failed",
|
||||
]),
|
||||
reason: z.string().nullable(),
|
||||
result: jsonObjectSchema,
|
||||
});
|
||||
export type GhidraExtensionResult = z.infer<typeof ghidraExtensionResultSchema>;
|
||||
|
||||
/** Provider-local registration contract for independently replaceable analysis adapters. */
|
||||
export interface GhidraExtensionAdapter {
|
||||
readonly id: string;
|
||||
readonly entryClass: string;
|
||||
configuredPath(config: AppConfig): string | undefined;
|
||||
unsupportedReason(
|
||||
target: BinaryTarget,
|
||||
platform: NodeJS.Platform,
|
||||
): string | null;
|
||||
validate(result: GhidraExtensionResult): string | null;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const adapters: readonly GhidraExtensionAdapter[] = [nativeAotAdapter];
|
||||
const MAX_EXTENSION_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Read a bounded regular artifact through its open handle, without ambient state changes. */
|
||||
const readJar = async (path: string): Promise<Buffer> => {
|
||||
const file = await open(
|
||||
path,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size < 4 || stat.size > MAX_EXTENSION_BYTES)
|
||||
throw new Error(
|
||||
`Ghidra extension must be a regular JAR of 4..${MAX_EXTENSION_BYTES} bytes: ${path}`,
|
||||
);
|
||||
const bytes = Buffer.alloc(stat.size);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
const read = await file.read(
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
offset,
|
||||
);
|
||||
if (read.bytesRead === 0)
|
||||
throw new Error(`Incomplete Ghidra extension read: ${path}`);
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
const extra = Buffer.alloc(1);
|
||||
if (
|
||||
(await file.read(extra, 0, 1, offset)).bytesRead !== 0 ||
|
||||
bytes.readUInt32LE(0) !== 0x04034b50
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra extension changed during read or is not a JAR: ${path}`,
|
||||
);
|
||||
return bytes;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve explicitly configured optional extensions into the immutable analysis profile. */
|
||||
export const resolveGhidraExtensions = async (
|
||||
config: AppConfig,
|
||||
target: BinaryTarget,
|
||||
platform: NodeJS.Platform,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<readonly GhidraExtension[], AnalysisError>> => {
|
||||
const extensions: GhidraExtension[] = [];
|
||||
for (const adapter of adapters) {
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
|
||||
const configured = adapter.configuredPath(config);
|
||||
if (configured === undefined) continue;
|
||||
const reason = adapter.unsupportedReason(target, platform);
|
||||
if (reason !== null)
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
|
||||
diagnostics: { extension: adapter.id, reason, path: configured },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const path = await realpath(configured);
|
||||
const bytes = await readJar(path);
|
||||
extensions.push({
|
||||
id: adapter.id,
|
||||
path,
|
||||
configured_path: configured,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
entry_class: adapter.entryClass,
|
||||
integration_api: 1,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "resolve_analysis_profile", {
|
||||
cause,
|
||||
diagnostics: {
|
||||
extension: adapter.id,
|
||||
path: configured,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
|
||||
return ok(extensions);
|
||||
};
|
||||
|
||||
/** Require explicit local configuration before admitting executable code from a profile. */
|
||||
export const validateGhidraExtensionProfile = (
|
||||
extensions: readonly GhidraExtension[],
|
||||
config: AppConfig,
|
||||
target: BinaryTarget,
|
||||
platform: NodeJS.Platform,
|
||||
): string | null => {
|
||||
const enabled = adapters.filter(
|
||||
(adapter) => adapter.configuredPath(config) !== undefined,
|
||||
);
|
||||
if (
|
||||
enabled.length !== extensions.length ||
|
||||
new Set(extensions.map((extension) => extension.id)).size !==
|
||||
extensions.length
|
||||
)
|
||||
return "Configured Ghidra extension inventory differs from the resolved analysis profile.";
|
||||
for (const extension of extensions) {
|
||||
const adapter = enabled.find((value) => value.id === extension.id);
|
||||
if (
|
||||
adapter === undefined ||
|
||||
adapter.entryClass !== extension.entry_class ||
|
||||
adapter.configuredPath(config) !== extension.configured_path
|
||||
)
|
||||
return `Ghidra extension profile lacks matching explicit local configuration: ${extension.id}`;
|
||||
const reason = adapter.unsupportedReason(target, platform);
|
||||
if (reason !== null) return reason;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Snapshot committed extension bytes into the private runtime before Java loads them. */
|
||||
export const snapshotGhidraExtensions = async (
|
||||
extensions: readonly GhidraExtension[],
|
||||
runtimeRoot: string,
|
||||
): Promise<readonly GhidraExtension[]> => {
|
||||
if (extensions.length === 0) return [];
|
||||
for (const extension of extensions) {
|
||||
ghidraExtensionSchema.parse(extension);
|
||||
const adapter = adapters.find((value) => value.id === extension.id);
|
||||
if (adapter === undefined || adapter.entryClass !== extension.entry_class)
|
||||
throw new Error(
|
||||
`Unregistered Ghidra extension identity: ${extension.id}`,
|
||||
);
|
||||
if ((await realpath(extension.configured_path)) !== extension.path)
|
||||
throw new Error(
|
||||
`Ghidra extension resolved path differs from explicit configuration: ${extension.id}`,
|
||||
);
|
||||
}
|
||||
const directory = join(runtimeRoot, "extensions");
|
||||
await mkdir(directory, { mode: 0o700 });
|
||||
const result: GhidraExtension[] = [];
|
||||
for (const extension of extensions) {
|
||||
const bytes = await readJar(extension.path);
|
||||
if (createHash("sha256").update(bytes).digest("hex") !== extension.sha256)
|
||||
throw new Error(
|
||||
`Ghidra extension digest differs from committed profile: ${extension.id} (${extension.path})`,
|
||||
);
|
||||
const path = join(directory, `${extension.id}.jar`);
|
||||
await writeFile(path, bytes, { flag: "wx", mode: 0o600 });
|
||||
result.push({ ...extension, path });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Require every configured producer result to match its artifact and registered ABI. */
|
||||
export const validateGhidraExtensionResults = (
|
||||
expected: readonly GhidraExtension[],
|
||||
results: readonly GhidraExtensionResult[],
|
||||
): string | null => {
|
||||
if (
|
||||
results.length !== expected.length ||
|
||||
new Set(results.map((result) => result.id)).size !== results.length
|
||||
)
|
||||
return "Ghidra extension result inventory differs from its committed profile.";
|
||||
for (const extension of expected) {
|
||||
const result = results.find((value) => value.id === extension.id);
|
||||
const adapter = adapters.find((value) => value.id === extension.id);
|
||||
if (
|
||||
result === undefined ||
|
||||
adapter === undefined ||
|
||||
result.sha256 !== extension.sha256
|
||||
)
|
||||
return `Ghidra extension identity mismatch: ${extension.id}`;
|
||||
const invalid = adapter.validate(result);
|
||||
if (invalid !== null) return invalid;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Exact adapter limitations for explicitly configured session extensions. */
|
||||
export const ghidraExtensionLimitations = (
|
||||
extensions: readonly GhidraExtension[],
|
||||
): readonly string[] =>
|
||||
adapters
|
||||
.filter((adapter) =>
|
||||
extensions.some((extension) => extension.id === adapter.id),
|
||||
)
|
||||
.flatMap((adapter) => adapter.limitations);
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { GhidraExtensionAdapter } from "../GhidraExtensions.js";
|
||||
import { validateNativeAotReport } from "./NativeAotReport.js";
|
||||
|
||||
/** NativeAOT-specific prerequisites and report interpretation behind the extension boundary. */
|
||||
export const nativeAotAdapter: GhidraExtensionAdapter = {
|
||||
id: "nativeaot",
|
||||
entryClass: "rea.extensions.nativeaot.NativeAotExtension",
|
||||
configuredPath: (config) => config.ghidraNativeAotJar,
|
||||
unsupportedReason: (target, platform) =>
|
||||
platform !== "linux"
|
||||
? "NativeAOT recovery is verified on Linux only; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis."
|
||||
: !["elf", "pe"].includes(target.format) ||
|
||||
target.architecture !== "x86_64" ||
|
||||
target.managed === true
|
||||
? "NativeAOT recovery requires an x86-64 ELF or native PE target on Linux; PE/CLI and ReadyToRun assemblies use inspect_managed_artifact; omit REA_GHIDRA_NATIVEAOT_JAR for ordinary native analysis."
|
||||
: null,
|
||||
validate: validateNativeAotReport,
|
||||
limitations: [
|
||||
"Optional NativeAOT recovery is verified for .NET 8.0.22 RTR 9.1 Linux x64 ELF and Windows x64 PE targets on a Linux host. Other runtime layouts, target architectures and hosts are unsupported.",
|
||||
"Type names and relationships use recovery heuristics; original C# source and custom field layouts are not recovered. Rehydrated analysis-memory bytes are derived, without original file offsets or runtime observations.",
|
||||
"Method prototypes are Ghidra inferences, not original signatures. Pre-recovery conventions are retained; new functions use the loaded compiler default. Verify parameter roles against instructions and call sites.",
|
||||
"The configured extension retains its actual JAR digest. Its reported source revision is not an attestation of caller-supplied build bytes.",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from "zod";
|
||||
import type { GhidraExtensionResult } from "../GhidraExtensions.js";
|
||||
|
||||
const report = z
|
||||
.object({
|
||||
id: z.literal("nativeaot"),
|
||||
integration_api: z.literal(1),
|
||||
source_revision: z.literal("effeb734fc570c32650f88b159608979dc7b423e"),
|
||||
source_revision_authority: z.literal("build-reported-unattested"),
|
||||
status: z.enum([
|
||||
"complete",
|
||||
"partial",
|
||||
"not_applicable",
|
||||
"unsupported",
|
||||
"failed",
|
||||
]),
|
||||
reason: z.string().nullable(),
|
||||
method_tables: z.number().int().nonnegative(),
|
||||
diagnostics: z.array(z.string()),
|
||||
header_address: z
|
||||
.string()
|
||||
.regex(/^0x[0-9a-f]+$/u)
|
||||
.optional(),
|
||||
discovery: z.enum(["symbol", "signature-heuristic"]).optional(),
|
||||
format_major: z.number().int().optional(),
|
||||
format_minor: z.number().int().optional(),
|
||||
derived_memory: z
|
||||
.object({
|
||||
address: z.string().regex(/^0x[0-9a-f]+$/u),
|
||||
size_bytes: z.number().int().nonnegative(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
file_offset: z.null(),
|
||||
})
|
||||
.optional(),
|
||||
coverage: z
|
||||
.strictObject({
|
||||
frozen_object_candidates: z.number().int().nonnegative(),
|
||||
frozen_objects_annotated: z.number().int().nonnegative(),
|
||||
basis: z.literal(
|
||||
"rehydrated-pointer-candidates-and-committed-instance-types",
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
types: z
|
||||
.array(
|
||||
z.object({
|
||||
address: z.string().regex(/^0x[0-9a-f]+$/u),
|
||||
type: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type NativeAotReport = z.infer<typeof report>;
|
||||
|
||||
/** Validate the pinned producer representation before its result enters Evidence. */
|
||||
export const validateNativeAotReport = (
|
||||
value: GhidraExtensionResult,
|
||||
): string | null => {
|
||||
if (
|
||||
value.status === "failed" &&
|
||||
typeof value.result.loader_failure === "string" &&
|
||||
value.result.loader_failure === value.reason
|
||||
)
|
||||
return null;
|
||||
const parsed = report.safeParse(value.result);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.status !== value.status ||
|
||||
parsed.data.reason !== value.reason
|
||||
)
|
||||
return "NativeAOT extension returned a malformed or inconsistent producer report.";
|
||||
return validateRecoveredMetadata(parsed.data);
|
||||
};
|
||||
|
||||
const validateRecoveredMetadata = (value: NativeAotReport): string | null => {
|
||||
if (
|
||||
["complete", "partial"].includes(value.status) &&
|
||||
(value.method_tables === 0 ||
|
||||
value.format_major !== 9 ||
|
||||
value.format_minor !== 1 ||
|
||||
value.types?.length !== value.method_tables ||
|
||||
value.derived_memory === undefined ||
|
||||
value.header_address === undefined ||
|
||||
value.discovery === undefined ||
|
||||
value.coverage === undefined ||
|
||||
value.reason !== null)
|
||||
)
|
||||
return "NativeAOT recovery omitted its supported format, method-table inventory or derived-memory identity.";
|
||||
const coverage = value.coverage;
|
||||
if (
|
||||
coverage !== undefined &&
|
||||
(coverage.frozen_objects_annotated > coverage.frozen_object_candidates ||
|
||||
(value.status === "complete" &&
|
||||
coverage.frozen_objects_annotated !==
|
||||
coverage.frozen_object_candidates))
|
||||
)
|
||||
return "NativeAOT recovery coverage contradicts its reported completion status.";
|
||||
const types = value.types ?? [];
|
||||
if (new Set(types.map((type) => type.address)).size !== types.length)
|
||||
return "NativeAOT recovery repeated a method-table identity.";
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<PublishAot>true</PublishAot>
|
||||
<StripSymbols>false</StripSymbols>
|
||||
<OptimizationPreference>Size</OptimizationPreference>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeFrameworkVersion>8.0.22</RuntimeFrameworkVersion>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Program.cs" />
|
||||
<LinkerArg Include="/MAP:$(NativeAotMapPath)" Condition="'$(OS)' == 'Windows_NT' and '$(NativeAotMapPath)' != ''" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
interface IProbe { int Compute(int value); }
|
||||
|
||||
class BaseProbe
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public virtual int Compute(int value) => value * 7 + 3;
|
||||
public override string ToString() => "REA_NATIVEAOT_BASE";
|
||||
}
|
||||
|
||||
sealed class DerivedProbe : BaseProbe, IProbe
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public override int Compute(int value) => value * 11 + 5;
|
||||
public override string ToString() => "REA_NATIVEAOT_DERIVED";
|
||||
}
|
||||
|
||||
static class Program
|
||||
{
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static int Dispatch(BaseProbe value, int input) => value.Compute(input);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static int DispatchInterface(IProbe value, int input) => value.Compute(input);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
static int Consume(string value) => value.Length;
|
||||
|
||||
static int Main(string[] args)
|
||||
{
|
||||
BaseProbe[] probes = { new BaseProbe(), new DerivedProbe() };
|
||||
int result = Dispatch(probes[args.Length % probes.Length], args.Length);
|
||||
result += DispatchInterface(new DerivedProbe(), args.Length);
|
||||
result += Consume("REA_NATIVEAOT_FROZEN");
|
||||
result += Consume(probes[args.Length % probes.Length].ToString());
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+1
Submodule third_party/ghidra-nativeaot added at effeb734fc
Reference in New Issue
Block a user