Flash loans have become a cornerstone of decentralized finance (DeFi), enabling users to borrow large amounts of assets without collateral—provided the loan is repaid within the same transaction. While this innovation unlocks powerful financial flexibility, it also introduces unique attack vectors, especially when implemented in Ethereum's primary smart contract language, Solidity.
This article dives into a common yet critical security flaw in Solidity-based flash loan implementations: reentrancy vulnerabilities triggered through balance manipulation. We'll examine a simplified but realistic contract design, demonstrate how an attacker can exploit it using a proof-of-concept (PoC), and provide actionable mitigation strategies to secure your DeFi projects.
Our focus is on flash loan safety, reentrancy protection, and secure balance validation—key concerns for developers building robust and resilient DeFi protocols.
Understanding the Flash Loan Mechanism in Solidity
In most Solidity flash loan designs, the core repayment check relies on comparing the contract’s ETH or token balance before and after the loan. The logic is straightforward:
- Record the contract’s initial balance.
- Send funds to the borrower.
- Execute the borrower’s callback function.
- Verify that the final balance is at least equal to the initial balance plus a fee.
At first glance, this method appears secure—it ensures no net loss of funds. However, problems arise when other functions in the same contract modify the balance, such as deposit, withdraw, or stake mechanisms.
👉 Discover how leading platforms prevent reentrancy attacks in real-time DeFi systems.
Case Study: A Vulnerable Flash Loan & Deposit Contract
Consider a simplified contract that combines two functionalities:
- Flash loan distribution
- Liquidity deposit and yield generation via fee sharing
The contract issues "LP tokens" (liquidity provider tokens) to users who deposit ETH, allowing them to later withdraw their proportional share of the pool.
Here's a condensed version of the vulnerable code:
contract loan {
uint256 public totalSupply;
mapping(address => uint256) public _balance;
function deposit() public payable returns (uint256) {
uint256 value = address(this).balance - msg.value;
uint256 mint_amount;
if (totalSupply == 0) {
mint_amount = msg.value;
} else {
mint_amount = (msg.value * totalSupply) / value;
}
_mint(mint_amount);
return mint_amount;
}
function flash_loan(uint256 amountOut, address to, bytes calldata data) external {
uint256 value = address(this).balance;
require(amountOut <= value);
// Send loan and trigger borrower's function
payable(to).call{value: amountOut}(data);
// Check repayment: balance >= initial + 1%
value = value + value / 100;
require(address(this).balance >= value);
}
}While the flash loan function checks that funds are repaid, it does not isolate external balance changes caused by other functions like deposit().
This opens the door for a devastating reentrancy attack.
Exploiting the Vulnerability: A Proof-of-Concept Attack
An attacker can craft a malicious contract that:
- Takes a flash loan close to the full pool balance.
- In the callback (
back()), deposits part of the borrowed funds back into the same contract. - This deposit increases the contract’s balance, falsely satisfying the repayment check.
- After the flash loan completes, the attacker redeems their newly minted LP tokens for real ETH.
How It Works Step-by-Step
- Attacker calls
start()on their PoC contract. - The contract requests a flash loan of
poolBalance - 1ETH. - The loan contract sends funds and calls
back()on the attacker’s contract. Inside
back(), the attacker deposits 102% of the borrowed amount into the loan contract viadeposit().- This inflates the contract’s balance.
- The attacker receives LP tokens representing a large share.
- The loan contract checks: Is current balance ≥ original + fee?
✅ Yes—because of the deposit—but it's not actual repayment. - The flash loan completes successfully.
- The attacker calls
withdrew()to redeem their LP tokens and drain most of the pool.
Result: The attacker walks away with nearly all ETH from the liquidity pool—without ever repaying the loan in good faith.
Why This Attack Succeeds
The root cause lies in shared state dependency:
- The flash loan repayment check assumes balance changes only come from loan repayment.
- But
deposit()also altersaddress(this).balance, creating a false positive during validation.
This is a classic case where business logic interference undermines security assumptions.
👉 Learn how top-tier DeFi protocols implement secure state isolation and reentrancy guards.
Mitigation Strategies for Secure Flash Loans
To prevent such exploits, developers must decouple balance validation from external manipulations. Here are three proven approaches:
1. Use Reentrancy Guards
Implement a mutex lock to prevent any function from being re-entered during a flash loan.
bool private locked;
modifier nonReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function flash_loan(...) public nonReentrant {
// ...
}This stops attackers from calling deposit() inside the callback.
2. Maintain an Isolated Balance Ledger
Track "virtual" balances or use accounting snapshots that exclude temporary inflows like deposits during a loan.
For example:
- Keep a separate
totalDepositsvariable. - During flash loan checks, subtract pending deposits from the current balance.
This ensures only genuine repayments count toward validation.
3. Use Safe Token Transfers (for ERC-20 Loans)
When dealing with tokens instead of ETH:
- Use
safeTransferFromwith known callbacks. - Enforce repayment via direct transfer before executing any external logic.
- Leverage standards like ERC-3156 for standardized flash loan interfaces with built-in safety.
Frequently Asked Questions (FAQ)
Q: Can flash loans be safe without reentrancy locks?
A: Only if all balance-affecting functions are isolated or if repayment is enforced through direct transfers rather than balance comparisons.
Q: Is checking contract balance inherently unsafe for flash loans?
A: Not inherently—but it becomes risky when other functions modify that balance. Always assume malicious actors will manipulate state.
Q: How do real-world protocols like Aave prevent this?
A: They use internal accounting, strict access control, and often separate liquidity management from flash loan logic.
Q: Are ERC-20 flash loans safer than ETH-based ones?
A: Often yes—because token transfers can be enforced via transferFrom, reducing reliance on post-call balance checks.
Q: What tools can detect such vulnerabilities?
A: Static analyzers like Slither, Mythril, and manual review using frameworks like Echidna help catch reentrancy and state interference issues.
👉 Explore advanced auditing techniques used by professional blockchain security teams.
Final Thoughts
Solidity’s flexibility is both its strength and its risk. Flash loans offer incredible utility, but when combined with other financial functions in the same contract, they require meticulous design to avoid catastrophic failures.
The key takeaway: never rely solely on balance checks in contracts with multiple interacting functions. Use reentrancy guards, isolate state changes, and prefer explicit repayment mechanisms over implicit assumptions.
As we continue this series with Move and Rust-based implementations, we’ll see how different languages enforce memory safety and concurrency controls—offering alternative paths to more secure DeFi primitives.
Stay vigilant, audit thoroughly, and build with defense in depth.