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
uncheckedblocks turn the checks off for gas, and casting to a smaller type still truncates silently- The habit
- Know your compiler version, treat every
uncheckedas 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:
// 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.
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.
// 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.
// 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.
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
- Solidity documentation, Solidity 0.8.0 Breaking Changes. Where checked arithmetic became the default.
- Solidity documentation, Checked or Unchecked Arithmetic. What
uncheckedturns off, and when it is safe. - OpenZeppelin, SafeCast. Downcasts that revert instead of truncating.
- Ethereum community, Smart contract security. The wider context for arithmetic safety.