idakit
Idiomatic, safe-Rust bindings over the IDA Pro kernel: drive idalib's disassembler and the Hex-Rays decompiler from Rust instead of the C++ SDK.
idakit is a Rust API over the IDA Pro kernel. It drives idalib, IDA's headless analysis engine, and the Hex-Rays decompiler from safe, idiomatic Rust, so you can enumerate functions, read bytes and cross-references, decompile to a C syntax tree, and rename or retype code without ever touching the C++ SDK. It is two crates: idakit-sys, the raw FFI surface over a compiled C++ facade, and idakit, the typed layer built on top.
use idakit::prelude::*;
// Flag every call into a risky C API, across a whole database.
let mut db = Ida::here()?;
db.open("path/to/database.i64").call()?;
const SINKS: &[&str] = &["strcpy", "system", "memcpy", "sprintf"];
for function in db.functions() {
let Some(tree) = function.decompile().ok().and_then(|d| d.ctree().ok()) else { continue };
for (_, callee, _) in tree.calls() {
let Some((_, Some(name))) = tree.kind(callee).as_obj() else { continue };
if SINKS.iter().any(|s| name.contains(s)) {
println!("{} calls {name}", function.name());
}
}
}
db.close(false);Almost everything interesting about idakit follows from one fact: the engine it wraps was never designed to be driven this way.
The kernel is hostile to a wrapper
idalib is a headless build of IDA's kernel, and it carries three constraints a naive binding trips over. It is single-threaded and thread-affine: the engine must be initialized and called from one thread for the life of the process. It is a process global: exactly one database is open at a time, with no handle to pass around. And it does not return errors for the conditions it cannot recover from, it ends the process. An unaccepted license runs verror() into qexit() into exit(); a failed internal assertion in the bundled LLVM calls abort(). Link idalib into your program and one bad call takes the whole thing down with it.
Each constraint shapes a part of idakit. The last one is the most surprising to solve.
Trapping a library that calls exit()
When idalib decides it cannot continue, it calls libc exit(). From Rust that is not an error you can catch, it is process death, and Ok/Err never gets a chance to matter. A wrapper that returns a Result from open() is lying if an unlicensed database simply terminates the caller.
idakit intercepts the call. On Linux it walks the dynamic relocations of every loaded libida object and rewrites the global offset table slots for exit and abort to point at its own stand-ins. Inside a guarded kernel call, the stand-in jumps back to a landing point armed just before the call and reports an Error::KernelExit; outside one, it forwards to the real exit/abort, so ordinary shutdown and genuine crashes are left untouched. Because it rewrites the GOT rather than interposing symbols the way LD_PRELOAD or a linker flag would, it needs no cooperation from the link: any binary that pulls in idakit gets the trap, with nothing to configure.
A trapped exit is only useful if you learn why. IDA writes its diagnostics straight to file descriptors 1 and 2, so idakit redirects those into an in-memory pipe for the duration of a guarded call and drains it into the returned error. Loader and format rejections report through a different channel, IDA's msg(), which is a no-op sink in headless mode, so a UI notification hook catches that text at its source instead. Whatever IDA meant to print on the way out rides back inside the error.
How the trap actually works
The stand-ins escape with a longjmp, not a C++ exception, and that choice is forced. libida's own stack frames carry no unwind information, so a thrown exception cannot propagate back through them, whereas a longjmp only has to restore the stack pointer. The cost is that the landing point and the trap must sit in the same C call chain with no Rust frame between them, since a Rust frame cannot be jumped over. Rewriting a GOT slot also means touching a page that RELRO may have marked read-only, so the writer flips it back with mprotect first. The scan runs through dl_iterate_phdr, walking each libida object's relocation tables for the exit and abort symbols.
The kernel thread, expressed as a type
The single-thread and single-database rules map cleanly onto Rust's ownership model, and idakit leans on the compiler to enforce them rather than on the programmer to remember them.
Database, the open database, is !Send and !Sync: a PhantomData<*const ()> field pins it to the thread that opened it, so it cannot be moved to another by accident. Only one may be live at a time, guarded by a process-wide atomic flag and an RAII claim that releases the kernel on drop; a second attempt returns InitError::AlreadyRunning.
Two entry points cover the two ways you might want to run it. Ida::here() claims the kernel on the current thread and hands the database back directly, the right shape for a script, test, or CLI that owns its thread. Ida::run() hosts the kernel on a dedicated thread and runs your code on the caller; any thread then marshals a closure onto the kernel with Ida::call(), which wraps every closure in catch_unwind so a panic on the kernel thread comes back as an error instead of poisoning the engine.
Reads and writes are separated by the borrow checker. A read borrows &Database and returns a lightweight Copy view, a Function or Segment that holds the borrow and re-queries the kernel per accessor. A write takes &mut Database. So a read view can never be held across a mutation: the code that would let a stale Function outlive the rename that invalidated it does not compile.
Crossing the C++ boundary
IDA's SDK is C++, and C++ has no stable ABI to bind against directly. idakit compiles a small C++ facade that includes the SDK headers, hides their C++ types behind opaque pointers and plain-old-data structs, and exposes the result to Rust. Part of that surface is a hand-written C ABI for the lifecycle and trap machinery; the rest is generated by the cxx crate, which marshals the per-domain data types. Either way, every allocating body is wrapped so that a C++ exception can never unwind across the boundary into Rust, it aborts instead.
The facade is compiled against the headers for one specific IDA version and pinned to it. IDA's ABI is stable within a minor release and breaks across minors, so idakit targets a single minor, currently 9.3, and treats a new minor as a deliberate bump rather than something to paper over.
Decompiling off the kernel thread
Decompilation is the reason to reach past the disassembler, and it is where the kernel-thread constraint bites hardest: you want to analyze pseudocode, and analysis is exactly the kind of open-ended work you would rather not pin to a single thread.
So idakit materializes the Hex-Rays output into an owned, Send snapshot. Ctree is a decompiled function's C syntax tree, built on the kernel thread and then walkable from anywhere; the example at the top of this page walks its call expressions looking for sink names. The tree lives in an index arena, the same pattern rust-analyzer uses for its own syntax trees: nodes are 32-bit typed handles into a flat vector, recursive structures are built placeholder-then-fill, and traversal is push-based. Once built, it owns its data and no longer needs the kernel at all.
What it costs
idakit is a binding, not a reimplementation, so it inherits IDA's requirements. Building it needs a local IDA install to link against, and running it needs a valid license, which the engine checks on startup; idakit ships none of IDA itself. It works only with 64-bit .i64 databases, since the facade is compiled __EA64__, though it can analyze a 32-bit binary from scratch, because the limitation is the database format and not the target.
The exit trap is Linux-only. It rewrites ELF GOT slots, which have no equivalent on the macOS and Windows loaders, so on those platforms a fatal condition inside the kernel still ends the process, exactly as raw idalib does. The crate builds and its portable tests pass on all three, but Linux is the only tier where a mislicensed database comes back as a catchable error rather than a dead process.