Skip links
overflows

Overflows and Underflows

Introduction

Since version 0.8, Solidity automatically checks for overflows and underflows in math operations, returning an error and reverting transactions that try to perform one. Therefore, the problem described below is generally not considered an exploitable issue when working with modern Solidity pragmas, unless the operations resulting in overflows or underflows are enclosed by an unchecked block.

Nevertheless, Ethereum is an ecosystem where contracts with different pragma declarations can interact with each other. It could be the case for an older codebase to interact with more recent code in the same project. This is why it is important for an auditor to have in mind possible overflow scenarios.

Description

Overflows occur when a stored number exceeds the maximum value allowed by its type. This is particularly relevant in Solidity for ints or uints which have fixed storage sizes.

Let’s say we have a uint8, an unsigned integer with a fixed size of 8 bits. Taking into account that the largest number it can store is 255, the following snippet represents a typical overflow:

uint8 balance = 255;
balance++;
getBalance() // 0

Since the balance variable is declared as a uint8, adding 1 to its maximum possible number – represented in binary as eight consecutive ones- has the effect of “resetting” the bit array. Think of it as the odometer of a car: when it reaches the maximum number it can hold, something like 999.999 miles, adding one extra mile will be shown as 000.000. Similarly, when storing a uint8, if you add 1 to the number 255 (or eight ones in binary), it resets the stored number back to zero.

On the other hand, underflows occur when we try to store a smaller number than that which is allowed by its type.

In the context of integer arithmetic, the term underflow can be understood as the exact opposite of an overflow. When subtracting 1 from a uint variable set to 0, instead of storing a negative number the variable will end up holding the maximum possible value the type can store. In the case of a uint8, 255.

It is important to notice that, in the context of floating point arithmetic, the word underflow usually has a different meaning. In this case, an underflow takes place when we try to store a number with a higher degree of precision than our data type or computer memory allows. This occurs, for example, when the magnitude of a floating point number we are trying to store is smaller than the minimum allowed. For this article, we will use the integer arithmetic definition of underflow.

Let’s see what can happen within a contract if a state variable underflows. In the snippet below you will find a very simple token written in Solidity version 0.6. It has only two functionalities implemented: transfer and mint.

// SPDX-License-Identifier: MIT

pragma solidity 0.6.0;

contract SimpleToken {
    mapping(address => uint256) public balanceOf;
    uint256 public totalSupply;
    address public owner;

    constructor(uint256 _initialSupply) public {
        totalSupply = _initialSupply;
        balanceOf[msg.sender] = _initialSupply;
        owner = msg.sender;
    }

    modifier onlyOwner(){
        require(msg.sender == owner);
        _;
    }

    function transfer(address _to, uint256 _amount) public {
        require(balanceOf[msg.sender] - _amount >= 0, "Not enough tokens");
        balanceOf[msg.sender] = balanceOf[msg.sender] - _amount;
        balanceOf[_to] = balanceOf[_to] + _amount;
    }

    function mint(uint256 amount) external onlyOwner {
        totalSupply = totalSupply + amount;
        balanceOf[owner] = balanceOf[owner] + amount;

    }

}

After taking your time to read the code above, test it using Remix IDE. First, save and compile this contract. Then, deploy this SimpleToken with – for example – 10000 initial tokens.

As you can see below, the deploying address -the owner of the contract- has all the tokens. If we check how many tokens another address has it should be zero.

However, if we try to transfer 1 token from this address with zero balance to another address, say the owner, we see that the transfer takes place without errors. To make matters worse, if we take a closer look at the balance of this address again, we see that it has reached the maximum number allowed for a uint256!

To understand how this exploit took place, take a closer look at the require statement of the transfer function above. It is required that the subtraction of the balance of the sender and the amount to be sent to be greater than or equal to zero, but this is always the case since subtraction of uints can never yield a negative number. Therefore, this require statement is never violated, even if the subtraction results in an underflow. When an account without tokens tries to transfer some, the amount is effectively subtracted from the balance in the following line, producing an underflow and an incorrect number being saved to the balance.

 require(balanceOf[msg.sender] - _amount >= 0, "Not enough tokens"); // Underflow occurs but result is a uint >= 0.
 balanceOf[msg.sender] = balanceOf[msg.sender] - _amount; // Underflow occurs and incorrect balance is saved.

Remediation

In order to fix this vulnerability while using a Solidity version prior to 0.8, we could import the SafeMath library from OpenZeppelin and replace the subtractions marked above with the method SafeMath.sub. In fact, replacing only the subtraction within the require statement would be sufficient, since subtracting an amount greater than the sender’s balance with SafeMath.sub would return an error, violating the condition and preventing the following underflow.

A more direct solution is to replace this subtraction in the require statement with a simple comparison of the two variables, by moving the _amount variable to the other side of the greater than or equal sign.

Ok, make these changes in the code, compile it again and test if this vulnerability has been fixed.

// SPDX-License-Identifier: MIT

pragma solidity 0.6.0;

    contract SimpleToken {
    mapping(address => uint256) public balanceOf;
    uint256 public totalSupply;
    address public owner;

    constructor(uint256 _initialSupply) public {
        totalSupply = _initialSupply;
        balanceOf[msg.sender] = _initialSupply;
        owner = msg.sender;
    }

    modifier onlyOwner(){
        require(msg.sender == owner);
        _;
    }

    function transfer(address _to, uint256 _amount) public {
        require(balanceOf[msg.sender] >= _amount, "Not enough tokens"); // Vulnerability solved by moving _amount to the right hand side.
        balanceOf[msg.sender] = balanceOf[msg.sender] - _amount; // The previous require statement ensures no underflow can occur here.
        balanceOf[_to] = balanceOf[_to] + _amount;
    }

    function mint(uint256 amount) external onlyOwner {
        totalSupply = totalSupply + amount;
        balanceOf[owner] = balanceOf[owner] + amount;
    }

}

If you performed the changes in the code above, you will notice that transactions from senders with zero balance are now reverted, preventing the underflow exploit that we had detected.

Further Reading