Refactor ilspy-cli for enhanced .NET decompilation and project support

- Introduced `decompile_project` function in IlSpyBridge to handle project-style decompilation, generating per-type .cs files.
- Updated FFI layer to support new decompile_project functionality.
- Enhanced CLI to accept output directory for project decompilation, ensuring compatibility with existing type and method flags.
- Improved error handling and output formatting for decompilation results.
- Added comprehensive command reference for ilspy-cli, detailing usage for detecting .NET vs native binaries, listing types and methods, and searching decompiled source.
- Integrated memory reading capabilities in GhidraCliBridge for enhanced analysis workflows.
- Updated main.rs to robustly check for existing bridge instances, improving reliability in project mode.
This commit is contained in:
Alexander Kiselev
2026-02-22 07:56:46 -08:00
parent ad53c59409
commit 9eb040885f
9 changed files with 722 additions and 423 deletions
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
---
name: ilspy-cli
description: >
Use ilspy-cli for .NET assembly decompilation and analysis. Produces clean C# source from .NET IL bytecode.
Activate when the user requests:
- .NET assembly decompilation
- .NET type or method inspection
- Detecting whether a binary is .NET or native
- Searching decompiled .NET source code
- Assembly metadata inspection
Prefer ilspy-cli over ghidra-cli for .NET binaries (ghidra produces poor output for .NET IL).
---
# ilspy-cli Agent Reference
Rust CLI for .NET decompilation using ILSpy (ICSharpCode.Decompiler). Binary name: `ilspy`.
## Architecture
```
Rust CLI (clap) ──netcorehost FFI──► C# Bridge DLL (IlSpyBridge.dll) ──► ICSharpCode.Decompiler
```
- **In-process**: .NET runtime hosted inside the Rust binary via `netcorehost` crate
- **No server/daemon**: each command loads the assembly, runs, exits
- **Data exchange**: JSON strings over FFI function pointers with `[UnmanagedCallersOnly]`
- **Key differentiator**: single-method decompilation (`--type T --method M`), which `ilspycmd` cannot do
## Requirements
- .NET 8 runtime (loaded by netcorehost at runtime)
- .NET 8 SDK (build time only, for `dotnet publish` of the C# bridge)
## When to Use ilspy-cli vs ghidra-cli
| Binary type | Tool | Reason |
|-------------|------|--------|
| .NET (.dll/.exe with CLR header) | `ilspy` | Clean C# decompilation |
| Native (C/C++/Delphi/Go) | `ghidra` | Assembly-level analysis |
| Unknown | `ilspy detect` first | Classifies .NET vs native |
Use `ilspy detect` to triage before choosing a tool.
## Global Flags
| Flag | Effect |
|------|--------|
| `--json` | Minified JSON output |
| `--pretty` | Pretty-printed JSON |
| `--compact` | One line per item |
| `-v` / `--verbose` | Verbose output |
**Format auto-detection**: TTY → table; non-TTY → compact.
## Command Reference
### Detect (.NET vs Native)
Classify binaries without loading .NET runtime (pure Rust PE header parsing).
```bash
ilspy detect FILE # single file
ilspy detect DIRECTORY # scan directory (top-level)
ilspy detect DIRECTORY --recursive # scan recursively
ilspy detect DIRECTORY --dotnet-only # show only .NET assemblies
ilspy detect DIRECTORY --native-only # show only native binaries
```
**Output fields**: path, isDotnet, framework, recommendedTool
Framework detection includes: .NET Core/5+/6+/7+/8+, .NET Framework, .NET Standard, Native (Delphi), Native (Qt/C++), Native (MFC/C++).
The `recommendedTool` field returns `"ilspy"` for .NET or `"ghidra"` for native.
### List Types
```bash
ilspy list types ASSEMBLY # all types
ilspy list types ASSEMBLY --filter Controller # substring filter (case-insensitive)
ilspy list types ASSEMBLY --kind class # filter by kind
ilspy list types ASSEMBLY --kind enum
ilspy list types ASSEMBLY --filter Crypto --kind class
```
**Kind values**: `class`, `interface`, `struct`, `enum`, `delegate`
**Output fields**: fullName, ns, name, kind, methodCount, propertyCount, fieldCount, isPublic
### List Methods
```bash
ilspy list methods ASSEMBLY # all methods
ilspy list methods ASSEMBLY --type MyNamespace.MyClass # methods of one type
ilspy list methods ASSEMBLY --type MyNamespace.MyClass --filter DoWork # filter by name
```
**Output fields**: typeName, name, returnType, parameters (name + type), accessibility, isStatic, isVirtual, isAbstract
### Decompile
```bash
ilspy decompile ASSEMBLY # full assembly → C# source
ilspy decompile ASSEMBLY --type MyNamespace.MyClass # single type
ilspy decompile ASSEMBLY --type MyNamespace.MyClass --method DoWork # single method!
```
**Output**: C# source code (default). With `--json`/`--pretty`: `{ "source": "...", "typeName": "...", "methodName": "...", "returnType": "..." }`
`--method` requires `--type` (must specify the containing type).
**Default format override**: decompile always outputs raw source unless `--json` or `--pretty` is specified.
### Search Decompiled Source
```bash
ilspy search ASSEMBLY "ConnectionString"
ilspy search ASSEMBLY "password|secret|key"
ilspy search ASSEMBLY "HttpClient\.Post"
```
Pattern is a **regex** (case-insensitive, multiline). The tool decompiles each type and runs the regex against the C# source.
**Output fields per match**: typeName, matchCount, matches[].line, matches[].matched, matches[].context (surrounding lines)
### Assembly Info
```bash
ilspy info ASSEMBLY
```
**Output fields**: name, typeCount, targetFramework, references[].name, references[].version
### Doctor (Health Check)
```bash
ilspy doctor
```
Checks: .NET runtime availability, bridge DLL presence, bridge loading.
**Environment variable**: `ILSPY_BRIDGE_DIR` overrides bridge DLL search path.
## Agent Best Practices
### 1. Triage First
Always detect before decompiling an unknown binary:
```bash
ilspy detect ./target.dll --json
# Check recommendedTool field
```
### 2. Count-First for Large Assemblies
```bash
# Check type count
ilspy info MyLib.dll --json
# If typeCount is large, filter:
ilspy list types MyLib.dll --filter Controller --json
```
### 3. Targeted Decompilation
Decompile specific types/methods instead of entire assemblies:
```bash
# GOOD: specific type
ilspy decompile MyLib.dll --type MyApp.Services.AuthService
# GOOD: specific method
ilspy decompile MyLib.dll --type MyApp.Services.AuthService --method ValidateToken
# AVOID for large assemblies: full decompile
ilspy decompile MyLib.dll
```
### 4. Search Before Decompile
Find relevant types first, then decompile:
```bash
# Find types containing crypto code
ilspy search MyLib.dll "AES|Rijndael|SHA256" --json
# Decompile the matching type
ilspy decompile MyLib.dll --type MyApp.Security.CryptoHelper
```
### 5. Use Compact/JSON for Parsing
```bash
ilspy list types MyLib.dll --json # minified JSON for piping
ilspy list types MyLib.dll --compact # one line per type
ilspy decompile MyLib.dll --type T --json # source wrapped in JSON
```
## Analysis Workflow
```bash
# 1. Detect binary type
ilspy detect ./target.dll
# 2. Get overview
ilspy info ./target.dll
ilspy list types ./target.dll --json
# 3. Find interesting types
ilspy list types ./target.dll --filter Service --kind class
ilspy list types ./target.dll --filter Crypto
# 4. Inspect methods
ilspy list methods ./target.dll --type MyApp.Services.AuthService
# 5. Decompile specific method
ilspy decompile ./target.dll --type MyApp.Services.AuthService --method ValidateToken
# 6. Search for patterns
ilspy search ./target.dll "password|credential|secret"
ilspy search ./target.dll "HttpClient|WebRequest|Socket"
ilspy search ./target.dll "Process\.Start|Shell|Exec"
```
## Directory Scanning Workflow
```bash
# Triage a directory of binaries
ilspy detect "C:\Program Files\MyApp" --recursive --json
# Focus on .NET assemblies only
ilspy detect "C:\Program Files\MyApp" --recursive --dotnet-only --compact
```
## Integration with ghidra-cli
ghidra-cli's `decompile` command warns when it encounters .NET IL bytecode:
> "This appears to be .NET managed code. Consider using ilspy-cli."
Workflow for mixed codebases:
```bash
# 1. Classify all binaries
ilspy detect ./binaries --recursive --json
# 2. Use ilspy for .NET
ilspy decompile ./binaries/managed.dll --type MyClass
# 3. Use ghidra for native
ghidra import ./binaries/native.exe --project analysis
ghidra decompile main --project analysis
```
## Error Recovery
| Problem | Fix |
|---------|-----|
| "dotnet not found" | Install .NET 8 SDK: https://dot.net/download |
| "Bridge DLL not found" | `cargo build` or set `ILSPY_BRIDGE_DIR` |
| "Type not found" | Use `ilspy list types ASSEMBLY --filter NAME` to find exact fullName |
| "Method not found" | Use `ilspy list methods ASSEMBLY --type T` to see available methods |
| Bridge load fails | Run `ilspy doctor` for diagnostics |
+42
View File
@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection.Metadata;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using ICSharpCode.Decompiler;
using ICSharpCode.Decompiler.CSharp;
using ICSharpCode.Decompiler.CSharp.ProjectDecompiler;
using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.TypeSystem;
@@ -398,6 +401,45 @@ public static class IlSpyBridge
}
}
[UnmanagedCallersOnly(EntryPoint = "DecompileProject")]
public static IntPtr DecompileProject(IntPtr pathPtr, int pathLen,
IntPtr targetDirPtr, int targetDirLen,
IntPtr resultLenPtr)
{
int resultLen = 0;
try
{
var path = ReadUtf8(pathPtr, pathLen);
var targetDir = ReadUtf8(targetDirPtr, targetDirLen);
var peFile = new PEFile(path);
Directory.CreateDirectory(targetDir);
var resolver = new UniversalAssemblyResolver(path, false, null);
var decompiler = new WholeProjectDecompiler(resolver);
decompiler.DecompileProject(peFile, targetDir, CancellationToken.None);
var fileCount = Directory.GetFiles(targetDir, "*.cs", SearchOption.AllDirectories).Length;
var result = new
{
files = fileCount,
directory = targetDir
};
var ptr = MarshalJson(result, out resultLen);
Marshal.WriteInt32(resultLenPtr, resultLen);
return ptr;
}
catch (Exception ex)
{
var ptr = MarshalError(ex.Message, out resultLen);
Marshal.WriteInt32(resultLenPtr, resultLen);
return ptr;
}
}
[UnmanagedCallersOnly(EntryPoint = "FreeMem")]
public static void FreeMem(IntPtr ptr)
{
+2
View File
@@ -33,6 +33,8 @@ pub type FnThreeArgs = unsafe extern "system" fn(
/// FFI function signature for FreeMem.
pub type FnFreeMem = unsafe extern "system" fn(*mut u8);
/// Call a two-arg bridge function and return the JSON string result.
/// Call a one-arg bridge function and return the JSON string result.
pub unsafe fn call_one_arg(func: FnOneArg, free: FnFreeMem, arg1: &str) -> String {
let mut result_len: c_int = 0;
+8
View File
@@ -17,6 +17,7 @@ pub struct IlSpyBridge {
decompile_type_fn: FnTwoArgs,
decompile_method_fn: FnThreeArgs,
decompile_full_fn: FnOneArg,
decompile_project_fn: FnTwoArgs,
assembly_info_fn: FnOneArg,
search_fn: FnTwoArgs,
free_fn: FnFreeMem,
@@ -73,6 +74,7 @@ impl IlSpyBridge {
decompile_type_fn: load!("DecompileType", FnTwoArgs),
decompile_method_fn: load!("DecompileMethod", FnThreeArgs),
decompile_full_fn: load!("DecompileFull", FnOneArg),
decompile_project_fn: load!("DecompileProject", FnTwoArgs),
assembly_info_fn: load!("GetAssemblyInfo", FnOneArg),
search_fn: load!("SearchSource", FnTwoArgs),
free_fn: load!("FreeMem", FnFreeMem),
@@ -137,6 +139,12 @@ impl IlSpyBridge {
Self::parse_result(&json)
}
/// Decompile a project into a directory of per-type .cs files.
pub fn decompile_project(&self, assembly: &str, target_dir: &str) -> Result<serde_json::Value> {
let json = unsafe { call_two_args(self.decompile_project_fn, self.free_fn, assembly, target_dir) };
Self::parse_result(&json)
}
// ── Internal helpers ─────────────────────────────────────────────
fn parse_result<T: serde::de::DeserializeOwned>(json: &str) -> Result<T> {
+4
View File
@@ -103,6 +103,10 @@ pub struct DecompileArgs {
/// Decompile a specific method (requires --type)
#[arg(long, short, requires = "type")]
pub method: Option<String>,
/// Output directory for project-style decompilation (one .cs file per type)
#[arg(long, short = 'o')]
pub output_dir: Option<PathBuf>,
}
#[derive(Args, Debug)]
+24
View File
@@ -8,6 +8,30 @@ pub fn decompile(bridge: &IlSpyBridge, args: &DecompileArgs, fmt: OutputFormat)
.unwrap_or_else(|_| args.assembly.clone());
let path_str = assembly.to_string_lossy();
if let Some(output_dir) = &args.output_dir {
if args.r#type.is_some() || args.method.is_some() {
return Err(crate::error::IlSpyError::BridgeCallFailed(
"--output-dir cannot be used with --type or --method".to_string()
));
}
let target_dir = dunce::canonicalize(output_dir)
.unwrap_or_else(|_| output_dir.clone());
let target_str = target_dir.to_string_lossy();
let result = bridge.decompile_project(&path_str, &target_str)?;
return Ok(match fmt {
OutputFormat::Json => serde_json::to_string(&result).unwrap_or_default(),
OutputFormat::JsonPretty => serde_json::to_string_pretty(&result).unwrap_or_default(),
_ => {
let files = result.get("files").and_then(|v| v.as_u64()).unwrap_or(0);
let dir = result.get("directory").and_then(|v| v.as_str()).unwrap_or("");
format!("Decompiled {} files to {}", files, dir)
}
});
}
let result = match (&args.r#type, &args.method) {
(Some(type_name), Some(method_name)) => {
// Single method decompilation
+72
View File
@@ -243,6 +243,8 @@ public class GhidraCliBridge extends GhidraScript {
case "batch": return handleBatch(args);
// Bridge info
case "bridge_info": return handleBridgeInfo();
// Memory read
case "read_memory": return handleReadMemory(args);
default: return null;
}
}
@@ -2603,4 +2605,74 @@ public class GhidraCliBridge extends GhidraScript {
// Batch operations are handled by the Rust side, not the bridge directly
return errorResult("Batch operations are handled by the CLI, not via bridge script");
}
// --- Memory Read Handler ---
private JsonObject handleReadMemory(JsonObject args) {
String addrStr = getArgString(args, "address");
if (addrStr == null) return errorResult("Address required");
int size = 200;
if (args != null && args.has("size")) {
size = args.get("size").getAsInt();
}
try {
ghidra.program.model.mem.Memory mem = currentProgram.getMemory();
ghidra.program.model.address.AddressFactory af = currentProgram.getAddressFactory();
// Parse address
long addrLong;
if (addrStr.startsWith("0x") || addrStr.startsWith("0X")) {
addrLong = Long.parseUnsignedLong(addrStr.substring(2), 16);
} else {
addrLong = Long.parseUnsignedLong(addrStr, 16);
}
ghidra.program.model.address.Address baseAddr = af.getDefaultAddressSpace().getAddress(addrLong);
// Read bytes
byte[] bytes = new byte[size];
int bytesRead = mem.getBytes(baseAddr, bytes);
// Build hex string
StringBuilder hexStr = new StringBuilder();
for (int i = 0; i < bytesRead; i++) {
hexStr.append(String.format("%02x", bytes[i] & 0xFF));
}
// Also interpret as array of 8-byte pointers
JsonArray pointers = new JsonArray();
for (int i = 0; i + 7 < bytesRead; i += 8) {
long val = 0;
for (int j = 0; j < 8; j++) {
val |= ((long)(bytes[i+j] & 0xFF)) << (8*j);
}
JsonObject ptrObj = new JsonObject();
ptrObj.addProperty("offset", i);
ptrObj.addProperty("address", String.format("0x%08x", addrLong + i));
ptrObj.addProperty("value", String.format("0x%016x", val));
// Check if value looks like a code address
if (val >= 0x00401000L && val <= 0x05bb99ffL) {
ghidra.program.model.address.Address funcAddr = af.getDefaultAddressSpace().getAddress(val);
ghidra.program.model.listing.Function func = currentProgram.getFunctionManager().getFunctionAt(funcAddr);
if (func != null) {
ptrObj.addProperty("function", func.getName());
}
}
pointers.add(ptrObj);
}
JsonObject result = new JsonObject();
result.addProperty("address", String.format("0x%08x", addrLong));
result.addProperty("size", bytesRead);
result.addProperty("hex", hexStr.toString());
result.add("pointers", pointers);
return result;
} catch (Exception e) {
return errorResult("Failed to read memory: " + e.getMessage());
}
}
}
+17 -3
View File
@@ -432,9 +432,23 @@ fn run_with_bridge(cli: Cli) -> anyhow::Result<()> {
anyhow::bail!("Binary not found: {}", args.binary);
}
// Check if bridge is already running
if let Some(port) = bridge::is_bridge_running(&project_path) {
// Bridge running - import via bridge command
// First, robustly check if a bridge is already running using ensure_bridge_running
// with Project mode. This will reuse an existing bridge or start a minimal one.
let existing_bridge_port = bridge::read_port_file(&project_path)
.ok()
.flatten()
.and_then(|port| {
// Verify this port is actually reachable
let client = BridgeClient::new(port);
if client.ping().unwrap_or(false) {
Some(port)
} else {
None
}
});
if let Some(port) = existing_bridge_port {
// Bridge is running - import via TCP command
let client = BridgeClient::new(port);
verify_bridge(&client)?;
let result = client.import_binary(&args.binary, args.program.as_deref())?;