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(®).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(®) {
+ 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",
+ ],
+ );
+}
| | | | | | | | | | | | |