- AgentConfig.reverse_tools (Settings -> Agents & commands) gives any mode binary_info and decompile; the clean-room check of the tools applies to craft only (ToolCtx.clean_room = clean room && craft). - decompile: optional processor (Ghidra language id) and base_address for raw images (BinaryLoader); separate cache per processor/address. - Skill "reverse-engineering": identify, convert HEX, processor table for common chips, decompile, explore, notes in analysis/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
302 lines
15 KiB
Rust
302 lines
15 KiB
Rust
//! Reverse engineering for craft "deep" mode: binary inspection (binutils)
|
|
//! and decompilation with a Ghidra installation in <data>/tools.
|
|
|
|
use crate::api::{ApiError, ApiResult};
|
|
use crate::app::App;
|
|
use crate::auth::require_admin;
|
|
use crate::store::User;
|
|
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str, truncate};
|
|
use anyhow::{Context, Result, bail};
|
|
use async_trait::async_trait;
|
|
use axum::extract::State;
|
|
use axum::{Extension, Json};
|
|
use serde_json::{Value, json};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Stdio;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
|
|
pub fn tools_dir(app: &App) -> PathBuf {
|
|
app.data_dir().join("tools")
|
|
}
|
|
|
|
/// Directory with CodeCraftExport.java and install-ghidra.sh.
|
|
pub fn scripts_dir() -> PathBuf {
|
|
if let Ok(p) = std::env::var("CODECRAFT_GHIDRA_SCRIPTS") {
|
|
return PathBuf::from(p);
|
|
}
|
|
["/app/ghidra", "./ghidra", "./server/ghidra"].iter().map(PathBuf::from).find(|p| p.join("CodeCraftExport.java").exists()).unwrap_or_else(|| PathBuf::from("/app/ghidra"))
|
|
}
|
|
|
|
pub fn ghidra_installed(app: &App) -> bool {
|
|
let t = tools_dir(app);
|
|
t.join("ghidra/support/analyzeHeadless").exists() && t.join("jdk/bin/java").exists()
|
|
}
|
|
|
|
fn deep_mode_required(ctx: &ToolCtx) -> Option<ToolOutput> {
|
|
ctx.clean_room.then(|| {
|
|
ToolOutput::error("Clean room mode: binaries may not be inspected. The user can switch this session to Deep mode to analyse the original's code.")
|
|
})
|
|
}
|
|
|
|
/// Run a command (sandboxed for non-admin users) and collect stdout+stderr.
|
|
async fn run(ctx: &ToolCtx, program: &str, args: &[&str], timeout: Duration) -> Result<String> {
|
|
let mut cmd = match &ctx.sandbox {
|
|
Some(spec) => {
|
|
if !crate::sandbox::available() {
|
|
bail!("the sandbox is not available on this server");
|
|
}
|
|
crate::sandbox::command(spec, &ctx.cwd, program, args)
|
|
}
|
|
None => {
|
|
let mut c = Command::new(program);
|
|
c.args(args).current_dir(&ctx.cwd);
|
|
c
|
|
}
|
|
};
|
|
let out = tokio::time::timeout(timeout, cmd.kill_on_drop(true).output()).await.context("timed out")??;
|
|
Ok(format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)))
|
|
}
|
|
|
|
pub struct BinaryInfo;
|
|
|
|
#[async_trait]
|
|
impl Tool for BinaryInfo {
|
|
fn name(&self) -> &str {
|
|
"binary_info"
|
|
}
|
|
fn description(&self) -> String {
|
|
"Inspect an executable or library (Windows PE .exe/.dll, Linux ELF, Mach-O, DOS MZ): file type, \
|
|
architecture, sections, imported libraries and functions, exported symbols and printable \
|
|
strings. Fast; use it before decompile."
|
|
.into()
|
|
}
|
|
fn schema(&self) -> Value {
|
|
json!({"type": "object", "properties": {"path": {"type": "string"}, "strings": {"type": "integer", "description": "Max. strings to list (default 200)"}}, "required": ["path"]})
|
|
}
|
|
fn read_only(&self) -> bool {
|
|
true
|
|
}
|
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
|
if let Some(e) = deep_mode_required(ctx) {
|
|
return Ok(e);
|
|
}
|
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
|
let p = path.to_string_lossy().to_string();
|
|
let max_strings = input["strings"].as_u64().unwrap_or(200).min(2000) as usize;
|
|
let t = Duration::from_secs(60);
|
|
let mut out = format!("## file\n{}\n", run(ctx, "file", &["-b", &p], t).await?.trim());
|
|
out.push_str(&format!("\n## headers and imports (objdump -x)\n{}\n", truncate(&run(ctx, "objdump", &["-x", "-w", &p], t).await?, 25_000)));
|
|
if run(ctx, "file", &["-b", &p], t).await?.contains("ELF") {
|
|
out.push_str(&format!("\n## dynamic symbols\n{}\n", truncate(&run(ctx, "objdump", &["-T", "-w", &p], t).await?, 10_000)));
|
|
}
|
|
let strings = run(ctx, "strings", &["-n", "6", &p], t).await?;
|
|
let lines: Vec<&str> = strings.lines().collect();
|
|
out.push_str(&format!("\n## strings ({} total, first {})\n{}\n", lines.len(), max_strings.min(lines.len()), lines.iter().take(max_strings).copied().collect::<Vec<_>>().join("\n")));
|
|
Ok(ToolOutput::text(truncate(&out, 60_000)))
|
|
}
|
|
}
|
|
|
|
pub struct Decompile;
|
|
|
|
fn file_hash(path: &Path) -> Result<String> {
|
|
use sha2::{Digest, Sha256};
|
|
let bytes = std::fs::read(path)?;
|
|
Ok(Sha256::digest(&bytes).iter().take(6).map(|b| format!("{b:02x}")).collect())
|
|
}
|
|
|
|
fn summarize(out_dir: &Path, cwd: &Path) -> Result<String> {
|
|
let rel = out_dir.strip_prefix(cwd).unwrap_or(out_dir).display().to_string();
|
|
let info = std::fs::read_to_string(out_dir.join("info.txt")).unwrap_or_default();
|
|
let funcs = std::fs::read_to_string(out_dir.join("functions.tsv")).unwrap_or_default();
|
|
let count_lines = |f: &str| std::fs::read_to_string(out_dir.join(f)).map(|s| s.lines().count().saturating_sub(1)).unwrap_or(0);
|
|
let mut rows: Vec<(u64, String)> = funcs
|
|
.lines()
|
|
.skip(1)
|
|
.filter_map(|l| {
|
|
let c: Vec<&str> = l.split('\t').collect();
|
|
Some((c.get(2)?.parse().ok()?, format!("{} {} ({} bytes) → {}", c[0], c[1], c[2], c.get(3)?)))
|
|
})
|
|
.collect();
|
|
rows.sort_by(|a, b| b.0.cmp(&a.0));
|
|
let top: Vec<String> = rows.iter().take(40).map(|r| r.1.clone()).collect();
|
|
Ok(format!(
|
|
"Decompiled output in {rel}/\n\n{info}\nFiles:\n- functions.tsv: {} decompiled functions (address, name, size, file, signature)\n- functions/*.c: pseudo-C per function\n- strings.tsv: {} strings\n- imports.tsv: {} imported functions\n- exports.tsv: {} exports\n\nLargest functions:\n{}\n\nUse grep/read on these files (e.g. grep -l \"CreateWindow\" {rel}/functions) to explore. \
|
|
Recreate behaviour in new code; do not copy decompiled code verbatim.",
|
|
rows.len(),
|
|
count_lines("strings.tsv"),
|
|
count_lines("imports.tsv"),
|
|
count_lines("exports.tsv"),
|
|
top.join("\n")
|
|
))
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for Decompile {
|
|
fn name(&self) -> &str {
|
|
"decompile"
|
|
}
|
|
fn description(&self) -> String {
|
|
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
|
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
|
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
|
large programs; results are cached per file. Raw firmware images (.bin without a file \
|
|
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
|
|
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
|
|
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
|
|
.into()
|
|
}
|
|
fn schema(&self) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
|
"force": {"type": "boolean", "description": "Re-run even if cached"},
|
|
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
|
|
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
|
|
},
|
|
"required": ["path"]
|
|
})
|
|
}
|
|
fn title(&self, input: &Value) -> String {
|
|
format!("Decompile {}", input["path"].as_str().unwrap_or("?"))
|
|
}
|
|
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
|
if let Some(e) = deep_mode_required(ctx) {
|
|
return Ok(e);
|
|
}
|
|
if !ghidra_installed(&ctx.app) {
|
|
return Ok(ToolOutput::error("Ghidra is not installed. An admin can install it under Settings → Tools."));
|
|
}
|
|
let path = ctx.resolve(arg_str(&input, "path")?)?;
|
|
if !path.is_file() {
|
|
bail!("{} is not a file", path.display());
|
|
}
|
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
|
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
|
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
|
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
|
|
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
|
|
}
|
|
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
|
|
bail!("invalid base_address (expected e.g. 0x08000000)");
|
|
}
|
|
// Another processor or load address is another analysis.
|
|
let variant = match (&processor, &base) {
|
|
(None, None) => String::new(),
|
|
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
|
|
};
|
|
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
|
|
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
|
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
|
}
|
|
tokio::fs::create_dir_all(&out_dir).await?;
|
|
let project = std::env::temp_dir().join(format!("cc-ghidra-{}", crate::types::new_id()));
|
|
tokio::fs::create_dir_all(&project).await?;
|
|
let tools = tools_dir(&ctx.app);
|
|
let headless = tools.join("ghidra/support/analyzeHeadless").display().to_string();
|
|
let scripts = scripts_dir().display().to_string();
|
|
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
|
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
|
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
|
|
// Raw firmware: the binary loader with the given processor and load address.
|
|
if let Some(proc_id) = processor.as_deref() {
|
|
args.extend(["-processor", proc_id]);
|
|
}
|
|
if let Some(addr) = base.as_deref() {
|
|
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
|
|
}
|
|
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
|
|
let java = tools.join("jdk").display().to_string();
|
|
let mut cmd = match &ctx.sandbox {
|
|
Some(spec) => {
|
|
// Ghidra itself (tools dir, scripts) is mounted read-only into the sandbox.
|
|
let mut c = crate::sandbox::command_with(spec, &ctx.cwd, &[tools.as_path(), Path::new(&scripts)], "env", &[]);
|
|
c.arg(format!("JAVA_HOME={java}")).arg(format!("PATH={java}/bin:/usr/bin:/bin")).arg(&headless).args(&args);
|
|
c
|
|
}
|
|
None => {
|
|
let mut c = Command::new(&headless);
|
|
c.args(&args).env("JAVA_HOME", &java).env("PATH", format!("{java}/bin:/usr/local/bin:/usr/bin:/bin")).current_dir(&ctx.cwd);
|
|
c
|
|
}
|
|
};
|
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true);
|
|
let mut child = cmd.spawn().context("cannot start Ghidra")?;
|
|
let stdout = child.stdout.take().unwrap();
|
|
let mut lines = BufReader::new(stdout).lines();
|
|
let mut log = String::new();
|
|
let deadline = tokio::time::sleep(Duration::from_secs(45 * 60));
|
|
tokio::pin!(deadline);
|
|
loop {
|
|
tokio::select! {
|
|
l = lines.next_line() => match l {
|
|
Ok(Some(l)) => {
|
|
if l.contains("CodeCraft:") || l.contains("ERROR") || l.contains("Analyzing") {
|
|
ctx.progress(l.clone());
|
|
}
|
|
if log.len() < 20_000 { log.push_str(&l); log.push('\n'); }
|
|
}
|
|
_ => break,
|
|
},
|
|
_ = &mut deadline => { let _ = child.kill().await; bail!("Ghidra timed out after 45 minutes"); }
|
|
_ = ctx.cancel.cancelled() => { let _ = child.kill().await; bail!("aborted"); }
|
|
}
|
|
}
|
|
let status = child.wait().await?;
|
|
let _ = tokio::fs::remove_dir_all(&project).await;
|
|
if !out_dir.join(".done").exists() {
|
|
let tail: String = log.lines().rev().take(25).collect::<Vec<_>>().into_iter().rev().collect::<Vec<_>>().join("\n");
|
|
return Ok(ToolOutput::error(format!("Ghidra did not finish (exit {status}). Last output:\n{tail}")));
|
|
}
|
|
Ok(ToolOutput::text(summarize(&out_dir, &ctx.cwd)?))
|
|
}
|
|
}
|
|
|
|
// --- admin: tool installation ------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
pub struct InstallState {
|
|
pub running: bool,
|
|
pub log: String,
|
|
}
|
|
|
|
pub async fn status(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
|
require_admin(&user)?;
|
|
let st = app.tool_install.lock().unwrap();
|
|
let version = std::fs::read_to_string(tools_dir(&app).join("ghidra/Ghidra/application.properties"))
|
|
.ok()
|
|
.and_then(|s| s.lines().find_map(|l| l.strip_prefix("application.version=").map(String::from)));
|
|
Ok(Json(json!({"ghidra": {"installed": ghidra_installed(&app), "version": version, "running": st.running, "log": st.log}})))
|
|
}
|
|
|
|
pub async fn install_ghidra(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
|
require_admin(&user)?;
|
|
{
|
|
let mut st = app.tool_install.lock().unwrap();
|
|
if st.running {
|
|
return Err(ApiError(axum::http::StatusCode::CONFLICT, "installation already running".into()));
|
|
}
|
|
st.running = true;
|
|
st.log.clear();
|
|
}
|
|
app.audit(Some(&user), "tools.install", "ghidra", "");
|
|
let app2 = app.clone();
|
|
tokio::spawn(async move {
|
|
let script = scripts_dir().join("install-ghidra.sh");
|
|
let res = Command::new("sh").arg(&script).arg(tools_dir(&app2)).stdout(Stdio::piped()).stderr(Stdio::piped()).output().await;
|
|
let mut st = app2.tool_install.lock().unwrap();
|
|
st.running = false;
|
|
st.log = match res {
|
|
Ok(o) => format!("{}{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr), if o.status.success() { "\n✓ done" } else { "\n✗ failed" }),
|
|
Err(e) => format!("cannot run installer: {e}"),
|
|
};
|
|
});
|
|
Ok(Json(json!({"started": true})))
|
|
}
|
|
|
|
pub type ToolInstall = Mutex<InstallState>;
|