diff --git a/CHANGELOG.md b/CHANGELOG.md index 59e7015..98d8cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ All notable changes to bootintel-cli are documented here. Format follows [Keep a ## [Unreleased] +### Added +- **`bootintel verdict` now reports what the bootloader actually verified**, not + only what the environment says it is configured to do. A capture containing + `Verifying Checksum ... OK` or `Verifying Hash Integrity ... sha256+ OK` no + longer gets "I cannot tell whether images are checked" while the answer sits in + the same log. Also reads the i.MX HAB fuse and UBIFS unauthenticated mounts. + + The distinction is kept deliberately: a passing checksum is reported as + `confirmed`, never `hardened`, and the detail says why. A CRC proves the image + was not corrupt; anyone who can write the image can recompute it. Only a + signature (`sha256,rsa2048:dev+ OK`) reports as `hardened`. + + New verdict entry **Secure boot anchor** when the SoC reports `hab fuse not + enabled`, because while that fuse is unblown the boot ROM runs unsigned images + whatever the bootloader prints afterwards. It is emitted without needing a + `printenv`, since the fact does not depend on one. + +### Changed +- **Breaking, library only: `boot_chain::assess` returns an `Assessment` struct** + (`session`, `integrity`, `verdicts`) instead of a `(UbootSession, Vec)` + tuple, and `boot_chain::verdict` takes the integrity alongside the session. The + alternative was a second entry point for the same operation, and two functions + differing only in how much they tell you is worse for whoever reads it next. No + CLI flag, output or exit code changed. +- `verdict --json` gained a `boot_integrity` object, mirroring the engine's key + names. Fields are omitted when the capture said nothing about them rather than + emitted as null. + ## [0.8.0] — 2026-09-28 — read the boot chain on the bench, offline Two halves of one workflow: take the U-Boot prompt on a board in front of you, diff --git a/crates/cli/src/cmd/verdict.rs b/crates/cli/src/cmd/verdict.rs index 5d527ce..ae9ef5f 100644 --- a/crates/cli/src/cmd/verdict.rs +++ b/crates/cli/src/cmd/verdict.rs @@ -22,7 +22,7 @@ use anyhow::Result; use clap::Args as ClapArgs; use std::io::Write; -use bootintel_detectors::boot_chain::{self, UbootSession, Verdict}; +use bootintel_detectors::boot_chain::{self, BootIntegrity, UbootSession, Verdict}; use crate::analyze::render::sanitize_for_term; use crate::output::{self, ColorMode}; @@ -89,14 +89,19 @@ pub fn run(args: Args) -> Result<()> { std::process::exit(EXIT_EMPTY_INPUT); } - let (session, verdicts) = boot_chain::assess(&log.text); + let assessment = boot_chain::assess(&log.text); + let (session, integrity, verdicts) = ( + &assessment.session, + &assessment.integrity, + &assessment.verdicts, + ); let stdout = std::io::stdout(); let color = output::resolve_color_mode(args.no_color, &stdout); let mut out = stdout.lock(); if args.json { - let payload = json(&log.source_label, &session, &verdicts); + let payload = json(&log.source_label, session, integrity, verdicts); if let Err(e) = serde_json::to_writer_pretty(&mut out, &payload) .map_err(anyhow::Error::from) .and_then(|()| writeln!(out).map_err(anyhow::Error::from)) @@ -107,7 +112,14 @@ pub fn run(args: Args) -> Result<()> { return Err(e); } } - } else if let Err(e) = write_text(&mut out, &log.source_label, &session, &verdicts, color) { + } else if let Err(e) = write_text( + &mut out, + &log.source_label, + session, + integrity, + verdicts, + color, + ) { if !crate::is_broken_pipe(&e) { return Err(e); } @@ -149,7 +161,12 @@ pub fn run(args: Args) -> Result<()> { Ok(()) } -fn json(source: &str, session: &UbootSession, verdicts: &[Verdict]) -> serde_json::Value { +fn json( + source: &str, + session: &UbootSession, + integrity: &BootIntegrity, + verdicts: &[Verdict], +) -> serde_json::Value { let mut shell = serde_json::Map::new(); shell.insert("reached".into(), session.reached.into()); shell.insert("evidence".into(), session.evidence.clone().into()); @@ -157,9 +174,53 @@ fn json(source: &str, session: &UbootSession, verdicts: &[Verdict]) -> serde_jso shell.insert("env_used_bytes".into(), used.into()); shell.insert("env_total_bytes".into(), total.into()); } + // Mirrors the engine's `boot_integrity` key names, and omits what was not + // observed rather than emitting nulls: absence of a field means the capture + // said nothing, which is different from a field saying "no". + let mut bi = serde_json::Map::new(); + let mut put = |k: &str, val: Option<&str>| { + if let Some(x) = val { + bi.insert(k.into(), x.into()); + } + }; + put("image_check", integrity.image_check.as_deref()); + put( + "image_check_result", + integrity.image_check_result.as_deref(), + ); + put( + "image_check_evidence", + integrity.image_check_evidence.as_deref(), + ); + put( + "image_check_failed", + integrity.image_check_failed.as_deref(), + ); + put("hab_fuse", integrity.hab_fuse.as_deref()); + put("hab_evidence", integrity.hab_evidence.as_deref()); + put( + "ubifs_unauthenticated", + integrity.ubifs_unauthenticated.as_deref(), + ); + put("env_crc_failed", integrity.env_crc_failed.as_deref()); + put( + "image_signature_evidence", + integrity.image_signature_evidence.as_deref(), + ); + if !integrity.image_hash_algorithms.is_empty() { + bi.insert( + "image_hash_algorithms".into(), + integrity.image_hash_algorithms.clone().into(), + ); + } + if integrity.image_signature_checked { + bi.insert("image_signature_checked".into(), true.into()); + } + serde_json::json!({ "source": source, "uboot_shell": shell, + "boot_integrity": bi, "uboot_env": session.env.iter() .map(|(k, v)| (k.clone(), serde_json::Value::from(v.clone()))) .collect::>(), @@ -187,6 +248,7 @@ pub(crate) fn write_text( out: &mut W, source: &str, session: &UbootSession, + integrity: &BootIntegrity, verdicts: &[Verdict], color: ColorMode, ) -> Result<()> { @@ -218,6 +280,24 @@ pub(crate) fn write_text( session.env.len(), if session.env.len() == 1 { "" } else { "s" } )?; + if let Some(check) = &integrity.image_check { + let mechanism = if check == "fit_hash" { + let algos = if integrity.image_hash_algorithms.is_empty() { + "unspecified".to_string() + } else { + integrity.image_hash_algorithms.join(", ") + }; + format!("FIT hash ({algos})") + } else { + "uImage CRC".to_string() + }; + writeln!( + out, + " image check {} ({})", + sanitize_for_term(&mechanism), + integrity.image_check_result.as_deref().unwrap_or("unknown") + )?; + } writeln!(out)?; for v in verdicts { diff --git a/crates/cli/src/term/run.rs b/crates/cli/src/term/run.rs index fe9e903..2cfd975 100644 --- a/crates/cli/src/term/run.rs +++ b/crates/cli/src/term/run.rs @@ -1552,8 +1552,7 @@ fn apply_autoboot( ); continue; }; - let (session, verdicts) = - bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); + let assessment = bootintel_detectors::boot_chain::assess(analyzer.log_so_far()); let _ = write!(out, "\r\n"); let color = if use_color { crate::output::ColorMode::On @@ -1561,8 +1560,14 @@ fn apply_autoboot( crate::output::ColorMode::Off }; let mut crlf = crate::output::CrlfWriter::new(&mut *out); - let _ = - crate::cmd::verdict::write_text(&mut crlf, source, &session, &verdicts, color); + let _ = crate::cmd::verdict::write_text( + &mut crlf, + source, + &assessment.session, + &assessment.integrity, + &assessment.verdicts, + color, + ); let _ = out.flush(); } } diff --git a/crates/detectors/src/boot_chain.rs b/crates/detectors/src/boot_chain.rs index 6f746ca..47f42cb 100644 --- a/crates/detectors/src/boot_chain.rs +++ b/crates/detectors/src/boot_chain.rs @@ -71,6 +71,174 @@ static RE_ENV_SIZE: LazyLock = static RE_ENV_LINE: LazyLock = LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap()); +// The integrity patterns, character-for-character from +// `api/analysis_engine/detectors/boot_integrity.py`, for the same reason as the +// session patterns above. +static RE_CHECKSUM: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Checksum\s*\.\.\.\s*(.*)$").unwrap()); +static RE_FIT_HASH: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Hash Integrity\s*\.\.\.\s*(.*)$").unwrap()); +// Deliberately NOT `## Checking (hash|sign)`. U-Boot's ordinary FIT output is +// `## Checking hash(es) for FIT Image at ...`, so that pattern reported a +// verified SIGNATURE on every device using unsigned FIT hashes, which is the +// common case. No corpus log prints the line, which is why the bug survived +// review on the engine side. +static RE_FIT_SIG: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap()); +// An algorithm entry that means a signature rather than a digest. U-Boot prints +// `sha256,rsa2048:dev+ OK` when a signature node was checked. +static RE_SIG_ALGO: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap()); +// Anchored to the whole line: a substring match on "Bad Magic Number" also +// catches `fsck.ext2: Bad magic number in super-block`, which is a filesystem +// complaint and produced a false high-severity finding on the engine side. +static RE_BAD: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)^\s*(Bad Data Hash|Bad Header Checksum|Bad Magic Number|Bad Data CRC)\.?\s*$") + .unwrap() +}); +static RE_BAD_SIG: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)signature check failed|Verifying Hash Integrity\s*\.\.\.\s*error").unwrap() +}); +static RE_HAB_OFF: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)hab fuse not enabled").unwrap()); +static RE_HAB_ON: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)hab fuse (?:is )?enabled").unwrap()); +static RE_UBIFS_UNAUTH: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)UBIFS\s*\(([^)]*)\):\s*Mounting in unauthenticated mode").unwrap() +}); +static RE_ENV_CRC: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)bad CRC, using default environment").unwrap()); +static RE_OK: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bOK\b").unwrap()); +static RE_ALGO_SPLIT: LazyLock = LazyLock::new(|| Regex::new(r"[+,\s]+").unwrap()); + +/// What the bootloader actually DID about verifying the image it booted, as +/// opposed to what the environment says it is configured to do. +/// +/// The distinction this type exists to preserve: a checksum is not a signature. +/// `Verifying Checksum ... OK` proves the image was not corrupt. Anyone who can +/// write the image can recompute the CRC, so it stops bit-rot, not an attacker. +/// Only a signature establishes that the image came from the signer, and on +/// i.MX none of it is enforced while the HAB fuse is unblown. +/// +/// Unlike the engine, this does not raise findings for any of it: the Rust and +/// browser detector sets are pinned to the same 14 labels, and a fifteenth would +/// break that parity. The facts and the verdicts are what the two +/// implementations share. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct BootIntegrity { + /// `uimage_crc` or `fit_hash`. + pub image_check: Option, + pub image_check_evidence: Option, + /// `passed`, `failed`, or `not_captured` when the check began but the + /// capture lost its result. "The check ran" is a different claim from "the + /// check passed". + pub image_check_result: Option, + pub image_hash_algorithms: Vec, + pub image_signature_checked: bool, + pub image_signature_evidence: Option, + pub image_check_failed: Option, + /// `not_enabled` or `enabled`. + pub hab_fuse: Option, + pub hab_evidence: Option, + pub ubifs_unauthenticated: Option, + pub env_crc_failed: Option, +} + +/// First observation wins, so a later repeat cannot overwrite the evidence line +/// that justified the original claim. Mirrors the engine's `setdefault`. +fn keep_first(slot: &mut Option, value: &str) { + if slot.is_none() { + *slot = Some(value.to_string()); + } +} + +/// Read the verification a bootloader reported performing. +pub fn parse_integrity(log: &str) -> BootIntegrity { + let mut bi = BootIntegrity::default(); + for raw in log.lines() { + let line = raw.trim_end_matches(['\r', '\n']); + let s = clip(line.trim(), 200); + + if let Some(caps) = RE_CHECKSUM.captures(line) { + let result = caps[1].trim(); + keep_first(&mut bi.image_check, "uimage_crc"); + keep_first(&mut bi.image_check_evidence, &s); + keep_first( + &mut bi.image_check_result, + if RE_OK.is_match(result) { + "passed" + } else if result.is_empty() { + "not_captured" + } else { + "failed" + }, + ); + continue; + } + + if let Some(caps) = RE_FIT_HASH.captures(line) { + let tail = caps[1].trim(); + let algos: Vec = RE_ALGO_SPLIT + .split(tail) + .filter(|a| !a.is_empty() && !RE_OK.is_match(a)) + .map(str::to_string) + .collect(); + keep_first(&mut bi.image_check, "fit_hash"); + keep_first(&mut bi.image_check_evidence, &s); + keep_first( + &mut bi.image_check_result, + if RE_OK.is_match(tail) { + "passed" + } else { + "failed" + }, + ); + if !algos.is_empty() { + if bi.image_hash_algorithms.is_empty() { + bi.image_hash_algorithms = algos.clone(); + } + if algos.iter().any(|a| RE_SIG_ALGO.is_match(a)) { + bi.image_signature_checked = true; + keep_first(&mut bi.image_signature_evidence, &s); + } + } + continue; + } + + if RE_FIT_SIG.is_match(line) { + bi.image_signature_checked = true; + keep_first(&mut bi.image_signature_evidence, &s); + continue; + } + + if RE_BAD.is_match(line) || RE_BAD_SIG.is_match(line) { + keep_first(&mut bi.image_check_failed, &s); + continue; + } + + if RE_HAB_OFF.is_match(line) { + keep_first(&mut bi.hab_fuse, "not_enabled"); + keep_first(&mut bi.hab_evidence, &s); + continue; + } + if RE_HAB_ON.is_match(line) { + keep_first(&mut bi.hab_fuse, "enabled"); + keep_first(&mut bi.hab_evidence, &s); + continue; + } + + if RE_ENV_CRC.is_match(line) { + keep_first(&mut bi.env_crc_failed, &s); + continue; + } + + if let Some(caps) = RE_UBIFS_UNAUTH.captures(line) { + keep_first(&mut bi.ubifs_unauthenticated, caps[1].trim()); + } + } + bi +} + /// Truncate to `max` CHARACTERS, mirroring Python's `s[:max]`. /// /// `String::truncate` counts bytes and panics mid-codepoint, and a capture is @@ -197,7 +365,7 @@ fn v( /// /// The boot log can say autoboot looks interruptible. The environment says /// what happens when you interrupt it, and whether you can change what boots. -pub fn verdict(s: &UbootSession) -> Vec { +pub fn verdict(s: &UbootSession, bi: &BootIntegrity) -> Vec { let mut out = Vec::new(); if !s.reached { return out; @@ -220,6 +388,125 @@ pub fn verdict(s: &UbootSession) -> Vec { ), ); + // Image verification, preferring what was observed over what was configured. + // + // One entry, never two. An earlier version emitted a speculative "unknown" + // alongside an explicit verify=no and produced two contradictory verdicts + // for one question; the same trap is here in a new form, because a capture + // can carry BOTH verify=no and an observed checksum pass. Rather than + // silently picking a winner, the entry reports the observation and names the + // conflict. + // + // Read before the environment because none of it depends on a printenv + // having been captured: bootintel-7 reaches a prompt, never dumps the + // environment, and still reports `hab fuse not enabled`. + let verify_off = matches!( + s.env + .get("verify") + .map(|x| x.trim().to_ascii_lowercase()) + .as_deref(), + Some("n") | Some("no") | Some("0") | Some("false") + ); + let conflict = if verify_off { + " The environment says verify=no, yet the bootloader still reported a check, so either \ + this capture predates that setting or a different boot path ran." + } else { + "" + }; + let observed = bi.image_check.as_deref(); + let result = bi.image_check_result.as_deref(); + if bi.image_signature_checked { + v( + &mut out, + "Image verification", + "hardened", + &format!( + "A signature was checked before boot, which establishes that the image is the \ + one the signer produced, not merely an uncorrupted one.{conflict}" + ), + bi.image_signature_evidence + .as_deref() + .unwrap_or("signature check observed"), + "medium", + Some( + "Confirm the verifying key lives somewhere an attacker with flash write access \ + cannot replace it.", + ), + ); + } else if observed.is_some() && result == Some("passed") { + let mechanism = if observed == Some("fit_hash") { + let algos = if bi.image_hash_algorithms.is_empty() { + "unspecified".to_string() + } else { + bi.image_hash_algorithms.join(", ") + }; + format!("a FIT hash ({algos})") + } else { + "a legacy uImage CRC".to_string() + }; + v( + &mut out, + "Image verification", + "confirmed", + &format!( + "The bootloader checked the image before booting it, using {mechanism}, and \ + the check passed. That proves the image was not corrupt. It is not a \ + signature: anyone who can write the image can recompute the checksum, so this \ + stops bit-rot rather than an attacker.{conflict}" + ), + bi.image_check_evidence.as_deref().unwrap_or(""), + "medium", + Some( + "Move to signed FIT images (CONFIG_FIT_SIGNATURE) so a deliberate modification is \ + detected and not just a corrupt one.", + ), + ); + } else if observed.is_some() && result == Some("not_captured") { + v( + &mut out, + "Image verification", + "unknown", + &format!( + "The bootloader began an image check but its result is not in the capture, so \ + whether it passed is unknown.{conflict}" + ), + bi.image_check_evidence.as_deref().unwrap_or(""), + "info", + None, + ); + } else if verify_off { + v( + &mut out, + "Image verification", + "exposed", + "verify is disabled, so U-Boot will not check image checksums before booting.", + &format!( + "verify={}", + s.env.get("verify").map(String::as_str).unwrap_or("") + ), + "high", + Some("Set verify=yes, and prefer signed FIT images over checksums."), + ); + } + + // The anchor. On i.MX none of the above is enforced while the fuse is unblown. + if bi.hab_fuse.as_deref() == Some("not_enabled") { + v( + &mut out, + "Secure boot anchor", + "exposed", + "The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned \ + image. Whatever the bootloader does about checksums afterwards is advisory: the chain \ + has no anchor.", + bi.hab_evidence.as_deref().unwrap_or("hab fuse not enabled"), + "high", + Some( + "Blow the HAB fuse and close the device only after a signed image is confirmed to \ + boot, since the operation is irreversible.", + ), + ); + } + if s.env.is_empty() { v( &mut out, @@ -310,7 +597,9 @@ pub fn verdict(s: &UbootSession) -> Vec { ); let verify_set = s.env.contains_key("verify"); let boots_image = ["bootm", "bootz", "booti"].iter().any(|t| cmd.contains(t)); - if boots_image && !verify_set && !cmd.contains("verify") { + // Only speculate when the capture contains no observation at all. The + // whole point of reading the boot output is to stop guessing here. + if boots_image && !verify_set && !cmd.contains("verify") && observed.is_none() { v( &mut out, "Image verification", @@ -325,23 +614,6 @@ pub fn verdict(s: &UbootSession) -> Vec { } } - if let Some(val) = s.env.get("verify") { - if matches!( - val.trim().to_ascii_lowercase().as_str(), - "n" | "no" | "0" | "false" - ) { - v( - &mut out, - "Image verification", - "exposed", - "verify is disabled, so U-Boot will not check image checksums before booting.", - &format!("verify={val}"), - "high", - Some("Set verify=yes, and prefer signed FIT images over checksums."), - ); - } - } - if s.env.contains_key("ipaddr") && s.env.contains_key("serverip") { let parts: Vec = ["ethaddr", "gatewayip", "ipaddr", "netmask", "serverip"] .iter() @@ -414,8 +686,28 @@ pub fn verdict(s: &UbootSession) -> Vec { } /// Convenience: parse and decide in one call. -pub fn assess(log: &str) -> (UbootSession, Vec) { - let s = parse_session(log); - let verdicts = verdict(&s); - (s, verdicts) +/// Everything one capture establishes about the boot chain. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Assessment { + pub session: UbootSession, + pub integrity: BootIntegrity, + pub verdicts: Vec, +} + +/// Parse and decide in one call. +/// +/// This replaced a `(UbootSession, Vec)` tuple when integrity reading +/// landed: a breaking change to a published crate, which under the policy at the +/// top of CHANGELOG.md moves the minor version pre-1.0. The alternative was a +/// second name for the same operation, and two entry points differing only in +/// how much they tell you is worse for whoever reads this next. +pub fn assess(log: &str) -> Assessment { + let session = parse_session(log); + let integrity = parse_integrity(log); + let verdicts = verdict(&session, &integrity); + Assessment { + session, + integrity, + verdicts, + } } diff --git a/crates/detectors/tests/boot_chain.rs b/crates/detectors/tests/boot_chain.rs index fb58a47..e9ded96 100644 --- a/crates/detectors/tests/boot_chain.rs +++ b/crates/detectors/tests/boot_chain.rs @@ -48,7 +48,8 @@ fn field(out: &mut Vec, indent: &str, key: &str, value: &str) { /// rather than JSON so it diffs cleanly in review, and so this crate needs no /// serde: it stays serde-free on purpose to keep the CLI binary small. fn render(name: &str, log: &str) -> Vec { - let (session, verdicts) = boot_chain::assess(log); + let a = boot_chain::assess(log); + let (session, integrity, verdicts) = (&a.session, &a.integrity, &a.verdicts); let mut out = vec![format!( "## fixture {name} fnv1a64={}", fnv1a64(log.as_bytes()) @@ -65,10 +66,59 @@ fn render(name: &str, log: &str) -> Vec { _ => String::new(), }; field(&mut out, " ", "env_bytes", &bytes); + // Same field order as `analysis_engine/parity_render.py`, and absent fields + // are omitted rather than rendered empty: "the capture said nothing about + // this" is a different claim from "this is off". + let mut integ = |k: &str, val: Option<&str>| { + if let Some(x) = val { + field(&mut out, " ", "integrity", &format!("{k}={x}")); + } + }; + integ("image_check", integrity.image_check.as_deref()); + integ( + "image_check_result", + integrity.image_check_result.as_deref(), + ); + integ( + "image_check_evidence", + integrity.image_check_evidence.as_deref(), + ); + let algos = integrity.image_hash_algorithms.join(", "); + integ( + "image_hash_algorithms", + if algos.is_empty() { + None + } else { + Some(algos.as_str()) + }, + ); + integ( + "image_signature_checked", + if integrity.image_signature_checked { + Some("true") + } else { + None + }, + ); + integ( + "image_signature_evidence", + integrity.image_signature_evidence.as_deref(), + ); + integ( + "image_check_failed", + integrity.image_check_failed.as_deref(), + ); + integ("hab_fuse", integrity.hab_fuse.as_deref()); + integ("hab_evidence", integrity.hab_evidence.as_deref()); + integ( + "ubifs_unauthenticated", + integrity.ubifs_unauthenticated.as_deref(), + ); + integ("env_crc_failed", integrity.env_crc_failed.as_deref()); for (key, value) in &session.env { field(&mut out, " ", "env", &format!("{key}={value}")); } - for verdict in &verdicts { + for verdict in verdicts { out.push(" verdict".to_string()); field(&mut out, " ", "title", &verdict.title); field(&mut out, " ", "state", &verdict.state); @@ -171,14 +221,17 @@ fn a_plain_boot_log_yields_no_session_and_no_verdict() { PATH=/usr/bin:/bin\n\ bootcmd=this is not really an environment\n\ [ 1.000000] procd: - init -\n"; - let (session, verdicts) = boot_chain::assess(plain); - assert!(!session.reached, "claimed a shell on a log with no session"); + let a = boot_chain::assess(plain); assert!( - session.env.is_empty(), + !a.session.reached, + "claimed a shell on a log with no session" + ); + assert!( + a.session.env.is_empty(), "invented an environment: {:?}", - session.env + a.session.env ); - assert!(verdicts.is_empty(), "produced verdicts with no session"); + assert!(a.verdicts.is_empty(), "produced verdicts with no session"); } #[test] @@ -186,9 +239,9 @@ fn an_unterminated_run_of_key_values_is_discarded() { // Same shape as a real dump but with no `Environment size:` line, so // nothing proves it was one. Absence of proof is not a verdict. let log = "bootdelay=3\nbootcmd=bootm 0x82000000\nverify=no\n"; - let (session, verdicts) = boot_chain::assess(log); - assert!(session.env.is_empty()); - assert!(verdicts.is_empty()); + let a = boot_chain::assess(log); + assert!(a.session.env.is_empty()); + assert!(a.verdicts.is_empty()); } /// Absence is reported as unknown, never as good news: U-Boot prints only what @@ -197,7 +250,7 @@ fn an_unterminated_run_of_key_values_is_discarded() { #[test] fn a_missing_variable_is_unknown_rather_than_hardened() { let log = "=> printenv\nbootcmd=run sfboot\nEnvironment size: 20/65532 bytes\n"; - let (_, verdicts) = boot_chain::assess(log); + let verdicts = boot_chain::assess(log).verdicts; let delay = verdicts .iter() .find(|v| v.title == "Autoboot delay") @@ -210,7 +263,7 @@ fn a_missing_variable_is_unknown_rather_than_hardened() { #[test] fn a_wrapped_value_is_rejoined_rather_than_ending_the_dump() { let log = fs::read_to_string(fixtures().join("wrapped-env.log")).expect("fixture"); - let (session, _) = boot_chain::assess(&log); + let session = boot_chain::assess(&log).session; let upfw = session .env .get("upfw") @@ -230,7 +283,7 @@ fn every_verdict_names_the_variable_it_read() { // quote a tool, so an unevidenced verdict is a broken one. for name in declared_fixtures(&expectation()) { let log = fs::read_to_string(fixtures().join(&name)).expect("fixture"); - for verdict in boot_chain::verdict(&boot_chain::parse_session(&log)) { + for verdict in boot_chain::assess(&log).verdicts { assert!( !verdict.evidence.trim().is_empty(), "{name}: {:?} has no evidence", @@ -245,3 +298,132 @@ fn every_verdict_names_the_variable_it_read() { } } } + +/// A checksum is not a signature, and the difference is the whole reason this +/// module reads the boot output rather than trusting `verify=`. +#[test] +fn a_passing_checksum_is_reported_as_integrity_not_authenticity() { + let log = "## Booting kernel from Legacy Image at 82000000 ...\n\ + Verifying Checksum ... OK\n\ + => printenv\nbootcmd=bootm 0x82000000\n\ + Environment size: 20/65532 bytes\n"; + let a = boot_chain::assess(log); + assert_eq!(a.integrity.image_check.as_deref(), Some("uimage_crc")); + assert_eq!(a.integrity.image_check_result.as_deref(), Some("passed")); + let v = a + .verdicts + .iter() + .find(|v| v.title == "Image verification") + .expect("a verdict about verification"); + // `confirmed`, not `hardened`: the check happened, and it does not protect + // against someone who can rewrite the image and its checksum. + assert_eq!(v.state, "confirmed"); + assert!(v.detail.contains("not a signature"), "{}", v.detail); + assert!(!a.integrity.image_signature_checked); +} + +/// U-Boot prints `sha256,rsa2048:dev+ OK` when a signature node was verified, +/// and `sha256+ OK` when only a digest was. Reporting the second as a signature +/// would tell a client authenticity was established when it was not. No corpus +/// log exercises either, so these are the documented formats. +#[test] +fn only_a_signature_algorithm_establishes_authenticity() { + let unsigned = boot_chain::parse_integrity(" Verifying Hash Integrity ... sha256+ OK\n"); + assert_eq!(unsigned.image_hash_algorithms, ["sha256"]); + assert!( + !unsigned.image_signature_checked, + "an unsigned FIT hash was reported as a verified signature" + ); + + let signed = + boot_chain::parse_integrity(" Verifying Hash Integrity ... sha256,rsa2048:dev+ OK\n"); + assert_eq!(signed.image_hash_algorithms, ["sha256", "rsa2048:dev"]); + assert!(signed.image_signature_checked); +} + +/// The false positive that reached production on the engine side: a substring +/// match on "Bad Magic Number" catches a filesystem complaint and reports a +/// failed image verification on a device whose bootloader said no such thing. +#[test] +fn a_filesystem_complaint_is_not_an_image_check_failure() { + let fsck = boot_chain::parse_integrity( + "fsck.ext2: Bad magic number in super-block while trying to open /dev/pramdisk0\n", + ); + assert_eq!(fsck.image_check_failed, None); + + // And a test harness quoting U-Boot's error strings in a list, which is what + // bootintel-7 actually contains. + let harness = boot_chain::parse_integrity( + "bootloader-commands: Wait for prompt ['=>', 'Bad Linux ARM64 Image magic!', 'TIMEOUT']\n", + ); + assert_eq!(harness.image_check_failed, None); + + // U-Boot's own standalone line still registers. + for line in [" Bad Magic Number\n", "Bad Header Checksum.\n"] { + assert!( + boot_chain::parse_integrity(line) + .image_check_failed + .is_some(), + "{line:?} was not recognised" + ); + } +} + +/// The HAB fuse is the anchor: while it is unblown the boot ROM runs unsigned +/// images whatever the bootloader prints afterwards. It must not depend on +/// having captured a printenv, because bootintel-7 does not have one. +#[test] +fn the_secure_boot_anchor_does_not_need_an_environment() { + let log = "Normal Boot\nhab fuse not enabled\nu-boot=> boot\n"; + let a = boot_chain::assess(log); + assert!(a.session.env.is_empty(), "this capture has no environment"); + let anchor = a + .verdicts + .iter() + .find(|v| v.title == "Secure boot anchor") + .expect("the anchor verdict survives a missing environment"); + assert_eq!(anchor.state, "exposed"); + assert_eq!(anchor.severity, "high"); +} + +/// A capture can carry both `verify=no` and an observed passing check. Emitting +/// one verdict for each produced two contradictory answers to one question, +/// which is a bug this codebase has already fixed once in another form. +#[test] +fn a_config_and_observation_conflict_is_one_verdict_that_names_it() { + let log = " Verifying Checksum ... OK\n=> printenv\nverify=no\n\ + Environment size: 20/65532 bytes\n"; + let entries: Vec<_> = boot_chain::assess(log) + .verdicts + .into_iter() + .filter(|v| v.title == "Image verification") + .collect(); + assert_eq!( + entries.len(), + 1, + "two verdicts for one question: {entries:?}" + ); + assert!( + entries[0].detail.contains("verify=no"), + "the conflict is not explained: {}", + entries[0].detail + ); +} + +/// "The check ran" is a different claim from "the check passed". +#[test] +fn a_check_with_no_captured_result_is_unknown_rather_than_passing() { + let log = " Verifying Checksum ...\n=> printenv\nbootcmd=bootm 0x82000000\n\ + Environment size: 20/65532 bytes\n"; + let a = boot_chain::assess(log); + assert_eq!( + a.integrity.image_check_result.as_deref(), + Some("not_captured") + ); + let v = a + .verdicts + .iter() + .find(|v| v.title == "Image verification") + .expect("a verdict"); + assert_eq!(v.state, "unknown"); +} diff --git a/crates/detectors/tests/fixtures/boot_chain/expect.txt b/crates/detectors/tests/fixtures/boot_chain/expect.txt index abc3bd7..889a02a 100644 --- a/crates/detectors/tests/fixtures/boot_chain/expect.txt +++ b/crates/detectors/tests/fixtures/boot_chain/expect.txt @@ -3,7 +3,7 @@ # BootIntel/cli crates/detectors/tests/fixtures/boot_chain/expect.txt # Each repo asserts its own implementation reproduces it, so the Python engine # and the Rust CLI cannot disagree about a device without a test going red. -# Regenerate BOTH copies with: python3 scripts/gen-boot-chain-expect.py +# Regenerate BOTH copies with: scripts/gen-boot-chain-expect.sh # Do not hand-edit. fnv1a64 pins the fixture log the block was produced from. ## fixture session.log fnv1a64=0dc3e8116533707d reached true @@ -24,6 +24,13 @@ evidence => printenv detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Image verification + state exposed + severity high + evidence verify=no + detail verify is disabled, so U-Boot will not check image checksums before booting. + remediation Set verify=yes, and prefer signed FIT images over checksums. verdict title Autoboot delay state exposed @@ -38,13 +45,6 @@ evidence bootcmd=bootm 0x9f020000 detail bootcmd is readable and, with the prompt reachable, settable. Whoever holds the console decides what the device boots. remediation Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a password at the prompt. - verdict - title Image verification - state exposed - severity high - evidence verify=no - detail verify is disabled, so U-Boot will not check image checksums before booting. - remediation Set verify=yes, and prefer signed FIT images over checksums. verdict title Network boot path state exposed @@ -214,3 +214,76 @@ evidence Environment size: 1524/65532 bytes detail The environment occupies 1524 of 65532 bytes of writable storage, so `saveenv` can persist a change across reboots. remediation Build with a read-only or signed environment for production. +## fixture hab-unblown.log fnv1a64=5dc74812dbd1cb3f + reached true + evidence u-boot=> setenv factorymode 1 + env_bytes + integrity hab_fuse=not_enabled + integrity hab_evidence=hab fuse not enabled + verdict + title U-Boot shell reached + state confirmed + severity high + evidence u-boot=> setenv factorymode 1 + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Secure boot anchor + state exposed + severity high + evidence hab fuse not enabled + detail The SoC reports the HAB fuse is not enabled, so the boot ROM will run an unsigned image. Whatever the bootloader does about checksums afterwards is advisory: the chain has no anchor. + remediation Blow the HAB fuse and close the device only after a signed image is confirmed to boot, since the operation is irreversible. + verdict + title Environment not captured + state unknown + severity info + evidence u-boot=> setenv factorymode 1 + detail The shell was reached but no printenv output was captured, so the boot chain below could not be assessed. Run `printenv` at the prompt. + remediation +## fixture verified-image.log fnv1a64=2b755c9900cf45ff + reached true + evidence Environment size: 1650/4091 bytes + env_bytes 1650/4091 + integrity image_check=uimage_crc + integrity image_check_result=passed + integrity image_check_evidence=Verifying Checksum ... OK + integrity image_check_failed=Bad Magic Number + env AP_FLAG=0 + env LOAD_SENSOR_IMG=0 + env MOSTRECENTKERNEL=1 + env WATCHDOG_COUNT=0 + env filesize=ba3 + env mem=130560k + env stderr=serial + env stdin=serial + env stdout=serial + env ver=U-Boot 1.2.0.07-dirty (Apr 20 2017 - 18:13:34) + verdict + title U-Boot shell reached + state confirmed + severity high + evidence Environment size: 1650/4091 bytes + detail An operator interrupted autoboot and got a command prompt. Everything below was read from the device, not inferred from its boot output. + remediation Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password. + verdict + title Image verification + state confirmed + severity medium + evidence Verifying Checksum ... OK + detail The bootloader checked the image before booting it, using a legacy uImage CRC, and the check passed. That proves the image was not corrupt. It is not a signature: anyone who can write the image can recompute the checksum, so this stops bit-rot rather than an attacker. + remediation Move to signed FIT images (CONFIG_FIT_SIGNATURE) so a deliberate modification is detected and not just a corrupt one. + verdict + title Autoboot delay + state unknown + severity info + evidence bootdelay absent + detail bootdelay is not set in the environment, so the built-in default applies and cannot be read from here. + remediation + verdict + title Environment storage + state confirmed + severity medium + evidence Environment size: 1650/4091 bytes + detail The environment occupies 1650 of 4091 bytes of writable storage, so `saveenv` can persist a change across reboots. + remediation Build with a read-only or signed environment for production. diff --git a/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log b/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log new file mode 100644 index 0000000..dbec548 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/hab-unblown.log @@ -0,0 +1,45 @@ +U-Boot SPL 2022.04 (Oct 05 2023 - 14:06:15 +0000) +power_bd71837_init +DDRINFO: start DRAM init +DDRINFO: DRAM rate 1600MTS +DDRINFO:ddrphy calibration done +DDRINFO: ddrmix config done +Normal Boot +Trying to boot from MMC1 +hab fuse not enabled +Authenticate image from DDR location 0x401fcdc0... +NOTICE: BL31: v2.6(release):lf-5.15.32-2.0.0-0-gc6a19b1a3-dirty +NOTICE: BL31: Built : 06:37:22, Jun 7 2022 +U-Boot 2022.04 (Oct 05 2023 - 14:06:15 +0000) +CPU: i.MX8MMD rev1.0 1600 MHz (running at 1200 MHz) +CPU: Industrial temperature grade (-40C to 105C) at 43C +Reset cause: POR +Model: grip-aio_200-02122A +DRAM: 224 MiB +board_init +Core: 61 devices, 21 uclasses, devicetree: separate +MMC: FSL_SDHC: 0 +Loading Environment from nowhere... OK +In: serial +Out: serial +Err: serial +SEC0: RNG instantiated + BuildInfo: + - ATF c6a19b1 +facmod value is 1! +pulse number is 0 +�flash target is MMC:0 +Fastboot: Normal +Normal Boot + +Hit any key to stop autoboot: 2 +end: 3.4.2 bootloader-interrupt (duration 00:00:04) [common] +start: 3.4.3 bootloader-commands (timeout 00:02:52) [common] +Setting prompt string to ['=>'] +bootloader-commands: Wait for prompt ['=>'] (timeout 00:02:52) + 0 +Setting prompt string to ['=>', 'Resetting CPU', 'Must RESET board to recover', 'TIMEOUT', 'Retry count exceeded', 'Retry time exceeded; starting again', 'ERROR: The remote end did not respond in time.', 'File not found', 'Bad Linux ARM64 Image magic!', 'Wrong Ramdisk Image Format', 'Ramdisk image is corrupt', 'ERROR: Failed to allocate', 'TFTP error: trying to overwrite reserved memory', 'Bad Linux RISCV Image magic!', 'Wrong Image Format for boot', 'ERROR: Did not find a cmdline Flattened Device Tree', 'ERROR: RD image overlaps OS image', 'Invalid partition'] +Sending with 5 millisecond of delay +setenv factorymode 1 +u-boot=> setenv factorymode 1 +bootloader-commands: Wait for prompt ['=>', 'Resetting CPU', 'Must RESET board to recover', 'TIMEOUT', 'Retry count exceeded', 'Retry time exceeded; starting again', 'ERROR: The remote end did not respond in time.', 'File not found', 'Bad Linux ARM64 Image magic!', 'Wrong Ramdisk Image Format', 'Ramdisk image is corrupt', 'ERROR: Failed to allocate', 'TFTP error: trying to overwrite reserved memory', 'Bad Linux RISCV Image magic!', 'Wrong Image Format for boot', 'ERROR: Did not find a cmdline Flattened Device Tree', 'ERROR: RD image overlaps OS image', 'Invalid partition'] (timeout 00:02:51) diff --git a/crates/detectors/tests/fixtures/boot_chain/verified-image.log b/crates/detectors/tests/fixtures/boot_chain/verified-image.log new file mode 100644 index 0000000..13e6256 --- /dev/null +++ b/crates/detectors/tests/fixtures/boot_chain/verified-image.log @@ -0,0 +1,106 @@ +AP_FLAG=0 +filesize=ba3 +LOAD_SENSOR_IMG=0 +MOSTRECENTKERNEL=1 +WATCHDOG_COUNT=0 +stdin=serial +stdout=serial +stderr=serial +mem=130560k +ver=U-Boot 1.2.0.07-dirty (Apr 20 2017 - 18:13:34) + +Environment size: 1650/4091 bytes +Boot-> help +? - alias for 'help' +autoscr - run script from memory +base - print or set address offset +bdinfo - print Board Info structure +boot - boot default, i.e., run 'bootcmd' +bootd - boot default, i.e., run 'bootcmd' +bootelf - Boot from an ELF image in memory +bootm - boot application image from memory +bootp - boot image via network using BootP/TFTP protocol +bootvx - Boot vxWorks from an ELF image +chenv - manipulate environment variables +cmp - memory compare +coninfo - print console devices and information +cp - memory copy +crc32 - checksum calculation +defenv - get environment variables from default block +dhcp - invoke DHCP client to obtain IP/boot params +echo - echo args to console +erase - erase FLASH memory +exit - exit script +flashtest - do flash check from 0xbf000000 to 0xbff00000 +flinfo - print FLASH memory information +fsinfo - print information about filesystems +fsload - load binary file from a filesystem image +go - start application at address 'addr' +help - print online help +iminfo - print header information for application image +imls - list all images found in flash +itest - return true/false on integer compare +loadb - load binary file over serial line (kermit mode) +loads - load S-Record file over serial line +loady - load binary file over serial line (ymodem mode) +loop - infinite loop on address range +ls - list files in a directory (default /) +md - memory display +mm - memory modify (auto-incrementing) +mtest - simple RAM test +mw - memory write (fill) +nfs - boot image via network using NFS protocol +nm - memory modify (constant address) +ping - send ICMP ECHO_REQUEST to network host +printenv- print environment variables +protect - enable or disable FLASH write protection +rarpboot- boot image via network using RARP/TFTP protocol +reset - Perform RESET of the CPU +run - run commands in an environment variable +saveenv - save environment variables to persistent storage +setenv - set environment variables +sleep - delay execution for some time +test - minimal test like /bin/sh +tftpboot- boot image via network using TFTP protocol +version - print monitor version +Boot-> bdinfo +boot_params = 0x87D2EFB0 +memstart = 0x80000000 +memsize = 0x08000000 +flashstart = 0xBF000000 +flashsize = 0x01000000 +flashoffset = 0x00000000 +ethaddr = 00:1F:45:F2:B7:3B +ip_addr = 192.168.1.20 +baudrate = 115200 bps +Boot-> md +Usage: +md - memory display + +Boot-> iminfo + +## Checking Image at 80100000 ... + Bad Magic Number +Boot-> imls +Boot-> go +Usage: +go - start application at address 'addr' + +Boot-> boot +### JFFS2 loading 'boot_kernel.img2' to 0x80400000 +Scanning JFFS2 FS: . done. +### JFFS2 load complete: 2979 bytes loaded to 0x80400000 + +## Checking Image at 80400000 ... + Image Name: Boot ROM Script + Created: 2010-06-21 17:46:19 UTC + Image Type: PowerPC Linux Script (uncompressed) + Data Size: 2915 Bytes = 2.8 kB + Load Address: 00000000 + Entry Point: 00000000 + Verifying Checksum ... OK +## Executing script at 80400000 +check for watchdog, limit=3... +check for reset by watchdog... +no watchdog... +bSaveEnviron=0 image_order=secondary primary