Command Palette

Search for a command to run...

Arithmetic — Integer Overflow

A staking contract computes rewards inside an unchecked block. A crafted multiplier wraps the integer past its maximum, producing a wrong bonus value. The Primer covers fixed-width integers, overflow mechanics, and the three defenses auditors check.

~7 min read

Available

Solidity integers are fixed-width: uint256 holds values from 0 to 2^256 - 1. In Solidity 0.8+ arithmetic reverts on overflow by default. But inside an unchecked{} block — or in any pre-0.8 code — addition, subtraction, and multiplication wrap silently. That silent wrap is the vulnerability.

1. Fixed-width integers and wrapping

A uint256 is 32 bytes: it can represent integers from 0 to 115792089237316195423570985008687907853269984665640564039457584007913129639935. Adding 1 to that maximum wraps to 0. Subtracting 1 from 0 wraps to the maximum. This is two's-complement wrapping — not an error in arithmetic, but expected hardware behaviour for unsigned integers.

Solidity 0.8.0 added checked arithmetic by default: any overflow or underflow causes a panic revert. This made many older bugs impossible. But the unchecked{} keyword re-enables wrapping inside its block. Developers use unchecked{} for gas savings (skipping the bounds check) in tight loops or when they have already proven bounds by other means.

The danger arises when unchecked{} is applied to expressions involving user-supplied inputs that have not been independently bounded. An attacker who controls a multiplier can pick a value that causes the product to wrap to a tiny number — even though the individual inputs look non-zero and 'normal'.

2. The three families of arithmetic bugs

Overflow: two values multiplied or added exceed type(uint256).max and wrap to a small result. Example: staked = 1, multiplier = 2^256. Product wraps to 0. Attacker claims zero reward, or worse, an incorrectly computed large reward in a subsequent calculation that divides or adds the small wrapped value.

Underflow: a subtraction goes below zero. Example: a balance of 0 minus 1 wraps to type(uint256).max. Now the attacker has a balance of 2^256 - 1 tokens. This was the class of bug that caused multiple token drain exploits before 0.8.

Precision / rounding loss: integer division truncates. (a * b) / c is not the same as a * (b / c) when the result is non-integer. An attacker exploits the ordering to extract more than their share in a division-based reward calculation.

3. The attack — crafted multiplier

The VulnerableStaking contract computes bonus inside unchecked{}: bonus = staked * multiplier. The attacker supplies a multiplier such that staked * multiplier wraps:

// staked = 1e18 (1 token)
// target bonus after wrap = large number that passes require(reward > 0)
// type(uint256).max / 1e18 ≈ 1.157e59
// multiplier = type(uint256).max / staked + 2 causes wrap to a small non-zero value
// or multiplier chosen so bonus / 1e18 = huge number, draining reward pool

The key insight: inside unchecked{}, multiplication does modular arithmetic mod 2^256. The attacker picks multiplier = (type(uint256).max / staked) + 2, which makes staked * multiplier = 1 (modular wrap). Then reward = 1 / 1e18 = 0 — which hits the require and reverts. A more skilled attacker picks a value that wraps to a non-trivial number that passes the check and claims a reward far exceeding their stake.

Attack timeline

  1. 1

    Reconnaissance

    Attacker reads the staking contract source. Sees claimReward(multiplier) uses unchecked{} for bonus = staked * multiplier. Notes that multiplier is fully user-controlled and unbounded.

  2. 2

    Calculation

    Attacker computes a multiplier value such that staked * multiplier mod 2^256 = a large number that passes require(reward > 0) and extracts more tokens than staked.

  3. 3

    Stake

    Attacker stakes a small amount (even 1 wei) to satisfy require(staked > 0). Now their address is registered.

  4. 4

    Claim with crafted multiplier

    Attacker calls claimReward(craftedMultiplier). The unchecked multiplication wraps. reward = wrappedBonus / 1e18 evaluates to a large value. Contract transfers a disproportionate reward.

  5. 5

    Drain

    Attacker repeats until the reward token balance is exhausted. Each call consumes a small gas cost but extracts far more than deposited.

4. The fix — checked arithmetic and input bounds

Remove the unchecked block from any arithmetic that involves user-controlled inputs. In Solidity 0.8+, removing unchecked is sufficient — the runtime will revert on overflow:

// FIXED: drop the unchecked block
function claimReward(uint256 multiplier) external {
    uint256 staked = stakedBalance[msg.sender];
    require(staked > 0, "nothing staked");
    // Solidity 0.8+ checked arithmetic: reverts on overflow
    uint256 bonus = staked * multiplier;
    uint256 reward = bonus / 1e18;
    require(reward > 0, "reward too small");
    rewardToken.transfer(msg.sender, reward);
}

For older codebases (pre-0.8), add OpenZeppelin SafeMath: replace `a * b` with `a.mul(b)`. SafeMath throws on overflow. As of Solidity 0.8, SafeMath is redundant outside unchecked blocks.

Add an explicit upper bound on multiplier: require(multiplier <= MAX_MULTIPLIER). This prevents even crafted inputs from reaching the arithmetic operation with dangerous values, and makes the intent of the function legible to the next auditor.

5. What to look for as an auditor

  • Search every unchecked{} block. For each arithmetic operation inside it, trace whether any operand comes from user input, external calls, or unvalidated storage. If yes, flag it.
  • Multiplication is higher risk than addition for overflow — the product grows quadratically. A * B where both A and B are large and unvalidated is almost always a finding.
  • Underflow in token balance tracking: look for patterns like `balance -= amount` without a prior require. In 0.8+ this reverts, but in 0.7 or inside unchecked it wraps to max uint.
  • Fixed-point math: when a value represents a scaled integer (e.g., 1e18 = 1 token), verify that the division is done LAST, not first. `(a / 1e18) * b` loses precision compared to `(a * b) / 1e18`.
  • Check the Solidity version pragma. `pragma solidity ^0.7` or `pragma solidity 0.6.x` means no built-in overflow protection — treat all arithmetic as suspect.
  • In loops with accumulation, check whether the accumulator can overflow if the loop runs to its maximum iteration count with worst-case inputs.

With the wrapping model in mind, open the Hunt tab. Identify the unchecked block, trace the user-controlled input, then verify the consequence and fix.

← Back to skill tree