This project demonstrates three critical blockchain security vulnerabilities through practical implementation and theoretical analysis. The main focus is a reentrancy attack simulation using a vulnerable banking contract, complemented by comprehensive test coverage. Additionally, it provides in-depth theoretical explanations of oracle manipulation and slippage attacks (including front-running and sandwich attacks), complete with prevention strategies and visual diagrams to enhance understanding of these common DeFi exploitation vectors.
A reentrancy attack occurs when a function calls another contract, and that second contract gains control of the execution flow. The vulnerability lies in the fact that when we make an external call, we transfer control to the receiving contract before our original function completes.
The key question is: What happens when msg.sender is not an external wallet, but another smart contract?
function withdraw() public {
require(userBalance[msg.sender] >= 1 ether, "User has not enough balance");
require(address(this).balance > 0, "Bank is rekt");
// π¨ VULNERABLE: Control transfers to msg.sender (could be a contract)
(bool success, ) = msg.sender.call{value: userBalance[msg.sender]}("");
require(success, "Fail");
// State update happens AFTER external call
userBalance[msg.sender] = 0;
}When msg.sender is a contract address, the ETH transfer automatically triggers the recipient contract's receive() function.
The attacking contract has four key components:
- SimpleBank instance: Set in constructor with target contract address
- attack() function: Entry point that deposits ETH and calls withdraw
- receive() function: Special Solidity function that auto-executes when receiving ETH
- Malicious logic: Inside
receive()to callwithdraw()again
receive() external payable {
if(address(simpleBank).balance >= 1 ether) {
simpleBank.withdraw();
}
}Why receive() and not function?
receive()is a reserved Solidity function- Must be declared as
receive() external payable - Automatically invoked when the contract receives ETH
- No
functionkeyword needed - it's built into the language
- SimpleBank.withdraw() executes requires
- ETH transfer to Attacker contract via
msg.sender.call - Control transfers to Attacker -
receive()auto-executes - Attacker's receive() calls
simpleBank.withdraw()again - But Original withdraw() hasn't reached user balance update yet
- Loop continues until bank is drained
- Only then update user balance
userBalance[msg.sender] = 0
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β SimpleBank β β Attacker β β Execution Flow β
β β β Contract β β β
β Balance: 60 ETH β β Balance: 10 ETH β β 1. Deposit 10ETHβ
β User: 50 ETH βββββΊβ β β 2. Call withdrawβ
β Attacker: 10ETH β β receive() { β β 3. receive()runsβ
β β β withdraw() β β 4. Re-call loop β
β withdraw() { β β } β β 5. Drain funds β
β β
requires β β β β 6. Update state β
β πΈ send ETH ββββ β β ββββββββββββββββββββββ
β π LOOP β β β β
β β balance=0 β β β β
β } β β β β
βββββββββββββββββββ βββββββββββββββββββ β
β² β β
βββββββββββββββββββββββββββββββββββββ
Control flows back after receive()
βββββββββββββββββββ
β Result β
β β
β Bank: 0 ETH β
β Attacker: 60ETH β
β User: Lost 50ETHβ
βββββββββββββββββββ
Setup:
- Deploy SimpleBank contract
- Deploy Attacker contract with SimpleBank address
- Legitimate user deposits 50 ETH
- Attacker deposits only 10 ETH
Attack Execution:
- Attacker calls
attack()with 10 ETH (extra gas to avoid revert) - Attacker's balance: 10 ETH, Bank total: 60 ETH
withdraw()sends 10 ETH to Attackerreceive()triggers, callswithdraw()again- Attacker's balance still shows 1 ETH (not updated yet!)
- Loop continues until bank empty
- Final result: Attacker gets 60 ETH, should only get 10 ET
CEI Pattern = Checks-Effects-Interactions
function withdraw() public {
// 1. CHECKS: Validate conditions
require(userBalance[msg.sender] >= 1 ether, "User has not enough balance");
require(address(this).balance > 0, "Bank is rekt");
// 2. EFFECTS: Update state FIRST
uint256 balance = userBalance[msg.sender];
userBalance[msg.sender] = 0;
// 3. INTERACTIONS: External calls LAST
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Fail");
}Why this works:
- When
receive()callswithdraw()again userBalance[msg.sender]is already 0- First require fails: "User has not enough balance"
- Attack reverts
The project demonstrates this attack through comprehensive testing:
function test_attack() public {
// Legitimate user deposits 20 ether
vm.deal(user, 20 ether);
vm.prank(user);
simpleBank.deposit{value: 20 ether}();
// Attacker executes reentrancy attack with 2 ether
vm.deal(address(attacker), 2 ether);
vm.prank(address(attacker));
attacker.attack{value: 2 ether}();
// Verify all funds drained: 22 ETH stolen with only 2 ETH deposit
assert(simpleBank.totalBalance() == 0);
}Test Results:
- β Legitimate deposits and withdrawals work
- β Attack successfully drains all funds
- β Attacker steals more than they deposited
- β Bank balance becomes zero
An oracle is a bridge between the real world and smart contracts. Smart contracts cannot access external data directly - they only know about their own state and other smart contracts they can interface with. If a smart contract needs real-world data (like ETH price, weather data, or sports results), it must rely on an oracle.
Example: A smart contract handling bets on football matches cannot know the match result by itself. It needs an oracle to provide this external information.
Oracle manipulation occurs when the oracle provides incorrect data, either accidentally or maliciously. This can have catastrophic consequences since smart contracts make critical decisions based on this data.
Example Scenario:
- Real ETH price: $3,000
- Manipulated oracle reports: $5,000
- Result: All calculations in dependent smart contracts use the wrong price, leading to incorrect token distributions and potential financial losses
- Technical Malfunction: Oracle may be broken or misconfigured
- Malicious Actor: Someone with control over the oracle acts in bad faith for personal gain
- DEX Pool Manipulation: Some applications use DEX pools (like Uniswap ETH/USDC) as price oracles
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β Real World β β Centralized β β DeFi Protocol β
β β β Oracle β β β
β ETH = $3,000 β β β β Betting/Lending β
β ββββββ€ Reports: $5,000 ββββββ€ Contract β
β Actual Price β β (Manipulated) β β β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β² β
βββββββββββββββββββ β βΌ
β DEX Pool β β βββββββββββββββββββ
β β β β Result β
β ETH/USDC Pool βββββββββββββββ β β
β Price manipulated β β Wrong payouts β
β by large trade β β User losses β
βββββββββββββββββββ β Protocol risk β
βββββββββββββββββββ
- Inject large amount of ETH into ETH/USDC pool
- Price temporarily changes due to pool imbalance
- Smart contract reads manipulated price at that exact moment
- Arbitrage bots eventually restore correct price, but damage is done
This manipulation is temporary due to arbitrage, but can be devastating if it occurs during a critical transaction.
The fundamental problem with oracle manipulation is relying on a single centralized entity. This contradicts the core principle of decentralized applications - we shouldn't depend on a central actor who holds all the power.
Chainlink solves the centralization problem through a network of independent nodes:
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract PriceConsumer {
AggregatorV3Interface internal priceFeed;
constructor() {
// ETH/USD price feed on Ethereum mainnet
priceFeed = AggregatorV3Interface(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419);
}
// Chainlink oracle to get the current eth/usd price conversion
function getEtherPrice() public view returns (uint256) {
(, int256 price,,,) = IAggregator(dataFeedAddress).latestRoundData();
price = price * 1e10;
return uint256(price);
}
}- Multiple Independent Nodes: Instead of one oracle, Chainlink uses a network of nodes
- Multiple Data Sources: Each node fetches price data from different external sources
- Consensus Mechanism: All nodes report their findings, and outliers are filtered out
- Median Calculation: Final price is calculated from consensus of honest nodes
- Economic Incentives: Nodes earn LINK tokens for accurate reporting
- Slashing Mechanism: Malicious or incorrect nodes are removed and lose their stake
- No Single Point of Failure: One corrupted node cannot manipulate the entire network
- Economic Security: Nodes have financial incentive to act honestly (earn LINK rewards)
- Reputation System: Bad actors are identified and removed from the network
- Diverse Data Sources: Multiple external sources make coordinated manipulation extremely difficult
Before understanding slippage, we need to understand how blockchain transactions work:
- User submits transaction β Goes to mempool (waiting pool)
- Validators select transactions from mempool based on gas fees (not FIFO)
- Higher gas = higher priority β Transaction included in next block
Slippage is the difference between the expected tokens to receive and the tokens actually received when performing a token swap.
Example Scenario:
- You want to swap 1 ETH for USDC on Uniswap
- Frontend shows: "You will receive 3,000 USDC"
- You click "Execute" β Transaction goes to mempool
- Before your transaction is included, someone else's transaction modifies the pool
- Result: You receive 2,998 USDC instead of 3,000 USDC
- Slippage: $2
- Attacker sees your transaction in mempool (1 ETH β USDC swap)
- Attacker pays higher gas to get included first
- Attacker's transaction manipulates ETH/USDC pool (adds massive USDC)
- Pool balance changes β ETH price drops to $500
- Your transaction executes β You get 500 USDC instead of 3,000 USDC
- Attacker profits from the price manipulation
A sandwich attack combines two front-running attacks:
- Front-run: Manipulate price DOWN before your transaction
- Your transaction: Executes at manipulated price
- Back-run: Restore price balance after your transaction
- Your transaction is "sandwiched" between two manipulated transactions
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
β Mempool β β Sandwich β β DEX Pool β
β β β Attacker β β ETH/USDC β
β User's TX: β β β β β
β 1 ETH β 3000$ βββββΊβ 1. Sees user TX β β Balance: Normal β
β β β 2. Front-run βββββΊβ Adds USDC β
βββββββββββββββββββ β (Higher gas) β β ETH = $500 β
β² β 3. User TX exec β βββββββββββββββββββ
β β (Bad price) β β
β β 4. Back-run β βΌ
βββββββββββββββββββ β (Restore) β βββββββββββββββββββ
β Result ββββββ€ β β Price Impact β
β β β 5. Extract MEV β β β
β User gets: 500$ β β β β 3000$ β 500$ β
β Expected: 3000$ β β β β β 3000$ (after) β
β Loss: $2500 β β β β β
βββββββββββββββββββ βββββββββββββββββββ βββββββββββββββββββ
The two main parameters to protect against slippage attacks:
function protectedSwap(
uint256 amountIn,
uint256 amountOutMin, // Must be calculated OFF-CHAIN
address[] memory path,
uint256 deadline
) external {
uint256 amountOut = uniswap.swapExactTokensForTokens(
amountIn,
amountOutMin, // Minimum tokens you're willing to accept
path,
msg.sender,
deadline
);
// If actual output < amountOutMin, transaction reverts
}Why OFF-CHAIN calculation?
- You CANNOT calculate
amountOutMininside the smart contract - Even calling Uniswap's
quote()function happens on-chain - Must be calculated in frontend before transaction submission
function swapWithDeadline(
uint256 amountIn,
uint256 amountOutMin,
address[] memory path,
uint256 deadline // Maximum time willing to wait
) external {
require(block.timestamp <= deadline, "Transaction expired");
uniswap.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
msg.sender,
deadline
);
}Why deadline matters?
- If attacker pays higher gas, your transaction waits longer in mempool
- Longer wait = more time for price manipulation
- Deadline protects by reverting if transaction takes too long
// Frontend calculates: if expecting 3000 USDC, set minimum to 2970 (1% slippage tolerance)
uint256 amountOutMin = expectedAmount * 99 / 100; // 1% tolerance
uint256 deadline = block.timestamp + 300; // 5 minutes max
uniswap.swapExactTokensForTokens(
1 ether, // amountIn
amountOutMin, // minimum acceptable output
path, // [WETH, USDC]
msg.sender, // recipient
deadline // time limit
);This project is for educational purposes only. Never deploy vulnerable contracts to mainnet.