use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;
lazy_static! {
static ref MERKLE_ROOT_REGEX: Regex =
Regex::new(r"(?i)mapping\s*\(\s*bytes32\s*=>\s*bytes32\s*\)\s*\w*\s*(\w+Root|root\w+|confirmedAt)").unwrap();
static ref VERIFY_FUNCTION_REGEX: Regex =
Regex::new(r"(?i)function\s+(verify|validate)\w*\s*\(").unwrap();
static ref ZERO_INIT_REGEX: Regex =
Regex::new(r"(?i)=\s*0\s*;|bytes32\s+\w+\s*;").unwrap();
}
pub fn detect_merkle_root_zero_default(source: &str, file_path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
for (var_line_num, var_line) in source.lines().enumerate() {
if !is_merkle_root_var(var_line) {
continue;
}
if is_zero_initialized(var_line) || is_uninitialized(var_line) {
let var_name = extract_variable_name(var_line);
let message = format!(
"Merkle root mapping '{}' is initialized to zero or uninitialized. \
This allows anyone to submit a proof with root == 0 and have it accepted \
as valid. H16 Nomad Bridge ($190M) was completely drained when this \
vulnerability was exploited. An attacker could create transactions with \
zero merkle proofs and withdraw all bridge funds. \
\
Required fix: Initialize to a non-zero value or add explicit checks: \
require(proposedRoot != 0, \"Cannot set root to zero\");",
var_name
);
findings.push(
Finding::new(
"evm_merkle_root_zero_default".to_string(),
sentri_core::Severity::Critical,
file_path.to_string(),
var_line_num + 1,
0,
message,
var_line.trim().to_string(),
)
.with_metadata("exploit_id".to_string(), "H16".to_string())
.with_metadata("exploit_name".to_string(), "Nomad Bridge".to_string())
.with_metadata("loss".to_string(), "$190M".to_string())
.with_metadata("year".to_string(), "2022".to_string())
.with_metadata(
"detector".to_string(),
"state_variable_analysis".to_string(),
),
);
}
}
for (func_line_num, func_line) in source.lines().enumerate() {
if !VERIFY_FUNCTION_REGEX.is_match(func_line) {
continue;
}
let func_name = extract_function_name(func_line);
let func_start = func_line_num;
let func_end = (func_line_num + 40).min(source.lines().count());
let func_body = source
.lines()
.skip(func_start)
.take(func_end - func_start)
.collect::<Vec<&str>>()
.join("\n");
if accepts_zero_merkle_root(&func_body) {
let message = format!(
"Proof verification function '{}' may accept zero merkle root without validation. \
Any proof with root == 0 would pass verification. \
H16 Nomad Bridge ($190M) was exploited when this check was missing.",
func_name
);
findings.push(
Finding::new(
"evm_merkle_root_zero_default".to_string(),
sentri_core::Severity::Critical,
file_path.to_string(),
func_line_num + 1,
0,
message,
func_line.trim().to_string(),
)
.with_metadata("exploit_id".to_string(), "H16".to_string())
.with_metadata("exploit_name".to_string(), "Nomad Bridge".to_string())
.with_metadata("loss".to_string(), "$190M".to_string())
.with_metadata("year".to_string(), "2022".to_string())
.with_metadata(
"vulnerability_type".to_string(),
"zero_root_acceptance".to_string(),
)
.with_metadata(
"detector".to_string(),
"proof_verification_analysis".to_string(),
)
.with_source_fragment(func_body),
);
}
}
findings
}
fn is_merkle_root_var(line: &str) -> bool {
let line_lower = line.to_lowercase();
(line_lower.contains("mapping") && line_lower.contains("root"))
|| (line_lower.contains("mapping") && line_lower.contains("confirmed"))
|| (line_lower.contains("bytes32") && line_lower.contains("root"))
}
fn is_zero_initialized(line: &str) -> bool {
line.contains("= 0") || (line.contains("bytes32") && line.contains(";") && !line.contains("="))
}
fn is_uninitialized(line: &str) -> bool {
line.contains("bytes32") && line.contains(";") && !line.contains("=")
}
fn extract_variable_name(line: &str) -> String {
if let Some(semi_pos) = line.rfind(';') {
let before_semi = &line[..semi_pos];
if let Some(space_pos) = before_semi.rfind(' ') {
return before_semi[space_pos + 1..].trim().to_string();
}
}
"root".to_string()
}
fn extract_function_name(line: &str) -> String {
if let Some(start) = line.find("function ") {
let after_function = &line[start + 9..];
if let Some(end) = after_function.find('(') {
return after_function[..end].trim().to_string();
}
}
"verify".to_string()
}
fn accepts_zero_merkle_root(func_body: &str) -> bool {
let func_lower = func_body.to_lowercase();
let has_zero_check = func_lower.contains("!= 0")
|| func_lower.contains("!= bytes32(0)")
|| func_lower.contains("require(root")
|| func_lower.contains("require(_root");
(func_lower.contains("root") || func_lower.contains("merkle")) && !has_zero_check
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vulnerable_zero_initialized_root() {
let code = r#"
contract NomadBridge {
mapping(bytes32 => bytes32) public confirmedAt = 0; // VULNERABLE!
function verifyProof(bytes32 root, bytes calldata proof) external {
require(roots[root] != 0); // Missing zero root check!
}
}
"#;
let findings = detect_merkle_root_zero_default(code, "bridge.sol");
assert!(!findings.is_empty(), "Should detect zero-initialized root");
assert!(findings[0].invariant_id.contains("merkle_root_zero"));
}
#[test]
fn test_vulnerable_uninitialized_root() {
let code = r#"
contract Bridge {
mapping(bytes32 => bytes32) public merkleRoot; // No initialization!
function verify(bytes32 root, bytes calldata proof) external returns (bool) {
return merkleRoot[keccak256(proof)] == root; // Accepts zero root!
}
}
"#;
let findings = detect_merkle_root_zero_default(code, "bridge.sol");
assert!(!findings.is_empty(), "Should detect uninitialized root");
}
#[test]
fn test_safe_with_zero_check() {
let code = r#"
contract SafeBridge {
mapping(bytes32 => bytes32) public merkleRoot = keccak256("initial");
function verify(bytes32 root, bytes calldata proof) external returns (bool) {
require(root != bytes32(0), "Root cannot be zero");
return merkleRoot[keccak256(proof)] == root;
}
}
"#;
let findings = detect_merkle_root_zero_default(code, "bridge.sol");
assert!(
findings.is_empty(),
"Should not flag when zero check is present"
);
}
#[test]
fn test_vulnerable_nomad_pattern() {
let code = r#"
contract NomadBridge {
mapping(bytes32 => bool) public committedRoot;
function commitRoot(bytes32 _root) external {
committedRoot[_root] = true; // No zero check!
}
function verifyProof(bytes calldata proof) external returns (bool) {
bytes32 root = keccak256(proof);
return committedRoot[root]; // Accepts root = 0x0!
}
}
"#;
let findings = detect_merkle_root_zero_default(code, "bridge.sol");
assert!(
!findings.is_empty(),
"Should detect Nomad-like vulnerability"
);
}
}