sentri-analyzer-evm 0.3.0

Sentri: EVM smart contract analyzer with static analysis and invariant checking for Ethereum, Polygon, and other EVM chains.
Documentation
//! Detector for oracle self-trade manipulation vulnerabilities.
//!
//! This detector identifies when oracle prices can be manipulated by user trades
//! within the same transaction or block. This pattern was exploited in H17 Mango Markets
//! ($117M, 2023) where users could liquidate themselves at manipulated prices.

use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;

lazy_static! {
    /// Regex to match external function definitions
    static ref EXTERNAL_FUNC_REGEX: Regex =
        Regex::new(r"(?i)function\s+\w+\s*\([^)]*\)\s*(external|public)").unwrap();

    /// Regex to match oracle price retrieval
    static ref ORACLE_PRICE_REGEX: Regex =
        Regex::new(r"(?i)(getPrice|price|exchange|rate|twap|spot)").unwrap();

    /// Regex to match user trade/swap functions
    static ref TRADE_REGEX: Regex =
        Regex::new(r"(?i)(swap|trade|execute|buy|sell)").unwrap();
}

/// Detects oracle self-trade manipulation vulnerabilities.
pub fn detect_oracle_self_trade(source: &str, file_path: &str) -> Vec<Finding> {
    let mut findings = Vec::new();

    // Pattern 1: Find functions that both trade and use oracle prices
    for (func_line_num, func_line) in source.lines().enumerate() {
        if !EXTERNAL_FUNC_REGEX.is_match(func_line) {
            continue;
        }

        let func_name = extract_function_name(func_line);

        // Extract function body (~50 lines)
        let func_start = func_line_num;
        let func_end = (func_line_num + 50).min(source.lines().count());
        let func_body = source
            .lines()
            .skip(func_start)
            .take(func_end - func_start)
            .collect::<Vec<&str>>()
            .join("\n");

        // Pattern 2: Check if function modifies state (trades) AND uses oracle
        let modifies_state = func_body.to_lowercase().contains("transfer")
            || func_body.to_lowercase().contains("swap")
            || func_body.to_lowercase().contains("mint")
            || func_body.to_lowercase().contains("burn");

        let uses_oracle = ORACLE_PRICE_REGEX.is_match(&func_body);

        if modifies_state && uses_oracle {
            // Pattern 3: Check if oracle price is validated
            if !has_oracle_safety_checks(&func_body) {
                let message = format!(
                    "Function '{}' modifies state and uses oracle prices without safety checks. \
                     An attacker can manipulate oracle prices via self-trades or liquidate themselves \
                     at manipulated prices within the same transaction. \
                     H17 Mango Markets ($117M) was exploited when users could execute trades that \
                     moved prices against them, then liquidate their own positions at those inflated prices. \
                     \
                     Attack: \
                     1. User initiates trade in {} function \
                     2. Trade moves oracle price adversely (or spreads widen) \
                     3. User liquidates themselves at the new price within same tx \
                     4. User profits from arbitrage between transaction start/end prices \
                     \
                     Required fix: \
                     1. Use time-weighted average price (TWAP) instead of spot price \
                     2. Add max price impact check: require(priceImpact < MAX_IMPACT) \
                     3. Use external oracle (Chainlink, Pyth, Band) with staleness checks \
                     4. Add circuit breaker if price moves > threshold",
                    func_name, func_name
                );

                findings.push(
                    Finding::new(
                        "evm_oracle_self_trade".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(), "H17".to_string())
                    .with_metadata("exploit_name".to_string(), "Mango Markets".to_string())
                    .with_metadata("loss".to_string(), "$117M".to_string())
                    .with_metadata("year".to_string(), "2023".to_string())
                    .with_metadata("also_affects".to_string(), "H34 Loopscale".to_string())
                    .with_metadata(
                        "vulnerability_type".to_string(),
                        "oracle_manipulation".to_string(),
                    )
                    .with_metadata(
                        "detector".to_string(),
                        "oracle_state_interaction_check".to_string(),
                    )
                    .with_source_fragment(func_body),
                );
            }
        }
    }

    findings
}

/// Extract function name from function declaration
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();
        }
    }
    "execute".to_string()
}

/// Check if function has oracle safety measures
fn has_oracle_safety_checks(func_body: &str) -> bool {
    let func_lower = func_body.to_lowercase();

    let has_twap = func_lower.contains("twap") || func_lower.contains("weighted");
    let has_staleness_check = func_lower.contains("stale")
        || func_lower.contains("block.timestamp")
        || func_lower.contains("timeout")
        || func_lower.contains("updatedAt");
    let has_price_impact = func_lower.contains("impact")
        || func_lower.contains("slippage")
        || func_lower.contains("min_amount")
        || func_lower.contains("max_price");
    let has_circuit_breaker = func_lower.contains("circuit")
        || func_lower.contains("pause")
        || func_lower.contains("max_deviation")
        || func_lower.contains("deviation");

    // Need at least 2 safety measures
    [
        has_twap,
        has_staleness_check,
        has_price_impact,
        has_circuit_breaker,
    ]
    .iter()
    .filter(|&&x| x)
    .count()
        >= 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_vulnerable_oracle_self_trade() {
        let code = r#"
            function liquidate(address account) external {
                uint price = getPrice(collateral);  // Spot price
                
                // Attacker can manipulate price via swap in same tx
                swapExactTokensForTokens(collateralAmount, 1, path, address(this), block.timestamp);
                
                uint newPrice = getPrice(collateral);  // Price changed!
                
                // Liquidate at new (manipulated) price
                liquidatePosition(account, newPrice);
            }
        "#;

        let findings = detect_oracle_self_trade(code, "protocol.sol");
        assert!(
            !findings.is_empty(),
            "Should detect oracle self-trade vulnerability"
        );
        assert!(findings[0].invariant_id.contains("oracle_self_trade"));
    }

    #[test]
    fn test_vulnerable_mango_pattern() {
        let code = r#"
            contract MangoMarkets {
                function liquidate(address account) external {
                    // Get current spot price
                    uint price = spotPrice[USDC];
                    
                    // Execute large trade (doesn't move price in vulnerable version)
                    swap(1000 USDC for WETH);
                    
                    // Liquidate at potentially different price
                    uint accountHealth = calculateHealth(account, spotPrice[USDC]);
                    require(accountHealth < THRESHOLD, "Not liquidatable");
                    
                    drainCollateral(account);
                }
            }
        "#;

        let findings = detect_oracle_self_trade(code, "mango.sol");
        assert!(
            !findings.is_empty(),
            "Should detect Mango-like vulnerability"
        );
    }

    #[test]
    fn test_safe_with_twap() {
        let code = r#"
            function liquidate(address account) external {
                // Use time-weighted average price (safe)
                uint price = getTWAP(30 minutes);
                
                require(account.health < THRESHOLD, "Not liquidatable");
                drainCollateral(account);
            }
        "#;

        let findings = detect_oracle_self_trade(code, "safe.sol");
        assert!(findings.is_empty(), "Should not flag TWAP usage");
    }

    #[test]
    fn test_safe_with_staleness_and_slippage() {
        let code = r#"
            function liquidate(address account) external {
                (uint price, uint updatedAt) = priceFeed.latestRoundData();
                
                // Check staleness
                require(block.timestamp - updatedAt < MAX_STALENESS, "Stale price");
                
                // Add slippage protection
                uint minPrice = price * 95 / 100;
                uint maxPrice = price * 105 / 100;
                
                require(account.health < THRESHOLD, "Not liquidatable");
                drainCollateral(account);
            }
        "#;

        let findings = detect_oracle_self_trade(code, "safe.sol");
        assert!(findings.is_empty(), "Should detect safety checks");
    }

    #[test]
    fn test_safe_with_circuit_breaker() {
        let code = r#"
            function liquidate(address account) external {
                uint price = getPrice(collateral);
                uint priorPrice = lastPrice;
                
                // Check price deviation (circuit breaker)
                require(
                    (price * 100 / priorPrice) < 120 && (price * 100 / priorPrice) > 80,
                    "Price moved too much"
                );
                
                require(account.health < THRESHOLD);
                drainCollateral(account);
                
                lastPrice = price;
            }
        "#;

        let findings = detect_oracle_self_trade(code, "safe.sol");
        assert!(findings.is_empty(), "Should not flag with circuit breaker");
    }
}