Integer Overflow and Underflow: The Vulnerability Solidity 0.8 Fixed (and Didn’t)
Solidity 0.8.0 shipped built-in arithmetic overflow checks in December 2020. Before that, integer arithmetic wrapped silently — uint8(255) + 1 == 0 with no error, no event, no revert. BatchOverflow, the April 2018 exploit that generated tokens from nothing, was possible because of this silent wrapping.
After 0.8, the same operation reverts. Problem solved, mostly.
The “mostly” is where things get interesting. Three categories of integer vulnerability survived the 0.8 transition, and they all require analysis that a simple compiler version check cannot provide.
What BatchOverflow Was
Beauty Chain (BEC) had a batch transfer function:
function batchTransfer(address[] _receivers, uint256 _value) public returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; // overflow here
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
}
return true;
}
The attacker passed two receiver addresses and _value = 2^255. The multiplication 2 * 2^255 = 2^256 overflows to zero. The balance check require(balances[msg.sender] >= 0) passes trivially. Each receiver gets 2^255 tokens. The attacker’s balance is unchanged.
On Solidity 0.8, this transaction would revert at the multiplication. The vulnerability is genuinely fixed for contracts compiled with the new version. But the fix created a performance problem: every arithmetic operation now has an overhead cost for the bounds check. That led to the escape hatch.
The unchecked Escape Hatch
Solidity 0.8 introduced unchecked blocks for operations where the developer can prove safety, eliminating the gas overhead:
// Common loop optimization — safe because i < array.length check prevents overflow
for (uint256 i = 0; i < array.length;) {
// ... loop body ...
unchecked { ++i; }
}
This is legitimate usage. The loop increment cannot overflow because the loop condition ensures i < array.length, and array.length is bounded by available memory. The unchecked is safe by construction.
But unchecked blocks also appear in less obviously safe contexts:
function calculateReward(uint256 balance, uint256 multiplier) external pure returns (uint256) {
unchecked {
return balance * multiplier;
}
}
If balance and multiplier are user-controlled, this function can return an arbitrarily small number despite large inputs. Whether that creates a vulnerability depends on how the return value is used — but the arithmetic can silently produce wrong results, which is exactly what 0.8 was supposed to prevent.
The detector identifies unchecked blocks by looking for the MUL, ADD, SUB, and EXP opcodes in sections of bytecode that lack the surrounding check sequence inserted by the compiler. In checked arithmetic, Solidity emits a comparison and a conditional revert after overflow-sensitive operations. In unchecked blocks, those sequences are absent. The presence of user-controlled data flowing into arithmetic operations without surrounding checks is flagged:
Finding: Unchecked Arithmetic with User Input
Severity: MEDIUM
Confidence: 0.78
Location: calculateReward(uint256,uint256) — offset 0x2A4
Pattern: MUL opcode without overflow check sequence
Input source: CALLDATALOAD (user-controlled)
Context: unchecked block — compiler overflow protection disabled
Risk: Silent arithmetic truncation on large inputs
Assembly Arithmetic
Inline assembly bypasses all of Solidity’s safety mechanisms. ADD, MUL, SUB, and DIV in assembly correspond directly to EVM opcodes with no implicit checks:
function unsafeAdd(uint256 a, uint256 b) public pure returns (uint256 result) {
assembly {
result := add(a, b) // No overflow protection
}
}
Assembly arithmetic has legitimate uses — Uniswap V3’s mulDiv function uses it to perform 512-bit multiplication for full-precision intermediate results. But the same pattern in user-facing financial logic can produce exploitable results.
The detection approach is the same as for unchecked: look for arithmetic opcodes with user-controlled operands and no preceding bounds validation. Assembly blocks are identified by the absence of compiler-generated preamble code around opcode sequences.
The Pre-Multiplication Division Problem
There is a third category that affects both pre-0.8 and post-0.8 contracts: precision loss and overflow in compound expressions.
Consider share calculation in a vault contract:
// Potential overflow at the multiplication step
function getShares(uint256 depositAmount, uint256 totalAssets, uint256 totalShares)
external pure returns (uint256)
{
return (depositAmount * totalShares) / totalAssets;
}
On Solidity 0.8, depositAmount * totalShares will revert if the intermediate result exceeds 2^256 - 1. But totalShares in a large vault could be on the order of 10^24 (1 million tokens with 18 decimals deposited across many users), and depositAmount could also be 10^24. Their product is 10^48, which exceeds 2^256 and causes a revert.
The solution is Uniswap’s mulDiv, which computes (a * b) / c using 512-bit intermediate arithmetic to avoid overflow:
// From Uniswap V3 — full precision without overflow
function mulDiv(uint256 a, uint256 b, uint256 denominator) internal pure returns (uint256) {
uint256 prod0;
uint256 prod1;
assembly {
let mm := mulmod(a, b, not(0))
prod0 := mul(a, b)
prod1 := sub(sub(mm, prod0), lt(mm, prod0))
}
// ... handle division using 512-bit intermediate
}
The detector identifies the pattern MUL → DIV where both the multiplicand and the multiplier are potentially large values (derived from storage reads or user input), and flags it as a potential overflow point.
Legacy Contracts
The fourth and simplest case: contracts deployed before 0.8. Thousands remain active on mainnet — DeFi protocols, tokens, governance systems. Their arithmetic is unprotected.
Detection here is a compiler version identification step. The deployed bytecode contains metadata that indicates the compiler version. If the version is below 0.8.0, every arithmetic operation is potentially vulnerable. The detector then scans for operations on user-controlled or balance-derived values without preceding SafeMath patterns.
SafeMath usage is detectable: the library inserts a specific sequence of opcodes (addition, comparison, conditional revert) that does not appear in unsafe arithmetic. A pre-0.8 contract that uses SafeMath consistently has a different bytecode signature than one that does not.
Finding: Legacy Arithmetic — Unsafe Compiler
Severity: HIGH
Confidence: 0.91
Compiler version: 0.5.16 (pre-0.8 overflow checks)
SafeMath usage: partial — 3 of 46 arithmetic operations
Unprotected operations:
MUL at offset 0x3A2 — user-controlled operand (CALLDATALOAD)
SUB at offset 0x4B1 — balance subtraction, no prior comparison
ADD at offset 0x5C3 — reward accumulation, no check
Recommendation: Migrate to Solidity ≥0.8.0, or apply SafeMath to all
user-controlled arithmetic operations
What the Detector Actually Checks
The full arithmetic safety detection runs four analyses:
-
Compiler version identification: Extract version from bytecode metadata. Flag pre-0.8 contracts.
-
Unchecked block detection: Identify arithmetic opcodes lacking the compiler-inserted check sequences. Cross-reference with data flow from
CALLDATALOAD. -
Assembly arithmetic scanning: Find
ADD/MUL/SUB/EXPin assembly contexts. Evaluate operand sources. -
Overflow-before-division pattern: Identify
(a * b) / cexpressions whereaandbare independently large. Flag formulDivrecommendation.
Solidity 0.8 closed the door on the most obvious overflow class. The interesting vulnerabilities now live in the deliberate exceptions — places where developers traded safety for performance and got the tradeoff wrong.