use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;
lazy_static! {
static ref EXTERNAL_FUNC_REGEX: Regex =
Regex::new(r"(?i)function\s+\w+\s*\([^)]*\)\s*(external|public)").unwrap();
static ref ORACLE_PRICE_REGEX: Regex =
Regex::new(r"(?i)(getPrice|price|exchange|rate|twap|spot)").unwrap();
static ref TRADE_REGEX: Regex =
Regex::new(r"(?i)(swap|trade|execute|buy|sell)").unwrap();
}
pub fn detect_oracle_self_trade(source: &str, file_path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
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);
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");
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 {
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
}
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()
}
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");
[
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");
}
}