Repository: https://github.com/x0prc/K-GEF

A Knowledge-Graph Approach to Firmware Security Analysis

Firmware security analysis has always been a tedious, manual process. You’re handed a binary blob, you extract it, you disassemble it, you spend hours tracing through function calls trying to figure out if that network handler might have a buffer overflow. It’s exhausting work, and honestly, it’s easy to miss things when you’re staring at thousands of functions.

That’s exactly why I built K-GEF. It’s not trying to replace the analyst—it’s trying to make your life easier by automating the boring stuff and giving you a graph-based view of the firmware that actually helps you find vulnerabilities faster.

Think of it as giving your brain a visual aid. Instead of scrolling through disassembled code, you get to work with a graph where nodes are functions and edges are calls, string references, or library usage. You can then ask questions like “show me every function that handles network data and passes it to memcpy” — and actually get an answer in seconds.

How It Works: The Pipeline

The tool follows a clear pipeline that mirrors how you’d analyze firmware manually, just much faster:

  1. Unpack the firmware using binwalk
  2. Index all ELF binaries and identify architectures
  3. Analyze each binary with Ghidra headlessly
  4. Build a knowledge graph from the analysis results
  5. Tag functions with security-relevant labels
  6. Query the graph for vulnerability patterns

Let me walk through each step.

Step 1: Unpacking Firmware

You start with a firmware image—maybe you pulled it off a router or an IoT device. K-GEF uses binwalk to extract files:

// From src/firmware.rs
pub fn unpack_firmware(input: &Path, out_dir: &Path) -> Result<()> {
    std::fs::create_dir_all(out_dir)?;
 
    let status = std::process::Command::new("binwalk")
        .arg("-e")
        .arg(input)
        .current_dir(out_dir)
        .status()?;
 
    if !status.success() {
        anyhow::bail!("Binwalk failed with status: {}", status);
    }
    Ok(())
}

Simple enough. You give it a firmware file, it runs binwalk in extraction mode, and dumps everything into your output directory.

Step 2: Finding ELF Binaries

Once extracted, you need to know what you’re working with. K-GEF walks the extracted directory and identifies ELF binaries:

// From src/firmware.rs
pub fn index_binaries(root: &Path) -> Result<Vec<BinaryInfo>> {
    let mut binaries = Vec::new();
    let mut counter = 0;
 
    for entry in WalkDir::new(root).follow_links(true) {
        let entry = entry?;
        if entry.file_type().is_file() {
            if let Ok(bytes) = std::fs::read(&entry.path()) {
                if let Ok(elf) = Elf::parse(&bytes) {
                    counter += 1;
                    binaries.push(BinaryInfo {
                        id: format!("bin_{:03}", counter),
                        path: entry.path().strip_prefix(root)?.to_path_buf(),
                        arch: format!("{:?}", elf.header.e_machine),
                        size: bytes.len() as u64,
                    });
                }
            }
        }
    }
    // ... save to binaries.json
}

The goblin crate does the heavy lifting here—it parses ELF headers and tells you the architecture. You’ll get a JSON file listing every binary, its path, and what architecture it’s built for.

Step 3: Running Ghidra Analysis

This is where things get interesting. K-GEF invokes Ghidra in headless mode for each binary and runs a custom Python script that exports the analysis results as JSON:

# From ghidra-scripts/export_fw.py
def export_analysis(program):
    results = {
        "functions": [],
        "calls": [],
        "strings": [],
        "imports": []
    }
    # Export Functions
    for func in program.getFunctionManager().getFunctions(True):
        results["functions"].append({
            "addr": hex(func.getEntryPoint().getOffset()),
            "name": func.getName(),
            "size": func.getBody().getNumAddresses()
        })
 
    # Export Caller -> Callee 
    for func in program.getFunctionManager().getFunctions(True):
        caller_addr = func.getEntryPoint().getOffset()
        for dest in func.getCallDestinations():
            results["calls"].append({
                "caller": hex(caller_addr),
                "callee": hex(dest.getDestinationAddress().getOffset())
            })
 
    # Export strings + xrefs
    for str_obj in program.getListing().getDefinedData(True):
        if str_obj.getValue() and isinstance(str_obj.getValue(), unicode):
            results["strings"].append({
                "addr": hex(str_obj.getAddress().getOffset()),
                "value": str_obj.getValue().toString()
            })
 
    # Export Imports
    for sym in program.getSymbolTable().getExternalSymbols():
        results["imports"].append({
            "name": sym.getName(),
            "addr": hex(sym.getAddress().getOffset())
        })
 
    return results

The script grabs functions, call relationships, string references, and external imports—all the stuff you’d otherwise have to manually scroll through Ghidra to find.

From Rust, running this looks like:

// From src/analysis.rs
pub fn analyze_binary(binary_path: &Path, output_path: &Path, ghidra_home: &str) -> Result<()> {
    let script_path = Path::new(ghidra_home).join("Ghidra/Features/Python/data/export_fw.py");
    
    let status = Command::new(format!("{}/analyzeHeadless", ghidra_home))
        .args(&[
            "/tmp/ghidra_proj",
            "temp_proj",
            "-import", binary_path.to_str().unwrap(),
            "-postScript", script_path.to_str().unwrap(),
            "-deleteProject",
            output_path.to_str().unwrap(),
        ])
        .status()?;
    // ...
}

Step 4: Building the Knowledge Graph

Now we have JSON files full of analysis data. The next step is to turn that into a graph. K-GEF uses petgraph, a Rust graph library, to build an in-memory graph:

// From src/graph.rs
pub fn build_graph(
    firmware_id: &str,
    analysis_dir: &Path,
) -> Result<(FwGraph, Vec<String>)> {
    let mut graph = FwGraph::new();
    
    let firmware_node = graph.add_node(NodeKind::Firmware {
        id: firmware_id.to_string(),
    });
    
    // For each JSON file (one per binary)...
    for entry in std::fs::read_dir(analysis_dir)? {
        let path = entry?.path();
        if path.extension().map_or(false, |e| e == "json") {
            let data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path)?)?;
            let bin_id = path.file_stem().unwrap().to_str().unwrap();
            
            // Add Binary node
            let binary_node = graph.add_node(NodeKind::Binary {
                id: bin_id.to_string(),
                path: format!("{}", path.display()),
            });
            graph.add_edge(firmware_node, binary_node, EdgeKind::Contains);
            
            // Add Functions as nodes
            let mut func_to_node: HashMap<String, NodeIndex> = HashMap::new();
            for func in data["functions"].as_array().unwrap() {
                let func_node = graph.add_node(NodeKind::Function {
                    addr: func["addr"].as_str().unwrap().to_string(),
                    name: func["name"].as_str().unwrap().to_string(),
                });
                graph.add_edge(binary_node, func_node, EdgeKind::Contains);
                func_to_node.insert(func["addr"].as_str().unwrap().to_string(), func_node);
            }
            
            // Add Call edges between functions
            for call in data["calls"].as_array().unwrap() {
                let caller_addr = call["caller"].as_str().unwrap();
                let callee_addr = call["callee"].as_str().unwrap();
                
                if let (Some(caller_node), Some(callee_node)) = 
                    (func_to_node.get(caller_addr), func_to_node.get(callee_addr)) {
                    graph.add_edge(*caller_node, *callee_node, EdgeKind::Calls);
                }
            }
            
            // Add String references and Library imports...
        }
    }
    
    Ok((graph, stats))
}

The graph structure is clean. You’ve got different node types:

  • Firmware — the root node
  • Binary — each ELF file in the firmware
  • Function — every function Ghidra identified
  • String — string constants found in the binary
  • Library — external libraries being used (libc, libssl, etc.)

And edges that represent relationships:

  • Contains — a firmware contains binaries, a binary contains functions
  • Calls — one function calls another
  • UsesString — a function references a string
  • UsesLib — a function uses an external library

Step 5: Semantic Tagging

This is one of my favorite features. Instead of just having a raw graph, K-GEF applies semantic tags to functions based on what they do. It uses regex patterns on function names and imports:

// From src/semantic.rs
pub fn tag_functions(analysis: &AnalysisResult) -> HashMap<String, FunctionTags> {
    let mut tagged = HashMap::new();
    
    let network_re = Regex::new(r"(socket|bind|accept|listen|recv|send)").unwrap();
    let memcopy_re = Regex::new(r"(memcpy|memmove|strcpy|strcat)").unwrap();
    let crypto_re = Regex::new(r"(AES|SHA|SSL|DES|RC4|mbedtls)").unwrap();
    
    for func in &analysis.functions {
        let func_name = func.name.to_lowercase();
        let mut tags = FunctionTags {
            network_source: false,
            memcopy_sink: false,
            crypto_usage: false,
            constant_key: false,
        };
        
        for import in &analysis.imports {
            let imp_name = import.name.to_lowercase();
            
            if network_re.is_match(&imp_name) {
                tags.network_source = true;
            }
            if memcopy_re.is_match(&imp_name) {
                tags.memcopy_sink = true;
            }
            if crypto_re.is_match(&imp_name) {
                tags.crypto_usage = true;
            }
        }
        
        // functions calling memcpy with string constants nearby = constant_key risk
        if tags.crypto_usage && tags.memcopy_sink {
            tags.constant_key = true;
        }
        
        tagged.insert(func.addr.clone(), tags);
    }
    
    tagged
}

So if a function imports both network functions (like socket or recv) AND memory manipulation functions (like memcpy), it gets tagged as a potential RCE candidate. If it uses crypto libraries and passes data to memory functions, it’s flagged for hardcoded key risks.

Step 6: Querying with Neo4j

The in-memory graph is great, but the real power comes from loading it into Neo4j, where you can run Cypher queries. K-GEF connects to Neo4j and lets you ask questions:

// From src/queries.rs
async fn query_rce_candidates(mut graph: Graph, firmware_id: &str) -> Result<()> {
    let rows = graph
        .query()
        .raw(
            "MATCH (f:Firmware {id: $fw_id})-[:CONTAINS*1..2]->(src:Function)
             WHERE toLower(src.name) CONTAINS 'socket' OR toLower(src.name) CONTAINS 'bind'
             OPTIONAL MATCH path=(src)-[:CALLS*1..4]-(sink:Function)
             WHERE toLower(sink.name) CONTAINS 'memcpy' OR toLower(sink.name) CONTAINS 'strcpy'
             RETURN src.name, sink.name, length(path) as distance, 
                    CASE WHEN sink.name IS NOT NULL THEN 'HIGH' ELSE 'MED' END as risk
             ORDER BY distance ASC LIMIT 20",
            params! { "fw_id" => firmware_id }
        )
        .await?;
    // ... display results in table
}

This query finds functions that handle network operations (source) and traces up to 4 calls deep to see if they eventually call dangerous sink functions like memcpy or strcpy. That’s exactly the pattern you’re looking for when hunting remote code execution vulnerabilities.

There are also built-in queries for crypto risks and vulnerable library detection:

async fn query_crypto_risks(mut graph: Graph, firmware_id: &str) -> Result<()> {
    // Find functions using crypto libraries
    let rows = graph
        .query()
        .raw(
            "MATCH (f:Firmware {id: $fw_id})-[:CONTAINS*]->(fn:Function)-[:CALLS|USES_LIB*]->(lib:Library)
             WHERE toLower(fn.name) CONTAINS 'crypto' OR toLower(lib.name) CONTAINS 'ssl'
             RETURN fn.name, lib.name, count(lib) as lib_count
             ORDER BY lib_count DESC LIMIT 15",
            params! { "fw_id" => firmware_id }
        )
        .await?;
    // ...
}
 
async fn query_vuln_libs(mut graph: Graph, firmware_id: &str) -> Result<()> {
    // Check for known vulnerable library versions
    let vuln_libs = vec!["openssl < 1.1.1", "libcurl < 7.80", "busybox"];
    // ... query and cross-reference
}

Using the Tool

The CLI is straightforward. Here’s the workflow:

# 1. Unpack firmware
kgef unpack -i firmware.bin -o extracted/
 
# 2. Index binaries
kgef index -r extracted/
 
# 3. Analyze with Ghidra
kgef analyze -r extracted/ -o analysis_output/ -g /opt/ghidra
 
# 4. Build the graph
kgef graph-build -f my-router-fw -a analysis_output/
 
# 5. Apply semantic tags
kgef graph-tag -f my-router-fw -a analysis_output/
 
# 6. Query for vulnerabilities (requires Neo4j running)
kgef query -f my-router-fw rce-candidates -u bolt://localhost:7687
kgef query -f my-router-fw crypto-risks
kgef query -f my-router-fw vuln-libs

Why This Approach Works

I’ve found three things make this approach genuinely useful:

  1. Graphs beat lists — When you’re analyzing firmware, you’re not just looking at functions in isolation. You’re tracing data flow. Graphs naturally represent these relationships, and Neo4j’s query language lets you ask complex questions that would take hours to answer manually.

  2. Automation beats repetition — Running binwalk, then Ghidra on each binary, then parsing results, then building the graph—doing this manually is error-prone and boring. K-GEF automates all of it.

  3. Patterns beat intuition — Security vulnerabilities often follow patterns. Network input → no validation → memory copy → overflow. By encoding these patterns as graph queries, you find issues systematically rather than relying on “feeling” like something is wrong.

What’s Next

The project is still early (version 0.0.1), but the foundation is solid. Future directions I’m thinking about:

  • Data flow analysis — Currently we look at call graphs, but taint analysis would be even more powerful
  • More vulnerability patterns — Format string bugs, integer overflows, race conditions
  • Interactive visualization — Maybe a web UI to explore the graph
  • CVE integration — Automatically mapping library versions to known CVEs

If you’re doing firmware analysis professionally, I’d love to hear what patterns you’d want to query. Pull requests are welcome too—it’s MIT licensed.

Wrapping Up

Firmware security is hard because the tooling hasn’t kept up with the complexity of modern embedded software. K-GEF won’t find every vulnerability—some things still require human intuition and deep understanding. But it gets you 80% of the way there faster, and lets you focus your expert attention where it actually matters.