//! Reverse engineering for craft "deep" mode: binary inspection (binutils) //! and decompilation with a Ghidra installation in /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 { 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 { 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 { 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::>().join("\n"))); Ok(ToolOutput::text(truncate(&out, 60_000))) } } pub struct Decompile; fn file_hash(path: &Path) -> Result { 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 { 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 = 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// 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 { 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::>().into_iter().rev().collect::>().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, State(app): State>) -> 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, State(app): State>) -> 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;