Skip to content

Integer Overflow Before Solidity 0.8

Reentrancy and The DAO was a bug of control flow: a contract handed control to an attacker mid-transaction and paid the price. This page is about a quieter enemy — a bug of arithmetic. Nothing re-enters, nobody calls back. A contract does one subtraction, the result is mathematically wrong, and an attacker walks away with a balance no one ever funded.

The uncomfortable truth is that on the EVM, a - b does not mean what a mathematician means by a - b. It means “a - b, modulo 2²⁵⁶.” Before Solidity 0.8, that difference was silent: no error, no revert, no warning — just a number that had wrapped around the edge of its type. This page explains why the machine works that way (it has to), how that gap turned into real token-minting exploits in 2018, and the two-stage fix — a library, then the compiler itself — that finally closed it. It connects directly to the book’s throughline: untrusting strangers can only agree on a shared state if every node computes the same arithmetic. Fixed-width modular math is what makes that agreement possible — and what made these bugs possible.

The EVM has exactly one integer type under the hood: a 256-bit word. Every uint256, address, and hash is that same 32-byte slot. Solidity’s smaller types (uint8, uint128) are conveniences the compiler masks down to fit; the machine itself only knows 256-bit words.

A 256-bit unsigned integer can hold values from 0 to 2²⁵⁶ − 1. That is a colossal range — about 1.16 × 10⁷⁷, more than the number of atoms in the observable universe — but it is still finite. And finite means there are edges. What happens at the edges is the whole story:

the uint256 number line is a CIRCLE, not a line:
0
┌─────●─────┐
2²⁵⁶-1 ● ● 1
│ wraps │
│ around │
└─────●─────┘
2²⁵⁵ (halfway)
0 - 1 → 2²⁵⁶ - 1 (underflow: fall off 0, land at the top)
(2²⁵⁶-1)+1 → 0 (overflow: fall off the top, land at 0)
2²⁵⁵ * 2 → 0 (multiply past the top, wrap to 0)

The arithmetic is modular: results are computed modulo 2²⁵⁶. This is not a quirk of Ethereum — it is how fixed-width integer hardware has always worked, and the EVM inherits it deliberately. You saw the exact same behaviour in this book’s mini-EVM, where ADD uses Rust’s wrapping_add:

// from ethmini/src/vm.rs — our machine wraps mod 2^64; real EVM wraps mod 2^256
Op::Add => {
let b = pop(&mut stack, "ADD")?;
let a = pop(&mut stack, "ADD")?;
push(&mut stack, a.wrapping_add(b))?; // u64::MAX + 1 → 0
pc = next;
}

The mini-EVM’s own test spells out the rule: u64::MAX + 1 wraps to 0, “just as EVM wraps mod 2²⁵⁶.” The width differs (64 bits versus 256); the behaviour is identical. And crucially: the machine does not consider wrapping to be an error. It is a perfectly valid result of a valid opcode. There is nothing for it to revert.

You might ask: why not just make ADD throw when it overflows? Because determinism is non-negotiable. Recall the throughline — thousands of nodes must replay every transaction and land on the bit-identical state root, or consensus fails. The simplest way to guarantee that is to give arithmetic one totally unambiguous definition with no special cases: every operation produces a 256-bit result, always, by taking the true result modulo 2²⁵⁶. No “sometimes it errors” branch to disagree about. The wrapping is not sloppiness; it is the price of every machine in the world agreeing on what a + b is.

The consequence is that “should this overflow be an error?” is a question about your application’s intent, not the machine’s. And a machine cannot know your intent. Before Solidity 0.8, that meant you had to say so — and if you forgot, the silence was total.

The exploit shape: subtract into a fortune

Section titled “The exploit shape: subtract into a fortune”

Here is the canonical vulnerable pattern. A token contract lets holders transfer balances. The naive transfer looks reasonable:

// VULNERABLE — Solidity < 0.8, no SafeMath
mapping(address => uint256) public balanceOf;
function transfer(address to, uint256 amount) public {
// BUG: no check that balanceOf[msg.sender] >= amount
balanceOf[msg.sender] -= amount; // underflows if amount > balance
balanceOf[to] += amount;
}

Walk it as an attacker with a balance of 0:

balanceOf[attacker] = 0
attacker calls transfer(victim, 1)
balanceOf[attacker] = 0 - 1
= 2²⁵⁶ - 1 ← underflow! now astronomically rich
balanceOf[victim] = 0 + 1 = 1
result: attacker holds ~1.16 × 10⁷⁷ tokens, funded from nothing.

One missing require(balanceOf[msg.sender] >= amount) and the subtraction that was meant to decrease the attacker’s balance instead maximised it. No exception was thrown; the EVM did exactly what it was told.

Multiplication is the subtler cousin. A batch-transfer function computes a total by multiplying a per-recipient amount by the number of recipients:

// VULNERABLE — the batchOverflow shape
function batchTransfer(address[] memory receivers, uint256 amount) public {
uint256 total = receivers.length * amount; // OVERFLOWS for crafted amount
require(balanceOf[msg.sender] >= total); // passes: `total` wrapped to a tiny number
balanceOf[msg.sender] -= total; // deducts almost nothing
for (uint i = 0; i < receivers.length; i++) {
balanceOf[receivers[i]] += amount; // credits the huge `amount` in full
}
}

The attacker picks an amount near 2²⁵⁵ and passes 2 receivers. Then total = 2 × 2²⁵⁵ = 2²⁵⁶, which wraps to 0. The require sees total == 0 and happily passes. The sender is debited 0. But each receiver is credited the un-wrapped amount — around 2²⁵⁵ tokens each, conjured from nothing. The overflow happened in the check, so the check protected nothing.

The SafeMath era: wrap every operator in a check

Section titled “The SafeMath era: wrap every operator in a check”

The 2018 incidents made one practice non-negotiable: never use a bare +, -, or * on untrusted quantities. The community’s answer, popularised by OpenZeppelin, was a library called SafeMath — a set of functions that do the arithmetic and verify the result makes sense, reverting if it doesn’t:

// SafeMath — the pre-0.8 defense (simplified from OpenZeppelin)
library SafeMath {
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
require(c >= a, "SafeMath: addition overflow"); // sum can't be smaller than an addend
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
require(b <= a, "SafeMath: subtraction underflow");
return a - b;
}
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) return 0;
uint256 c = a * b;
require(c / a == b, "SafeMath: multiplication overflow"); // divide back out to verify
return c;
}
}

The trick in each function is to detect the wrap after it happens by exploiting a property that only holds when there was no wrap:

  • Addition: a true sum is never smaller than either input. If c < a, it wrapped. Revert.
  • Subtraction: guard the underflow before it happens — require b <= a.
  • Multiplication: if a * b didn’t overflow, then (a * b) / a == b exactly. If the wrapped product fails that identity, it overflowed. Revert.

Contracts then wrote every operation through the library, usually via using SafeMath for uint256; so the calls read almost like operators:

using SafeMath for uint256;
function transfer(address to, uint256 amount) public {
balanceOf[msg.sender] = balanceOf[msg.sender].sub(amount); // reverts if amount > balance
balanceOf[to] = balanceOf[to].add(amount); // reverts on overflow
}

SafeMath worked. For roughly three years it was the discipline of safe Solidity, and any audit would flag a raw arithmetic operator on a balance. But it had two real costs. First, it was opt-in: safety depended on every developer remembering to route every operation through it, and a single forgotten - reopened the hole. Second, it was verbose and gas-hungry — every operation now carried an extra function call and a comparison, turning a + b into a small subroutine.

The language-level fix: checked-by-default in Solidity 0.8

Section titled “The language-level fix: checked-by-default in Solidity 0.8”

The deeper fix was to stop treating overflow safety as a library the developer must remember, and make it the compiler’s default. Released in December 2020, Solidity 0.8.0 flipped the polarity: from 0.8 onward, the compiler injects overflow and underflow checks into every +, -, and * automatically, and a wrapping operation reverts with a Panic(0x11) error instead of silently wrapping.

// Solidity >= 0.8 — checked by default, no library needed
function transfer(address to, uint256 amount) public {
balanceOf[msg.sender] -= amount; // AUTO-CHECKED: reverts if amount > balance
balanceOf[to] += amount; // AUTO-CHECKED: reverts on overflow
}

This exact code — the vulnerable pattern from earlier in the page — is now safe, because the compiler emits the check the developer forgot. The default became the safe default. SafeMath, as a library, quietly became unnecessary for the common case; OpenZeppelin now marks it as obsolete for Solidity 0.8+.

Under the hood — what the compiler emits

Section titled “Under the hood — what the compiler emits”

The compiler doesn’t add a new opcode. It emits ordinary EVM instructions around each operation that recompute the SafeMath-style guard and jump to a revert if it fails. Conceptually, an addition becomes:

a + b compiled (>= 0.8) to roughly:
c = a + b ; the raw, wrapping ADD
if c < a: revert with Panic(0x11) ; overflow detected → abort
use c
a `Panic(0x11)` is the standardised "arithmetic over/underflow" error selector,
distinct from a plain require() revert, so tools can tell them apart.

Because the check is in the emitted bytecode, it is unforgeable at the source level: you cannot “forget” it the way you could forget to call SafeMath.sub. The safety moved from the developer’s memory into the compiler’s output.

The opt-out: unchecked and why hot paths still use it

Section titled “The opt-out: unchecked and why hot paths still use it”

Checks cost gas — a handful of extra instructions on every arithmetic operation, executed on every call, on every node, forever. For most code that cost is invisible and worth paying. But in hot paths — a loop counter incremented millions of times, a math routine where the developer has proven overflow is impossible — that overhead adds up. So 0.8 pairs the safe default with an explicit escape hatch, the unchecked block:

function sumTo(uint256 n) public pure returns (uint256 total) {
for (uint256 i = 0; i < n; ) {
total += i; // checked: this genuinely could overflow, keep the guard
unchecked { ++i; } // opt out: i < n already bounds i, so ++i cannot overflow
}
}

Inside unchecked { ... }, arithmetic goes back to the old silent-wrapping behaviour — no checks, no reverts, cheaper gas. This is the crucial trade-off, and it is a deliberate one: the language makes the safe path the default and the dangerous path loud and explicit. You can only reintroduce wrapping by typing the word unchecked, which is exactly the flag an auditor greps for. The 2018 bugs were dangerous because the risk was invisible; unchecked keeps the escape hatch but makes the risk visible.

  • Why does it exist? Fixed-width modular arithmetic exists because the EVM must give every node one unambiguous, deterministic definition of a + b — a 256-bit result computed modulo 2²⁵⁶, with no error branch to disagree about. Wrapping is the by-product of that determinism.
  • What problem does it solve? It lets untrusting strangers replay the same transactions and reach the bit-identical state root, because there is exactly one possible result for every arithmetic op. That determinism is the foundation the whole shared world computer stands on.
  • What are the trade-offs? Safety (checked math that reverts) versus gas (a few extra instructions per operation). Solidity 0.8 chose safety by default and made the gas-saving unchecked opt-out explicit and visible, so the dangerous choice is never the accidental one.
  • When should I avoid it? Avoid unchecked unless you can prove the operation cannot over/underflow — a loop counter already bounded below its limit, for instance. Reaching for it to shave gas without a proof re-opens the exact hole that minted an astronomical pile of BEC tokens.
  • What breaks if I remove it? Remove the deterministic modular definition and nodes could compute different results for the same transaction — consensus itself breaks. Remove the 0.8 checks (via blanket unchecked) and you are back in 2018: a forgotten guard silently mints balances from nothing.
  1. Explain in one sentence why 0 - 1 on a uint256 produces 2²⁵⁶ − 1 rather than an error, and why the EVM is designed to behave this way rather than throwing.
  2. In the vulnerable transfer, an attacker with a balance of 0 calls transfer(victim, 1). Trace the two balance updates and state the resulting balance of the attacker.
  3. In the batchTransfer overflow, the require(balanceOf[msg.sender] >= total) check passes even though the attacker is broke. What value does total take, and why does the check fail to protect anything?
  4. SafeMath’s mul checks overflow with require(c / a == b). Why does that identity hold exactly when — and only when — the multiplication did not wrap?
  5. Solidity 0.8 makes arithmetic checked by default but adds unchecked blocks. State the trade-off this pair encodes, and give one situation where using unchecked is legitimately correct.
Show answers
  1. uint256 is a fixed-width, 256-bit type, so arithmetic is done modulo 2²⁵⁶; 0 − 1 wraps around the bottom edge to the largest representable value, 2²⁵⁶ − 1. The EVM defines it this way so every node computes one unambiguous result — determinism — with no “sometimes it errors” branch that nodes could disagree on and break consensus.
  2. balanceOf[attacker] = 0 - 1, which underflows to 2²⁵⁶ − 1 (an astronomically large number); balanceOf[victim] = 0 + 1 = 1. The attacker ends up holding roughly 1.16 × 10⁷⁷ tokens, funded from nothing, because there was no require(balance >= amount) guard.
  3. The attacker picks amount ≈ 2²⁵⁵ and 2 receivers, so total = 2 × 2²⁵⁵ = 2²⁵⁶, which wraps to 0. The check becomes balance >= 0, which is trivially true, so the sender is debited 0 — yet each receiver is still credited the full un-wrapped amount. The overflow happened inside the value the check reads, so the check protects nothing.
  4. If a * b does not overflow, the stored product c is the true mathematical product, so c / a recovers b exactly. If it did overflow, c is the wrapped (smaller) value, and c / a will not equal b — the identity is broken, and SafeMath reverts. (The a == 0 case is handled separately to avoid dividing by zero.)
  5. The trade-off is safety versus gas: checked math reverts on overflow but costs a few extra instructions per operation; unchecked restores silent wrapping to save that gas, but makes the risk explicit and greppable. It is legitimately correct when overflow is provably impossible — e.g. incrementing a loop counter i that is already bounded by i < n < 2²⁵⁶.