balrog.git


balrog

~ ELF file analysis tool to be used after firmware extraction
git clone https://github.com/djnnvx/balrog.git
log | files | refs | archive | README

commit b1504833e05949a8f04b10f1adc2e8b658d06b5b
Author: djnn <[email protected]>
Date: Fri, 18 Sep 2026 21:28:41 +0200

initial commit

Diffstat:
A.gitignore | 1+
ACargo.lock | 401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 18++++++++++++++++++
AREADME.md | 36++++++++++++++++++++++++++++++++++++
Asrc/arch/aarch64.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/arch/arm.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/arch/mips.rs | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/arch/mod.rs | 280+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/graph.rs | 303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/load.rs | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/main.rs | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/rules.rs | 1477+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/scorecard.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

13 files changed, 3299 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,401 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "balrog"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "capstone",
+ "clap",
+ "goblin",
+ "rayon",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "capstone"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "015ef5d5ca1743e3f94af9509ba6bd2886523cfee46e48d15c2ef5216fd4ac9a"
+dependencies = [
+ "capstone-sys",
+ "libc",
+]
+
+[[package]]
+name = "capstone-sys"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2267cb8d16a1e4197863ec4284ffd1aec26fe7e57c58af46b02590a0235809a0"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "cc"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
+
+[[package]]
+name = "goblin"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8"
+dependencies = [
+ "log",
+ "plain",
+ "scroll",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "scroll"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add"
+dependencies = [
+ "scroll_derive",
+]
+
+[[package]]
+name = "scroll_derive"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "balrog"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+capstone = "0.13"
+goblin = "0.10"
+rayon = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+clap = { version = "4", features = ["derive"] }
+anyhow = "1"
+
+[profile.release]
+opt-level = 3
+lto = true
+codegen-units = 1
diff --git a/README.md b/README.md
@@ -0,0 +1,36 @@
+# balrog
+
+Ranks the call sites in a firmware ELF tree that are worth opening in a decompiler.
+
+This tool is meant to be used with `moria` and `mithril`. Basically, you can
+think of it as a triager for ELF files, once you've extracted a firmware and looked
+for CVEs. What deserves your attention first ?
+
+- https://github.com/nmatt0/moria
+- https://github.com/nmatt0/mithril
+
+```sh
+moria -e firmware.bin              # unpack
+mithril firmware.bin.extracted/    # secrets, SBOM, CVEs
+balrog scan firmware.bin.extracted/
+```
+
+## Build
+
+```sh
+cargo build --release
+CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc \
+  cargo build --release --target x86_64-unknown-linux-musl   # static binary
+```
+
+## Use
+
+```
+balrog scan <path>...          rank risky calls, grouped by class
+balrog scan -j <path>          JSON
+balrog scan --class exec <p>   one class only
+balrog scan --min 100 <p>      score floor
+balrog scan --per-bin 2 <p>    cap findings per class per binary
+balrog imports <file>          resolved imports and their stub addresses
+```
+
diff --git a/src/arch/aarch64.rs b/src/arch/aarch64.rs
@@ -0,0 +1,50 @@
+use super::{imm, mem, reg, Kind, Op};
+use capstone::arch::arm64::Arm64Reg;
+
+pub const ARG_REGS: [u16; 8] = [
+    Arm64Reg::ARM64_REG_X0 as u16,
+    Arm64Reg::ARM64_REG_X1 as u16,
+    Arm64Reg::ARM64_REG_X2 as u16,
+    Arm64Reg::ARM64_REG_X3 as u16,
+    Arm64Reg::ARM64_REG_X4 as u16,
+    Arm64Reg::ARM64_REG_X5 as u16,
+    Arm64Reg::ARM64_REG_X6 as u16,
+    Arm64Reg::ARM64_REG_X7 as u16,
+];
+
+pub const SP: u16 = Arm64Reg::ARM64_REG_SP as u16;
+
+pub fn decode(mnemonic: &str, o: &[Op; 3]) -> Option<Kind> {
+    match mnemonic {
+        "bl" => Some(Kind::Call(imm(o, 0)? as u64)),
+        "blr" => Some(Kind::CallInd { reg: reg(o, 0)? }),
+        "ret" => Some(Kind::Ret),
+        "adrp" | "adr" => Some(Kind::SetConst {
+            dst: reg(o, 0)?,
+            val: imm(o, 1)? as u64,
+        }),
+        "ldr" => {
+            let (base, disp) = mem(o, 1)?;
+            Some(Kind::Load {
+                dst: reg(o, 0)?,
+                base,
+                disp,
+            })
+        }
+        "add" => Some(Kind::AddImm {
+            dst: reg(o, 0)?,
+            base: reg(o, 1)?,
+            imm: imm(o, 2)?,
+        }),
+        "sub" => Some(Kind::AddImm {
+            dst: reg(o, 0)?,
+            base: reg(o, 1)?,
+            imm: -imm(o, 2)?,
+        }),
+        "mov" => Some(Kind::Mov {
+            dst: reg(o, 0)?,
+            src: reg(o, 1)?,
+        }),
+        _ => None,
+    }
+}
diff --git a/src/arch/arm.rs b/src/arch/arm.rs
@@ -0,0 +1,92 @@
+use super::{imm, mem, reg, Kind, Op, HIGH_HALF_SHIFT, IMM16_MASK};
+use crate::load::Binary;
+use capstone::arch::arm::ArmReg;
+
+const PC_BIAS: u64 = 8;
+const WORD: usize = 4;
+
+pub const ARG_REGS: [u16; 4] = [
+    ArmReg::ARM_REG_R0 as u16,
+    ArmReg::ARM_REG_R1 as u16,
+    ArmReg::ARM_REG_R2 as u16,
+    ArmReg::ARM_REG_R3 as u16,
+];
+
+pub const SP: u16 = ArmReg::ARM_REG_SP as u16;
+
+fn pc_relative(addr: u64, disp: i64) -> u64 {
+    addr.wrapping_add(PC_BIAS).wrapping_add(disp as u64)
+}
+
+fn literal(addr: u64, disp: i64, bin: &Binary) -> Option<u64> {
+    let pool = pc_relative(addr, disp);
+    let word: [u8; WORD] = bin.read_vaddr(pool, WORD)?.try_into().ok()?;
+    Some(if bin.little {
+        u32::from_le_bytes(word) as u64
+    } else {
+        u32::from_be_bytes(word) as u64
+    })
+}
+
+pub fn decode(mnemonic: &str, o: &[Op; 3], addr: u64, bin: &Binary) -> Option<Kind> {
+    match mnemonic {
+        "bl" | "blx" => match o[0] {
+            Op::Imm(target) => Some(Kind::Call(target as u64)),
+            Op::Reg(r) => Some(Kind::CallInd { reg: r }),
+            _ => None,
+        },
+        "bx" => Some(Kind::Ret),
+        "movw" => {
+            let low_half = (imm(o, 1)? as u64) & IMM16_MASK;
+            Some(Kind::SetConst {
+                dst: reg(o, 0)?,
+                val: low_half,
+            })
+        }
+        "movt" => {
+            let high_half = ((imm(o, 1)? as u64) << HIGH_HALF_SHIFT) as i64;
+            Some(Kind::AddImm {
+                dst: reg(o, 0)?,
+                base: reg(o, 0)?,
+                imm: high_half,
+            })
+        }
+        "ldr" => {
+            let (base, disp) = mem(o, 1)?;
+            let dst = reg(o, 0)?;
+            if base == ArmReg::ARM_REG_PC as u16 {
+                Some(Kind::PcVal {
+                    dst,
+                    val: literal(addr, disp, bin)?,
+                })
+            } else {
+                Some(Kind::Load { dst, base, disp })
+            }
+        }
+        "add" => {
+            let (dst, base) = (reg(o, 0)?, reg(o, 1)?);
+            if base == ArmReg::ARM_REG_PC as u16 {
+                Some(Kind::SetConst {
+                    dst,
+                    val: pc_relative(addr, imm(o, 2)?),
+                })
+            } else {
+                Some(Kind::AddImm {
+                    dst,
+                    base,
+                    imm: imm(o, 2)?,
+                })
+            }
+        }
+        "sub" => Some(Kind::AddImm {
+            dst: reg(o, 0)?,
+            base: reg(o, 1)?,
+            imm: -imm(o, 2)?,
+        }),
+        "mov" => Some(Kind::Mov {
+            dst: reg(o, 0)?,
+            src: reg(o, 1)?,
+        }),
+        _ => None,
+    }
+}
diff --git a/src/arch/mips.rs b/src/arch/mips.rs
@@ -0,0 +1,82 @@
+use super::{imm, mem, reg, Kind, Op, HIGH_HALF_SHIFT, IMM16_MASK};
+use crate::load::{dyn_val, sym_name};
+use capstone::arch::mips::MipsReg;
+use goblin::elf::Elf;
+use std::collections::HashMap;
+
+const DT_PLTGOT: u64 = 3;
+const DT_MIPS_LOCAL_GOTNO: u64 = 0x7000_000a;
+const DT_MIPS_SYMTABNO: u64 = 0x7000_0011;
+const DT_MIPS_GOTSYM: u64 = 0x7000_0013;
+const GP_BIAS: u64 = 0x7ff0;
+
+pub const ARG_REGS: [u16; 4] = [
+    MipsReg::MIPS_REG_A0 as u16,
+    MipsReg::MIPS_REG_A1 as u16,
+    MipsReg::MIPS_REG_A2 as u16,
+    MipsReg::MIPS_REG_A3 as u16,
+];
+
+pub const SP: u16 = MipsReg::MIPS_REG_SP as u16;
+pub const GP: u16 = MipsReg::MIPS_REG_GP as u16;
+
+pub fn decode(mnemonic: &str, o: &[Op; 3]) -> Option<Kind> {
+    match mnemonic {
+        "jal" | "bal" | "jals" => Some(Kind::Call(imm(o, 0)? as u64)),
+        "jalr" => Some(Kind::CallInd { reg: reg(o, 0)? }),
+        "jr" => (reg(o, 0)? == MipsReg::MIPS_REG_RA as u16).then_some(Kind::Ret),
+        "lui" => {
+            let high_half = ((imm(o, 1)? as u64) & IMM16_MASK) << HIGH_HALF_SHIFT;
+            Some(Kind::SetConst {
+                dst: reg(o, 0)?,
+                val: high_half,
+            })
+        }
+        "lw" => {
+            let (base, disp) = mem(o, 1)?;
+            Some(Kind::Load {
+                dst: reg(o, 0)?,
+                base,
+                disp,
+            })
+        }
+        "addiu" | "addi" | "ori" => Some(Kind::AddImm {
+            dst: reg(o, 0)?,
+            base: reg(o, 1)?,
+            imm: imm(o, 2)?,
+        }),
+        "move" => Some(Kind::Mov {
+            dst: reg(o, 0)?,
+            src: reg(o, 1)?,
+        }),
+        _ => None,
+    }
+}
+
+pub fn resolve_got(elf: &Elf, got2sym: &mut HashMap<u64, String>) -> Option<u64> {
+    let got = dyn_val(elf, DT_PLTGOT)?;
+    let gp = got.wrapping_add(GP_BIAS);
+
+    let (Some(local_gotno), Some(gotsym), Some(symtabno)) = (
+        dyn_val(elf, DT_MIPS_LOCAL_GOTNO),
+        dyn_val(elf, DT_MIPS_GOTSYM),
+        dyn_val(elf, DT_MIPS_SYMTABNO),
+    ) else {
+        return Some(gp);
+    };
+    let entry_size = if elf.is_64 { 8 } else { 4 };
+    let last_dynsym = symtabno.min(elf.dynsyms.len() as u64);
+    for sym_index in gotsym..last_dynsym {
+        let got_entry = local_gotno
+            .wrapping_add(sym_index - gotsym)
+            .wrapping_mul(entry_size);
+        let slot = got.wrapping_add(got_entry);
+        let Some(sym) = elf.dynsyms.get(sym_index as usize) else {
+            continue;
+        };
+        if let Some(name) = sym_name(&elf.dynstrtab, sym.st_name) {
+            got2sym.entry(slot).or_insert(name);
+        }
+    }
+    Some(gp)
+}
diff --git a/src/arch/mod.rs b/src/arch/mod.rs
@@ -0,0 +1,280 @@
+pub mod aarch64;
+pub mod arm;
+pub mod mips;
+
+use crate::load::Binary;
+use anyhow::Result;
+use capstone::arch::arm::ArmOperandType;
+use capstone::arch::arm64::Arm64OperandType;
+use capstone::arch::mips::MipsOperand;
+use capstone::arch::ArchOperand;
+use capstone::prelude::*;
+use capstone::{Endian, Insn as CsInsn};
+use std::collections::HashMap;
+
+const IMM16_MASK: u64 = 0xffff;
+const HIGH_HALF_SHIFT: u32 = 16;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize)]
+pub enum Arch {
+    Arm,
+    Aarch64,
+    Mips,
+    X86,
+    X86_64,
+}
+
+pub const ANALYZABLE_ARCHES: &str = "mips, arm, aarch64";
+
+impl Arch {
+    pub fn align(self) -> u64 {
+        match self {
+            Arch::X86 | Arch::X86_64 => 1,
+            _ => 4,
+        }
+    }
+    pub fn name(self) -> &'static str {
+        match self {
+            Arch::Arm => "arm",
+            Arch::Aarch64 => "aarch64",
+            Arch::Mips => "mips",
+            Arch::X86 => "x86",
+            Arch::X86_64 => "x86_64",
+        }
+    }
+    pub fn analyzable(self) -> bool {
+        matches!(self, Arch::Arm | Arch::Aarch64 | Arch::Mips)
+    }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum Kind {
+    Call(u64),
+    CallInd { reg: u16 },
+    SetConst { dst: u16, val: u64 },
+    Load { dst: u16, base: u16, disp: i64 },
+    AddImm { dst: u16, base: u16, imm: i64 },
+    PcVal { dst: u16, val: u64 },
+    Mov { dst: u16, src: u16 },
+    Ret,
+    Other,
+}
+
+pub struct Ins {
+    pub addr: u64,
+    pub kind: Kind,
+}
+
+pub fn build(arch: Arch, little: bool) -> Result<Capstone> {
+    let endian = if little { Endian::Little } else { Endian::Big };
+    let cs = match arch {
+        Arch::Arm => Capstone::new()
+            .arm()
+            .mode(capstone::arch::arm::ArchMode::Arm)
+            .endian(endian)
+            .detail(true)
+            .build(),
+        Arch::Aarch64 => Capstone::new()
+            .arm64()
+            .mode(capstone::arch::arm64::ArchMode::Arm)
+            .endian(endian)
+            .detail(true)
+            .build(),
+        Arch::Mips => Capstone::new()
+            .mips()
+            .mode(capstone::arch::mips::ArchMode::Mips32)
+            .endian(endian)
+            .detail(true)
+            .build(),
+        Arch::X86 => Capstone::new()
+            .x86()
+            .mode(capstone::arch::x86::ArchMode::Mode32)
+            .detail(true)
+            .build(),
+        Arch::X86_64 => Capstone::new()
+            .x86()
+            .mode(capstone::arch::x86::ArchMode::Mode64)
+            .detail(true)
+            .build(),
+    }?;
+    Ok(cs)
+}
+
+pub fn sweep(cs: &Capstone, bin: &Binary) -> Vec<Ins> {
+    let align = bin.arch.align();
+    let mut out = Vec::new();
+    for seg in bin.segs.iter().filter(|seg| seg.exec) {
+        let start = seg.off as usize;
+        let end = ((seg.off + seg.filesz) as usize).min(bin.data.len());
+        if start >= end {
+            continue;
+        }
+        let code = &bin.data[start..end];
+        let mut pos: u64 = 0;
+        let len = (end - start) as u64;
+        while pos < len {
+            let Ok(insns) = cs.disasm_all(&code[pos as usize..], seg.vaddr + pos) else {
+                pos += align;
+                continue;
+            };
+            if insns.is_empty() {
+                pos += align;
+                continue;
+            }
+            let mut last_end = pos;
+            for insn in insns.iter() {
+                out.push(lower(cs, bin, insn));
+                last_end = (insn.address() - seg.vaddr) + insn.len() as u64;
+            }
+            pos = last_end.max(pos + align);
+        }
+    }
+    out.sort_by_key(|ins| ins.addr);
+    out.dedup_by_key(|ins| ins.addr);
+    out
+}
+
+fn lower(cs: &Capstone, bin: &Binary, insn: &CsInsn) -> Ins {
+    Ins {
+        addr: insn.address(),
+        kind: classify(cs, bin, insn).unwrap_or(Kind::Other),
+    }
+}
+
+#[derive(Clone, Copy)]
+pub enum Op {
+    Reg(u16),
+    Imm(i64),
+    Mem(u16, i64),
+    None,
+}
+
+fn operands(ops: &[ArchOperand]) -> [Op; 3] {
+    std::array::from_fn(|n| match ops.get(n) {
+        Some(ArchOperand::MipsOperand(MipsOperand::Reg(r))) => Op::Reg(r.0),
+        Some(ArchOperand::MipsOperand(MipsOperand::Imm(v))) => Op::Imm(*v),
+        Some(ArchOperand::MipsOperand(MipsOperand::Mem(m))) => Op::Mem(m.base().0, m.disp()),
+        Some(ArchOperand::ArmOperand(o)) => match o.op_type {
+            ArmOperandType::Reg(r) => Op::Reg(r.0),
+            ArmOperandType::Imm(v) => Op::Imm(v as i64),
+            ArmOperandType::Mem(m) => Op::Mem(m.base().0, m.disp() as i64),
+            _ => Op::None,
+        },
+        Some(ArchOperand::Arm64Operand(o)) => match o.op_type {
+            Arm64OperandType::Reg(r) => Op::Reg(r.0),
+            Arm64OperandType::Imm(v) => Op::Imm(v),
+            Arm64OperandType::Mem(m) => Op::Mem(m.base().0, m.disp() as i64),
+            _ => Op::None,
+        },
+        _ => Op::None,
+    })
+}
+
+fn reg(o: &[Op; 3], n: usize) -> Option<u16> {
+    match o[n] {
+        Op::Reg(r) => Some(r),
+        _ => None,
+    }
+}
+
+fn imm(o: &[Op; 3], n: usize) -> Option<i64> {
+    match o[n] {
+        Op::Imm(v) => Some(v),
+        _ => None,
+    }
+}
+
+fn mem(o: &[Op; 3], n: usize) -> Option<(u16, i64)> {
+    match o[n] {
+        Op::Mem(b, d) => Some((b, d)),
+        _ => None,
+    }
+}
+
+fn classify(cs: &Capstone, bin: &Binary, insn: &CsInsn) -> Option<Kind> {
+    let detail = cs.insn_detail(insn).ok()?;
+    let ops = operands(&detail.arch_detail().operands());
+    let mnemonic = insn.mnemonic()?;
+    match bin.arch {
+        Arch::Mips => mips::decode(mnemonic, &ops),
+        Arch::Aarch64 => aarch64::decode(mnemonic, &ops),
+        Arch::Arm => arm::decode(mnemonic, &ops, insn.address(), bin),
+        _ => None,
+    }
+}
+
+fn arg_regs(arch: Arch) -> &'static [u16] {
+    match arch {
+        Arch::Mips => &mips::ARG_REGS,
+        Arch::Aarch64 => &aarch64::ARG_REGS,
+        Arch::Arm => &arm::ARG_REGS,
+        _ => &[],
+    }
+}
+
+pub fn arg_reg(arch: Arch, n: usize) -> Option<u16> {
+    arg_regs(arch).get(n).copied()
+}
+
+pub fn arg_count(arch: Arch) -> usize {
+    arg_regs(arch).len()
+}
+
+pub fn gp_reg(arch: Arch) -> Option<u16> {
+    match arch {
+        Arch::Mips => Some(mips::GP),
+        _ => None,
+    }
+}
+
+pub fn sp_reg(arch: Arch) -> u16 {
+    match arch {
+        Arch::Mips => mips::SP,
+        Arch::Aarch64 => aarch64::SP,
+        Arch::Arm => arm::SP,
+        _ => 0,
+    }
+}
+
+pub fn resolve_stubs(bin: &Binary, insns: &[Ins]) -> HashMap<u64, String> {
+    let mut out = HashMap::new();
+    for (name, addr) in &bin.undef_stub {
+        out.insert(*addr, name.clone());
+    }
+    let mut tracked: HashMap<u16, (u64, u64)> = HashMap::new();
+    for ins in insns {
+        match ins.kind {
+            Kind::SetConst { dst, val } => {
+                tracked.insert(dst, (ins.addr, val));
+            }
+            Kind::AddImm { dst, base, imm } => {
+                if let Some(&(origin, value)) = tracked.get(&base) {
+                    tracked.insert(dst, (origin, value.wrapping_add(imm as u64)));
+                } else {
+                    tracked.remove(&dst);
+                }
+            }
+            Kind::Load { dst, base, disp } => {
+                if let Some(&(origin, value)) = tracked.get(&base) {
+                    let slot = value.wrapping_add(disp as u64);
+                    if let Some(name) = bin.got2sym.get(&slot) {
+                        out.insert(origin, name.clone());
+                    }
+                }
+                tracked.remove(&dst);
+            }
+            Kind::PcVal { dst, val } => {
+                tracked.insert(dst, (ins.addr, val));
+            }
+            Kind::Mov { dst, src } => {
+                if let Some(&origin_value) = tracked.get(&src) {
+                    tracked.insert(dst, origin_value);
+                } else {
+                    tracked.remove(&dst);
+                }
+            }
+            _ => {}
+        }
+    }
+    out
+}
diff --git a/src/graph.rs b/src/graph.rs
@@ -0,0 +1,303 @@
+use crate::arch::{self, Ins, Kind};
+use crate::load::Binary;
+use std::collections::hash_map::Entry;
+use std::collections::{HashMap, HashSet, VecDeque};
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Val {
+    Num(u64),
+    Stack(i64),
+    Import(u64),
+}
+
+pub struct CallSite {
+    pub site: u64,
+    pub func: u64,
+    pub import: Option<String>,
+    pub target: u64,
+    pub args: Vec<Option<Val>>,
+}
+
+pub struct Graph {
+    pub names: HashMap<u64, String>,
+    pub sites: Vec<CallSite>,
+    pub edges: HashMap<u64, HashSet<u64>>,
+    pub strings: HashMap<u64, Vec<String>>,
+    pub addr_taken: HashSet<u64>,
+}
+
+fn function_starts(bin: &Binary, insns: &[Ins], stubs: &HashMap<u64, String>) -> Vec<u64> {
+    let decoded: HashSet<u64> = insns.iter().map(|ins| ins.addr).collect();
+    let mut starts: HashSet<u64> = HashSet::new();
+    if decoded.contains(&bin.entry) {
+        starts.insert(bin.entry);
+    }
+    for (_, addr) in bin.locals.iter().chain(bin.exports.iter()) {
+        if decoded.contains(addr) {
+            starts.insert(*addr);
+        }
+    }
+    for ins in insns {
+        if let Kind::Call(target) = ins.kind {
+            if !stubs.contains_key(&target) && decoded.contains(&target) {
+                starts.insert(target);
+            }
+        }
+    }
+    for addr in stubs.keys() {
+        starts.remove(addr);
+    }
+    let mut starts: Vec<u64> = starts.into_iter().collect();
+    starts.sort_unstable();
+    starts
+}
+
+fn function_names(bin: &Binary) -> HashMap<u64, String> {
+    let mut names = HashMap::new();
+    for (name, addr) in bin.locals.iter().chain(bin.exports.iter()) {
+        names.entry(*addr).or_insert_with(|| name.clone());
+    }
+    names
+}
+
+struct Regs<'a> {
+    bin: &'a Binary,
+    sp: u16,
+    gp: Option<u16>,
+    nargs: usize,
+    vals: HashMap<u16, Val>,
+}
+
+impl<'a> Regs<'a> {
+    fn new(bin: &'a Binary) -> Regs<'a> {
+        Regs {
+            bin,
+            sp: arch::sp_reg(bin.arch),
+            gp: arch::gp_reg(bin.arch),
+            nargs: arch::arg_count(bin.arch),
+            vals: HashMap::new(),
+        }
+    }
+
+    fn enter(&mut self) {
+        self.vals.clear();
+        self.vals.insert(self.sp, Val::Stack(0));
+        self.pin_gp();
+    }
+
+    fn pin_gp(&mut self) {
+        if let (Some(g), Some(r)) = (self.bin.gp, self.gp) {
+            self.vals.insert(r, Val::Num(g));
+        }
+    }
+
+    fn set(&mut self, dst: u16, v: Option<Val>) -> Option<Val> {
+        match v {
+            Some(v) => self.vals.insert(dst, v),
+            None => self.vals.remove(&dst),
+        };
+        v
+    }
+
+    fn got_slot(&self, base: u16, disp: i64) -> Option<u64> {
+        match self.vals.get(&base) {
+            Some(Val::Num(addr)) => Some(addr.wrapping_add(disp as u64)),
+            _ => None,
+        }
+    }
+
+    fn step(&mut self, kind: Kind) -> Option<Val> {
+        let wrote = match kind {
+            Kind::SetConst { dst, val } | Kind::PcVal { dst, val } => {
+                self.set(dst, Some(Val::Num(val)))
+            }
+            Kind::AddImm { dst, base, imm } => {
+                let sum = match self.vals.get(&base) {
+                    Some(Val::Num(addr)) => Some(Val::Num(addr.wrapping_add(imm as u64))),
+                    Some(Val::Stack(offset)) => Some(Val::Stack(offset + imm)),
+                    _ => None,
+                };
+                self.set(dst, sum)
+            }
+            Kind::Mov { dst, src } => {
+                let moved = self.vals.get(&src).copied();
+                self.set(dst, moved)
+            }
+            Kind::Load { dst, base, disp } => {
+                let import = self
+                    .got_slot(base, disp)
+                    .filter(|slot| self.bin.got2sym.contains_key(slot))
+                    .map(Val::Import);
+                self.set(dst, import)
+            }
+            _ => None,
+        };
+        self.pin_gp();
+        wrote
+    }
+
+    fn arg_value(&self, index: usize) -> Option<Val> {
+        let reg = arch::arg_reg(self.bin.arch, index)?;
+        let value = self.vals.get(&reg).copied()?;
+        if matches!(value, Val::Import(_)) {
+            return None;
+        }
+        Some(value)
+    }
+
+    fn args(&self) -> Vec<Option<Val>> {
+        (0..self.nargs).map(|index| self.arg_value(index)).collect()
+    }
+
+    fn import_at(&self, reg: u16) -> Option<&String> {
+        match self.vals.get(&reg) {
+            Some(Val::Import(slot)) => self.bin.got2sym.get(slot),
+            _ => None,
+        }
+    }
+
+    fn clobber_args(&mut self) {
+        for n in 0..self.nargs {
+            if let Some(r) = arch::arg_reg(self.bin.arch, n) {
+                self.vals.remove(&r);
+            }
+        }
+    }
+}
+
+const MAX_STRINGS_PER_FUNC: usize = 24;
+const MIN_STRING_LEN: usize = 4;
+
+fn record_constant(bin: &Binary, func: u64, value: u64, starts: &[u64], out: &mut Graph) {
+    if starts.binary_search(&value).is_ok() {
+        out.addr_taken.insert(value);
+        return;
+    }
+    let Some(text) = bin.cstr_at(value) else {
+        return;
+    };
+    if text.len() < MIN_STRING_LEN {
+        return;
+    }
+    let strings = out.strings.entry(func).or_default();
+    if strings.len() < MAX_STRINGS_PER_FUNC && !strings.contains(&text) {
+        strings.push(text);
+    }
+}
+
+fn first_arg_is_constant(site: &CallSite) -> bool {
+    matches!(site.args.first(), Some(Some(Val::Num(_))))
+}
+
+impl Graph {
+    pub fn build(bin: &Binary, insns: &[Ins], stubs: HashMap<u64, String>) -> Graph {
+        let starts = function_starts(bin, insns, &stubs);
+        let mut g = Graph {
+            names: function_names(bin),
+            sites: Vec::new(),
+            edges: HashMap::new(),
+            strings: HashMap::new(),
+            addr_taken: HashSet::new(),
+        };
+        let mut regs = Regs::new(bin);
+        let mut cur_func: u64 = starts.first().copied().unwrap_or(0);
+
+        for ins in insns {
+            if starts.binary_search(&ins.addr).is_ok() {
+                cur_func = ins.addr;
+                regs.enter();
+            }
+            let wrote = regs.step(ins.kind);
+            match ins.kind {
+                Kind::Call(target) => {
+                    let import = stubs.get(&target).cloned();
+                    if import.is_none() && starts.binary_search(&target).is_ok() {
+                        g.edges.entry(cur_func).or_default().insert(target);
+                    }
+                    g.sites.push(CallSite {
+                        site: ins.addr,
+                        func: cur_func,
+                        import,
+                        target,
+                        args: regs.args(),
+                    });
+                    regs.clobber_args();
+                }
+                Kind::CallInd { reg } => {
+                    if let Some(name) = regs.import_at(reg).cloned() {
+                        g.sites.push(CallSite {
+                            site: ins.addr,
+                            func: cur_func,
+                            import: Some(name),
+                            target: 0,
+                            args: regs.args(),
+                        });
+                    }
+                    regs.clobber_args();
+                }
+                _ => {}
+            }
+            if let Some(Val::Num(value)) = wrote {
+                record_constant(bin, cur_func, value, &starts, &mut g);
+            }
+        }
+        g
+    }
+
+    pub fn func_name(&self, addr: u64) -> String {
+        self.names
+            .get(&addr)
+            .cloned()
+            .unwrap_or_else(|| format!("sub_{addr:x}"))
+    }
+
+    pub fn reach_from(&self, inputs: &[&str]) -> HashMap<u64, u32> {
+        let mut dist: HashMap<u64, u32> = HashMap::new();
+        let mut queue = VecDeque::new();
+        for site in &self.sites {
+            let Some(import) = &site.import else { continue };
+            if inputs.contains(&import.as_str()) {
+                dist.insert(site.func, 0);
+                queue.push_back(site.func);
+            }
+        }
+        while let Some(func) = queue.pop_front() {
+            let depth = dist[&func];
+            let Some(callees) = self.edges.get(&func) else {
+                continue;
+            };
+            for &callee in callees {
+                if let Entry::Vacant(slot) = dist.entry(callee) {
+                    slot.insert(depth + 1);
+                    queue.push_back(callee);
+                }
+            }
+        }
+        dist
+    }
+
+    pub fn shell_equivalent(&self, seeds: &[&str]) -> HashSet<u64> {
+        let mut tainted: HashSet<u64> = HashSet::new();
+        for site in &self.sites {
+            let Some(import) = &site.import else { continue };
+            if seeds.contains(&import.as_str()) && !first_arg_is_constant(site) {
+                tainted.insert(site.func);
+            }
+        }
+        loop {
+            let before = tainted.len();
+            for site in &self.sites {
+                if site.import.is_none()
+                    && tainted.contains(&site.target)
+                    && !first_arg_is_constant(site)
+                {
+                    tainted.insert(site.func);
+                }
+            }
+            if tainted.len() == before {
+                break;
+            }
+        }
+        tainted
+    }
+}
diff --git a/src/load.rs b/src/load.rs
@@ -0,0 +1,181 @@
+use crate::arch::Arch;
+use anyhow::{bail, Result};
+use goblin::elf::Elf;
+use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+const SHN_UNDEF: usize = 0;
+const MAX_CSTR_LEN: usize = 512;
+
+fn is_text_byte(byte: u8) -> bool {
+    matches!(byte, 0x09..=0x0d | 0x20..=0x7e)
+}
+
+pub struct Seg {
+    pub vaddr: u64,
+    pub off: u64,
+    pub filesz: u64,
+    pub exec: bool,
+}
+
+pub struct Binary {
+    pub path: PathBuf,
+    pub data: Vec<u8>,
+    pub arch: Arch,
+    pub little: bool,
+    pub entry: u64,
+    pub segs: Vec<Seg>,
+    pub got2sym: HashMap<u64, String>,
+    pub undef_stub: Vec<(String, u64)>,
+    pub imports: HashSet<String>,
+    pub exports: Vec<(String, u64)>,
+    pub locals: Vec<(String, u64)>,
+    pub gp: Option<u64>,
+}
+
+fn machine(elf: &Elf) -> Result<Arch> {
+    Ok(match elf.header.e_machine {
+        goblin::elf::header::EM_ARM => Arch::Arm,
+        goblin::elf::header::EM_AARCH64 => Arch::Aarch64,
+        goblin::elf::header::EM_MIPS => Arch::Mips,
+        goblin::elf::header::EM_386 => Arch::X86,
+        goblin::elf::header::EM_X86_64 => Arch::X86_64,
+        m => bail!("unsupported machine {m}"),
+    })
+}
+
+pub(crate) fn sym_name(tab: &goblin::strtab::Strtab, idx: usize) -> Option<String> {
+    tab.get_at(idx)
+        .filter(|n| !n.is_empty())
+        .map(str::to_string)
+}
+
+fn segments(elf: &Elf) -> Vec<Seg> {
+    elf.program_headers
+        .iter()
+        .filter(|p| p.p_type == goblin::elf::program_header::PT_LOAD)
+        .map(|p| Seg {
+            vaddr: p.p_vaddr,
+            off: p.p_offset,
+            filesz: p.p_filesz,
+            exec: p.is_executable(),
+        })
+        .collect()
+}
+
+fn plt_imports(elf: &Elf) -> HashMap<u64, String> {
+    let mut got2sym = HashMap::new();
+    for reloc in elf.pltrelocs.iter() {
+        let Some(sym) = elf.dynsyms.get(reloc.r_sym) else {
+            continue;
+        };
+        if let Some(name) = sym_name(&elf.dynstrtab, sym.st_name) {
+            got2sym.insert(reloc.r_offset, name);
+        }
+    }
+    got2sym
+}
+
+#[derive(Default)]
+struct DynSyms {
+    imports: HashSet<String>,
+    undef_stub: Vec<(String, u64)>,
+    exports: Vec<(String, u64)>,
+}
+
+fn dyn_symbols(elf: &Elf) -> DynSyms {
+    let mut syms = DynSyms::default();
+    for sym in elf.dynsyms.iter() {
+        let Some(name) = sym_name(&elf.dynstrtab, sym.st_name) else {
+            continue;
+        };
+        if sym.st_shndx == SHN_UNDEF {
+            if sym.st_value != 0 {
+                syms.undef_stub.push((name.clone(), sym.st_value));
+            }
+            syms.imports.insert(name);
+        } else if sym.st_value != 0 && sym.is_function() {
+            syms.exports.push((name, sym.st_value));
+        }
+    }
+    syms
+}
+
+fn local_funcs(elf: &Elf) -> Vec<(String, u64)> {
+    elf.syms
+        .iter()
+        .filter(|sym| sym.is_function() && sym.st_value != 0)
+        .filter_map(|sym| sym_name(&elf.strtab, sym.st_name).map(|name| (name, sym.st_value)))
+        .collect()
+}
+
+pub(crate) fn dyn_val(elf: &Elf, tag: u64) -> Option<u64> {
+    let dynamic = elf.dynamic.as_ref()?;
+    let entry = dynamic.dyns.iter().find(|entry| entry.d_tag == tag)?;
+    Some(entry.d_val)
+}
+
+impl Binary {
+    pub fn load(path: &Path) -> Result<Binary> {
+        let data = std::fs::read(path)?;
+        let elf = Elf::parse(&data)?;
+        let arch = machine(&elf)?;
+        let little = elf.little_endian;
+        let entry = elf.header.e_entry;
+        let segs = segments(&elf);
+        let mut got2sym = plt_imports(&elf);
+        let syms = dyn_symbols(&elf);
+        let gp = match arch {
+            Arch::Mips => crate::arch::mips::resolve_got(&elf, &mut got2sym),
+            _ => None,
+        };
+        let locals = local_funcs(&elf);
+        drop(elf);
+
+        Ok(Binary {
+            path: path.to_path_buf(),
+            data,
+            arch,
+            little,
+            entry,
+            segs,
+            got2sym,
+            undef_stub: syms.undef_stub,
+            imports: syms.imports,
+            exports: syms.exports,
+            locals,
+            gp,
+        })
+    }
+
+    pub fn read_vaddr(&self, va: u64, len: usize) -> Option<&[u8]> {
+        let seg = self
+            .segs
+            .iter()
+            .find(|seg| (seg.vaddr..seg.vaddr + seg.filesz).contains(&va))?;
+        let start = (seg.off + (va - seg.vaddr)) as usize;
+        if start >= self.data.len() {
+            return None;
+        }
+        let seg_end = (seg.off + seg.filesz) as usize;
+        let end = (start + len).min(seg_end).min(self.data.len());
+        Some(&self.data[start..end])
+    }
+
+    pub fn cstr_at(&self, va: u64) -> Option<String> {
+        let window = self.read_vaddr(va, MAX_CSTR_LEN)?;
+        let nul = window.iter().position(|&byte| byte == 0)?;
+        let text = &window[..nul];
+        if text.is_empty() || !text.iter().all(|&byte| is_text_byte(byte)) {
+            return None;
+        }
+        Some(String::from_utf8_lossy(text).into_owned())
+    }
+
+    pub fn name(&self) -> String {
+        self.path
+            .file_name()
+            .map(|s| s.to_string_lossy().into_owned())
+            .unwrap_or_default()
+    }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,307 @@
+mod arch;
+mod graph;
+mod load;
+mod rules;
+
+use anyhow::Result;
+use clap::{Args, Parser, Subcommand};
+use rayon::prelude::*;
+use std::cmp::Ordering;
+use std::collections::{HashMap, HashSet};
+use std::io::{self, BufWriter, Write};
+use std::path::{Path, PathBuf};
+
+const ELF_MAGIC: [u8; 4] = [0x7f, b'E', b'L', b'F'];
+const STUB_PREVIEW: usize = 12;
+
+#[derive(Parser)]
+#[command(
+    name = "balrog",
+    version,
+    about = "risky-call and reachability triage for firmware ELF trees"
+)]
+struct Cli {
+    #[command(subcommand)]
+    cmd: Cmd,
+}
+
+#[derive(Subcommand)]
+enum Cmd {
+    #[command(about = "rank risky calls in a binary or a whole extracted tree")]
+    Scan(ScanArgs),
+    #[command(about = "resolve imports and their PLT/lazy-binding stubs")]
+    Imports { path: PathBuf },
+}
+
+#[derive(Args)]
+struct ScanArgs {
+    #[arg(
+        required = true,
+        help = "one or more binaries or trees. Firmware has tiers: rootfs and app bundles are separate trees and both need triaging"
+    )]
+    paths: Vec<PathBuf>,
+    #[arg(short, long)]
+    json: bool,
+    #[arg(short = 'n', long = "top", default_value_t = 15)]
+    per_class_limit: usize,
+    #[arg(long = "min", default_value_t = 40, help = "minimum score to report")]
+    min_score: i32,
+    #[arg(
+        long = "per-bin",
+        default_value_t = 2,
+        help = "at most this many findings per class per binary"
+    )]
+    max_per_binary: usize,
+    #[arg(long = "class", value_enum, help = "only report this class")]
+    only_class: Option<rules::Class>,
+}
+
+fn is_elf(path: &Path) -> bool {
+    let Ok(mut file) = std::fs::File::open(path) else {
+        return false;
+    };
+    use std::io::Read;
+    let mut magic = [0u8; 4];
+    file.read_exact(&mut magic).is_ok() && magic == ELF_MAGIC
+}
+
+fn collect_elf_files(root: &Path) -> Vec<PathBuf> {
+    if root.is_file() {
+        return vec![root.to_path_buf()];
+    }
+    let mut elf_files = Vec::new();
+    let mut unvisited_dirs = vec![root.to_path_buf()];
+    while let Some(dir) = unvisited_dirs.pop() {
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            match entry.file_type() {
+                Ok(kind) if kind.is_dir() => unvisited_dirs.push(path),
+                Ok(kind) if kind.is_file() && is_elf(&path) => elf_files.push(path),
+                _ => {}
+            }
+        }
+    }
+    elf_files.sort();
+    elf_files
+}
+
+fn decode(path: &Path) -> Result<(load::Binary, Vec<arch::Ins>, HashMap<u64, String>)> {
+    let bin = load::Binary::load(path)?;
+    let cs = arch::build(bin.arch, bin.little)?;
+    let insns = arch::sweep(&cs, &bin);
+    let stubs = arch::resolve_stubs(&bin, &insns);
+    Ok((bin, insns, stubs))
+}
+
+enum Skipped {
+    UnsupportedArch(&'static str),
+    Undecodable(anyhow::Error),
+}
+
+impl std::fmt::Display for Skipped {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match self {
+            Skipped::UnsupportedArch(arch) => {
+                write!(
+                    f,
+                    "{arch}, balrog only analyzes {}",
+                    arch::ANALYZABLE_ARCHES
+                )
+            }
+            Skipped::Undecodable(err) => write!(f, "{err}"),
+        }
+    }
+}
+
+fn scan(path: &Path) -> Result<Vec<rules::Finding>, Skipped> {
+    let (bin, insns, stubs) = decode(path).map_err(Skipped::Undecodable)?;
+    if !bin.arch.analyzable() {
+        return Err(Skipped::UnsupportedArch(bin.arch.name()));
+    }
+    if insns.is_empty() {
+        return Ok(Vec::new());
+    }
+    let callgraph = graph::Graph::build(&bin, &insns, stubs);
+    Ok(rules::analyze(&bin, &callgraph))
+}
+
+fn passes_filters(
+    finding: &rules::Finding,
+    min_score: i32,
+    only_class: Option<rules::Class>,
+) -> bool {
+    finding.score >= min_score && only_class.is_none_or(|class| finding.class == class)
+}
+
+fn scan_and_filter(
+    binaries: &[PathBuf],
+    min_score: i32,
+    only_class: Option<rules::Class>,
+) -> Vec<rules::Finding> {
+    let scan_results: Vec<Result<Vec<rules::Finding>, Skipped>> =
+        binaries.par_iter().map(|binary| scan(binary)).collect();
+    let mut findings = Vec::new();
+    for (path, result) in binaries.iter().zip(scan_results) {
+        match result {
+            Ok(found) => findings.extend(
+                found
+                    .into_iter()
+                    .filter(|finding| passes_filters(finding, min_score, only_class)),
+            ),
+            Err(skipped) => eprintln!("warning: {}: {skipped}", path.display()),
+        }
+    }
+    findings
+}
+
+fn dedup_templates(findings: &mut Vec<rules::Finding>) {
+    let mut seen = HashSet::new();
+    findings.retain(|finding| {
+        let template = finding
+            .evidence
+            .iter()
+            .find(|line| line.starts_with("format ") || line.starts_with("command template"))
+            .cloned();
+        seen.insert((
+            finding.bin.clone(),
+            finding.risky_call.clone(),
+            finding.class,
+            template,
+        ))
+    });
+}
+
+fn cap_per_binary(findings: &mut Vec<rules::Finding>, max_per_binary: usize) {
+    let mut kept_so_far = HashMap::new();
+    findings.retain(|finding| {
+        let kept = kept_so_far
+            .entry((finding.bin.clone(), finding.class))
+            .or_insert(0usize);
+        *kept += 1;
+        *kept <= max_per_binary
+    });
+}
+
+fn by_score(a: &rules::Finding, b: &rules::Finding) -> Ordering {
+    b.score
+        .cmp(&a.score)
+        .then(a.bin.cmp(&b.bin))
+        .then(a.site.cmp(&b.site))
+        .then(a.risky_call.cmp(&b.risky_call))
+}
+
+fn by_class_then_score(a: &rules::Finding, b: &rules::Finding) -> Ordering {
+    a.class.cmp(&b.class).then(by_score(a, b))
+}
+
+fn rank(mut findings: Vec<rules::Finding>, max_per_binary: usize) -> Vec<rules::Finding> {
+    findings.sort_by(by_score);
+    dedup_templates(&mut findings);
+    cap_per_binary(&mut findings, max_per_binary);
+    findings.sort_by(by_class_then_score);
+    findings
+}
+
+fn print_finding(out: &mut impl Write, finding: &rules::Finding) -> io::Result<()> {
+    writeln!(
+        out,
+        "\n[{:>3}] {}  {}+{:#x}  {}",
+        finding.score, finding.risky_call, finding.bin, finding.site, finding.func
+    )?;
+    for line in &finding.evidence {
+        writeln!(out, "       - {line}")?;
+    }
+    Ok(())
+}
+
+fn report(
+    out: &mut impl Write,
+    findings: &[rules::Finding],
+    elf_count: usize,
+    per_class_limit: usize,
+) -> io::Result<()> {
+    writeln!(out, "{elf_count} ELF, {} leads", findings.len())?;
+    for class in rules::ORDER {
+        let in_class: Vec<&rules::Finding> = findings
+            .iter()
+            .filter(|finding| finding.class == *class)
+            .collect();
+        if in_class.is_empty() {
+            continue;
+        }
+        writeln!(
+            out,
+            "\n== {} ({} of {})",
+            class.tag().to_uppercase(),
+            in_class.len().min(per_class_limit),
+            in_class.len()
+        )?;
+        for finding in in_class.iter().take(per_class_limit) {
+            print_finding(out, finding)?;
+        }
+    }
+    Ok(())
+}
+
+fn ignore_broken_pipe(r: io::Result<()>) -> Result<()> {
+    match r {
+        Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
+        other => Ok(other?),
+    }
+}
+
+fn run_scan(args: ScanArgs) -> Result<()> {
+    let binaries: Vec<PathBuf> = args
+        .paths
+        .iter()
+        .flat_map(|root| collect_elf_files(root))
+        .collect();
+    let findings = scan_and_filter(&binaries, args.min_score, args.only_class);
+    let ranked = rank(findings, args.max_per_binary);
+
+    let mut out = BufWriter::new(io::stdout().lock());
+    let written = if args.json {
+        writeln!(out, "{}", serde_json::to_string_pretty(&ranked)?)
+    } else {
+        report(&mut out, &ranked, binaries.len(), args.per_class_limit)
+    };
+    ignore_broken_pipe(written.and_then(|_| out.flush()))
+}
+
+fn run_imports(path: &Path) -> Result<()> {
+    let (bin, insns, stubs) = decode(path)?;
+    if !bin.arch.analyzable() {
+        anyhow::bail!(
+            "{} is {}, balrog only analyzes {}",
+            path.display(),
+            bin.arch.name(),
+            arch::ANALYZABLE_ARCHES
+        );
+    }
+    println!(
+        "{} {} {} insns={} jmprel={} undef={} stubs={}",
+        bin.name(),
+        bin.arch.name(),
+        if bin.little { "LE" } else { "BE" },
+        insns.len(),
+        bin.got2sym.len(),
+        bin.imports.len(),
+        stubs.len()
+    );
+    let mut sorted_stubs: Vec<_> = stubs.iter().collect();
+    sorted_stubs.sort();
+    for (addr, name) in sorted_stubs.iter().take(STUB_PREVIEW) {
+        println!("  {addr:#x} {name}");
+    }
+    Ok(())
+}
+
+fn main() -> Result<()> {
+    match Cli::parse().cmd {
+        Cmd::Scan(args) => run_scan(args),
+        Cmd::Imports { path } => run_imports(&path),
+    }
+}
diff --git a/src/rules.rs b/src/rules.rs
@@ -0,0 +1,1477 @@
+use crate::graph::{CallSite, Graph, Val};
+use crate::load::Binary;
+use serde::Serialize;
+use std::collections::{HashMap, HashSet};
+
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Serialize, clap::ValueEnum)]
+#[serde(rename_all = "lowercase")]
+pub enum Class {
+    Exec,
+    Ipc,
+    Tls,
+    Fmt,
+    Mem,
+    Auth,
+    Priv,
+    Path,
+}
+
+pub const ORDER: &[Class] = &[
+    Class::Exec,
+    Class::Ipc,
+    Class::Tls,
+    Class::Fmt,
+    Class::Mem,
+    Class::Auth,
+    Class::Priv,
+    Class::Path,
+];
+
+impl Class {
+    fn base(self) -> i32 {
+        match self {
+            Class::Exec => 50,
+            Class::Ipc => 45,
+            Class::Tls => 45,
+            Class::Fmt => 45,
+            Class::Mem => 35,
+            Class::Auth => 30,
+            Class::Priv => 30,
+            Class::Path => 20,
+        }
+    }
+    pub fn tag(self) -> &'static str {
+        match self {
+            Class::Exec => "exec",
+            Class::Ipc => "ipc",
+            Class::Tls => "tls",
+            Class::Fmt => "fmt",
+            Class::Mem => "mem",
+            Class::Auth => "auth",
+            Class::Priv => "priv",
+            Class::Path => "path",
+        }
+    }
+}
+
+pub const SHELL: &[&str] = &[
+    "system",
+    "popen",
+    "wordexp",
+    "twsystem",
+    "CsteSystem",
+    "bstar_system",
+    "___system",
+    "doShell",
+    "cgi_deal_popen",
+    "ExeCmd",
+    "ExecShell",
+    "exec_shell_popen",
+    "exec_shell_popen_str",
+    "exec_shell_async",
+    "exec_shell_sync",
+    "exec_shell_sync2",
+    "execFormatCmd",
+    "exec_cmd",
+    "tp_systemEx",
+    "pegaSystem",
+    "SLIBCSystem",
+    "SLIBCExec",
+    "SLIBCExecv",
+    "SLIBCPopen",
+    "smbrun",
+    "smbrun_no_sanitize",
+    "smbrunsecret",
+    "afprun",
+    "popen_utf8",
+    "do_system",
+    "safe_system",
+    "_backexec",
+    "execRoot_bg2",
+    "ugs_execRoot_shell",
+    "prctl_runCommandInShellWithTimeout",
+    "safe_popen",
+];
+pub const SHELL_ARG1: &[&str] = &["SLIBCExecl"];
+pub const EVAL: &[&str] = &[
+    "zend_eval_string",
+    "zend_eval_stringl",
+    "zend_eval_string_ex",
+    "PyRun_SimpleString",
+    "PyRun_SimpleStringFlags",
+    "PyRun_String",
+    "PyRun_StringFlags",
+    "Py_CompileString",
+    "PyEval_EvalCode",
+];
+pub const PRINTF_EXEC: &[&str] = &["doSystem", "doSystemCmd", "sysprintf", "lxmldbc_system"];
+pub const ARGV_EXEC: &[&str] = &[
+    "execl",
+    "execlp",
+    "execle",
+    "execv",
+    "execvp",
+    "execvpe",
+    "execve",
+    "posix_spawn",
+    "posix_spawnp",
+    "_eval",
+    "_cpu_eval",
+    "_cpu_mask_eval",
+    "fm_exec",
+];
+pub const UNBOUNDED: &[(&str, usize, Option<usize>)] = &[
+    ("strcpy", 0, None),
+    ("strcat", 0, None),
+    ("stpcpy", 0, None),
+    ("sprintf", 0, Some(1)),
+    ("vsprintf", 0, Some(1)),
+    ("gets", 0, None),
+    ("wcscpy", 0, None),
+    ("wcscat", 0, None),
+    ("getwd", 0, None),
+    ("__sprintf_chk", 0, Some(3)),
+    ("__vsprintf_chk", 0, Some(3)),
+    ("__strcpy_chk", 0, None),
+    ("__strcat_chk", 0, None),
+];
+pub const SIZED: &[(&str, usize)] = &[
+    ("memcpy", 2),
+    ("memmove", 2),
+    ("strncpy", 2),
+    ("strncat", 2),
+    ("bcopy", 2),
+    ("mempcpy", 2),
+    ("stpncpy", 2),
+    ("wcsncpy", 2),
+    ("wmemcpy", 2),
+    ("strlcpy", 2),
+    ("strlcat", 2),
+    ("__memcpy_chk", 2),
+    ("__memmove_chk", 2),
+];
+pub const FORTIFY_BOUND: &[(&str, usize)] = &[
+    ("__sprintf_chk", 2),
+    ("__vsprintf_chk", 2),
+    ("__strcpy_chk", 2),
+    ("__strcat_chk", 2),
+    ("__memcpy_chk", 3),
+    ("__memmove_chk", 3),
+    ("__snprintf_chk", 3),
+    ("__vsnprintf_chk", 3),
+];
+pub const FORMAT: &[(&str, usize)] = &[
+    ("printf", 0),
+    ("syslog", 1),
+    ("fprintf", 1),
+    ("vsyslog", 1),
+    ("asprintf", 1),
+    ("vasprintf", 1),
+    ("vprintf", 0),
+    ("vfprintf", 1),
+    ("snprintf", 2),
+    ("vsnprintf", 2),
+    ("dprintf", 1),
+    ("vdprintf", 1),
+    ("g_strdup_printf", 0),
+    ("setproctitle", 0),
+    ("err", 1),
+    ("errx", 1),
+    ("verr", 1),
+    ("verrx", 1),
+    ("warn", 0),
+    ("warnx", 0),
+    ("vwarn", 0),
+    ("vwarnx", 0),
+    ("__printf_chk", 1),
+    ("__fprintf_chk", 2),
+    ("__syslog_chk", 2),
+    ("__snprintf_chk", 4),
+    ("__vsnprintf_chk", 4),
+];
+pub const SCANF: &[(&str, usize)] = &[
+    ("scanf", 0),
+    ("fscanf", 1),
+    ("sscanf", 1),
+    ("__isoc99_scanf", 0),
+    ("__isoc99_fscanf", 1),
+    ("__isoc99_sscanf", 1),
+    ("vscanf", 0),
+    ("vfscanf", 1),
+    ("vsscanf", 1),
+];
+pub const PRIVOPS: &[&str] = &[
+    "setuid",
+    "setgid",
+    "seteuid",
+    "setegid",
+    "setresuid",
+    "setresgid",
+    "setreuid",
+    "setregid",
+    "setfsuid",
+    "setfsgid",
+    "setgroups",
+    "initgroups",
+    "umask",
+];
+pub const TEMPFILE: &[&str] = &["mktemp", "tmpnam", "tmpnam_r", "tempnam"];
+pub const WEAK_RANDOM: &[&str] = &[
+    "rand",
+    "random",
+    "rand_r",
+    "drand48",
+    "lrand48",
+    "mrand48",
+    "RAND_pseudo_bytes",
+];
+pub const SEED: &[&str] = &["srand", "srandom", "srand48", "seed48"];
+pub const WEAK_CRYPTO: &[&str] = &[
+    "MD5",
+    "MD5_Init",
+    "MD4",
+    "SHA1_Init",
+    "EVP_md5",
+    "EVP_md4",
+    "EVP_sha1",
+    "mbedtls_md5",
+    "mbedtls_sha1",
+    "RC4",
+    "RC4_set_key",
+    "EVP_rc4",
+    "EVP_des_cbc",
+    "EVP_des_ecb",
+    "DES_set_key",
+    "DES_ecb_encrypt",
+    "EVP_rc2_cbc",
+    "EVP_bf_cbc",
+    "mbedtls_des_setkey_enc",
+    "EVP_aes_128_ecb",
+    "EVP_aes_192_ecb",
+    "EVP_aes_256_ecb",
+];
+pub const OLD_TLS: &[&str] = &[
+    "SSLv2_method",
+    "SSLv3_method",
+    "SSLv3_client_method",
+    "SSLv3_server_method",
+    "TLSv1_method",
+    "TLSv1_client_method",
+    "TLSv1_1_method",
+];
+pub const VERIFY_MODE: &[&str] = &[
+    "SSL_CTX_set_verify",
+    "SSL_set_verify",
+    "wolfSSL_CTX_set_verify",
+    "wolfSSL_set_verify",
+];
+pub const DESER: &[&str] = &[
+    "soap_get",
+    "soap_serve",
+    "soap_begin_recv",
+    "xmlReadMemory",
+    "xmlParseMemory",
+    "xmlReadDoc",
+    "XML_Parse",
+    "d2i_X509",
+    "PEM_read_bio_X509",
+];
+pub const UNTRUSTED_INPUTS: &[&str] = &[
+    "recv",
+    "recvfrom",
+    "recvmsg",
+    "recvmmsg",
+    "read",
+    "accept",
+    "accept4",
+    "getenv",
+    "fgets",
+    "getline",
+    "__getdelim",
+    "fread",
+    "SSL_read",
+    "BIO_read",
+    "json_tokener_parse",
+    "json_tokener_parse_ex",
+    "json_object_from_file",
+    "cJSON_Parse",
+    "cJSON_ParseWithLength",
+    "blobmsg_data",
+    "blobmsg_parse",
+    "soap_get",
+    "websGetVar",
+    "websDecodeUrl",
+    "FCGX_GetParam",
+    "hdf_get_value",
+    "config_get",
+    "find_val",
+    "get_cgi",
+    "httpGetEnv",
+    "nvram_get",
+    "nvram_safe_get",
+    "_nvram_get",
+    "nvram_get_int",
+    "nvram_get_buf",
+    "nvram_bufget",
+    "nvram_getf",
+    "nvram_get_adv",
+    "envram_get",
+    "envram_getf",
+    "apmib_get",
+    "artblock_get",
+    "artblock_safe_get",
+    "acosNvramConfig_get",
+    "acos_nvram_get",
+    "WAN_ith_CONFIG_GET",
+    "uci_lookup_option_string",
+    "uci_lookup_option",
+    "Uci_Get_Str",
+    "GetValue",
+    "acosNvramConfig_read",
+    "bcm_nvram_get",
+    "wlcsm_nvram_get",
+    "dni_nvram_get",
+    "PTI_nvram_get",
+    "CGI_Find_Parameter",
+    "getcgivars",
+    "getcgi",
+    "mg_get_var",
+    "webGetVar",
+    "websGetVarString",
+    "cmsObj_get",
+    "cJSON_GetObjectItem",
+    "cJSON_GetObjectItemCaseSensitive",
+];
+pub const PEERCRED: &[&str] = &["getsockopt", "getpeereid", "getpeername", "SO_PEERCRED"];
+
+pub const SANITIZERS: &[&str] = &[
+    "canonicalize_file_name",
+    "strtol",
+    "strtoul",
+    "strtoll",
+    "strtoull",
+    "strtoimax",
+    "strtoumax",
+    "inet_pton",
+    "inet_aton",
+    "inet_ntop",
+    "fnmatch",
+    "g_shell_quote",
+    "quote_for_shell",
+    "secure_getenv",
+];
+pub const BOUNDED: &[&str] = &[
+    "snprintf",
+    "vsnprintf",
+    "strlcpy",
+    "strlcat",
+    "strncpy",
+    "strncat",
+    "strnlen",
+    "stpecpy",
+    "strscpy",
+    "__snprintf_chk",
+    "__strcpy_chk",
+    "__memcpy_chk",
+];
+pub const CT_COMPARE: &[&str] = &[
+    "CRYPTO_memcmp",
+    "timingsafe_bcmp",
+    "timingsafe_memcmp",
+    "sodium_memcmp",
+    "mbedtls_ct_memcmp",
+];
+pub const CSPRNG: &[&str] = &[
+    "getrandom",
+    "arc4random",
+    "arc4random_buf",
+    "arc4random_uniform",
+    "RAND_bytes",
+    "mbedtls_ctr_drbg_random",
+];
+pub const HOSTNAME_CHECK: &[&str] = &[
+    "X509_check_host",
+    "X509_check_ip",
+    "X509_check_ip_asc",
+    "SSL_set1_host",
+    "SSL_add1_host",
+    "X509_VERIFY_PARAM_set1_host",
+];
+pub const SIGVERIFY: &[&str] = &[
+    "RSA_verify",
+    "EVP_VerifyFinal",
+    "EVP_DigestVerifyFinal",
+    "ECDSA_verify",
+    "mbedtls_pk_verify",
+];
+
+const CURL_VERIFY_OPTS: &[(u64, &str)] = &[
+    (64, "CURLOPT_SSL_VERIFYPEER"),
+    (81, "CURLOPT_SSL_VERIFYHOST"),
+    (248, "CURLOPT_PROXY_SSL_VERIFYPEER"),
+    (249, "CURLOPT_PROXY_SSL_VERIFYHOST"),
+];
+const WRDE_NOCMD: u64 = 4;
+const MBEDTLS_VERIFY_NONE: u64 = 0;
+const MBEDTLS_VERIFY_OPTIONAL: u64 = 1;
+const PROT_WRITE_EXEC: u64 = 0o6;
+const VERIFY_OFF_BONUS: i32 = 30;
+const FORTIFY_OFF_BONUS: i32 = 40;
+const FORTIFY_OFF_NOTE: &str = "FORTIFY bound is -1: the size check is disabled";
+const SHELL_TEMPLATE_BONUS: i32 = 25;
+const DIGEST_LENGTHS: &[u64] = &[16, 20, 32, 48, 64];
+const MAX_TEMPLATES: usize = 3;
+const MAX_SOCKET_PATHS: usize = 2;
+const MAX_EXEC_NOTES: usize = 3;
+const MAX_CONTEXT_STRINGS: usize = 2;
+const MIN_CONTEXT_STRING: usize = 8;
+const CGI_VARS: &[&str] = &[
+    "QUERY_STRING",
+    "REQUEST_URI",
+    "CONTENT_LENGTH",
+    "HTTP_COOKIE",
+];
+const SEARCH_PATH_VARS: &[&str] = &[
+    "LD_PRELOAD",
+    "LD_LIBRARY_PATH",
+    "PATH",
+    "IFS",
+    "SHELL",
+    "PHPRC",
+    "PERL5LIB",
+    "PYTHONSTARTUP",
+];
+const PRIV_DROP: &[&str] = &[
+    "setuid",
+    "setgid",
+    "seteuid",
+    "setegid",
+    "setresuid",
+    "setresgid",
+    "setreuid",
+    "setregid",
+    "setfsuid",
+    "setfsgid",
+];
+
+const CMD_HEADS: &[&str] = &[
+    "cp ",
+    "mv ",
+    "rm ",
+    "cat ",
+    "tar ",
+    "chmod ",
+    "chown ",
+    "wget ",
+    "curl ",
+    "tftp ",
+    "ping ",
+    "traceroute ",
+    "iwpriv ",
+    "ifconfig ",
+    "route ",
+    "killall ",
+    "iptables ",
+    "nvram ",
+    "sed ",
+    "mount ",
+    "umount ",
+    "dd ",
+    "mtd ",
+    "echo ",
+    "touch ",
+    "mkdir ",
+    "insmod ",
+    "modprobe ",
+    "reboot",
+    "sh ",
+];
+
+#[derive(Serialize)]
+pub struct Finding {
+    pub bin: String,
+    pub class: Class,
+    pub risky_call: String,
+    #[serde(serialize_with = "hex")]
+    pub site: u64,
+    pub func: String,
+    pub reach: Option<u32>,
+    pub evidence: Vec<String>,
+    pub score: i32,
+}
+
+fn hex<S: serde::Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
+    s.serialize_str(&format!("{v:#x}"))
+}
+
+fn stack_off(k: i64) -> String {
+    if k < 0 {
+        format!("sp-{:#x}", -k)
+    } else {
+        format!("sp+{k:#x}")
+    }
+}
+
+fn looks_shell(s: &str) -> bool {
+    if !(s.contains("%s") || s.contains("%d")) {
+        return false;
+    }
+    let meta = [
+        " -",
+        ";",
+        "|",
+        "&&",
+        "`",
+        "$(",
+        ">",
+        "${IFS}",
+        "2>&1",
+        "/bin/",
+        "/sbin/",
+        "/usr/bin/",
+    ];
+    if meta.iter().any(|m| s.contains(m)) {
+        return true;
+    }
+    let trimmed = s.trim_start();
+    CMD_HEADS.iter().any(|head| trimmed.starts_with(head))
+}
+
+fn is_neutered(v: u64) -> bool {
+    v == u64::MAX || v == 0xffff_ffff
+}
+
+fn unbounded_scan(fmt: &str) -> bool {
+    let chars: Vec<char> = fmt.chars().collect();
+    let mut i = 0;
+    while i < chars.len() {
+        if chars[i] != '%' {
+            i += 1;
+            continue;
+        }
+        let mut j = i + 1;
+        if j < chars.len() && chars[j] == '%' {
+            i = j + 1;
+            continue;
+        }
+        if j < chars.len() && chars[j] == '*' {
+            j += 1;
+        }
+        let had_width = j < chars.len() && chars[j].is_ascii_digit();
+        while j < chars.len() && chars[j].is_ascii_digit() {
+            j += 1;
+        }
+        if j < chars.len() && (chars[j] == 's' || chars[j] == '[') && !had_width {
+            return true;
+        }
+        i = j + 1;
+    }
+    false
+}
+
+fn mode_note(m: u64) -> Option<String> {
+    if m & 0o4000 != 0 {
+        Some(format!("setuid bit in mode {m:#o}"))
+    } else if m & 0o2000 != 0 {
+        Some(format!("setgid bit in mode {m:#o}"))
+    } else if m & 0o2 != 0 {
+        Some(format!("world-writable mode {m:#o}"))
+    } else {
+        None
+    }
+}
+
+struct Hit {
+    class: Class,
+    score: i32,
+    evidence: Vec<String>,
+}
+
+impl Hit {
+    fn new(class: Class, adjust: i32) -> Hit {
+        Hit {
+            class,
+            score: class.base() + adjust,
+            evidence: Vec::new(),
+        }
+    }
+    fn note(&mut self, ev: impl Into<String>) {
+        self.evidence.push(ev.into());
+    }
+    fn bump(&mut self, delta: i32, ev: impl Into<String>) {
+        self.score += delta;
+        self.evidence.push(ev.into());
+    }
+}
+
+struct Scan<'a> {
+    bin: &'a Binary,
+    graph: &'a Graph,
+    name: String,
+    reach: HashMap<u64, u32>,
+    wrappers: HashSet<u64>,
+    calls: HashMap<u64, Vec<&'a str>>,
+    unix_paths: Vec<String>,
+    exec_imports: Vec<&'a str>,
+    tokenish: bool,
+    no_peercred: bool,
+    drops_privilege: bool,
+}
+
+fn call_index(g: &Graph) -> HashMap<u64, Vec<&str>> {
+    let mut m: HashMap<u64, Vec<&str>> = HashMap::new();
+    for s in &g.sites {
+        if let Some(n) = s.import.as_deref() {
+            m.entry(s.func).or_default().push(n);
+        }
+    }
+    for v in m.values_mut() {
+        v.sort_unstable();
+        v.dedup();
+    }
+    m
+}
+
+fn is_unix_socket_path(text: &str) -> bool {
+    text.contains(".sock") && text.starts_with('/')
+}
+
+fn socket_paths(g: &Graph) -> Vec<String> {
+    let mut paths: Vec<String> = g
+        .strings
+        .values()
+        .flatten()
+        .filter(|text| is_unix_socket_path(text))
+        .cloned()
+        .collect();
+    paths.sort_unstable();
+    paths.dedup();
+    paths
+}
+
+fn is_exec_primitive(import: &str) -> bool {
+    SHELL.contains(&import)
+        || PRINTF_EXEC.contains(&import)
+        || ARGV_EXEC.contains(&import)
+        || import.contains("exec")
+        || import.contains("system_cmd")
+}
+
+fn exec_imports(bin: &Binary) -> Vec<&str> {
+    let mut exec_like: Vec<&str> = bin
+        .imports
+        .iter()
+        .map(String::as_str)
+        .filter(|import| is_exec_primitive(import))
+        .collect();
+    exec_like.sort_unstable();
+    exec_like
+}
+
+fn names_credential(text: &str) -> bool {
+    let lower = text.to_ascii_lowercase();
+    lower.contains("token") || lower.contains("session") || lower.contains("cookie")
+}
+
+fn mentions_session_token(g: &Graph) -> bool {
+    g.strings
+        .values()
+        .flatten()
+        .any(|text| names_credential(text))
+}
+
+impl<'a> Scan<'a> {
+    fn new(bin: &'a Binary, graph: &'a Graph) -> Scan<'a> {
+        let mut seeds: Vec<&str> = SHELL.to_vec();
+        seeds.extend_from_slice(PRINTF_EXEC);
+        Scan {
+            bin,
+            graph,
+            name: bin.name(),
+            reach: graph.reach_from(UNTRUSTED_INPUTS),
+            wrappers: graph.shell_equivalent(&seeds),
+            calls: call_index(graph),
+            unix_paths: socket_paths(graph),
+            exec_imports: exec_imports(bin),
+            tokenish: mentions_session_token(graph),
+            no_peercred: !PEERCRED.iter().any(|n| bin.imports.contains(*n)),
+            drops_privilege: PRIV_DROP.iter().any(|n| bin.imports.contains(*n)),
+        }
+    }
+    fn has(&self, n: &str) -> bool {
+        self.bin.imports.contains(n)
+    }
+    fn any(&self, v: &[&str]) -> bool {
+        v.iter().any(|n| self.has(n))
+    }
+}
+
+struct Site<'a> {
+    scan: &'a Scan<'a>,
+    call: &'a CallSite,
+    imp: &'a str,
+    reach: Option<u32>,
+}
+
+impl Site<'_> {
+    fn text(&self, n: usize) -> Option<String> {
+        match self.call.args.get(n) {
+            Some(Some(Val::Num(a))) => self.scan.bin.cstr_at(*a),
+            _ => None,
+        }
+    }
+    fn num(&self, n: usize) -> Option<u64> {
+        match self.call.args.get(n) {
+            Some(Some(Val::Num(v))) => Some(*v),
+            _ => None,
+        }
+    }
+    fn stack(&self, n: usize) -> Option<i64> {
+        match self.call.args.get(n) {
+            Some(Some(Val::Stack(k))) => Some(*k),
+            _ => None,
+        }
+    }
+    fn unknown(&self, n: usize) -> bool {
+        self.call.args.get(n).map(Option::is_none).unwrap_or(false)
+    }
+    fn visible(&self, n: usize) -> bool {
+        n < self.call.args.len()
+    }
+    fn calls(&self) -> &[&str] {
+        self.scan
+            .calls
+            .get(&self.call.func)
+            .map_or(&[], Vec::as_slice)
+    }
+    fn strings(&self) -> &[String] {
+        self.scan
+            .graph
+            .strings
+            .get(&self.call.func)
+            .map_or(&[], Vec::as_slice)
+    }
+    fn has(&self, n: &str) -> bool {
+        self.scan.has(n)
+    }
+    fn any(&self, v: &[&str]) -> bool {
+        self.scan.any(v)
+    }
+}
+
+fn shell(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Exec, 0);
+    let cmd_arg = if SHELL_ARG1.contains(&s.imp) { 1 } else { 0 };
+    if PRINTF_EXEC.contains(&s.imp) {
+        h.note(format!(
+            "{} takes a printf format, not a finished command",
+            s.imp
+        ));
+    }
+    match s.text(cmd_arg) {
+        Some(t) if t.contains('%') => h.bump(25, format!("command template {t:?}")),
+        Some(t) => h.bump(-30, format!("constant command {t:?}")),
+        None if s.num(cmd_arg).is_some() => {
+            h.bump(-10, "argument is a constant that is not readable text")
+        }
+        None => h.bump(15, "argument not a compile-time constant"),
+    }
+    if s.imp == "wordexp" {
+        match s.num(2) {
+            Some(f) if f & WRDE_NOCMD != 0 => {
+                h.bump(-25, "WRDE_NOCMD set, command substitution refused")
+            }
+            _ => h.bump(25, "no WRDE_NOCMD: `$(...)` in the input is executed"),
+        }
+    }
+    let templates: Vec<&String> = s.strings().iter().filter(|t| looks_shell(t)).collect();
+    for t in templates.iter().take(MAX_TEMPLATES) {
+        h.note(format!("command template {t:?}"));
+    }
+    if !templates.is_empty() {
+        h.score += SHELL_TEMPLATE_BONUS;
+    }
+    Some(h)
+}
+
+fn script_eval(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Exec, -5);
+    h.note(format!("{} evaluates a script at runtime", s.imp));
+    if s.text(0).is_none() {
+        h.bump(20, "the evaluated string is not a compile-time constant");
+    }
+    Some(h)
+}
+
+fn argv_exec(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Exec, -25);
+    h.note("argv exec, metacharacters are inert");
+    let path_relative = !matches!(s.text(0), Some(t) if t.starts_with('/'));
+    if (s.imp == "execvp" || s.imp == "execlp") && path_relative {
+        h.bump(
+            25,
+            "execvp/execlp resolves through $PATH, not an absolute path",
+        );
+    }
+    Some(h)
+}
+
+fn dynamic_load(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Exec, -20);
+    match s.text(0) {
+        Some(t) if t.starts_with("/lib") || t.starts_with("/usr/lib") => return None,
+        Some(t) => h.note(format!("loads library from {t:?}")),
+        None => {
+            h.note("library path is not a compile-time constant");
+            if !s.calls().contains(&"strchr") {
+                h.bump(20, "no strchr guard on the name: no '/' rejection");
+            }
+        }
+    }
+    Some(h)
+}
+
+fn env_hijack(s: &Site) -> Option<Hit> {
+    let name = s.text(0)?;
+    if !SEARCH_PATH_VARS.iter().any(|v| name.starts_with(v)) {
+        return None;
+    }
+    let mut h = Hit::new(Class::Exec, -15);
+    h.note(format!(
+        "sets {name:?}, a search-path primitive for any child"
+    ));
+    Some(h)
+}
+
+fn curl_verify(s: &Site) -> Option<Hit> {
+    let (opt, val) = (s.num(1)?, s.num(2)?);
+    let (_, name) = CURL_VERIFY_OPTS.iter().find(|(o, _)| *o == opt)?;
+    if val != 0 {
+        return None;
+    }
+    let mut h = Hit::new(Class::Tls, VERIFY_OFF_BONUS);
+    h.note(format!("{name} set to 0: certificate check disabled"));
+    h.note("DNS-hijack MITM works without a valid certificate");
+    Some(h)
+}
+
+fn verify_mode(s: &Site) -> Option<Hit> {
+    if s.num(1) != Some(0) {
+        return None;
+    }
+    let mut h = Hit::new(Class::Tls, VERIFY_OFF_BONUS);
+    h.note("verify mode 0 (VERIFY_NONE): peer certificate not checked");
+    if s.has("SSL_get_verify_result") {
+        h.bump(
+            -20,
+            "SSL_get_verify_result present, the result may still be inspected",
+        );
+    }
+    Some(h)
+}
+
+fn mbedtls_authmode(s: &Site) -> Option<Hit> {
+    let (adjust, note) = match s.num(1)? {
+        MBEDTLS_VERIFY_NONE => (
+            VERIFY_OFF_BONUS,
+            "MBEDTLS_SSL_VERIFY_NONE: peer certificate not checked",
+        ),
+        MBEDTLS_VERIFY_OPTIONAL => (
+            0,
+            "MBEDTLS_SSL_VERIFY_OPTIONAL: handshake continues on failure",
+        ),
+        _ => return None,
+    };
+    let mut h = Hit::new(Class::Tls, adjust);
+    h.note(note);
+    Some(h)
+}
+
+fn unbounded_write(s: &Site, dest: usize, fmt: Option<usize>) -> Option<Hit> {
+    let mut h = Hit::new(Class::Mem, 0);
+    let stack_dest = s.stack(dest);
+    if let Some(k) = stack_dest {
+        h.bump(
+            20,
+            format!("destination is a stack buffer at {}", stack_off(k)),
+        );
+    }
+    let mut conversions = 0;
+    if let Some(fmt) = fmt {
+        match s.text(fmt) {
+            Some(t) => {
+                conversions = t.matches("%s").count();
+                h.bump(10 * conversions as i32, format!("format {t:?}"));
+            }
+            None if s.num(fmt).is_some() => h.note("format not a readable literal"),
+            None => h.bump(25, "format argument is not a literal (format string bug)"),
+        }
+    }
+    if stack_dest.is_some() && (conversions > 0 || fmt.is_none()) {
+        h.bump(
+            35,
+            "unbounded write of a variable-length value into a fixed stack buffer",
+        );
+    }
+    fortify_note(s, &mut h);
+    Some(h)
+}
+
+fn fortify_bound(s: &Site) -> Option<u64> {
+    let (_, bound) = FORTIFY_BOUND.iter().find(|(n, _)| *n == s.imp)?;
+    s.num(*bound)
+}
+
+fn fortify_note(s: &Site, h: &mut Hit) {
+    match fortify_bound(s) {
+        Some(v) if is_neutered(v) => h.bump(FORTIFY_OFF_BONUS, FORTIFY_OFF_NOTE),
+        Some(_) => h.bump(-30, "FORTIFY bound is a real constant, overflow aborts"),
+        None => {}
+    }
+}
+
+fn measures_input(s: &Site) -> bool {
+    s.calls().contains(&"strlen") || s.calls().contains(&"strspn")
+}
+
+fn sized_write(s: &Site, len: usize) -> Option<Hit> {
+    if s.num(len) == Some(0) {
+        let mut h = Hit::new(Class::Mem, -10);
+        h.note("length 0 turns this into a no-op, usually an underflow artefact");
+        return Some(h);
+    }
+    let fortify_disabled = fortify_bound(s).is_some_and(is_neutered);
+    let stack_dest = s.stack(0);
+    let measures_reachable_input = measures_input(s) && s.reach == Some(0);
+    let suspicious = stack_dest.is_some() || fortify_disabled || measures_reachable_input;
+    if !s.unknown(len) || !suspicious {
+        return None;
+    }
+    let mut h = Hit::new(Class::Mem, 0);
+    if let Some(k) = stack_dest {
+        h.note(format!(
+            "non-constant length into a stack buffer at {}",
+            stack_off(k)
+        ));
+    }
+    if fortify_disabled {
+        h.bump(FORTIFY_OFF_BONUS, FORTIFY_OFF_NOTE);
+    }
+    if measures_input(s) {
+        h.bump(
+            20,
+            "strlen/strspn in the same function: size may be derived from the \
+             input length, not the destination size",
+        );
+    }
+    Some(h)
+}
+
+fn stack_alloc(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Mem, -5);
+    match s.num(0) {
+        Some(v) => h.bump(-20, format!("alloca of a constant {v:#x}")),
+        None => h.note("alloca with a non-constant size: stack clash"),
+    }
+    Some(h)
+}
+
+fn unbounded_read(s: &Site, fmt: usize) -> Option<Hit> {
+    if !s.visible(fmt) {
+        return None;
+    }
+    let f = s.text(fmt)?;
+    if !unbounded_scan(&f) {
+        return None;
+    }
+    let mut h = Hit::new(Class::Mem, 10);
+    h.note(format!("format {f:?} has a %s or %[ with no field width"));
+    Some(h)
+}
+
+fn format_string(s: &Site, fmt: usize) -> Option<Hit> {
+    if !s.visible(fmt) || s.text(fmt).is_some() {
+        return None;
+    }
+    let mut h = Hit::new(Class::Fmt, 0);
+    h.note("format argument is not a readable literal");
+    Some(h)
+}
+
+fn writable_executable(s: &Site) -> Option<Hit> {
+    let prot = s.num(2)?;
+    if prot & PROT_WRITE_EXEC != PROT_WRITE_EXEC {
+        return None;
+    }
+    let mut h = Hit::new(Class::Mem, 5);
+    h.note(format!(
+        "prot {prot:#x} is PROT_WRITE|PROT_EXEC: writable and executable at once"
+    ));
+    Some(h)
+}
+
+fn des_crypt(s: &Site) -> Option<Hit> {
+    let salt = s.text(1)?;
+    if salt.starts_with('$') {
+        return None;
+    }
+    let mut h = Hit::new(Class::Auth, 15);
+    h.note(format!(
+        "salt {salt:?} selects traditional DES crypt, key truncated to 8 characters"
+    ));
+    Some(h)
+}
+
+fn weak_random(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Auth, -15);
+    h.note(format!("{} is not a CSPRNG", s.imp));
+    if !s.any(CSPRNG) {
+        h.bump(
+            15,
+            "no getrandom/arc4random/RAND_bytes anywhere in this binary",
+        );
+    }
+    if s.scan.tokenish {
+        h.bump(20, "binary references token/session/cookie strings");
+    }
+    Some(h)
+}
+
+fn predictable_seed(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Auth, -10);
+    match s.num(0) {
+        Some(v) => h.note(format!("seeded with the constant {v:#x}")),
+        None if s.calls().iter().any(|c| *c == "time" || *c == "getpid") => {
+            h.note("seeded from time()/getpid() in the same function")
+        }
+        None => return None,
+    }
+    Some(h)
+}
+
+fn mac_compare(s: &Site) -> Option<Hit> {
+    let len = s.num(2)?;
+    if !DIGEST_LENGTHS.contains(&len) {
+        return None;
+    }
+    let beside_mac = s.calls().iter().any(|c| {
+        c.contains("HMAC") || c.contains("MD5") || c.contains("SHA") || c.contains("CMAC")
+    });
+    if !beside_mac {
+        return None;
+    }
+    let mut h = Hit::new(Class::Auth, 0);
+    h.note(format!(
+        "memcmp of {len} bytes beside a MAC/hash call: not constant time"
+    ));
+    if s.any(CT_COMPARE) {
+        h.bump(-20, "a constant-time compare exists in this binary");
+    }
+    Some(h)
+}
+
+fn privilege_op(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Priv, -10);
+    match (s.imp, s.num(0)) {
+        ("umask", Some(mode)) => {
+            let masks_world_write = mode != 0 && mode & 0o2 != 0;
+            if masks_world_write {
+                return None;
+            }
+            h.bump(
+                25,
+                format!("umask({mode:#o}) leaves later creations group- or world-writable"),
+            );
+        }
+        (_, Some(v)) => h.note(format!("argument {v:#x}")),
+        _ => {}
+    }
+    if s.imp == "setuid" && !s.has("setgroups") && !s.has("initgroups") {
+        h.bump(
+            20,
+            "no setgroups/initgroups anywhere: setuid keeps the supplementary groups",
+        );
+    }
+    Some(h)
+}
+
+fn chroot_escape(s: &Site) -> Option<Hit> {
+    if s.calls().contains(&"chdir") {
+        return None;
+    }
+    let mut h = Hit::new(Class::Priv, 20);
+    h.note("no chdir in the same function: `../` walks out of the jail");
+    Some(h)
+}
+
+fn permissive_mode(s: &Site) -> Option<Hit> {
+    let note = mode_note(s.num(1)?)?;
+    let mut h = Hit::new(Class::Path, 20);
+    h.note(note);
+    if let Some(p) = s.text(0) {
+        h.note(format!("path {p:?}"));
+    }
+    Some(h)
+}
+
+fn temp_file(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Path, 10);
+    h.note(format!(
+        "{} names a temp file without creating it atomically",
+        s.imp
+    ));
+    if let Some(p) = s.text(0) {
+        h.note(format!("template {p:?}"));
+    }
+    Some(h)
+}
+
+fn unix_listener(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Ipc, 0);
+    for p in s.scan.unix_paths.iter().take(MAX_SOCKET_PATHS) {
+        h.note(format!("socket path {p:?}"));
+    }
+    if s.scan.no_peercred {
+        h.bump(30, "no SO_PEERCRED / getpeereid anywhere in this binary");
+    }
+    let execs = &s.scan.exec_imports;
+    for e in execs.iter().take(MAX_EXEC_NOTES) {
+        h.note(format!("exec reachable via {e}"));
+    }
+    if !execs.is_empty() {
+        h.score += 20;
+    }
+    if !s.scan.drops_privilege && !execs.is_empty() {
+        h.bump(
+            15,
+            "no setuid/setgid anywhere: this listener never drops privilege",
+        );
+    }
+    Some(h)
+}
+
+fn classify(s: &Site) -> Option<Hit> {
+    let imp = s.imp;
+    if SHELL.contains(&imp) || PRINTF_EXEC.contains(&imp) || SHELL_ARG1.contains(&imp) {
+        shell(s)
+    } else if EVAL.contains(&imp) {
+        script_eval(s)
+    } else if ARGV_EXEC.contains(&imp) {
+        argv_exec(s)
+    } else if imp == "dlopen" {
+        dynamic_load(s)
+    } else if imp == "setenv" || imp == "putenv" {
+        env_hijack(s)
+    } else if imp == "curl_easy_setopt" {
+        curl_verify(s)
+    } else if VERIFY_MODE.contains(&imp) {
+        verify_mode(s)
+    } else if imp == "mbedtls_ssl_conf_authmode" {
+        mbedtls_authmode(s)
+    } else if OLD_TLS.contains(&imp) {
+        obsolete_tls(s)
+    } else if let Some((_, dest, fmt)) = UNBOUNDED.iter().find(|(n, _, _)| *n == imp) {
+        unbounded_write(s, *dest, *fmt)
+    } else if let Some((_, len)) = SIZED.iter().find(|(n, _)| *n == imp) {
+        sized_write(s, *len)
+    } else if imp == "alloca" {
+        stack_alloc(s)
+    } else if let Some((_, fmt)) = SCANF.iter().find(|(n, _)| *n == imp) {
+        unbounded_read(s, *fmt)
+    } else if let Some((_, fmt)) = FORMAT.iter().find(|(n, _)| *n == imp) {
+        format_string(s, *fmt)
+    } else if imp == "mmap" || imp == "mprotect" {
+        writable_executable(s)
+    } else if imp == "crypt" || imp == "crypt_r" {
+        des_crypt(s)
+    } else if WEAK_CRYPTO.contains(&imp) {
+        weak_crypto(s)
+    } else if WEAK_RANDOM.contains(&imp) {
+        weak_random(s)
+    } else if SEED.contains(&imp) {
+        predictable_seed(s)
+    } else if imp == "memcmp" {
+        mac_compare(s)
+    } else if DESER.contains(&imp) {
+        parses_document(s)
+    } else if PRIVOPS.contains(&imp) {
+        privilege_op(s)
+    } else if imp == "chroot" {
+        chroot_escape(s)
+    } else if imp == "chmod" || imp == "fchmod" || imp == "mkdir" {
+        permissive_mode(s)
+    } else if TEMPFILE.contains(&imp) {
+        temp_file(s)
+    } else if imp == "bind" {
+        unix_listener(s)
+    } else {
+        None
+    }
+}
+
+fn note_sanitizers(s: &Site, h: &mut Hit) {
+    if matches!(h.class, Class::Ipc | Class::Tls) {
+        return;
+    }
+    let sanitizers_in_function: Vec<&str> = s
+        .calls()
+        .iter()
+        .copied()
+        .filter(|name| SANITIZERS.contains(name))
+        .collect();
+    if sanitizers_in_function.is_empty() {
+        return;
+    }
+    h.bump(
+        -20,
+        format!(
+            "value may be normalized first: {} in the same function",
+            sanitizers_in_function.join(", ")
+        ),
+    );
+}
+
+fn note_function_strings(s: &Site, h: &mut Hit) {
+    if !matches!(h.class, Class::Mem | Class::Fmt) {
+        return;
+    }
+    let context_strings = s
+        .strings()
+        .iter()
+        .filter(|text| text.len() >= MIN_CONTEXT_STRING)
+        .take(MAX_CONTEXT_STRINGS);
+    for text in context_strings {
+        h.note(format!("function references {text:?}"));
+    }
+}
+
+fn obsolete_tls(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Tls, -15);
+    h.note(format!("{} pins an obsolete protocol version", s.imp));
+    Some(h)
+}
+
+fn weak_crypto(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Auth, -10);
+    h.note(format!("{}: broken or ECB-mode primitive", s.imp));
+    Some(h)
+}
+
+fn parses_document(s: &Site) -> Option<Hit> {
+    let mut h = Hit::new(Class::Mem, -10);
+    h.note(format!("{} parses an attacker-supplied document", s.imp));
+    Some(h)
+}
+
+fn is_cgi_variable(text: &str) -> bool {
+    text.starts_with("HTTP_") || CGI_VARS.contains(&text)
+}
+
+fn note_cgi_variable(s: &Site, h: &mut Hit) {
+    let Some(var) = s.strings().iter().find(|text| is_cgi_variable(text)) else {
+        return;
+    };
+    h.bump(
+        20,
+        format!("reads the CGI variable {var:?}, a raw client header"),
+    );
+}
+
+fn note_reachability(s: &Site, h: &mut Hit) {
+    match s.reach {
+        Some(0) => h.bump(40, "same function as a network/env input"),
+        Some(d) if d <= 2 => h.bump(25, format!("{d} hop(s) from a network/env input")),
+        Some(d) if d <= 5 => h.bump(10, format!("{d} hops from a network/env input")),
+        Some(_) => {}
+        None if s.scan.graph.addr_taken.contains(&s.call.func) => {
+            h.note("address-taken: reached by indirect dispatch, not a direct call")
+        }
+        None => h.bump(-20, "no path from any network or environment input"),
+    }
+}
+
+fn adjust(s: &Site, h: &mut Hit) {
+    note_sanitizers(s, h);
+    note_function_strings(s, h);
+    note_cgi_variable(s, h);
+    if s.scan.wrappers.contains(&s.call.func) && h.class != Class::Exec {
+        h.bump(10, "inside a shell-equivalent wrapper");
+    }
+    note_reachability(s, h);
+}
+
+pub fn analyze(bin: &Binary, graph: &Graph) -> Vec<Finding> {
+    let scan = Scan::new(bin, graph);
+    let mut out = Vec::new();
+    for call in &graph.sites {
+        let Some(imp) = call.import.as_deref() else {
+            continue;
+        };
+        let s = Site {
+            scan: &scan,
+            call,
+            imp,
+            reach: scan.reach.get(&call.func).copied(),
+        };
+        let Some(mut h) = classify(&s) else { continue };
+        adjust(&s, &mut h);
+        out.push(Finding {
+            bin: scan.name.clone(),
+            class: h.class,
+            risky_call: imp.to_string(),
+            site: call.site,
+            func: graph.func_name(call.func),
+            reach: s.reach,
+            evidence: h.evidence,
+            score: h.score,
+        });
+    }
+    out.extend(absences(&scan));
+    out.sort_by(|a, b| b.score.cmp(&a.score).then(a.site.cmp(&b.site)));
+    out
+}
+
+struct Absence {
+    present: &'static [&'static str],
+    absent: &'static [&'static str],
+    class: Class,
+    risky_call: &'static str,
+    score: i32,
+    evidence: &'static [&'static str],
+}
+
+const SSL_CLIENT: &[&str] = &["SSL_connect", "SSL_CTX_new"];
+
+const ABSENT: &[Absence] = &[
+    Absence {
+        present: SSL_CLIENT,
+        absent: &["SSL_CTX_set_verify", "SSL_set_verify"],
+        class: Class::Tls,
+        risky_call: "(no verify mode set)",
+        score: 75,
+        evidence: &[
+            "SSL_CTX_new/SSL_connect present but no verify mode is ever configured",
+            "OpenSSL's documented default is SSL_VERIFY_NONE, so the handshake completes regardless of the certificate",
+        ],
+    },
+    Absence {
+        present: SSL_CLIENT,
+        absent: HOSTNAME_CHECK,
+        class: Class::Tls,
+        risky_call: "(no hostname check)",
+        score: 65,
+        evidence: &[
+            "no X509_check_host / SSL_set1_host / X509_VERIFY_PARAM_set1_host",
+            "OpenSSL does not bind a chain to a hostname on its own, so any CA-signed certificate for any name is accepted",
+        ],
+    },
+    Absence {
+        present: SSL_CLIENT,
+        absent: &["SSL_CTX_load_verify_locations", "SSL_CTX_set_default_verify_paths"],
+        class: Class::Tls,
+        risky_call: "(no trust store)",
+        score: 55,
+        evidence: &["no CA store is ever loaded, so chain verification cannot succeed"],
+    },
+    Absence {
+        present: &["mbedtls_ssl_handshake"],
+        absent: &["mbedtls_ssl_set_hostname"],
+        class: Class::Tls,
+        risky_call: "(no mbedtls hostname)",
+        score: 65,
+        evidence: &["mbedtls_ssl_set_hostname is never called, so CN/SAN matching is skipped"],
+    },
+    Absence {
+        present: &["chroot"],
+        absent: &["chdir"],
+        class: Class::Priv,
+        risky_call: "(chroot without chdir)",
+        score: 55,
+        evidence: &["chroot imported but chdir is not, so the jail is trivially escaped"],
+    },
+    Absence {
+        present: TEMPFILE,
+        absent: &["mkstemp", "mkostemp", "mkdtemp"],
+        class: Class::Path,
+        risky_call: "(no atomic temp file)",
+        score: 45,
+        evidence: &["mktemp/tmpnam used and no mkstemp/mkdtemp is linked at all"],
+    },
+];
+
+fn is_unbounded_write(import: &str) -> bool {
+    UNBOUNDED.iter().any(|(name, _, _)| *name == import)
+}
+
+fn uses_unbounded_write(g: &Graph) -> bool {
+    let mut imports = g.sites.iter().filter_map(|site| site.import.as_deref());
+    imports.any(is_unbounded_write)
+}
+
+fn names_flash_target(text: &str) -> bool {
+    text.contains("sysupgrade") || text.starts_with("mtd ") || text.contains("/dev/mtd")
+}
+
+fn flashes_firmware(g: &Graph) -> bool {
+    g.strings
+        .values()
+        .flatten()
+        .any(|text| names_flash_target(text))
+}
+
+fn absences(sc: &Scan) -> Vec<Finding> {
+    let mut out = Vec::new();
+    let mut push = |class: Class, risky_call: &str, score: i32, ev: &[&str]| {
+        out.push(Finding {
+            bin: sc.name.clone(),
+            class,
+            risky_call: risky_call.to_string(),
+            site: 0,
+            func: "(whole binary)".into(),
+            reach: None,
+            evidence: ev.iter().map(|e| e.to_string()).collect(),
+            score,
+        })
+    };
+
+    let unbounded = uses_unbounded_write(sc.graph);
+    if unbounded && !sc.has("__stack_chk_fail") {
+        push(
+            Class::Mem,
+            "(no stack canary)",
+            60,
+            &[
+                "no __stack_chk_fail import: built without -fstack-protector",
+                "every stack overflow here reaches the saved return address directly",
+            ],
+        );
+    }
+    if unbounded && !sc.any(BOUNDED) {
+        push(
+            Class::Mem,
+            "(no bounded copy)",
+            55,
+            &[
+                "no snprintf/strlcpy/strncpy/strncat anywhere in this binary",
+                "every string operation it performs is unbounded",
+            ],
+        );
+    }
+    for a in ABSENT {
+        if sc.any(a.present) && !sc.any(a.absent) {
+            push(a.class, a.risky_call, a.score, a.evidence);
+        }
+    }
+    if flashes_firmware(sc.graph) && (sc.has("system") || sc.has("popen")) && !sc.any(SIGVERIFY) {
+        push(
+            Class::Exec,
+            "(unsigned update path)",
+            60,
+            &[
+                "references mtd/sysupgrade and shells out, but links no RSA/ECDSA verify",
+                "check whether the update image is authenticated at all",
+            ],
+        );
+    }
+    out
+}
diff --git a/tests/scorecard.rs b/tests/scorecard.rs
@@ -0,0 +1,71 @@
+use std::process::Command;
+
+fn run(bin: &str, class: &str) -> String {
+    let exe = env!("CARGO_BIN_EXE_balrog");
+    let out = Command::new(exe)
+        .args([
+            "scan",
+            bin,
+            "--class",
+            class,
+            "--min=0",
+            "--per-bin=99",
+            "-n",
+            "10",
+        ])
+        .output()
+        .expect("balrog runs");
+    String::from_utf8_lossy(&out.stdout).into_owned()
+}
+
+fn expect(var: &str, class: &str, proofs: &[&str]) {
+    let Ok(path) = std::env::var(var) else {
+        eprintln!("skip {var}: not set");
+        return;
+    };
+    let out = run(&path, class);
+    for p in proofs {
+        assert!(out.contains(p), "{var}: missing evidence {p:?} in:\n{out}");
+    }
+}
+
+#[test]
+fn proftpd_efmlog_stack_overflow() {
+    expect(
+        "BALROG_PROFTPD",
+        "mem",
+        &[
+            "sprintf",
+            "\"%s(%s)\"",
+            "destination is a stack buffer",
+            "unbounded write",
+        ],
+    );
+}
+
+#[test]
+fn easydisc_udp_command_injection() {
+    expect(
+        "BALROG_EASYDISC",
+        "exec",
+        &[
+            "system",
+            "dev_sta set -m esw_networkId_merge",
+            "same function as a network/env input",
+        ],
+    );
+}
+
+#[test]
+fn porter_unauthenticated_root_socket() {
+    expect(
+        "BALROG_PORTER",
+        "ipc",
+        &[
+            "bind",
+            "/tmp/porter.sock",
+            "no SO_PEERCRED",
+            "qid_sys_execute_update_script",
+        ],
+    );
+}