Ethernaut Walkthrough — Level 15: Naught Coin

Published on Jan 12, 2022

This level requires us to transfer funds out of the contract to another address and we know that our own address has the full INITIAL_SUPPLY. 

When looking at the code I initially has two different thoughts of how to approach this. First of all, I took a look at the modifier named lockTokens

function transfer(address _to, uint256 _value) override public lockTokens returns(bool) {
    super.transfer(_to, _value);
  }

  // Prevent the initial owner from transferring tokens until the timelock has passed
  modifier lockTokens() {
    if (msg.sender == player) {
      require(now > timeLock);
      _;
    } else {
     _;
    }
} 

First I just thought about using another account (or contract) to call the transfer() method, but that would have been way too easy. Of course this wouldn't help because only the initial owner or player owns the full supply of tokens, so calling a transfer from another address wouldn't be able to touch the funds of the original player/owner. 

The second idea I had made more sense. I've created a couple of test ERC20 coins just to learn more about the standard and the OpenZeppelin contracts often used to create these tokens. When creating these tokens you typically inherit a contract from openzeppelin.

The first line of code shows us that the NaughtCoin is indeed an ERC20 token, based on the ERC20.sol contract derived from openzeppelin. That means there's probably methods that have been inherited that we might be able to call. 

import '@openzeppelin/contracts/token/ERC20/ERC20.sol';

We can take a look at the ERC20.sol from OpenZeppelin and explore some of the methods that every ERC20 token must have. You will notice that there are methods like balanceOf() that must return the currect balance of a given address. We can even try out the method in the Ethernaut console to prove that even when the level's code doesn't show the method, it has been inherited from the contract and can be used like so:

player; // shows your player address 0x2E5090E6f147B84Aa6bBe6483acD3AfBedECCdDB
balance = await contract.balanceOf(player);
balance.toString(); // '1000000000000000000000000'

Take some time to go over the standard here and here and get a rough idea of what methods we could call in order to transfer the funds of the account. The easiest way would be to call the transfer() method, but that has been secured with the lockTokens modifier so we'll need to dig a little deeper.

The hack

Let's try to use the transferFrom() method to transfer funds from one account to another.

This method needs approval first and it looks like we can just approve the transfer of the full amount because this level does not implement any of the methods except for transfer(), meaning we'll be calling the original and unsecured (no modifier is used) methods from the openzeppelin contract.

First, let's see what the allowance is we get to spend right now. This of course should be 0.

(await contract.allowance(player, player)).toString();
// '0'

This was confusing at first to me and reading this article helped. Think of who should be allowed to move whose money. In the case, we (the player) should be allowed to move our own money (again, the player address) to whoever we want. So we need to tell the contract that we should be allowed to spend our own money. 

await contract.approve(player, "1000000000000000000000000");

This essentially tells the contract that the player is allowed to spend an amount of tokens on our behalf. If this is confusing to you, it was to me as well. But think about it in the context of having co-founders. You could approve them to spend a portion of the tokens in your account. In this example, we're using the same mehod to allow ourselves to move our tokens to another addess without using the time-locked transfer() method.

Check the allowance again if you want:

(await contract.allowance(player, player)).toString();
// '1000000000000000000000000'

Now we see that we (player) are allowed to transfer tokens on behalf of player. Let's transfer the tokens to another address.

contract.transferFrom(player, '0x9Ca132EEC5d8b7eeA5fC1DBE7651D5d64cBA65F1', "1000000000000000000000000");

This will evade the transfer() function with the time lock and we'll have moved our funds to another account that can now cash out.

(await contract.balanceOf(player)).toString();
// '0'

Security lessons learned

This level teaches us to always think hard about what methods you are inheriting from other contract that might need additional security. I'll definitely think twice now when overwriting openzeppelin contracts.

If you want to see an alternative solution, here's a video where the developer is using other methods from the same contract to get to the same goal.

No comments? But that’s like a Gin & Tonic without the ice?

I’ve removed the comments but you can shoot me a message on Twitter @GoodBytes to keep the conversation going.