Integer overflow and underflow
Before Solidity 0.8.0, arithmetic wrapped silently: subtracting 1 from 0 in a uint256 produced the maximum possible value. Since 0.8.0 the compiler inserts overflow checks by default, which removed most of this class. It still appears in older code, inside unchecked blocks, and anywhere assembly is used.
Vulnerable code
// pragma solidity ^0.7.0 — no built-in checks
function transfer(address to, uint256 amt) public {
balance[msg.sender] -= amt; // underflows to a huge number
balance[to] += amt;
}
// Or, in modern Solidity, the same bug reintroduced:
function deduct(uint256 amt) public {
unchecked {
balance[msg.sender] -= amt; // checks disabled here
}
}
Why it breaks
In the first function there is no balance check at all. Calling it with an amount larger than your balance underflows the subtraction, wrapping your balance to a number near 2256. You now hold effectively infinite tokens. The second version compiles under Solidity 0.8.x but the unchecked block explicitly turns the compiler's protection off, restoring the original bug.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
// pragma solidity ^0.8.0
function transfer(address to, uint256 amt) public {
require(balance[msg.sender] >= amt, "insufficient balance");
balance[msg.sender] -= amt; // reverts on underflow anyway
balance[to] += amt;
}
// If you use unchecked for gas, prove the bound first:
for (uint256 i; i < len; ) {
// ... i cannot overflow because len is bounded by array length
unchecked { ++i; }
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Total supply never decreases on a mint, and no single balance ever exceeds total supply.
invariant totalSupply(t) >= totalSupply(t-1) on mint invariant max(balanceOf(a)) <= totalSupply()
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testCannotUnderflow() public {
vm.expectRevert();
token.transfer(bob, 1); // caller holds 0
}
// Better: fuzz it. Foundry will search the input space for you.
function testFuzzTransfer(uint256 amt) public {
amt = bound(amt, 0, type(uint256).max);
if (amt > token.balanceOf(address(this))) vm.expectRevert();
token.transfer(bob, amt);
}
Where it has caused real losses
Several ERC-20 tokens in 2018 carried a 'batchTransfer' overflow that let an attacker mint astronomical balances by passing a crafted array. Exchanges suspended trading on the affected tokens. The underlying bug was a multiplication that overflowed before the balance check ran.
What we check during review
Two places this still bites in 2026. Downcasting: uint128(someUint256) truncates silently rather than reverting, so a value above the smaller type's range wraps. Use OpenZeppelin's SafeCast. And assembly blocks, where none of the compiler's checks apply at all: every arithmetic operation in Yul is unchecked by definition.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission