Docs / Application security

Field guide · Smart contract security

Smart contract security

Integer overflow, underflow, and unchecked math

By Abhimanyu Gupta, Founder & Principal Operator

On this page

Fixed-width integers wrap like an odometer: subtract one from zero and you do not get minus one, you get an enormous number. For years that was the second-most-exploited bug in smart contracts.

Read with a purpose. On Solidity 0.8+ the compiler reverts on overflow by default, so the classic exploit is closed; what is left is every unchecked block and every downcast, which is where you look now.

At a glance

The bug
A number wraps around: subtract 1 from 0 and you get the largest possible value, add past the maximum and you land back near zero
Why it mattered
Before Solidity 0.8, arithmetic wrapped silently, so an underflowed balance became astronomically large and the holder could withdraw everything
The good news
Since 0.8, the compiler checks every add, subtract and multiply and reverts on overflow. The class of bug is mostly closed by default
The catch
unchecked blocks turn the checks off for gas, and casting to a smaller type still truncates silently
The habit
Know your compiler version, treat every unchecked as a place you have promised the maths cannot overflow, and be careful with downcasts

What "overflow" means when there are no negative numbers

Solidity's most-used integer type, uint256, holds whole numbers from 0 up to 2^256 - 1. There is no room below zero and no room above the maximum, so the arithmetic wraps like an odometer. Subtract 1 from 0 and you do not get -1; you get the largest number the type can hold. Add 1 to the maximum and you get 0. That wraparound is called underflow at the bottom and overflow at the top, and for years it was the second-most-exploited bug in smart contracts after reentrancy.

The classic underflow

Picture a token written for an old compiler. The transfer function subtracts before it checks, or checks the wrong thing:

Vulnerable.sol · Solidity 0.7 and earliersolidity
// pragma solidity ^0.7.0;
function transfer(address to, uint256 amount) external {
    // No check that the sender actually has 'amount'.
    balanceOf[msg.sender] -= amount;   // underflows if amount > balance
    balanceOf[to] += amount;
}

If msg.sender has a balance of 10 and transfers 11, the subtraction underflows. Their balance does not go to -1; it wraps to a number with seventy-plus digits. They now appear to own more of the token than will ever exist, and can transfer it out until the accounting is meaningless. The whole exploit is one unchecked subtraction.

How it was fixed before the compiler did it

For years the answer was SafeMath, a library that wrapped every operation in a check and reverted on overflow. You wrote a.sub(b) instead of a - b, and the library reverted if b > a. It worked, it was everywhere, and it made every arithmetic operation a function call.

The old waysolidity
using SafeMath for uint256;
balanceOf[msg.sender] = balanceOf[msg.sender].sub(amount);  // reverts on underflow

Solidity 0.8: checks by default

Since version 0.8, the compiler inserts those checks itself. Ordinary +, - and * revert on overflow and underflow with no library and no ceremony. The transfer above, recompiled under 0.8, reverts instead of wrapping. This is the single most important thing to know about this whole class of bug: on a modern compiler it is closed by default, and the first question about any overflow claim is which compiler version the code was built with.

Safe.sol · Solidity 0.8+solidity
// pragma solidity ^0.8.20;
function transfer(address to, uint256 amount) external {
    balanceOf[msg.sender] -= amount;   // reverts automatically if amount > balance
    balanceOf[to] += amount;
}

Where the danger moved: unchecked and downcasts

The checks cost a little gas, so Solidity lets you switch them off inside an unchecked block. This is legitimate and common in loops and in maths the author has proven cannot overflow. It is also where the modern version of this bug lives: an unchecked block is a promise, and a wrong promise wraps silently again.

A promise you must keepsolidity
// Fine: i cannot exceed the array length, so ++ cannot overflow.
for (uint256 i = 0; i < items.length; ) {
    // ...
    unchecked { ++i; }
}

// Dangerous: nothing here guarantees the subtraction is safe.
unchecked { balance = balance - amount; }   // wraps if amount > balance

The other trap the compiler does not catch is casting to a smaller type. Converting a uint256 to a uint8 keeps only the low 8 bits, silently, with no revert. A value of 256 becomes 0.

Silent truncationsolidity
uint256 big = 256;
uint8 small = uint8(big);   // small is 0, no error

Read the version pragma first. When you review a contract, the compiler version at the top of the file tells you which world you are in. ^0.7.0 means every arithmetic line is a potential overflow and you look for SafeMath. ^0.8.0 or later means the arithmetic is guarded, so you look instead at every unchecked block and every downcast, because those are the only places the guard is off.

The short version. Fixed-width integers wrap: zero minus one is not negative, it is enormous. On Solidity 0.8 and later the compiler reverts on that wraparound by default, which closes the classic exploit. What is left is the places you opted out: unchecked blocks, where you have promised the maths is safe, and casts to smaller types, which truncate without complaint. Know your compiler version, and treat every unchecked as a claim you have to justify.

References & further reading

  1. Solidity documentation, Solidity 0.8.0 Breaking Changes. Where checked arithmetic became the default.
  2. Solidity documentation, Checked or Unchecked Arithmetic. What unchecked turns off, and when it is safe.
  3. OpenZeppelin, SafeCast. Downcasts that revert instead of truncating.
  4. Ethereum community, Smart contract security. The wider context for arithmetic safety.
All guides Web app penetration testing

Want this tested on you?

Reading about it is one thing. Seeing it proven on your own systems is another.