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
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.
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 poolThe 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
- 2
- 3
- 4
- 5
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:
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.