changes to daemon mode

This commit is contained in:
Alexander Kiselev
2026-01-26 06:58:39 -08:00
parent d467f23ac6
commit f7b5049625
11 changed files with 1218 additions and 397 deletions
File diff suppressed because it is too large Load Diff
+48 -2
View File
@@ -1,3 +1,49 @@
NEVER SKIP TESTS! IF GHIDRA IS NOT INSTALLED, THE TESTS MUST FAIL.
# Agent Instructions
DEFAULT OUTPUT FORMAT SHOULD BE HUMAN AND AGENT READABLE, NOT JSON (--json and --pretty for json/pretty json)
## Critical Rules
1. **NEVER SKIP TESTS!** If Ghidra is not installed, the tests MUST fail.
2. **DEFAULT OUTPUT FORMAT** should be human and agent readable, NOT JSON. Use `--json` and `--pretty` for JSON output.
## Architecture
ghidra-cli uses a **daemon-only architecture**:
- All commands route through a daemon process
- Daemon manages a persistent Ghidra bridge connection
- Import/Analyze/Quick commands auto-start the daemon
- One daemon per project, one program per daemon
## Key Patterns
### Starting Analysis
```bash
# Quickest path - auto-starts daemon
ghidra quick ./binary
# Or explicit steps (daemon auto-starts on import)
ghidra import ./binary --project myproj --program prog
ghidra analyze --project myproj --program prog
```
### Daemon is Always Running
After import/analyze/quick, the daemon is running. All query commands use it automatically:
```bash
ghidra function list # Uses daemon
ghidra decompile main # Uses daemon
ghidra find crypto # Uses daemon
```
### Manual Daemon Control
```bash
ghidra daemon status # Check if running
ghidra daemon stop # Stop daemon
ghidra daemon restart --project p --program new_prog # Switch program
```
## Code Organization
- `src/main.rs` - CLI entry point, command routing
- `src/daemon/` - Daemon process, IPC server, command handlers
- `src/ghidra/bridge.rs` - Ghidra bridge connection management
- `src/ghidra/scripts/bridge.py` - Python script running inside Ghidra
- `src/ipc/` - IPC protocol and client
+53 -27
View File
@@ -4,16 +4,38 @@ A high-performance Rust CLI for automating Ghidra reverse engineering tasks, des
## Features
- **Fast daemon mode** - Keeps Ghidra loaded in memory for sub-second response times
- **Daemon-only architecture** - All operations route through a persistent daemon for consistency
- **Auto-start daemon** - Import/analyze commands automatically start the daemon
- **Fast queries** - Sub-second response times with Ghidra kept in memory
- **Comprehensive analysis** - Functions, symbols, types, strings, cross-references
- **Binary patching** - Modify bytes, NOP instructions, export patches
- **Call graphs** - Generate caller/callee graphs, export to DOT format
- **Search capabilities** - Find strings, bytes, functions, crypto patterns
- **Script execution** - Run Python/Java scripts, inline or from files
- **Batch operations** - Execute multiple commands from a file
- **Flexible output** - JSON, table, or count formats with field selection
- **Flexible output** - Human-readable, JSON, or pretty JSON formats
- **Filtering** - Powerful expression-based filtering (e.g., `size > 100`)
## Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ CLI Command │────▶│ Daemon (IPC) │────▶│ GhidraBridge │
│ ghidra ... │ │ Unix socket │ │ TCP to Ghidra │
└─────────────────┘ └──────────────────┘ └─────────────────┘
┌─────────────────┐
│ bridge.py │
│ (Ghidra Script)│
└─────────────────┘
```
All commands go through the daemon, which maintains a persistent connection to Ghidra via the bridge script. This provides:
- **Consistent state** - Single Ghidra process for all operations
- **Fast queries** - No JVM startup overhead per command
- **Auto-start** - Daemon starts automatically when needed
## Installation
### From Source
@@ -43,14 +65,14 @@ ghidra config set ghidra_path /path/to/ghidra
# Check installation
ghidra doctor
# Import and analyze a binary
# Import and analyze a binary (daemon auto-starts)
ghidra quick ./binary
# Or step by step:
ghidra import ./binary --project myproject --program mybinary
ghidra analyze --project myproject --program mybinary
# Start the daemon for fast repeated queries
ghidra daemon start --project myproject --program mybinary
# List functions
# Query functions (uses running daemon)
ghidra function list
# Decompile a function
@@ -73,8 +95,9 @@ ghidra graph calls main --depth 3
ghidra project create <name> # Create project
ghidra project list # List projects
ghidra project delete <name> # Delete project
ghidra import <binary> --project <p> # Import binary
ghidra import <binary> --project <p> # Import binary (auto-starts daemon)
ghidra analyze --project <p> # Run analysis
ghidra quick <binary> # Import + analyze in one step
```
### Function Analysis
@@ -149,36 +172,39 @@ ghidra stats # Program statistics
ghidra summary # Program summary
```
## Daemon Mode
## Daemon Management
The daemon keeps Ghidra loaded in memory for fast queries:
The daemon keeps Ghidra loaded in memory. It starts automatically when needed, but you can also control it manually:
```bash
# Start daemon with a program loaded
ghidra daemon start --project myproject --program mybinary
# All subsequent commands use the daemon automatically
ghidra function list # Fast!
ghidra decompile main # Fast!
# Check daemon status
ghidra daemon status
# All commands use the daemon automatically
ghidra function list # Fast!
ghidra decompile main # Fast!
# Stop daemon
ghidra daemon stop
# Restart with different program
ghidra daemon restart --project myproject --program otherbinary
```
## Output Formats
```bash
# JSON output (default)
ghidra function list --format json
# Human-readable (default)
ghidra function list
# Table format
ghidra function list --format table
# JSON output
ghidra function list --json
# Count only
ghidra function list --format count
# Pretty JSON
ghidra function list --pretty
# Select specific fields
ghidra function list --fields "name,address,size"
@@ -196,15 +222,15 @@ ghidra strings list --filter "length > 20"
## AI Agent Integration
Ghidra CLI is designed to work seamlessly with AI coding assistants like Claude Code. The structured JSON output and comprehensive command set make it ideal for automated reverse engineering workflows.
Ghidra CLI is designed to work seamlessly with AI coding assistants like Claude Code. The structured output and comprehensive command set make it ideal for automated reverse engineering workflows.
Example workflow with an AI agent:
1. `ghidra import suspicious.exe --project analysis --program suspicious`
2. `ghidra analyze --project analysis --program suspicious`
3. `ghidra daemon start --project analysis --program suspicious`
4. `ghidra find interesting` - AI analyzes suspicious patterns
5. `ghidra decompile <func>` - AI examines specific functions
6. `ghidra x-ref to <addr>` - AI traces data flow
1. `ghidra quick suspicious.exe` - Import, analyze, start daemon
2. `ghidra find interesting` - AI analyzes suspicious patterns
3. `ghidra decompile <func>` - AI examines specific functions
4. `ghidra x-ref to <addr>` - AI traces data flow
5. `ghidra patch nop <addr>` - AI patches anti-debug code
6. `ghidra patch export` - Export patched binary
## Contributing
+383 -105
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
# Daemon Module
The daemon is the central execution authority for ghidra-cli. All commands route through the daemon, which maintains a persistent connection to Ghidra via the bridge.
## Architecture
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CLI Client │────▶│ IPC Server │────▶│ Handler │────▶│ GhidraBridge│
│ (DaemonCli) │ │ (Unix sock) │ │ (Routing) │ │ (TCP→Ghidra)│
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
┌─────────────┐
│ bridge.py │
│ (In Ghidra) │
└─────────────┘
```
## Key Components
| File | Purpose |
|------|---------|
| `mod.rs` | Daemon main loop, startup, shutdown |
| `ipc_server.rs` | Unix socket server, accepts client connections |
| `handler.rs` | Routes IPC commands to bridge or specialized handlers |
| `process.rs` | Daemon lifecycle, lock files, process management |
| `queue.rs` | Command queue execution |
| `cache.rs` | Result caching |
| `state.rs` | Daemon state management |
| `handlers/` | Specialized command handlers |
## Command Flow
1. **CLI sends command** via IPC (Unix socket)
2. **IPC server** receives request, parses JSON
3. **Handler** routes to appropriate processor:
- Direct bridge commands (decompile, function list, etc.)
- Import/Analyze commands (via bridge.py handlers)
- ExecuteCli for generic CLI command forwarding
4. **Bridge** sends to Ghidra via TCP, receives response
5. **Response** flows back through IPC to CLI
## Auto-Start Behavior
Import, Analyze, and Quick commands auto-start the daemon:
1. CLI checks if daemon is running for project
2. If not, starts daemon in background (`daemonize_unix` / `daemonize_windows`)
3. Waits briefly for daemon to initialize
4. Connects and sends command
## Lifecycle
- **One daemon per project** - Lock file prevents duplicates
- **One program per daemon** - Daemon loads a single program
- **Graceful shutdown** - Handles SIGTERM, SIGINT, IPC shutdown command
- **Lock files** - Located at `~/.local/share/ghidra-cli/daemon-{hash}.lock`
- **Logs** - Located at `~/.local/share/ghidra-cli/daemon.log`
## Handlers
Specialized handlers in `handlers/` directory:
| Handler | Commands |
|---------|----------|
| `program.rs` | Program info, memory, imports, exports |
| `symbols.rs` | Symbol operations |
| `types.rs` | Data type operations |
| `comments.rs` | Comment operations |
| `graph.rs` | Call graph operations |
| `find.rs` | Search operations |
| `diff.rs` | Program diff operations |
| `patch.rs` | Binary patching |
| `script.rs` | Script execution |
| `disasm.rs` | Disassembly |
| `stats.rs` | Statistics |
| `batch.rs` | Batch command execution |
## Bridge Commands
Commands sent to bridge.py in Ghidra:
- `import` - Import binary using AutoImporter
- `analyze` - Trigger analysis using AutoAnalysisManager
- `list_functions`, `decompile`, `list_strings`, etc.
See `src/ghidra/scripts/bridge.py` for the full command reference.
+15
View File
@@ -97,6 +97,21 @@ async fn handle_command_inner(
}))).await
}
Command::Import { binary_path, project, program } => {
execute_bridge_command(bridge, "import", Some(json!({
"binary_path": binary_path,
"project": project,
"program": program,
}))).await
}
Command::Analyze { project, program } => {
execute_bridge_command(bridge, "analyze", Some(json!({
"project": project,
"program": program,
}))).await
}
Command::ExecuteCli { command_json } => {
// Deserialize and execute CLI command through the queue handlers
let cli_command: crate::cli::Commands = serde_json::from_str(&command_json)
-81
View File
@@ -6,7 +6,6 @@ pub mod scripts;
pub mod setup;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::Config;
use crate::error::{GhidraError, Result};
@@ -87,86 +86,6 @@ impl GhidraClient {
Ok(())
}
pub fn import_binary(&self, project_name: &str, binary_path: &Path, program_name: Option<&str>) -> Result<String> {
if !self.project_exists(project_name) {
self.create_project(project_name)?;
}
let program_name = program_name.unwrap_or_else(|| {
binary_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("program")
});
let project_path = self.get_project_path(project_name);
let headless = self.get_headless_script();
let output = Command::new(&headless)
.arg(project_path.to_str().unwrap())
.arg(project_name)
.arg("-import")
.arg(binary_path.to_str().unwrap())
.arg("-overwrite")
.output()?;
if !output.status.success() {
return Err(GhidraError::ExecutionFailed(
String::from_utf8_lossy(&output.stderr).to_string()
));
}
Ok(program_name.to_string())
}
pub fn analyze_program(&self, project_name: &str, program_name: &str) -> Result<()> {
let project_path = self.get_project_path(project_name);
let headless = self.get_headless_script();
let output = Command::new(&headless)
.arg(project_path.to_str().unwrap())
.arg(project_name)
.arg("-process")
.arg(program_name)
.output()?;
if !output.status.success() {
return Err(GhidraError::ExecutionFailed(
String::from_utf8_lossy(&output.stderr).to_string()
));
}
Ok(())
}
pub fn run_script(&self, project_name: &str, program_name: &str, script_path: &Path, args: &[String]) -> Result<String> {
let project_path = self.get_project_path(project_name);
let headless = self.get_headless_script();
let mut cmd = Command::new(&headless);
cmd.arg(project_path.to_str().unwrap())
.arg(project_name)
.arg("-process")
.arg(program_name)
.arg("-scriptPath")
.arg(script_path.parent().unwrap().to_str().unwrap())
.arg("-postScript")
.arg(script_path.file_name().unwrap().to_str().unwrap());
for arg in args {
cmd.arg(arg);
}
let output = cmd.output()?;
if !output.status.success() {
return Err(GhidraError::ExecutionFailed(
String::from_utf8_lossy(&output.stderr).to_string()
));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn get_scripts_dir(&self) -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| GhidraError::ConfigError("Could not determine config directory".to_string()))?;
+78
View File
@@ -800,8 +800,86 @@ def handle_stats(args):
except Exception as e:
return {"error": "Failed to get stats: " + str(e)}
# --- Import/Analyze Handlers ---
def handle_import(args):
"""Import a binary into the current project."""
from ghidra.app.util.importer import AutoImporter
from ghidra.util.task import ConsoleTaskMonitor
from java.io import File
binary_path = args.get("binary_path")
if not binary_path:
return {"error": "No binary_path provided"}
program_name = args.get("program")
if not program_name:
binary_file = File(binary_path)
program_name = binary_file.getName()
project = state.getProject()
if project is None:
return {"error": "No project open"}
try:
binary_file = File(binary_path)
if not binary_file.exists():
return {"error": "Binary file not found: " + binary_path}
monitor = ConsoleTaskMonitor()
project_data = project.getProjectData()
imported = AutoImporter.importByUsingBestGuess(
binary_file,
None,
project_data.getRootFolder(),
program_name,
monitor
)
if imported is None:
return {"error": "Failed to import binary"}
return {"status": "success", "program": program_name}
except Exception as e:
return {"error": "Import failed: " + str(e)}
def handle_analyze(args):
"""Trigger auto-analysis on the current program."""
from ghidra.app.cmd.analysis import AutoAnalysisManager
from ghidra.util.task import ConsoleTaskMonitor
program_name = args.get("program")
if not program_name:
return {"error": "No program name provided"}
if currentProgram is None:
return {"error": "No program currently loaded"}
if currentProgram.getName() != program_name:
return {"error": "Program mismatch: expected " + program_name + " but current is " + currentProgram.getName()}
try:
monitor = ConsoleTaskMonitor()
auto_mgr = AutoAnalysisManager.getAnalysisManager(currentProgram)
if auto_mgr is None:
return {"error": "Could not get AutoAnalysisManager"}
auto_mgr.reAnalyzeAll(None)
auto_mgr.startAnalysis(monitor)
return {"status": "success", "program": program_name}
except Exception as e:
return {"error": "Analysis failed: " + str(e)}
COMMANDS = {
"ping": handle_ping,
# Import/Analyze commands
"import": handle_import,
"analyze": handle_analyze,
"program_info": handle_program_info,
"program_close": handle_program_close,
"program_delete": handle_program_delete,
+28
View File
@@ -152,6 +152,34 @@ impl DaemonClient {
pub async fn execute_cli_json(&mut self, command_json: String) -> Result<serde_json::Value> {
self.send_command(Command::ExecuteCli { command_json }).await
}
/// Import a binary into a project.
pub async fn import_binary(
&mut self,
binary_path: &str,
project: &str,
program: Option<&str>,
) -> Result<serde_json::Value> {
self.send_command(Command::Import {
binary_path: binary_path.to_string(),
project: project.to_string(),
program: program.map(|s| s.to_string()),
})
.await
}
/// Analyze a program in a project.
pub async fn analyze_program(
&mut self,
project: &str,
program: &str,
) -> Result<serde_json::Value> {
self.send_command(Command::Analyze {
project: project.to_string(),
program: program.to_string(),
})
.await
}
}
/// Check if daemon is running (without establishing a full connection).
+12
View File
@@ -112,6 +112,18 @@ pub enum Command {
/// Get cross-references from an address
XRefsFrom { address: String },
// === Project Management ===
/// Import a binary into a project
Import {
binary_path: String,
project: String,
#[serde(skip_serializing_if = "Option::is_none")]
program: Option<String>,
},
/// Analyze a program in a project
Analyze { project: String, program: String },
// === Session Management ===
/// Health check
Ping,
+131 -78
View File
@@ -51,14 +51,14 @@ fn run(cli: Cli) -> anyhow::Result<()> {
Commands::Init => handle_init(),
Commands::Doctor => handle_doctor(),
Commands::Version => handle_version(),
Commands::Import(args) => handle_import(args),
Commands::Analyze(args) => handle_analyze(args),
Commands::Quick(args) => handle_quick(args),
Commands::Config(cmd) => handle_config_command(cmd),
Commands::SetDefault(args) => handle_set_default(args),
Commands::Project(args) => handle_project_command(args.command),
// Commands requiring daemon are handled by run_with_daemon_check
Commands::Query(_)
Commands::Import(_)
| Commands::Analyze(_)
| Commands::Quick(_)
| Commands::Query(_)
| Commands::Summary(_)
| Commands::Function(_)
| Commands::Strings(_)
@@ -88,7 +88,10 @@ async fn run_async(cli: Cli) -> anyhow::Result<()> {
fn requires_daemon(command: &Commands) -> bool {
matches!(
command,
Commands::Query(_)
Commands::Import(_)
| Commands::Analyze(_)
| Commands::Quick(_)
| Commands::Query(_)
| Commands::Decompile(_)
| Commands::Function(_)
| Commands::Strings(_)
@@ -117,33 +120,147 @@ async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> {
return run(cli);
}
// Query-type commands REQUIRE the daemon
let config = Config::load()?;
let project_path = match &cli.command {
Commands::Import(args) => {
resolve_project_path(&args.project, &config)?
}
Commands::Analyze(args) => {
resolve_project_path(&args.project, &config)?
}
Commands::Quick(args) => {
resolve_project_path(&args.project, &config)?
}
_ => {
resolve_project_path(&None, &config)?
}
};
ensure_daemon_running(&project_path).await?;
match ipc::client::DaemonClient::connect().await {
Ok(mut client) => {
info!("Connected to daemon via IPC");
let output = execute_via_daemon(&mut client, &cli.command).await?;
println!("{}", output);
if !output.is_empty() {
println!("{}", output);
}
Ok(())
}
Err(_) => {
eprintln!("Error: This command requires the daemon to be running.");
Err(e) => {
eprintln!("Error: Failed to connect to daemon: {}", e);
eprintln!();
eprintln!("Start the daemon with:");
eprintln!(" ghidra daemon start --project <project-name>");
eprintln!();
eprintln!("Or run a quick analysis first:");
eprintln!(" ghidra quick <binary>");
eprintln!("The daemon may still be starting. Try again in a moment.");
std::process::exit(1);
}
}
}
/// Ensure daemon is running for the given project path.
async fn ensure_daemon_running(project_path: &PathBuf) -> anyhow::Result<()> {
let data_dir = get_data_dir()?;
if get_running_daemon_info(&data_dir, project_path)?.is_some() {
return Ok(());
}
let config = Config::load()?;
let log_file = data_dir.join("daemon.log");
let daemon_config = DaemonConfig {
project_path: project_path.clone(),
ghidra_install_dir: config.ghidra_install_dir.map(PathBuf::from),
log_file,
program_name: config.default_program.clone(),
};
#[cfg(unix)]
{
daemonize_unix(daemon_config, None)?;
}
#[cfg(windows)]
{
daemonize_windows(daemon_config, None)?;
}
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
Ok(())
}
/// Execute a command via the daemon IPC connection.
async fn execute_via_daemon(
client: &mut ipc::client::DaemonClient,
command: &Commands,
) -> anyhow::Result<String> {
let result = match command {
Commands::Import(args) => {
let binary_path = PathBuf::from(&args.binary);
if !binary_path.exists() {
anyhow::bail!("Binary not found: {}", args.binary);
}
let result = client.import_binary(
&args.binary,
&args.project.as_ref().unwrap_or(&"quick-analysis".to_string()),
args.program.as_deref(),
).await?;
if let Some(program_name) = result.as_str() {
println!("Successfully imported as: {}", program_name);
} else if let Some(program_name) = result.get("program").and_then(|p| p.as_str()) {
println!("Successfully imported as: {}", program_name);
}
return Ok(String::new());
}
Commands::Analyze(args) => {
let config = Config::load()?;
let program = resolve_program(&args.program, &config)?;
let project = resolve_project(&args.project, &config, &program)?;
println!("Analyzing {}...", program);
client.analyze_program(&project, &program).await?;
println!("Analysis complete!");
return Ok(String::new());
}
Commands::Quick(args) => {
let project = args.project.clone().unwrap_or_else(|| "quick-analysis".to_string());
let binary_path = PathBuf::from(&args.binary);
println!("Quick analysis of {}...\n", args.binary);
println!("[1/3] Importing binary...");
let result = client.import_binary(&args.binary, &project, None).await?;
let program_name = if let Some(name) = result.as_str() {
name.to_string()
} else if let Some(name) = result.get("program").and_then(|p| p.as_str()) {
name.to_string()
} else {
binary_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("program")
.to_string()
};
println!("[2/3] Running analysis...");
client.analyze_program(&project, &program_name).await?;
println!("[3/3] Done!\n");
println!("Analysis complete. To query the binary, start the daemon:");
println!(" ghidra daemon start --project {} --program {}", project, program_name);
println!("\nThen run queries like:");
println!(" ghidra function list");
println!(" ghidra decompile main");
println!(" ghidra summary");
return Ok(String::new());
}
Commands::Query(args) => {
match args.data_type.as_str() {
"functions" => client.list_functions(args.limit, args.filter.clone()).await?,
@@ -658,70 +775,6 @@ fn handle_version() -> anyhow::Result<()> {
Ok(())
}
fn handle_import(args: cli::ImportArgs) -> anyhow::Result<()> {
let config = Config::load()?;
let client = GhidraClient::new(config.clone())?;
let project = resolve_project(&args.project, &config, &args.program.as_ref().unwrap_or(&"unknown".to_string()))?;
let binary_path = PathBuf::from(&args.binary);
if !binary_path.exists() {
anyhow::bail!(format!("Binary not found: {}", args.binary));
}
println!("Importing {} into project {}...", args.binary, project);
let program_name = client.import_binary(&project, &binary_path, args.program.as_deref())?;
println!("Successfully imported as: {}", program_name);
Ok(())
}
fn handle_analyze(args: cli::AnalyzeArgs) -> anyhow::Result<()> {
let config = Config::load()?;
let client = GhidraClient::new(config.clone())?;
let program = resolve_program(&args.program, &config)?;
let project = resolve_project(&args.project, &config, &program)?;
println!("Analyzing {}...", program);
client.analyze_program(&project, &program)?;
println!("Analysis complete!");
Ok(())
}
fn handle_quick(args: cli::QuickArgs) -> anyhow::Result<()> {
let config = Config::load()?;
let client = GhidraClient::new(config.clone())?;
let project = args.project.unwrap_or_else(|| "quick-analysis".to_string());
let binary_path = PathBuf::from(&args.binary);
println!("Quick analysis of {}...\n", args.binary);
// Import
println!("[1/3] Importing binary...");
let program_name = client.import_binary(&project, &binary_path, None)?;
// Analyze
println!("[2/3] Running analysis...");
client.analyze_program(&project, &program_name)?;
// Done
println!("[3/3] Done!\n");
println!("Analysis complete. To query the binary, start the daemon:");
println!(" ghidra daemon start --project {} --program {}", project, program_name);
println!("\nThen run queries like:");
println!(" ghidra function list");
println!(" ghidra decompile main");
println!(" ghidra summary");
Ok(())
}
fn handle_config_command(cmd: cli::ConfigCommands) -> anyhow::Result<()> {
use cli::ConfigCommands;