Flash Loan Validation
Detects missing repayment validation in flash loan patterns, allowing borrowers to avoid repaying borrowed funds.
Flash Loan Validation
Overview
Remediation Guide: How to Fix Flash Loan Validation
The flash loan validation detector identifies programs with flash loan patterns where tokens are lent and then expected to be repaid within the same transaction, but the repayment validation is missing or insufficient. Flash loans allow anyone to borrow an unlimited amount of tokens with no collateral, provided they are repaid (plus a fee) in the same transaction. If the program does not verify that the final balance exceeds the initial balance plus fees, the borrower can default without consequence.
The detector looks for pairs of transfer operations (incoming borrow, outgoing repay) without intervening balance validation.
Why This Is an Issue
Flash loans are a powerful DeFi primitive, but their safety depends entirely on repayment validation. Without it:
- Free borrowing: The borrower takes tokens from the pool and never returns them.
- Pool drainage: Repeated unvalidated flash loans drain the entire lending pool.
- Oracle manipulation: Attackers use flash-borrowed tokens to manipulate prices on AMMs, then exploit protocols that use those prices.
- Governance attacks: Flash-borrowed governance tokens are used to pass proposals.
The lending pool expects final_balance >= initial_balance + fee, and this invariant must be enforced on-chain.
CWE mapping: CWE-754 (Improper Check for Unusual or Exceptional Conditions).
How to Resolve
// Before: Vulnerable -- no repayment validation
pub fn flash_loan(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let pool = &accounts[0];
let borrower = &accounts[1];
// Lend tokens to borrower
**pool.try_borrow_mut_lamports()? -= amount;
**borrower.try_borrow_mut_lamports()? += amount;
// Borrower performs arbitrary operations (CPI callback)
invoke(&callback_ix, accounts)?;
// VULNERABLE: no check that funds were returned
Ok(())
}
// After: Validate repayment
pub fn flash_loan(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let pool = &accounts[0];
let borrower = &accounts[1];
// Record initial balance
let initial_balance = **pool.try_borrow_lamports()?;
// Calculate required repayment
let fee = amount
.checked_mul(FLASH_LOAN_FEE_BPS)
.ok_or(ProgramError::ArithmeticOverflow)?
.checked_div(10_000)
.ok_or(ProgramError::ArithmeticOverflow)?;
let required_repayment = amount
.checked_add(fee)
.ok_or(ProgramError::ArithmeticOverflow)?;
// Lend tokens
**pool.try_borrow_mut_lamports()? -= amount;
**borrower.try_borrow_mut_lamports()? += amount;
// Borrower performs operations
invoke(&callback_ix, accounts)?;
// FIXED: verify repayment
let final_balance = **pool.try_borrow_lamports()?;
if final_balance < initial_balance.checked_add(fee)
.ok_or(ProgramError::ArithmeticOverflow)?
{
return Err(ProgramError::InsufficientFunds);
}
Ok(())
}
Examples
Vulnerable Code
pub fn process_flash_loan(
accounts: &[AccountInfo],
borrow_amount: u64,
callback_data: &[u8],
) -> ProgramResult {
let lending_pool = &accounts[0];
let borrower = &accounts[1];
let callback_program = &accounts[2];
// Transfer tokens to borrower
let lend_ix = spl_token::instruction::transfer(
&spl_token::id(),
lending_pool.key, borrower.key,
lending_pool.key, &[], borrow_amount,
)?;
invoke(&lend_ix, accounts)?;
// Execute borrower's callback
let callback_ix = Instruction {
program_id: *callback_program.key,
accounts: vec![/* borrower accounts */],
data: callback_data.to_vec(),
};
invoke(&callback_ix, accounts)?;
// MISSING: no balance check after callback
// Borrower keeps the tokens
Ok(())
}
Fixed Code
pub fn process_flash_loan(
accounts: &[AccountInfo],
borrow_amount: u64,
callback_data: &[u8],
) -> ProgramResult {
let lending_pool = &accounts[0];
let borrower = &accounts[1];
let callback_program = &accounts[2];
// FIXED: validate callback program
if callback_program.key != &ALLOWED_CALLBACK_PROGRAM {
return Err(ProgramError::IncorrectProgramId);
}
// Record pre-loan balance
let pool_data = lending_pool.try_borrow_data()?;
let pool_token = spl_token::state::Account::unpack(&pool_data)?;
let pre_balance = pool_token.amount;
drop(pool_data);
// Calculate minimum repayment
let fee = borrow_amount
.checked_mul(FLASH_FEE_BPS)
.ok_or(ProgramError::ArithmeticOverflow)?
.checked_div(10_000)
.ok_or(ProgramError::ArithmeticOverflow)?
.max(1); // Minimum 1 lamport fee
let min_repayment = pre_balance
.checked_add(fee)
.ok_or(ProgramError::ArithmeticOverflow)?;
// Lend
let lend_ix = spl_token::instruction::transfer(
&spl_token::id(),
lending_pool.key, borrower.key,
lending_pool.key, &[], borrow_amount,
)?;
invoke(&lend_ix, accounts)?;
// Callback
invoke(&callback_ix, accounts)?;
// FIXED: verify repayment
let pool_data = lending_pool.try_borrow_data()?;
let pool_token = spl_token::state::Account::unpack(&pool_data)?;
if pool_token.amount < min_repayment {
msg!(
"Flash loan repayment insufficient: got {}, need {}",
pool_token.amount, min_repayment
);
return Err(ProgramError::InsufficientFunds);
}
Ok(())
}
Sample Sigvex Output
{
"detector_id": "flash-loan-validation",
"severity": "critical",
"confidence": 0.78,
"description": "Flash loan pattern detected: tokens are transferred IN at block 2 statement 3 and transferred OUT at block 4 statement 1. No balance validation is performed between the borrow and repay. An attacker could perform arbitrary operations after borrowing tokens and before validation occurs.",
"location": { "function": "process_flash_loan", "offset": 3 }
}
Detection Methodology
The detector identifies flash loan patterns through transfer pair analysis:
- Transfer collection: Identifies all token transfer operations (CPI to Token program, lamport transfers) and records their block and statement indices, along with direction (incoming vs. outgoing based on account position).
- Pattern matching: Looks for pairs of transfers where an incoming transfer (borrow) is followed by an outgoing transfer (repay). At least two transfers are required to form a flash loan pattern.
- Validation gap detection: Checks whether any balance validation (comparison of account balance against a threshold) occurs between the borrow and repay operations. A balance validation is a conditional statement that reads the lending pool’s balance and compares it against a minimum value.
- Missing validation reporting: When no validation exists between the borrow and repay, a finding is emitted with the borrow and repay locations.
- Category filtering: Only runs on programs identified as DeFi-related by the detection context.
Limitations
False positives:
- Programs that perform flash loan validation in a separate instruction within the same transaction (cross-instruction balance checks).
- Programs where the balance check uses a custom validation pattern not recognized by the detector.
False negatives:
- Flash loan patterns implemented through intermediate wrapper programs where the borrow and repay are in different CPI levels.
- Balance validation performed through complex conditional logic or helper functions.
Related Detectors
- DeFi Reentrancy — reentrancy in DeFi operations often combined with flash loans
- Oracle Manipulation — flash loans enable price manipulation
- Unchecked Fee/Rent Math — incorrect fee calculations in flash loan repayment