mirror of
https://github.com/REMnux/remnux-mcp-server
synced 2026-06-21 13:45:33 +00:00
feat(tools): add r2ghidra decompilation for native PE/ELF
Surface radare2's r2ghidra (pdg) Ghidra decompiler as a deep-tier registry tool tagged pe/elf/decompilation. It is requiresUserArgs (function-scoped, empty on packed/stripped samples), so analyze_file surfaces the exact invocation in tools_skipped rather than auto-running it, and suggest_tools teaches the pdg/afl/pdc recipe in the PE/ELF hints; function-level targeting rides on run_tool. Also tags cfr/jadx with the decompilation marker and clarifies the requiresUserArgs doc comment. Invocation verified on REMnux radare2 6.1.6.
This commit is contained in:
@@ -17,6 +17,22 @@ describe("handleAnalyzeFile", () => {
|
||||
expect(env.data.tools_run.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("surfaces r2ghidra as a skipped (manual) decompile step at deep depth, never auto-run", async () => {
|
||||
const deps = createMockDeps();
|
||||
vi.mocked(deps.connector.execute).mockResolvedValue(
|
||||
ok("/samples/test.exe: PE32 executable")
|
||||
);
|
||||
vi.mocked(deps.connector.executeShell).mockResolvedValue(ok("tool output"));
|
||||
|
||||
const result = await handleAnalyzeFile(deps, { file: "test.exe", depth: "deep" });
|
||||
const env = parseEnvelope(result);
|
||||
const skipped = env.data.tools_skipped.find((t: { name: string }) => t.name === "r2ghidra");
|
||||
expect(skipped).toBeDefined();
|
||||
expect(skipped.skip_type).toBe("requires_user_args");
|
||||
expect(skipped.invocation).toContain("pdg @ entry0; pdg @ main");
|
||||
expect(env.data.tools_run.map((t: { name: string }) => t.name)).not.toContain("r2ghidra");
|
||||
});
|
||||
|
||||
it("skips tools with exit code 127 (not installed)", async () => {
|
||||
const deps = createMockDeps();
|
||||
vi.mocked(deps.connector.execute).mockResolvedValue(
|
||||
|
||||
@@ -220,4 +220,29 @@ describe("handleSuggestTools", () => {
|
||||
// OneNote has no catalog mapping, so additional_tools should be absent
|
||||
expect(env.data.additional_tools).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recommends r2ghidra for native PE at deep depth and teaches pdg in hints", async () => {
|
||||
const deps = createMockDeps();
|
||||
vi.mocked(deps.connector.execute).mockResolvedValue(
|
||||
ok("/samples/test.exe: PE32 executable (console) Intel 80386")
|
||||
);
|
||||
const result = await handleSuggestTools(deps, { file: "test.exe", depth: "deep" });
|
||||
const env = parseEnvelope(result);
|
||||
const r2g = env.data.recommended_tools.find((t: { name: string }) => t.name === "r2ghidra");
|
||||
expect(r2g).toBeDefined();
|
||||
expect(r2g.invocation).toContain("pdg @ entry0; pdg @ main");
|
||||
expect(env.data.analysis_hints).toContain("pdg @ main");
|
||||
expect(env.data.analysis_hints).toContain("afl");
|
||||
});
|
||||
|
||||
it("does not recommend r2ghidra at standard depth but still teaches pdg in hints", async () => {
|
||||
const deps = createMockDeps();
|
||||
vi.mocked(deps.connector.execute).mockResolvedValue(
|
||||
ok("/samples/test.exe: PE32 executable (console) Intel 80386")
|
||||
);
|
||||
const result = await handleSuggestTools(deps, { file: "test.exe", depth: "standard" });
|
||||
const env = parseEnvelope(result);
|
||||
expect(env.data.recommended_tools.map((t: { name: string }) => t.name)).not.toContain("r2ghidra");
|
||||
expect(env.data.analysis_hints).toContain("pdg @ main");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,7 +53,8 @@ const BASE_HINTS: Record<string, string> = {
|
||||
"1768.py analyzes Cobalt Strike beacons. disitool.py examines Authenticode signatures. " +
|
||||
"redress recovers Go compiler info, packages, and types from Go-compiled executables — returns minimal output on non-Go PE files. " +
|
||||
"For deep analysis, capa -vv shows matched rule details with addresses. " +
|
||||
"pedump shows raw PE structure and brxor bruteforces XOR-encoded strings.",
|
||||
"pedump shows raw PE structure and brxor bruteforces XOR-encoded strings. " +
|
||||
"For native (non-.NET) code, r2ghidra decompiles to pseudo-C via radare2's Ghidra plugin and is function-scoped: list functions with run_tool command=\"r2 -A -q -c afl <file>\", then decompile a target with run_tool command=\"r2 -A -q -c 'pdg @ main' <file>\" ('pdg @ entry0', 'pdg @ sym.<name>', or 'pdg @ 0x<addr>'). Use 'pdc' instead of 'pdg' if the plugin is unavailable. For .NET assemblies use ilspycmd instead.",
|
||||
PDF:
|
||||
"Start with pdfid and pdfcop for triage — pdfid identifies notable elements " +
|
||||
"(/JS, /JavaScript, /OpenAction, /Launch), pdfcop detects malicious structures. " +
|
||||
@@ -87,7 +88,8 @@ const BASE_HINTS: Record<string, string> = {
|
||||
"readelf -S lists sections — look for unusual section names or sizes. " +
|
||||
"capa detects capabilities in ELF binaries similar to PE analysis. " +
|
||||
"redress analyzes Go binaries — recovers package names, types, compiler version, and source structure. On non-Go ELF files it returns minimal output (just OS/arch). " +
|
||||
"For deep analysis, capa -vv shows matched rule details with addresses.",
|
||||
"For deep analysis, capa -vv shows matched rule details with addresses. " +
|
||||
"r2ghidra decompiles ELF functions to pseudo-C (Ghidra plugin under radare2): list functions with run_tool command=\"r2 -A -q -c afl <file>\", then run_tool command=\"r2 -A -q -c 'pdg @ main' <file>\" for a target ('pdg @ sym.<name>' or 'pdg @ 0x<addr>'). 'pdc' is the native fallback if the plugin is absent.",
|
||||
JavaScript:
|
||||
"js-beautify reformats and deobfuscates JavaScript — look for eval(), " +
|
||||
"document.write(), String.fromCharCode(), and unescape() patterns. " +
|
||||
|
||||
@@ -36,4 +36,14 @@ describe("tool definition guards", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("r2ghidra surfaces the quoted pdg script as one intact -c argument", () => {
|
||||
const r2g = TOOL_DEFINITIONS.find((d) => d.name === "r2ghidra");
|
||||
expect(r2g).toBeDefined();
|
||||
const inv = buildInvocationTemplate(r2g!);
|
||||
// bash -c reparses the assembled string, so the single-quoted script must
|
||||
// render intact — guards the fixedArgs join against silently splitting it.
|
||||
expect(inv).toContain("-c 'pdg @ entry0; pdg @ main'");
|
||||
expect(inv.split(" ")[0]).toBe("r2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,4 +83,23 @@ describe("ToolRegistry", () => {
|
||||
expect(vol3info!.inputFlag).toBe("-f");
|
||||
expect(vol3info!.suffixArgs).toEqual(["windows.info"]);
|
||||
});
|
||||
|
||||
it("registers r2ghidra for native PE/ELF decompilation at deep tier only", () => {
|
||||
const r2g = toolRegistry.get("r2ghidra");
|
||||
expect(r2g).toBeDefined();
|
||||
expect(r2g!.command).toBe("r2");
|
||||
expect(r2g!.tier).toBe("deep");
|
||||
expect(r2g!.requiresUserArgs).toBe(true);
|
||||
expect(r2g!.tags).toEqual(expect.arrayContaining(["pe", "elf", "decompilation"]));
|
||||
|
||||
expect(toolRegistry.byTagAndTier("pe", "deep").map((t) => t.name)).toContain("r2ghidra");
|
||||
expect(toolRegistry.byTagAndTier("elf", "deep").map((t) => t.name)).toContain("r2ghidra");
|
||||
// deep-tier tool: not surfaced during quick/standard analysis
|
||||
expect(toolRegistry.byTagAndTier("pe", "standard").map((t) => t.name)).not.toContain("r2ghidra");
|
||||
});
|
||||
|
||||
it("marks cfr and jadx with the cross-cutting 'decompilation' tag", () => {
|
||||
expect(toolRegistry.get("cfr")!.tags).toContain("decompilation");
|
||||
expect(toolRegistry.get("jadx")!.tags).toContain("decompilation");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,6 +537,40 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
tier: "standard",
|
||||
},
|
||||
|
||||
// ── Native decompilation (r2ghidra) ─────────────────────────────────────
|
||||
{
|
||||
name: "r2ghidra",
|
||||
description:
|
||||
"Decompile native PE/ELF functions to pseudo-C using the r2ghidra (Ghidra) decompiler in radare2. " +
|
||||
"Function-scoped — one function per 'pdg'. List functions first: run_tool command=\"r2 -A -q -c afl <file>\", " +
|
||||
"then decompile a target: run_tool command=\"r2 -A -q -c 'pdg @ main' <file>\" " +
|
||||
"('pdg @ entry0', 'pdg @ sym.<name>', or 'pdg @ 0x<addr>'). " +
|
||||
"If the Ghidra plugin is unavailable, use radare2's native 'pdc' instead of 'pdg' (lower fidelity, no plugin). " +
|
||||
"Decompiled output can be fed to extract_iocs.",
|
||||
command: "r2",
|
||||
inputStyle: "positional",
|
||||
// bash -c reparses the assembled string, so the inner single-quotes hold as one -c argument.
|
||||
// Default template decompiles entry0 (always resolves) + main (added value when present).
|
||||
// -e log.quiet=true silences r2's analysis-progress logs (verified on REMnux radare2 6.1.6).
|
||||
// Do NOT add bin.relocs.apply=true: r2 warns relocs aren't applied and suggests it, but on
|
||||
// test it renders calls as raw addresses ((**0x22f98)) instead of the informative
|
||||
// _reloc.<symbol> names — measurably worse decompilation. The warning is benign here.
|
||||
fixedArgs: ["-A", "-q", "-e", "scr.color=0", "-e", "log.quiet=true", "-c", "'pdg @ entry0; pdg @ main'"],
|
||||
outputFormat: "text",
|
||||
timeout: 180,
|
||||
tags: ["pe", "elf", "decompilation"],
|
||||
tier: "deep",
|
||||
// Not literally "needs user args" — repurposed (as autoit-ripper/uncompyle6 are) to mean
|
||||
// "don't auto-run; surface the invocation." Native decompile is function-scoped and empty
|
||||
// on packed/stripped samples, so it runs on demand via run_tool.
|
||||
requiresUserArgs: true,
|
||||
// r2 exits 0 even when pdg finds nothing (verified on REMnux), so empty output is not an
|
||||
// error code — the description covers the stripped/packed "no output" case (list via afl).
|
||||
exitCodeHints: {
|
||||
127: "r2 (radare2) or the r2ghidra 'pdg' plugin is unavailable. Verify: r2 -qc 'pdg?' /bin/ls. Native fallback: use 'pdc' instead of 'pdg'.",
|
||||
},
|
||||
},
|
||||
|
||||
// ── ELF analysis ────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "readelf-header",
|
||||
@@ -683,7 +717,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
inputStyle: "positional",
|
||||
outputFormat: "text",
|
||||
timeout: 120,
|
||||
tags: ["jar"],
|
||||
tags: ["jar", "decompilation"],
|
||||
tier: "standard",
|
||||
},
|
||||
|
||||
@@ -763,7 +797,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
||||
fixedArgs: ["-d", "%OUTPUT%/jadx-out", "--no-debug-info"],
|
||||
outputFormat: "text",
|
||||
timeout: 120,
|
||||
tags: ["apk"],
|
||||
tags: ["apk", "decompilation"],
|
||||
tier: "standard",
|
||||
},
|
||||
|
||||
|
||||
@@ -40,7 +40,11 @@ export interface ToolDefinition {
|
||||
tags?: string[];
|
||||
/** Minimum depth tier that includes this tool */
|
||||
tier: DepthTier;
|
||||
/** Tool requires user-supplied arguments and cannot run in automated analysis */
|
||||
/**
|
||||
* Skip in automated analyze_file runs — surfaced as a manual run_tool invocation
|
||||
* in tools_skipped instead. Either the tool needs user-supplied arguments, or it
|
||||
* has a sensible default but is better driven manually (e.g. autoit-ripper, r2ghidra).
|
||||
*/
|
||||
requiresUserArgs?: boolean;
|
||||
/** Human-readable hints for specific non-zero exit codes */
|
||||
exitCodeHints?: Record<number, string>;
|
||||
|
||||
Reference in New Issue
Block a user