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

rules.rs (38864B)

      1 use crate::graph::{CallSite, Graph, Val};
      2 use crate::load::Binary;
      3 use serde::Serialize;
      4 use std::collections::{HashMap, HashSet};
      5 
      6 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, Serialize, clap::ValueEnum)]
      7 #[serde(rename_all = "lowercase")]
      8 pub enum Class {
      9     Exec,
     10     Ipc,
     11     Tls,
     12     Fmt,
     13     Mem,
     14     Auth,
     15     Priv,
     16     Path,
     17 }
     18 
     19 pub const ORDER: &[Class] = &[
     20     Class::Exec,
     21     Class::Ipc,
     22     Class::Tls,
     23     Class::Fmt,
     24     Class::Mem,
     25     Class::Auth,
     26     Class::Priv,
     27     Class::Path,
     28 ];
     29 
     30 impl Class {
     31     fn base(self) -> i32 {
     32         match self {
     33             Class::Exec => 50,
     34             Class::Ipc => 45,
     35             Class::Tls => 45,
     36             Class::Fmt => 45,
     37             Class::Mem => 35,
     38             Class::Auth => 30,
     39             Class::Priv => 30,
     40             Class::Path => 20,
     41         }
     42     }
     43     pub fn tag(self) -> &'static str {
     44         match self {
     45             Class::Exec => "exec",
     46             Class::Ipc => "ipc",
     47             Class::Tls => "tls",
     48             Class::Fmt => "fmt",
     49             Class::Mem => "mem",
     50             Class::Auth => "auth",
     51             Class::Priv => "priv",
     52             Class::Path => "path",
     53         }
     54     }
     55 }
     56 
     57 pub const SHELL: &[&str] = &[
     58     "system",
     59     "popen",
     60     "wordexp",
     61     "twsystem",
     62     "CsteSystem",
     63     "bstar_system",
     64     "___system",
     65     "doShell",
     66     "cgi_deal_popen",
     67     "ExeCmd",
     68     "ExecShell",
     69     "exec_shell_popen",
     70     "exec_shell_popen_str",
     71     "exec_shell_async",
     72     "exec_shell_sync",
     73     "exec_shell_sync2",
     74     "execFormatCmd",
     75     "exec_cmd",
     76     "tp_systemEx",
     77     "pegaSystem",
     78     "SLIBCSystem",
     79     "SLIBCExec",
     80     "SLIBCExecv",
     81     "SLIBCPopen",
     82     "smbrun",
     83     "smbrun_no_sanitize",
     84     "smbrunsecret",
     85     "afprun",
     86     "popen_utf8",
     87     "do_system",
     88     "safe_system",
     89     "_backexec",
     90     "execRoot_bg2",
     91     "ugs_execRoot_shell",
     92     "prctl_runCommandInShellWithTimeout",
     93     "safe_popen",
     94 ];
     95 pub const SHELL_ARG1: &[&str] = &["SLIBCExecl"];
     96 pub const EVAL: &[&str] = &[
     97     "zend_eval_string",
     98     "zend_eval_stringl",
     99     "zend_eval_string_ex",
    100     "PyRun_SimpleString",
    101     "PyRun_SimpleStringFlags",
    102     "PyRun_String",
    103     "PyRun_StringFlags",
    104     "Py_CompileString",
    105     "PyEval_EvalCode",
    106 ];
    107 pub const PRINTF_EXEC: &[&str] = &["doSystem", "doSystemCmd", "sysprintf", "lxmldbc_system"];
    108 pub const ARGV_EXEC: &[&str] = &[
    109     "execl",
    110     "execlp",
    111     "execle",
    112     "execv",
    113     "execvp",
    114     "execvpe",
    115     "execve",
    116     "posix_spawn",
    117     "posix_spawnp",
    118     "_eval",
    119     "_cpu_eval",
    120     "_cpu_mask_eval",
    121     "fm_exec",
    122 ];
    123 pub const UNBOUNDED: &[(&str, usize, Option<usize>)] = &[
    124     ("strcpy", 0, None),
    125     ("strcat", 0, None),
    126     ("stpcpy", 0, None),
    127     ("sprintf", 0, Some(1)),
    128     ("vsprintf", 0, Some(1)),
    129     ("gets", 0, None),
    130     ("wcscpy", 0, None),
    131     ("wcscat", 0, None),
    132     ("getwd", 0, None),
    133     ("__sprintf_chk", 0, Some(3)),
    134     ("__vsprintf_chk", 0, Some(3)),
    135     ("__strcpy_chk", 0, None),
    136     ("__strcat_chk", 0, None),
    137 ];
    138 pub const SIZED: &[(&str, usize)] = &[
    139     ("memcpy", 2),
    140     ("memmove", 2),
    141     ("strncpy", 2),
    142     ("strncat", 2),
    143     ("bcopy", 2),
    144     ("mempcpy", 2),
    145     ("stpncpy", 2),
    146     ("wcsncpy", 2),
    147     ("wmemcpy", 2),
    148     ("strlcpy", 2),
    149     ("strlcat", 2),
    150     ("__memcpy_chk", 2),
    151     ("__memmove_chk", 2),
    152 ];
    153 pub const FORTIFY_BOUND: &[(&str, usize)] = &[
    154     ("__sprintf_chk", 2),
    155     ("__vsprintf_chk", 2),
    156     ("__strcpy_chk", 2),
    157     ("__strcat_chk", 2),
    158     ("__memcpy_chk", 3),
    159     ("__memmove_chk", 3),
    160     ("__snprintf_chk", 3),
    161     ("__vsnprintf_chk", 3),
    162 ];
    163 pub const FORMAT: &[(&str, usize)] = &[
    164     ("printf", 0),
    165     ("syslog", 1),
    166     ("fprintf", 1),
    167     ("vsyslog", 1),
    168     ("asprintf", 1),
    169     ("vasprintf", 1),
    170     ("vprintf", 0),
    171     ("vfprintf", 1),
    172     ("snprintf", 2),
    173     ("vsnprintf", 2),
    174     ("dprintf", 1),
    175     ("vdprintf", 1),
    176     ("g_strdup_printf", 0),
    177     ("setproctitle", 0),
    178     ("err", 1),
    179     ("errx", 1),
    180     ("verr", 1),
    181     ("verrx", 1),
    182     ("warn", 0),
    183     ("warnx", 0),
    184     ("vwarn", 0),
    185     ("vwarnx", 0),
    186     ("__printf_chk", 1),
    187     ("__fprintf_chk", 2),
    188     ("__syslog_chk", 2),
    189     ("__snprintf_chk", 4),
    190     ("__vsnprintf_chk", 4),
    191 ];
    192 pub const SCANF: &[(&str, usize)] = &[
    193     ("scanf", 0),
    194     ("fscanf", 1),
    195     ("sscanf", 1),
    196     ("__isoc99_scanf", 0),
    197     ("__isoc99_fscanf", 1),
    198     ("__isoc99_sscanf", 1),
    199     ("vscanf", 0),
    200     ("vfscanf", 1),
    201     ("vsscanf", 1),
    202 ];
    203 pub const PRIVOPS: &[&str] = &[
    204     "setuid",
    205     "setgid",
    206     "seteuid",
    207     "setegid",
    208     "setresuid",
    209     "setresgid",
    210     "setreuid",
    211     "setregid",
    212     "setfsuid",
    213     "setfsgid",
    214     "setgroups",
    215     "initgroups",
    216     "umask",
    217 ];
    218 pub const TEMPFILE: &[&str] = &["mktemp", "tmpnam", "tmpnam_r", "tempnam"];
    219 pub const WEAK_RANDOM: &[&str] = &[
    220     "rand",
    221     "random",
    222     "rand_r",
    223     "drand48",
    224     "lrand48",
    225     "mrand48",
    226     "RAND_pseudo_bytes",
    227 ];
    228 pub const SEED: &[&str] = &["srand", "srandom", "srand48", "seed48"];
    229 pub const WEAK_CRYPTO: &[&str] = &[
    230     "MD5",
    231     "MD5_Init",
    232     "MD4",
    233     "SHA1_Init",
    234     "EVP_md5",
    235     "EVP_md4",
    236     "EVP_sha1",
    237     "mbedtls_md5",
    238     "mbedtls_sha1",
    239     "RC4",
    240     "RC4_set_key",
    241     "EVP_rc4",
    242     "EVP_des_cbc",
    243     "EVP_des_ecb",
    244     "DES_set_key",
    245     "DES_ecb_encrypt",
    246     "EVP_rc2_cbc",
    247     "EVP_bf_cbc",
    248     "mbedtls_des_setkey_enc",
    249     "EVP_aes_128_ecb",
    250     "EVP_aes_192_ecb",
    251     "EVP_aes_256_ecb",
    252 ];
    253 pub const OLD_TLS: &[&str] = &[
    254     "SSLv2_method",
    255     "SSLv3_method",
    256     "SSLv3_client_method",
    257     "SSLv3_server_method",
    258     "TLSv1_method",
    259     "TLSv1_client_method",
    260     "TLSv1_1_method",
    261 ];
    262 pub const VERIFY_MODE: &[&str] = &[
    263     "SSL_CTX_set_verify",
    264     "SSL_set_verify",
    265     "wolfSSL_CTX_set_verify",
    266     "wolfSSL_set_verify",
    267 ];
    268 pub const DESER: &[&str] = &[
    269     "soap_get",
    270     "soap_serve",
    271     "soap_begin_recv",
    272     "xmlReadMemory",
    273     "xmlParseMemory",
    274     "xmlReadDoc",
    275     "XML_Parse",
    276     "d2i_X509",
    277     "PEM_read_bio_X509",
    278 ];
    279 pub const UNTRUSTED_INPUTS: &[&str] = &[
    280     "recv",
    281     "recvfrom",
    282     "recvmsg",
    283     "recvmmsg",
    284     "read",
    285     "accept",
    286     "accept4",
    287     "getenv",
    288     "fgets",
    289     "getline",
    290     "__getdelim",
    291     "fread",
    292     "SSL_read",
    293     "BIO_read",
    294     "json_tokener_parse",
    295     "json_tokener_parse_ex",
    296     "json_object_from_file",
    297     "cJSON_Parse",
    298     "cJSON_ParseWithLength",
    299     "blobmsg_data",
    300     "blobmsg_parse",
    301     "soap_get",
    302     "websGetVar",
    303     "websDecodeUrl",
    304     "FCGX_GetParam",
    305     "hdf_get_value",
    306     "config_get",
    307     "find_val",
    308     "get_cgi",
    309     "httpGetEnv",
    310     "nvram_get",
    311     "nvram_safe_get",
    312     "_nvram_get",
    313     "nvram_get_int",
    314     "nvram_get_buf",
    315     "nvram_bufget",
    316     "nvram_getf",
    317     "nvram_get_adv",
    318     "envram_get",
    319     "envram_getf",
    320     "apmib_get",
    321     "artblock_get",
    322     "artblock_safe_get",
    323     "acosNvramConfig_get",
    324     "acos_nvram_get",
    325     "WAN_ith_CONFIG_GET",
    326     "uci_lookup_option_string",
    327     "uci_lookup_option",
    328     "Uci_Get_Str",
    329     "GetValue",
    330     "acosNvramConfig_read",
    331     "bcm_nvram_get",
    332     "wlcsm_nvram_get",
    333     "dni_nvram_get",
    334     "PTI_nvram_get",
    335     "CGI_Find_Parameter",
    336     "getcgivars",
    337     "getcgi",
    338     "mg_get_var",
    339     "webGetVar",
    340     "websGetVarString",
    341     "cmsObj_get",
    342     "cJSON_GetObjectItem",
    343     "cJSON_GetObjectItemCaseSensitive",
    344 ];
    345 pub const PEERCRED: &[&str] = &["getsockopt", "getpeereid", "getpeername", "SO_PEERCRED"];
    346 
    347 pub const SANITIZERS: &[&str] = &[
    348     "canonicalize_file_name",
    349     "strtol",
    350     "strtoul",
    351     "strtoll",
    352     "strtoull",
    353     "strtoimax",
    354     "strtoumax",
    355     "inet_pton",
    356     "inet_aton",
    357     "inet_ntop",
    358     "fnmatch",
    359     "g_shell_quote",
    360     "quote_for_shell",
    361     "secure_getenv",
    362 ];
    363 pub const BOUNDED: &[&str] = &[
    364     "snprintf",
    365     "vsnprintf",
    366     "strlcpy",
    367     "strlcat",
    368     "strncpy",
    369     "strncat",
    370     "strnlen",
    371     "stpecpy",
    372     "strscpy",
    373     "__snprintf_chk",
    374     "__strcpy_chk",
    375     "__memcpy_chk",
    376 ];
    377 pub const CT_COMPARE: &[&str] = &[
    378     "CRYPTO_memcmp",
    379     "timingsafe_bcmp",
    380     "timingsafe_memcmp",
    381     "sodium_memcmp",
    382     "mbedtls_ct_memcmp",
    383 ];
    384 pub const CSPRNG: &[&str] = &[
    385     "getrandom",
    386     "arc4random",
    387     "arc4random_buf",
    388     "arc4random_uniform",
    389     "RAND_bytes",
    390     "mbedtls_ctr_drbg_random",
    391 ];
    392 pub const HOSTNAME_CHECK: &[&str] = &[
    393     "X509_check_host",
    394     "X509_check_ip",
    395     "X509_check_ip_asc",
    396     "SSL_set1_host",
    397     "SSL_add1_host",
    398     "X509_VERIFY_PARAM_set1_host",
    399 ];
    400 pub const SIGVERIFY: &[&str] = &[
    401     "RSA_verify",
    402     "EVP_VerifyFinal",
    403     "EVP_DigestVerifyFinal",
    404     "ECDSA_verify",
    405     "mbedtls_pk_verify",
    406 ];
    407 
    408 const CURL_VERIFY_OPTS: &[(u64, &str)] = &[
    409     (64, "CURLOPT_SSL_VERIFYPEER"),
    410     (81, "CURLOPT_SSL_VERIFYHOST"),
    411     (248, "CURLOPT_PROXY_SSL_VERIFYPEER"),
    412     (249, "CURLOPT_PROXY_SSL_VERIFYHOST"),
    413 ];
    414 const WRDE_NOCMD: u64 = 4;
    415 const MBEDTLS_VERIFY_NONE: u64 = 0;
    416 const MBEDTLS_VERIFY_OPTIONAL: u64 = 1;
    417 const PROT_WRITE_EXEC: u64 = 0o6;
    418 const VERIFY_OFF_BONUS: i32 = 30;
    419 const FORTIFY_OFF_BONUS: i32 = 40;
    420 const FORTIFY_OFF_NOTE: &str = "FORTIFY bound is -1: the size check is disabled";
    421 const SHELL_TEMPLATE_BONUS: i32 = 25;
    422 const DIGEST_LENGTHS: &[u64] = &[16, 20, 32, 48, 64];
    423 const MAX_TEMPLATES: usize = 3;
    424 const MAX_SOCKET_PATHS: usize = 2;
    425 const MAX_EXEC_NOTES: usize = 3;
    426 const MAX_CONTEXT_STRINGS: usize = 2;
    427 const MIN_CONTEXT_STRING: usize = 8;
    428 const CGI_VARS: &[&str] = &[
    429     "QUERY_STRING",
    430     "REQUEST_URI",
    431     "CONTENT_LENGTH",
    432     "HTTP_COOKIE",
    433 ];
    434 const SEARCH_PATH_VARS: &[&str] = &[
    435     "LD_PRELOAD",
    436     "LD_LIBRARY_PATH",
    437     "PATH",
    438     "IFS",
    439     "SHELL",
    440     "PHPRC",
    441     "PERL5LIB",
    442     "PYTHONSTARTUP",
    443 ];
    444 const PRIV_DROP: &[&str] = &[
    445     "setuid",
    446     "setgid",
    447     "seteuid",
    448     "setegid",
    449     "setresuid",
    450     "setresgid",
    451     "setreuid",
    452     "setregid",
    453     "setfsuid",
    454     "setfsgid",
    455 ];
    456 
    457 const CMD_HEADS: &[&str] = &[
    458     "cp ",
    459     "mv ",
    460     "rm ",
    461     "cat ",
    462     "tar ",
    463     "chmod ",
    464     "chown ",
    465     "wget ",
    466     "curl ",
    467     "tftp ",
    468     "ping ",
    469     "traceroute ",
    470     "iwpriv ",
    471     "ifconfig ",
    472     "route ",
    473     "killall ",
    474     "iptables ",
    475     "nvram ",
    476     "sed ",
    477     "mount ",
    478     "umount ",
    479     "dd ",
    480     "mtd ",
    481     "echo ",
    482     "touch ",
    483     "mkdir ",
    484     "insmod ",
    485     "modprobe ",
    486     "reboot",
    487     "sh ",
    488 ];
    489 
    490 #[derive(Serialize)]
    491 pub struct Finding {
    492     pub bin: String,
    493     pub class: Class,
    494     pub risky_call: String,
    495     #[serde(serialize_with = "hex")]
    496     pub site: u64,
    497     pub func: String,
    498     pub reach: Option<u32>,
    499     pub evidence: Vec<String>,
    500     pub score: i32,
    501 }
    502 
    503 fn hex<S: serde::Serializer>(v: &u64, s: S) -> Result<S::Ok, S::Error> {
    504     s.serialize_str(&format!("{v:#x}"))
    505 }
    506 
    507 fn stack_off(k: i64) -> String {
    508     if k < 0 {
    509         format!("sp-{:#x}", -k)
    510     } else {
    511         format!("sp+{k:#x}")
    512     }
    513 }
    514 
    515 fn looks_shell(s: &str) -> bool {
    516     if !(s.contains("%s") || s.contains("%d")) {
    517         return false;
    518     }
    519     let meta = [
    520         " -",
    521         ";",
    522         "|",
    523         "&&",
    524         "`",
    525         "$(",
    526         ">",
    527         "${IFS}",
    528         "2>&1",
    529         "/bin/",
    530         "/sbin/",
    531         "/usr/bin/",
    532     ];
    533     if meta.iter().any(|m| s.contains(m)) {
    534         return true;
    535     }
    536     let trimmed = s.trim_start();
    537     CMD_HEADS.iter().any(|head| trimmed.starts_with(head))
    538 }
    539 
    540 fn is_neutered(v: u64) -> bool {
    541     v == u64::MAX || v == 0xffff_ffff
    542 }
    543 
    544 fn unbounded_scan(fmt: &str) -> bool {
    545     let chars: Vec<char> = fmt.chars().collect();
    546     let mut i = 0;
    547     while i < chars.len() {
    548         if chars[i] != '%' {
    549             i += 1;
    550             continue;
    551         }
    552         let mut j = i + 1;
    553         if j < chars.len() && chars[j] == '%' {
    554             i = j + 1;
    555             continue;
    556         }
    557         if j < chars.len() && chars[j] == '*' {
    558             j += 1;
    559         }
    560         let had_width = j < chars.len() && chars[j].is_ascii_digit();
    561         while j < chars.len() && chars[j].is_ascii_digit() {
    562             j += 1;
    563         }
    564         if j < chars.len() && (chars[j] == 's' || chars[j] == '[') && !had_width {
    565             return true;
    566         }
    567         i = j + 1;
    568     }
    569     false
    570 }
    571 
    572 fn mode_note(m: u64) -> Option<String> {
    573     if m & 0o4000 != 0 {
    574         Some(format!("setuid bit in mode {m:#o}"))
    575     } else if m & 0o2000 != 0 {
    576         Some(format!("setgid bit in mode {m:#o}"))
    577     } else if m & 0o2 != 0 {
    578         Some(format!("world-writable mode {m:#o}"))
    579     } else {
    580         None
    581     }
    582 }
    583 
    584 struct Hit {
    585     class: Class,
    586     score: i32,
    587     evidence: Vec<String>,
    588 }
    589 
    590 impl Hit {
    591     fn new(class: Class, adjust: i32) -> Hit {
    592         Hit {
    593             class,
    594             score: class.base() + adjust,
    595             evidence: Vec::new(),
    596         }
    597     }
    598     fn note(&mut self, ev: impl Into<String>) {
    599         self.evidence.push(ev.into());
    600     }
    601     fn bump(&mut self, delta: i32, ev: impl Into<String>) {
    602         self.score += delta;
    603         self.evidence.push(ev.into());
    604     }
    605 }
    606 
    607 struct Scan<'a> {
    608     bin: &'a Binary,
    609     graph: &'a Graph,
    610     name: String,
    611     reach: HashMap<u64, u32>,
    612     wrappers: HashSet<u64>,
    613     calls: HashMap<u64, Vec<&'a str>>,
    614     unix_paths: Vec<String>,
    615     exec_imports: Vec<&'a str>,
    616     tokenish: bool,
    617     no_peercred: bool,
    618     drops_privilege: bool,
    619 }
    620 
    621 fn call_index(g: &Graph) -> HashMap<u64, Vec<&str>> {
    622     let mut m: HashMap<u64, Vec<&str>> = HashMap::new();
    623     for s in &g.sites {
    624         if let Some(n) = s.import.as_deref() {
    625             m.entry(s.func).or_default().push(n);
    626         }
    627     }
    628     for v in m.values_mut() {
    629         v.sort_unstable();
    630         v.dedup();
    631     }
    632     m
    633 }
    634 
    635 fn is_unix_socket_path(text: &str) -> bool {
    636     text.contains(".sock") && text.starts_with('/')
    637 }
    638 
    639 fn socket_paths(g: &Graph) -> Vec<String> {
    640     let mut paths: Vec<String> = g
    641         .strings
    642         .values()
    643         .flatten()
    644         .filter(|text| is_unix_socket_path(text))
    645         .cloned()
    646         .collect();
    647     paths.sort_unstable();
    648     paths.dedup();
    649     paths
    650 }
    651 
    652 fn is_exec_primitive(import: &str) -> bool {
    653     SHELL.contains(&import)
    654         || PRINTF_EXEC.contains(&import)
    655         || ARGV_EXEC.contains(&import)
    656         || import.contains("exec")
    657         || import.contains("system_cmd")
    658 }
    659 
    660 fn exec_imports(bin: &Binary) -> Vec<&str> {
    661     let mut exec_like: Vec<&str> = bin
    662         .imports
    663         .iter()
    664         .map(String::as_str)
    665         .filter(|import| is_exec_primitive(import))
    666         .collect();
    667     exec_like.sort_unstable();
    668     exec_like
    669 }
    670 
    671 fn names_credential(text: &str) -> bool {
    672     let lower = text.to_ascii_lowercase();
    673     lower.contains("token") || lower.contains("session") || lower.contains("cookie")
    674 }
    675 
    676 fn mentions_session_token(g: &Graph) -> bool {
    677     g.strings
    678         .values()
    679         .flatten()
    680         .any(|text| names_credential(text))
    681 }
    682 
    683 impl<'a> Scan<'a> {
    684     fn new(bin: &'a Binary, graph: &'a Graph) -> Scan<'a> {
    685         let mut seeds: Vec<&str> = SHELL.to_vec();
    686         seeds.extend_from_slice(PRINTF_EXEC);
    687         Scan {
    688             bin,
    689             graph,
    690             name: bin.name(),
    691             reach: graph.reach_from(UNTRUSTED_INPUTS),
    692             wrappers: graph.shell_equivalent(&seeds),
    693             calls: call_index(graph),
    694             unix_paths: socket_paths(graph),
    695             exec_imports: exec_imports(bin),
    696             tokenish: mentions_session_token(graph),
    697             no_peercred: !PEERCRED.iter().any(|n| bin.imports.contains(*n)),
    698             drops_privilege: PRIV_DROP.iter().any(|n| bin.imports.contains(*n)),
    699         }
    700     }
    701     fn has(&self, n: &str) -> bool {
    702         self.bin.imports.contains(n)
    703     }
    704     fn any(&self, v: &[&str]) -> bool {
    705         v.iter().any(|n| self.has(n))
    706     }
    707 }
    708 
    709 struct Site<'a> {
    710     scan: &'a Scan<'a>,
    711     call: &'a CallSite,
    712     imp: &'a str,
    713     reach: Option<u32>,
    714 }
    715 
    716 impl Site<'_> {
    717     fn text(&self, n: usize) -> Option<String> {
    718         match self.call.args.get(n) {
    719             Some(Some(Val::Num(a))) => self.scan.bin.cstr_at(*a),
    720             _ => None,
    721         }
    722     }
    723     fn num(&self, n: usize) -> Option<u64> {
    724         match self.call.args.get(n) {
    725             Some(Some(Val::Num(v))) => Some(*v),
    726             _ => None,
    727         }
    728     }
    729     fn stack(&self, n: usize) -> Option<i64> {
    730         match self.call.args.get(n) {
    731             Some(Some(Val::Stack(k))) => Some(*k),
    732             _ => None,
    733         }
    734     }
    735     fn unknown(&self, n: usize) -> bool {
    736         self.call.args.get(n).map(Option::is_none).unwrap_or(false)
    737     }
    738     fn visible(&self, n: usize) -> bool {
    739         n < self.call.args.len()
    740     }
    741     fn calls(&self) -> &[&str] {
    742         self.scan
    743             .calls
    744             .get(&self.call.func)
    745             .map_or(&[], Vec::as_slice)
    746     }
    747     fn strings(&self) -> &[String] {
    748         self.scan
    749             .graph
    750             .strings
    751             .get(&self.call.func)
    752             .map_or(&[], Vec::as_slice)
    753     }
    754     fn has(&self, n: &str) -> bool {
    755         self.scan.has(n)
    756     }
    757     fn any(&self, v: &[&str]) -> bool {
    758         self.scan.any(v)
    759     }
    760 }
    761 
    762 fn shell(s: &Site) -> Option<Hit> {
    763     let mut h = Hit::new(Class::Exec, 0);
    764     let cmd_arg = if SHELL_ARG1.contains(&s.imp) { 1 } else { 0 };
    765     if PRINTF_EXEC.contains(&s.imp) {
    766         h.note(format!(
    767             "{} takes a printf format, not a finished command",
    768             s.imp
    769         ));
    770     }
    771     match s.text(cmd_arg) {
    772         Some(t) if t.contains('%') => h.bump(25, format!("command template {t:?}")),
    773         Some(t) => h.bump(-30, format!("constant command {t:?}")),
    774         None if s.num(cmd_arg).is_some() => {
    775             h.bump(-10, "argument is a constant that is not readable text")
    776         }
    777         None => h.bump(15, "argument not a compile-time constant"),
    778     }
    779     if s.imp == "wordexp" {
    780         match s.num(2) {
    781             Some(f) if f & WRDE_NOCMD != 0 => {
    782                 h.bump(-25, "WRDE_NOCMD set, command substitution refused")
    783             }
    784             _ => h.bump(25, "no WRDE_NOCMD: `$(...)` in the input is executed"),
    785         }
    786     }
    787     let templates: Vec<&String> = s.strings().iter().filter(|t| looks_shell(t)).collect();
    788     for t in templates.iter().take(MAX_TEMPLATES) {
    789         h.note(format!("command template {t:?}"));
    790     }
    791     if !templates.is_empty() {
    792         h.score += SHELL_TEMPLATE_BONUS;
    793     }
    794     Some(h)
    795 }
    796 
    797 fn script_eval(s: &Site) -> Option<Hit> {
    798     let mut h = Hit::new(Class::Exec, -5);
    799     h.note(format!("{} evaluates a script at runtime", s.imp));
    800     if s.text(0).is_none() {
    801         h.bump(20, "the evaluated string is not a compile-time constant");
    802     }
    803     Some(h)
    804 }
    805 
    806 fn argv_exec(s: &Site) -> Option<Hit> {
    807     let mut h = Hit::new(Class::Exec, -25);
    808     h.note("argv exec, metacharacters are inert");
    809     let path_relative = !matches!(s.text(0), Some(t) if t.starts_with('/'));
    810     if (s.imp == "execvp" || s.imp == "execlp") && path_relative {
    811         h.bump(
    812             25,
    813             "execvp/execlp resolves through $PATH, not an absolute path",
    814         );
    815     }
    816     Some(h)
    817 }
    818 
    819 fn dynamic_load(s: &Site) -> Option<Hit> {
    820     let mut h = Hit::new(Class::Exec, -20);
    821     match s.text(0) {
    822         Some(t) if t.starts_with("/lib") || t.starts_with("/usr/lib") => return None,
    823         Some(t) => h.note(format!("loads library from {t:?}")),
    824         None => {
    825             h.note("library path is not a compile-time constant");
    826             if !s.calls().contains(&"strchr") {
    827                 h.bump(20, "no strchr guard on the name: no '/' rejection");
    828             }
    829         }
    830     }
    831     Some(h)
    832 }
    833 
    834 fn env_hijack(s: &Site) -> Option<Hit> {
    835     let name = s.text(0)?;
    836     if !SEARCH_PATH_VARS.iter().any(|v| name.starts_with(v)) {
    837         return None;
    838     }
    839     let mut h = Hit::new(Class::Exec, -15);
    840     h.note(format!(
    841         "sets {name:?}, a search-path primitive for any child"
    842     ));
    843     Some(h)
    844 }
    845 
    846 fn curl_verify(s: &Site) -> Option<Hit> {
    847     let (opt, val) = (s.num(1)?, s.num(2)?);
    848     let (_, name) = CURL_VERIFY_OPTS.iter().find(|(o, _)| *o == opt)?;
    849     if val != 0 {
    850         return None;
    851     }
    852     let mut h = Hit::new(Class::Tls, VERIFY_OFF_BONUS);
    853     h.note(format!("{name} set to 0: certificate check disabled"));
    854     h.note("DNS-hijack MITM works without a valid certificate");
    855     Some(h)
    856 }
    857 
    858 fn verify_mode(s: &Site) -> Option<Hit> {
    859     if s.num(1) != Some(0) {
    860         return None;
    861     }
    862     let mut h = Hit::new(Class::Tls, VERIFY_OFF_BONUS);
    863     h.note("verify mode 0 (VERIFY_NONE): peer certificate not checked");
    864     if s.has("SSL_get_verify_result") {
    865         h.bump(
    866             -20,
    867             "SSL_get_verify_result present, the result may still be inspected",
    868         );
    869     }
    870     Some(h)
    871 }
    872 
    873 fn mbedtls_authmode(s: &Site) -> Option<Hit> {
    874     let (adjust, note) = match s.num(1)? {
    875         MBEDTLS_VERIFY_NONE => (
    876             VERIFY_OFF_BONUS,
    877             "MBEDTLS_SSL_VERIFY_NONE: peer certificate not checked",
    878         ),
    879         MBEDTLS_VERIFY_OPTIONAL => (
    880             0,
    881             "MBEDTLS_SSL_VERIFY_OPTIONAL: handshake continues on failure",
    882         ),
    883         _ => return None,
    884     };
    885     let mut h = Hit::new(Class::Tls, adjust);
    886     h.note(note);
    887     Some(h)
    888 }
    889 
    890 fn unbounded_write(s: &Site, dest: usize, fmt: Option<usize>) -> Option<Hit> {
    891     let mut h = Hit::new(Class::Mem, 0);
    892     let stack_dest = s.stack(dest);
    893     if let Some(k) = stack_dest {
    894         h.bump(
    895             20,
    896             format!("destination is a stack buffer at {}", stack_off(k)),
    897         );
    898     }
    899     let mut conversions = 0;
    900     if let Some(fmt) = fmt {
    901         match s.text(fmt) {
    902             Some(t) => {
    903                 conversions = t.matches("%s").count();
    904                 h.bump(10 * conversions as i32, format!("format {t:?}"));
    905             }
    906             None if s.num(fmt).is_some() => h.note("format not a readable literal"),
    907             None => h.bump(25, "format argument is not a literal (format string bug)"),
    908         }
    909     }
    910     if stack_dest.is_some() && (conversions > 0 || fmt.is_none()) {
    911         h.bump(
    912             35,
    913             "unbounded write of a variable-length value into a fixed stack buffer",
    914         );
    915     }
    916     fortify_note(s, &mut h);
    917     Some(h)
    918 }
    919 
    920 fn fortify_bound(s: &Site) -> Option<u64> {
    921     let (_, bound) = FORTIFY_BOUND.iter().find(|(n, _)| *n == s.imp)?;
    922     s.num(*bound)
    923 }
    924 
    925 fn fortify_note(s: &Site, h: &mut Hit) {
    926     match fortify_bound(s) {
    927         Some(v) if is_neutered(v) => h.bump(FORTIFY_OFF_BONUS, FORTIFY_OFF_NOTE),
    928         Some(_) => h.bump(-30, "FORTIFY bound is a real constant, overflow aborts"),
    929         None => {}
    930     }
    931 }
    932 
    933 fn measures_input(s: &Site) -> bool {
    934     s.calls().contains(&"strlen") || s.calls().contains(&"strspn")
    935 }
    936 
    937 fn sized_write(s: &Site, len: usize) -> Option<Hit> {
    938     if s.num(len) == Some(0) {
    939         let mut h = Hit::new(Class::Mem, -10);
    940         h.note("length 0 turns this into a no-op, usually an underflow artefact");
    941         return Some(h);
    942     }
    943     let fortify_disabled = fortify_bound(s).is_some_and(is_neutered);
    944     let stack_dest = s.stack(0);
    945     let measures_reachable_input = measures_input(s) && s.reach == Some(0);
    946     let suspicious = stack_dest.is_some() || fortify_disabled || measures_reachable_input;
    947     if !s.unknown(len) || !suspicious {
    948         return None;
    949     }
    950     let mut h = Hit::new(Class::Mem, 0);
    951     if let Some(k) = stack_dest {
    952         h.note(format!(
    953             "non-constant length into a stack buffer at {}",
    954             stack_off(k)
    955         ));
    956     }
    957     if fortify_disabled {
    958         h.bump(FORTIFY_OFF_BONUS, FORTIFY_OFF_NOTE);
    959     }
    960     if measures_input(s) {
    961         h.bump(
    962             20,
    963             "strlen/strspn in the same function: size may be derived from the \
    964              input length, not the destination size",
    965         );
    966     }
    967     Some(h)
    968 }
    969 
    970 fn stack_alloc(s: &Site) -> Option<Hit> {
    971     let mut h = Hit::new(Class::Mem, -5);
    972     match s.num(0) {
    973         Some(v) => h.bump(-20, format!("alloca of a constant {v:#x}")),
    974         None => h.note("alloca with a non-constant size: stack clash"),
    975     }
    976     Some(h)
    977 }
    978 
    979 fn unbounded_read(s: &Site, fmt: usize) -> Option<Hit> {
    980     if !s.visible(fmt) {
    981         return None;
    982     }
    983     let f = s.text(fmt)?;
    984     if !unbounded_scan(&f) {
    985         return None;
    986     }
    987     let mut h = Hit::new(Class::Mem, 10);
    988     h.note(format!("format {f:?} has a %s or %[ with no field width"));
    989     Some(h)
    990 }
    991 
    992 fn format_string(s: &Site, fmt: usize) -> Option<Hit> {
    993     if !s.visible(fmt) || s.text(fmt).is_some() {
    994         return None;
    995     }
    996     let mut h = Hit::new(Class::Fmt, 0);
    997     h.note("format argument is not a readable literal");
    998     Some(h)
    999 }
   1000 
   1001 fn writable_executable(s: &Site) -> Option<Hit> {
   1002     let prot = s.num(2)?;
   1003     if prot & PROT_WRITE_EXEC != PROT_WRITE_EXEC {
   1004         return None;
   1005     }
   1006     let mut h = Hit::new(Class::Mem, 5);
   1007     h.note(format!(
   1008         "prot {prot:#x} is PROT_WRITE|PROT_EXEC: writable and executable at once"
   1009     ));
   1010     Some(h)
   1011 }
   1012 
   1013 fn des_crypt(s: &Site) -> Option<Hit> {
   1014     let salt = s.text(1)?;
   1015     if salt.starts_with('$') {
   1016         return None;
   1017     }
   1018     let mut h = Hit::new(Class::Auth, 15);
   1019     h.note(format!(
   1020         "salt {salt:?} selects traditional DES crypt, key truncated to 8 characters"
   1021     ));
   1022     Some(h)
   1023 }
   1024 
   1025 fn weak_random(s: &Site) -> Option<Hit> {
   1026     let mut h = Hit::new(Class::Auth, -15);
   1027     h.note(format!("{} is not a CSPRNG", s.imp));
   1028     if !s.any(CSPRNG) {
   1029         h.bump(
   1030             15,
   1031             "no getrandom/arc4random/RAND_bytes anywhere in this binary",
   1032         );
   1033     }
   1034     if s.scan.tokenish {
   1035         h.bump(20, "binary references token/session/cookie strings");
   1036     }
   1037     Some(h)
   1038 }
   1039 
   1040 fn predictable_seed(s: &Site) -> Option<Hit> {
   1041     let mut h = Hit::new(Class::Auth, -10);
   1042     match s.num(0) {
   1043         Some(v) => h.note(format!("seeded with the constant {v:#x}")),
   1044         None if s.calls().iter().any(|c| *c == "time" || *c == "getpid") => {
   1045             h.note("seeded from time()/getpid() in the same function")
   1046         }
   1047         None => return None,
   1048     }
   1049     Some(h)
   1050 }
   1051 
   1052 fn mac_compare(s: &Site) -> Option<Hit> {
   1053     let len = s.num(2)?;
   1054     if !DIGEST_LENGTHS.contains(&len) {
   1055         return None;
   1056     }
   1057     let beside_mac = s.calls().iter().any(|c| {
   1058         c.contains("HMAC") || c.contains("MD5") || c.contains("SHA") || c.contains("CMAC")
   1059     });
   1060     if !beside_mac {
   1061         return None;
   1062     }
   1063     let mut h = Hit::new(Class::Auth, 0);
   1064     h.note(format!(
   1065         "memcmp of {len} bytes beside a MAC/hash call: not constant time"
   1066     ));
   1067     if s.any(CT_COMPARE) {
   1068         h.bump(-20, "a constant-time compare exists in this binary");
   1069     }
   1070     Some(h)
   1071 }
   1072 
   1073 fn privilege_op(s: &Site) -> Option<Hit> {
   1074     let mut h = Hit::new(Class::Priv, -10);
   1075     match (s.imp, s.num(0)) {
   1076         ("umask", Some(mode)) => {
   1077             let masks_world_write = mode != 0 && mode & 0o2 != 0;
   1078             if masks_world_write {
   1079                 return None;
   1080             }
   1081             h.bump(
   1082                 25,
   1083                 format!("umask({mode:#o}) leaves later creations group- or world-writable"),
   1084             );
   1085         }
   1086         (_, Some(v)) => h.note(format!("argument {v:#x}")),
   1087         _ => {}
   1088     }
   1089     if s.imp == "setuid" && !s.has("setgroups") && !s.has("initgroups") {
   1090         h.bump(
   1091             20,
   1092             "no setgroups/initgroups anywhere: setuid keeps the supplementary groups",
   1093         );
   1094     }
   1095     Some(h)
   1096 }
   1097 
   1098 fn chroot_escape(s: &Site) -> Option<Hit> {
   1099     if s.calls().contains(&"chdir") {
   1100         return None;
   1101     }
   1102     let mut h = Hit::new(Class::Priv, 20);
   1103     h.note("no chdir in the same function: `../` walks out of the jail");
   1104     Some(h)
   1105 }
   1106 
   1107 fn permissive_mode(s: &Site) -> Option<Hit> {
   1108     let note = mode_note(s.num(1)?)?;
   1109     let mut h = Hit::new(Class::Path, 20);
   1110     h.note(note);
   1111     if let Some(p) = s.text(0) {
   1112         h.note(format!("path {p:?}"));
   1113     }
   1114     Some(h)
   1115 }
   1116 
   1117 fn temp_file(s: &Site) -> Option<Hit> {
   1118     let mut h = Hit::new(Class::Path, 10);
   1119     h.note(format!(
   1120         "{} names a temp file without creating it atomically",
   1121         s.imp
   1122     ));
   1123     if let Some(p) = s.text(0) {
   1124         h.note(format!("template {p:?}"));
   1125     }
   1126     Some(h)
   1127 }
   1128 
   1129 fn unix_listener(s: &Site) -> Option<Hit> {
   1130     let mut h = Hit::new(Class::Ipc, 0);
   1131     for p in s.scan.unix_paths.iter().take(MAX_SOCKET_PATHS) {
   1132         h.note(format!("socket path {p:?}"));
   1133     }
   1134     if s.scan.no_peercred {
   1135         h.bump(30, "no SO_PEERCRED / getpeereid anywhere in this binary");
   1136     }
   1137     let execs = &s.scan.exec_imports;
   1138     for e in execs.iter().take(MAX_EXEC_NOTES) {
   1139         h.note(format!("exec reachable via {e}"));
   1140     }
   1141     if !execs.is_empty() {
   1142         h.score += 20;
   1143     }
   1144     if !s.scan.drops_privilege && !execs.is_empty() {
   1145         h.bump(
   1146             15,
   1147             "no setuid/setgid anywhere: this listener never drops privilege",
   1148         );
   1149     }
   1150     Some(h)
   1151 }
   1152 
   1153 fn classify(s: &Site) -> Option<Hit> {
   1154     let imp = s.imp;
   1155     if SHELL.contains(&imp) || PRINTF_EXEC.contains(&imp) || SHELL_ARG1.contains(&imp) {
   1156         shell(s)
   1157     } else if EVAL.contains(&imp) {
   1158         script_eval(s)
   1159     } else if ARGV_EXEC.contains(&imp) {
   1160         argv_exec(s)
   1161     } else if imp == "dlopen" {
   1162         dynamic_load(s)
   1163     } else if imp == "setenv" || imp == "putenv" {
   1164         env_hijack(s)
   1165     } else if imp == "curl_easy_setopt" {
   1166         curl_verify(s)
   1167     } else if VERIFY_MODE.contains(&imp) {
   1168         verify_mode(s)
   1169     } else if imp == "mbedtls_ssl_conf_authmode" {
   1170         mbedtls_authmode(s)
   1171     } else if OLD_TLS.contains(&imp) {
   1172         obsolete_tls(s)
   1173     } else if let Some((_, dest, fmt)) = UNBOUNDED.iter().find(|(n, _, _)| *n == imp) {
   1174         unbounded_write(s, *dest, *fmt)
   1175     } else if let Some((_, len)) = SIZED.iter().find(|(n, _)| *n == imp) {
   1176         sized_write(s, *len)
   1177     } else if imp == "alloca" {
   1178         stack_alloc(s)
   1179     } else if let Some((_, fmt)) = SCANF.iter().find(|(n, _)| *n == imp) {
   1180         unbounded_read(s, *fmt)
   1181     } else if let Some((_, fmt)) = FORMAT.iter().find(|(n, _)| *n == imp) {
   1182         format_string(s, *fmt)
   1183     } else if imp == "mmap" || imp == "mprotect" {
   1184         writable_executable(s)
   1185     } else if imp == "crypt" || imp == "crypt_r" {
   1186         des_crypt(s)
   1187     } else if WEAK_CRYPTO.contains(&imp) {
   1188         weak_crypto(s)
   1189     } else if WEAK_RANDOM.contains(&imp) {
   1190         weak_random(s)
   1191     } else if SEED.contains(&imp) {
   1192         predictable_seed(s)
   1193     } else if imp == "memcmp" {
   1194         mac_compare(s)
   1195     } else if DESER.contains(&imp) {
   1196         parses_document(s)
   1197     } else if PRIVOPS.contains(&imp) {
   1198         privilege_op(s)
   1199     } else if imp == "chroot" {
   1200         chroot_escape(s)
   1201     } else if imp == "chmod" || imp == "fchmod" || imp == "mkdir" {
   1202         permissive_mode(s)
   1203     } else if TEMPFILE.contains(&imp) {
   1204         temp_file(s)
   1205     } else if imp == "bind" {
   1206         unix_listener(s)
   1207     } else {
   1208         None
   1209     }
   1210 }
   1211 
   1212 fn note_sanitizers(s: &Site, h: &mut Hit) {
   1213     if matches!(h.class, Class::Ipc | Class::Tls) {
   1214         return;
   1215     }
   1216     let sanitizers_in_function: Vec<&str> = s
   1217         .calls()
   1218         .iter()
   1219         .copied()
   1220         .filter(|name| SANITIZERS.contains(name))
   1221         .collect();
   1222     if sanitizers_in_function.is_empty() {
   1223         return;
   1224     }
   1225     h.bump(
   1226         -20,
   1227         format!(
   1228             "value may be normalized first: {} in the same function",
   1229             sanitizers_in_function.join(", ")
   1230         ),
   1231     );
   1232 }
   1233 
   1234 fn note_function_strings(s: &Site, h: &mut Hit) {
   1235     if !matches!(h.class, Class::Mem | Class::Fmt) {
   1236         return;
   1237     }
   1238     let context_strings = s
   1239         .strings()
   1240         .iter()
   1241         .filter(|text| text.len() >= MIN_CONTEXT_STRING)
   1242         .take(MAX_CONTEXT_STRINGS);
   1243     for text in context_strings {
   1244         h.note(format!("function references {text:?}"));
   1245     }
   1246 }
   1247 
   1248 fn obsolete_tls(s: &Site) -> Option<Hit> {
   1249     let mut h = Hit::new(Class::Tls, -15);
   1250     h.note(format!("{} pins an obsolete protocol version", s.imp));
   1251     Some(h)
   1252 }
   1253 
   1254 fn weak_crypto(s: &Site) -> Option<Hit> {
   1255     let mut h = Hit::new(Class::Auth, -10);
   1256     h.note(format!("{}: broken or ECB-mode primitive", s.imp));
   1257     Some(h)
   1258 }
   1259 
   1260 fn parses_document(s: &Site) -> Option<Hit> {
   1261     let mut h = Hit::new(Class::Mem, -10);
   1262     h.note(format!("{} parses an attacker-supplied document", s.imp));
   1263     Some(h)
   1264 }
   1265 
   1266 fn is_cgi_variable(text: &str) -> bool {
   1267     text.starts_with("HTTP_") || CGI_VARS.contains(&text)
   1268 }
   1269 
   1270 fn note_cgi_variable(s: &Site, h: &mut Hit) {
   1271     let Some(var) = s.strings().iter().find(|text| is_cgi_variable(text)) else {
   1272         return;
   1273     };
   1274     h.bump(
   1275         20,
   1276         format!("reads the CGI variable {var:?}, a raw client header"),
   1277     );
   1278 }
   1279 
   1280 fn note_reachability(s: &Site, h: &mut Hit) {
   1281     match s.reach {
   1282         Some(0) => h.bump(40, "same function as a network/env input"),
   1283         Some(d) if d <= 2 => h.bump(25, format!("{d} hop(s) from a network/env input")),
   1284         Some(d) if d <= 5 => h.bump(10, format!("{d} hops from a network/env input")),
   1285         Some(_) => {}
   1286         None if s.scan.graph.addr_taken.contains(&s.call.func) => {
   1287             h.note("address-taken: reached by indirect dispatch, not a direct call")
   1288         }
   1289         None => h.bump(-20, "no path from any network or environment input"),
   1290     }
   1291 }
   1292 
   1293 fn adjust(s: &Site, h: &mut Hit) {
   1294     note_sanitizers(s, h);
   1295     note_function_strings(s, h);
   1296     note_cgi_variable(s, h);
   1297     if s.scan.wrappers.contains(&s.call.func) && h.class != Class::Exec {
   1298         h.bump(10, "inside a shell-equivalent wrapper");
   1299     }
   1300     note_reachability(s, h);
   1301 }
   1302 
   1303 pub fn analyze(bin: &Binary, graph: &Graph) -> Vec<Finding> {
   1304     let scan = Scan::new(bin, graph);
   1305     let mut out = Vec::new();
   1306     for call in &graph.sites {
   1307         let Some(imp) = call.import.as_deref() else {
   1308             continue;
   1309         };
   1310         let s = Site {
   1311             scan: &scan,
   1312             call,
   1313             imp,
   1314             reach: scan.reach.get(&call.func).copied(),
   1315         };
   1316         let Some(mut h) = classify(&s) else { continue };
   1317         adjust(&s, &mut h);
   1318         out.push(Finding {
   1319             bin: scan.name.clone(),
   1320             class: h.class,
   1321             risky_call: imp.to_string(),
   1322             site: call.site,
   1323             func: graph.func_name(call.func),
   1324             reach: s.reach,
   1325             evidence: h.evidence,
   1326             score: h.score,
   1327         });
   1328     }
   1329     out.extend(absences(&scan));
   1330     out.sort_by(|a, b| b.score.cmp(&a.score).then(a.site.cmp(&b.site)));
   1331     out
   1332 }
   1333 
   1334 struct Absence {
   1335     present: &'static [&'static str],
   1336     absent: &'static [&'static str],
   1337     class: Class,
   1338     risky_call: &'static str,
   1339     score: i32,
   1340     evidence: &'static [&'static str],
   1341 }
   1342 
   1343 const SSL_CLIENT: &[&str] = &["SSL_connect", "SSL_CTX_new"];
   1344 
   1345 const ABSENT: &[Absence] = &[
   1346     Absence {
   1347         present: SSL_CLIENT,
   1348         absent: &["SSL_CTX_set_verify", "SSL_set_verify"],
   1349         class: Class::Tls,
   1350         risky_call: "(no verify mode set)",
   1351         score: 75,
   1352         evidence: &[
   1353             "SSL_CTX_new/SSL_connect present but no verify mode is ever configured",
   1354             "OpenSSL's documented default is SSL_VERIFY_NONE, so the handshake completes regardless of the certificate",
   1355         ],
   1356     },
   1357     Absence {
   1358         present: SSL_CLIENT,
   1359         absent: HOSTNAME_CHECK,
   1360         class: Class::Tls,
   1361         risky_call: "(no hostname check)",
   1362         score: 65,
   1363         evidence: &[
   1364             "no X509_check_host / SSL_set1_host / X509_VERIFY_PARAM_set1_host",
   1365             "OpenSSL does not bind a chain to a hostname on its own, so any CA-signed certificate for any name is accepted",
   1366         ],
   1367     },
   1368     Absence {
   1369         present: SSL_CLIENT,
   1370         absent: &["SSL_CTX_load_verify_locations", "SSL_CTX_set_default_verify_paths"],
   1371         class: Class::Tls,
   1372         risky_call: "(no trust store)",
   1373         score: 55,
   1374         evidence: &["no CA store is ever loaded, so chain verification cannot succeed"],
   1375     },
   1376     Absence {
   1377         present: &["mbedtls_ssl_handshake"],
   1378         absent: &["mbedtls_ssl_set_hostname"],
   1379         class: Class::Tls,
   1380         risky_call: "(no mbedtls hostname)",
   1381         score: 65,
   1382         evidence: &["mbedtls_ssl_set_hostname is never called, so CN/SAN matching is skipped"],
   1383     },
   1384     Absence {
   1385         present: &["chroot"],
   1386         absent: &["chdir"],
   1387         class: Class::Priv,
   1388         risky_call: "(chroot without chdir)",
   1389         score: 55,
   1390         evidence: &["chroot imported but chdir is not, so the jail is trivially escaped"],
   1391     },
   1392     Absence {
   1393         present: TEMPFILE,
   1394         absent: &["mkstemp", "mkostemp", "mkdtemp"],
   1395         class: Class::Path,
   1396         risky_call: "(no atomic temp file)",
   1397         score: 45,
   1398         evidence: &["mktemp/tmpnam used and no mkstemp/mkdtemp is linked at all"],
   1399     },
   1400 ];
   1401 
   1402 fn is_unbounded_write(import: &str) -> bool {
   1403     UNBOUNDED.iter().any(|(name, _, _)| *name == import)
   1404 }
   1405 
   1406 fn uses_unbounded_write(g: &Graph) -> bool {
   1407     let mut imports = g.sites.iter().filter_map(|site| site.import.as_deref());
   1408     imports.any(is_unbounded_write)
   1409 }
   1410 
   1411 fn names_flash_target(text: &str) -> bool {
   1412     text.contains("sysupgrade") || text.starts_with("mtd ") || text.contains("/dev/mtd")
   1413 }
   1414 
   1415 fn flashes_firmware(g: &Graph) -> bool {
   1416     g.strings
   1417         .values()
   1418         .flatten()
   1419         .any(|text| names_flash_target(text))
   1420 }
   1421 
   1422 fn absences(sc: &Scan) -> Vec<Finding> {
   1423     let mut out = Vec::new();
   1424     let mut push = |class: Class, risky_call: &str, score: i32, ev: &[&str]| {
   1425         out.push(Finding {
   1426             bin: sc.name.clone(),
   1427             class,
   1428             risky_call: risky_call.to_string(),
   1429             site: 0,
   1430             func: "(whole binary)".into(),
   1431             reach: None,
   1432             evidence: ev.iter().map(|e| e.to_string()).collect(),
   1433             score,
   1434         })
   1435     };
   1436 
   1437     let unbounded = uses_unbounded_write(sc.graph);
   1438     if unbounded && !sc.has("__stack_chk_fail") {
   1439         push(
   1440             Class::Mem,
   1441             "(no stack canary)",
   1442             60,
   1443             &[
   1444                 "no __stack_chk_fail import: built without -fstack-protector",
   1445                 "every stack overflow here reaches the saved return address directly",
   1446             ],
   1447         );
   1448     }
   1449     if unbounded && !sc.any(BOUNDED) {
   1450         push(
   1451             Class::Mem,
   1452             "(no bounded copy)",
   1453             55,
   1454             &[
   1455                 "no snprintf/strlcpy/strncpy/strncat anywhere in this binary",
   1456                 "every string operation it performs is unbounded",
   1457             ],
   1458         );
   1459     }
   1460     for a in ABSENT {
   1461         if sc.any(a.present) && !sc.any(a.absent) {
   1462             push(a.class, a.risky_call, a.score, a.evidence);
   1463         }
   1464     }
   1465     if flashes_firmware(sc.graph) && (sc.has("system") || sc.has("popen")) && !sc.any(SIGVERIFY) {
   1466         push(
   1467             Class::Exec,
   1468             "(unsigned update path)",
   1469             60,
   1470             &[
   1471                 "references mtd/sysupgrade and shells out, but links no RSA/ECDSA verify",
   1472                 "check whether the update image is authenticated at all",
   1473             ],
   1474         );
   1475     }
   1476     out
   1477 }