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:
lennyzeltser
2026-06-19 00:08:25 -04:00
parent 847baa3ddd
commit 18d8244f90
7 changed files with 115 additions and 5 deletions
@@ -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");
});
});
+4 -2
View File
@@ -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");
});
});
+19
View File
@@ -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");
});
});
+36 -2
View File
@@ -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",
},
+5 -1
View File
@@ -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>;