use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
pub title: String,
pub state: String,
pub detail: String,
pub evidence: String,
pub severity: String,
pub remediation: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct UbootSession {
pub reached: bool,
pub evidence: String,
pub env: BTreeMap<String, String>,
pub env_used_bytes: Option<u64>,
pub env_total_bytes: Option<u64>,
pub bdinfo: BTreeMap<String, String>,
pub mtd_device: Option<String>,
pub mtd_partitions: Vec<MtdPartition>,
}
use std::sync::LazyLock;
use regex::Regex;
static RE_PROMPT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*(?:=>|u-?boot\s*[>#]|[\w.-]+\s*=>)\s*(\S.*)?$").unwrap());
static RE_ENV_SIZE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*Environment size:\s*(\d+)\s*/\s*(\d+)\s*bytes").unwrap());
static RE_ENV_LINE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([A-Za-z_][A-Za-z0-9_.]{0,63})=(.*)$").unwrap());
static RE_CHECKSUM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Checksum\s*\.\.\.\s*(.*)$").unwrap());
static RE_FIT_HASH: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^\s*Verifying Hash Integrity\s*\.\.\.\s*(.*)$").unwrap());
static RE_FIT_SIG: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)Verifying Signature\b").unwrap());
static RE_SIG_ALGO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)^(?:rsa\d*|ecdsa\d*|pkcs1)").unwrap());
static RE_BAD: LazyLock<Regex> = 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<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)signature check failed|Verifying Hash Integrity\s*\.\.\.\s*error").unwrap()
});
static RE_HAB_OFF: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)hab fuse not enabled").unwrap());
static RE_HAB_ON: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)hab fuse (?:is )?enabled").unwrap());
static RE_UBIFS_UNAUTH: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)UBIFS\s*\(([^)]*)\):\s*Mounting in unauthenticated mode").unwrap()
});
static RE_ENV_CRC: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)bad CRC, using default environment").unwrap());
static RE_OK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bOK\b").unwrap());
static RE_ALGO_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[+,\s]+").unwrap());
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct BootIntegrity {
pub image_check: Option<String>,
pub image_check_evidence: Option<String>,
pub image_check_result: Option<String>,
pub image_hash_algorithms: Vec<String>,
pub image_signature_checked: bool,
pub image_signature_evidence: Option<String>,
pub image_check_failed: Option<String>,
pub hab_fuse: Option<String>,
pub hab_evidence: Option<String>,
pub ubifs_unauthenticated: Option<String>,
pub env_crc_failed: Option<String>,
}
fn keep_first(slot: &mut Option<String>, value: &str) {
if slot.is_none() {
*slot = Some(value.to_string());
}
}
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<String> = 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
}
static RE_BDINFO: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*([A-Za-z][\w /()\-]{0,31}?)\s+=\s+(\S.*?)\s*$").unwrap());
const BDINFO_KEYS: &[&str] = &[
"arch_number",
"boot_params",
"dram bank",
"flashstart",
"flashsize",
"flashoffset",
"baudrate",
"relocaddr",
"reloc off",
"ethaddr",
"ip_addr",
"fdt_blob",
"irq_sp",
"sp start",
"eth0name",
"memstart",
"memsize",
"eth1name",
"ethaddr1",
"current eth",
"fdt_addr",
"sp_start",
"reloc_off",
"dram_bank",
];
static RE_MTD_DEV: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*device\s+(\S+)(?:\s+<[^>]*>)?\s*,\s*#\s*parts\s*=\s*(\d+)").unwrap()
});
static RE_MTD_PART: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^\s*\d+:\s*(\S+)\s+0x([0-9a-f]+)\s+0x([0-9a-f]+)\s+(\d)").unwrap()
});
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MtdPartition {
pub name: String,
pub size: u64,
pub offset: u64,
pub read_only: bool,
}
fn clip(s: &str, max: usize) -> String {
s.chars().take(max).collect()
}
pub fn parse_session(log: &str) -> UbootSession {
let mut s = UbootSession::default();
let mut in_env_dump = false;
let mut candidates: Vec<(String, String)> = Vec::new();
let mut continuations = 0usize;
fn note(s: &mut UbootSession, line: &str) {
if !s.reached {
s.reached = true;
s.evidence = clip(line.trim(), 200);
}
}
for raw in log.lines() {
let line = raw.trim_end_matches(['\r', '\n']);
if let Some(caps) = RE_PROMPT.captures(line) {
note(&mut s, line);
let typed = caps
.get(1)
.map(|m| m.as_str().trim().to_ascii_lowercase())
.unwrap_or_default();
in_env_dump = ["printenv", "print", "env print"]
.iter()
.any(|p| typed.starts_with(p));
continue;
}
if let Some(caps) = RE_ENV_SIZE.captures(line) {
note(&mut s, line);
s.env_used_bytes = caps[1].parse().ok();
s.env_total_bytes = caps[2].parse().ok();
in_env_dump = false;
for (k, val) in candidates.drain(..) {
s.env.entry(k).or_insert(val);
}
continuations = 0;
continue;
}
if let Some(caps) = RE_ENV_LINE.captures(line) {
let key = caps[1].to_string();
let value = clip(caps[2].trim(), 1024);
if in_env_dump {
s.env.entry(key).or_insert(value);
} else {
continuations = 0;
if candidates.len() < 256 {
candidates.push((key, value));
}
}
continue;
}
if line.trim().is_empty() {
continue;
}
if let Some(caps) = RE_BDINFO.captures(line) {
let key = caps[1].trim();
if BDINFO_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
s.bdinfo
.entry(key.to_string())
.or_insert_with(|| caps[2].trim().to_string());
note(&mut s, line);
continue;
}
}
if let Some(caps) = RE_MTD_DEV.captures(line) {
if s.mtd_device.is_none() {
s.mtd_device = Some(caps[1].to_string());
}
note(&mut s, line);
continue;
}
if let Some(caps) = RE_MTD_PART.captures(line) {
if let (Ok(size), Ok(offset)) = (
u64::from_str_radix(&caps[2], 16),
u64::from_str_radix(&caps[3], 16),
) {
let part = MtdPartition {
name: caps[1].to_string(),
size,
offset,
read_only: &caps[4] == "1",
};
if !s.mtd_partitions.contains(&part) {
s.mtd_partitions.push(part);
}
note(&mut s, line);
continue;
}
}
if !candidates.is_empty() && continuations < 3 {
continuations += 1;
let last = candidates.last_mut().expect("checked non-empty");
last.1 = clip(&(last.1.clone() + line.trim()), 1024);
continue;
}
candidates.clear();
continuations = 0;
}
s
}
fn v(
out: &mut Vec<Verdict>,
title: &str,
state: &str,
detail: &str,
evidence: &str,
severity: &str,
remediation: Option<&str>,
) {
out.push(Verdict {
title: title.to_string(),
state: state.to_string(),
detail: detail.to_string(),
evidence: evidence.to_string(),
severity: severity.to_string(),
remediation: remediation.map(str::to_string),
});
}
pub fn verdict(s: &UbootSession, bi: &BootIntegrity) -> Vec<Verdict> {
let mut out = Vec::new();
if !s.reached {
return out;
}
v(
&mut out,
"U-Boot shell reached",
"confirmed",
"An operator interrupted autoboot and got a command prompt. Everything below \
was read from the device, not inferred from its boot output.",
if s.evidence.is_empty() {
"U-Boot prompt"
} else {
&s.evidence
},
"high",
Some(
"Set bootdelay=-1 and build with CONFIG_AUTOBOOT_KEYED so the prompt needs a password.",
),
);
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."),
);
}
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,
"Environment not captured",
"unknown",
"The shell was reached but no printenv output was captured, so the boot \
chain below could not be assessed. Run `printenv` at the prompt.",
&s.evidence,
"info",
None,
);
return out;
}
match s.env.get("bootdelay") {
Some(raw) => {
let ev = format!("bootdelay={raw}");
match raw.trim().parse::<i64>() {
Err(_) => v(
&mut out,
"Autoboot delay",
"unknown",
&format!("bootdelay is not a number: {raw:?}."),
&ev,
"info",
None,
),
Ok(d) if d < 0 => v(
&mut out,
"Autoboot delay",
"hardened",
"bootdelay is negative, so autoboot cannot be interrupted by a keypress. \
The prompt was still reached, so something else allowed it.",
&ev,
"medium",
None,
),
Ok(0) => v(
&mut out,
"Autoboot delay",
"hardened",
"bootdelay is 0: no interrupt window. The prompt was still reached, so \
something else allowed it.",
&ev,
"medium",
None,
),
Ok(d) => v(
&mut out,
"Autoboot delay",
"exposed",
&format!(
"bootdelay is {d}s, so anyone with console access gets {d}s to \
take the prompt on every boot."
),
&ev,
"high",
Some("Set bootdelay=-1 and require a password (CONFIG_AUTOBOOT_KEYED)."),
),
}
}
None => v(
&mut out,
"Autoboot delay",
"unknown",
"bootdelay is not set in the environment, so the built-in default applies \
and cannot be read from here.",
"bootdelay absent",
"info",
None,
),
}
if let Some(cmd) = s.env.get("bootcmd") {
let short: String = cmd.chars().take(160).collect();
v(
&mut out,
"Boot command",
"exposed",
"bootcmd is readable and, with the prompt reachable, settable. Whoever holds \
the console decides what the device boots.",
&format!("bootcmd={short}"),
"high",
Some(
"Lock the environment (CONFIG_ENV_IS_NOWHERE or a signed env) and require a \
password at the prompt.",
),
);
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") && observed.is_none() {
v(
&mut out,
"Image verification",
"unknown",
"bootcmd boots an image without a visible verification step. That is not \
proof verification is absent: a FIT signature check can be implicit in \
the image. Confirm with the boot output of an actual `bootm`.",
&format!("bootcmd={short}"),
"info",
None,
);
}
}
if s.env.contains_key("ipaddr") && s.env.contains_key("serverip") {
let parts: Vec<String> = ["ethaddr", "gatewayip", "ipaddr", "netmask", "serverip"]
.iter()
.filter_map(|k| s.env.get(*k).map(|val| format!("{k}={val}")))
.collect();
v(
&mut out,
"Network boot path",
"exposed",
"ipaddr and serverip are both set, so the bootloader is pre-configured to \
fetch over the network. That is a route in as much as a recovery route out.",
&parts.join(", "),
"medium",
Some("Clear ipaddr/serverip on production images unless netboot is required."),
);
}
if let Some(args) = s.env.get("bootargs") {
let short: String = args.chars().take(160).collect();
let ev = format!("bootargs={short}");
let debug = [
("init=/bin/sh", "a root shell as init"),
("init=/bin/bash", "a root shell as init"),
("single", "single-user mode"),
("rdinit=/bin/sh", "a root shell as rdinit"),
]
.iter()
.find(|(tok, _)| args.contains(tok))
.map(|(_, what)| *what);
match debug {
Some(what) => v(
&mut out,
"Boot arguments",
"exposed",
&format!("bootargs already requests {what}."),
&ev,
"high",
Some("Remove debug boot arguments from production images."),
),
None => v(
&mut out,
"Boot arguments",
"confirmed",
"bootargs is readable and settable from the prompt, which is how a root \
shell is usually obtained on a board like this.",
&ev,
"medium",
Some("Lock the environment so bootargs cannot be rewritten at the console."),
),
}
}
if let (Some(used), Some(total)) = (s.env_used_bytes, s.env_total_bytes) {
if total > 0 {
v(
&mut out,
"Environment storage",
"confirmed",
&format!(
"The environment occupies {used} of {total} bytes of writable storage, so \
`saveenv` can persist a change across reboots."
),
&format!("Environment size: {used}/{total} bytes"),
"medium",
Some("Build with a read-only or signed environment for production."),
);
}
}
out
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Assessment {
pub session: UbootSession,
pub integrity: BootIntegrity,
pub verdicts: Vec<Verdict>,
}
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,
}
}