Solidity 0.8.0 introduced a significant safety improvement: integer overflow and underflow now revert by default instead of silently wrapping. Before this change, uint256(0) - 1 would produce 2**256 - 1 — a massive number — without any indication that something went wrong. The 0.8 release eliminated an entire class of bugs that had drained hundreds of millions of dollars from DeFi protocols.
But overflow protection is not the same as arithmetic safety. Developers still regularly reach for unchecked blocks to optimize gas costs, perform type casts that silently truncate values, and write formulas in the wrong order. The arithmetic bugs that cause exploits in 2026 are subtler than a plain uint256 counter++ wrapping, but they are no less dangerous.
This post covers six specific arithmetic vulnerability patterns that appear repeatedly in real audits and post-mortems. For each one, you will see the vulnerable pattern, the mechanism of the exploit, and the correct fix.
1. Unchecked Block Overflow in Loop Counter
The most common legitimate use of unchecked is loop iteration. Checking for overflow on a loop counter costs gas, and since array lengths cannot exceed type(uint256).max, the check is provably unnecessary. However, the pattern breaks down in two situations: the developer uses a smaller integer type for the counter, or the unchecked block covers more than just the increment.
// VULNERABLE: uint8 counter overflows at 256
function processItems(address[] calldata items) external {
for (uint8 i = 0; i < items.length; i++) {
unchecked {
balances[items[i]] += reward;
i++; // double increment — items skipped
}
}
}
In the example above, two bugs compound. First, uint8 i overflows silently at 256 — if the array has 300 elements, the loop never terminates past index 255, it wraps to 0 and re-processes the beginning. Second, the i++ in the loop header and the i++ inside the unchecked block together mean every other item is skipped.
The safe pattern is narrow: move only the counter increment into unchecked, keep the counter as uint256, and ensure the loop condition itself prevents the counter from reaching type(uint256).max.
// SAFE: uint256 counter, only the increment is unchecked
function processItems(address[] calldata items) external {
uint256 length = items.length;
for (uint256 i = 0; i < length; ) {
balances[items[i]] += reward;
unchecked { ++i; }
}
}
The loop condition i < length guarantees that i never reaches type(uint256).max before the loop exits, so the unchecked increment is safe. Prefer ++i over i++ to avoid creating a temporary value.
2. Unsafe Type Downcast Truncation
Solidity allows explicit type casts between integer sizes. The compiler accepts them without warning, and they silently truncate the high-order bits of the value. This is correct behavior for the language but a common source of bugs.
// VULNERABLE: amount is truncated from uint256 to uint8
function setFee(uint256 basisPoints) external onlyOwner {
// basisPoints = 300 (3%)
// uint8(300) = 300 % 256 = 44 — stored fee is 1.7% instead of 3%
feeRate = uint8(basisPoints);
}
function calculateFee(uint256 amount) public view returns (uint256) {
return (amount * feeRate) / 10000;
}
The attack surface is broad. In token contracts, uint256 balances cast to uint128 or uint96 for storage packing can truncate large balances to zero or a small fraction. In access control, a role bitmask stored in a smaller integer type may drop bits that represent admin privileges, effectively granting access. In fee calculations, truncation makes the effective rate different from the intended rate.
The fix is to use OpenZeppelin's SafeCast library, which reverts if the value does not fit in the target type:
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
// SAFE: reverts if basisPoints > type(uint8).max
function setFee(uint256 basisPoints) external onlyOwner {
feeRate = SafeCast.toUint8(basisPoints);
}
When you need to downcast for storage packing and you control the input range, add an explicit bounds check before the cast. Never assume a caller will pass a value that fits.
3. Division Before Multiplication (Precision Loss)
Integer division in Solidity truncates toward zero. When you divide before multiplying, you lose precision that cannot be recovered. The result can be zero even when the correct mathematical answer is non-zero.
// VULNERABLE: division before multiplication loses precision
function calculateReward(uint256 amount, uint256 rewardRate) public pure returns (uint256) {
// If amount = 500, rewardRate = 300, PRECISION = 10000:
// (500 / 10000) * 300 = 0 * 300 = 0
// Correct answer: (500 * 300) / 10000 = 15
return (amount / PRECISION) * rewardRate;
}
This is especially common in fee calculations where PRECISION is 10000 (basis points) or 1e18. Any amount smaller than PRECISION produces a zero fee, effectively letting small trades or deposits bypass fees entirely. In reward distribution contracts, this means small stakers receive nothing even when they are entitled to a proportional share.
The fix is always to multiply before you divide:
// SAFE: multiply first, then divide
function calculateReward(uint256 amount, uint256 rewardRate) public pure returns (uint256) {
// (500 * 300) / 10000 = 150000 / 10000 = 15
return (amount * rewardRate) / PRECISION;
}
Be aware that multiplying first increases the risk of intermediate overflow. When amount and rewardRate are both large, amount * rewardRate may exceed type(uint256).max. This is addressed in vulnerability 5. The key point here is ordering: multiplication always comes before division.
4. Subtraction Underflow in Fee Calculation
Solidity 0.8 made underflow revert. This is safe for most purposes, but it breaks protocols that relied on wrapping behavior intentionally, and it creates denial-of-service vectors when underflow is possible but not anticipated. Worse, developers who know about the gas cost of overflow checks sometimes wrap entire functions in unchecked, unknowingly re-enabling wrapping underflow.
// VULNERABLE: unchecked block allows underflow to wrap
function distributeReward(address user, uint256 grossAmount) external {
unchecked {
// If fee > grossAmount (e.g., fee = 200, grossAmount = 50):
// 50 - 200 wraps to 2**256 - 150 — user receives massive amount
uint256 fee = (grossAmount * feeRate) / 10000;
uint256 netAmount = grossAmount - fee;
token.transfer(user, netAmount);
totalFees += fee;
}
}
This pattern appears in reward distribution contracts that wrap for-loops in unchecked but then also include the arithmetic inside the same block. An attacker who can influence grossAmount to be smaller than the computed fee — through rounding manipulation or by triggering the function with a dust amount — causes netAmount to underflow to a value near 2**256, draining the contract.
The correct approach is to keep only the counter increment in unchecked, or to add an explicit guard:
// SAFE: guard before the subtraction, fee calc outside unchecked
function distributeReward(address user, uint256 grossAmount) external {
uint256 fee = (grossAmount * feeRate) / 10000;
require(grossAmount >= fee, "fee exceeds amount");
uint256 netAmount = grossAmount - fee;
token.transfer(user, netAmount);
totalFees += fee;
}
When reviewing contracts that use unchecked, audit every arithmetic operation inside the block — not just the operation the developer intended to skip checking.
5. Fixed-Point Multiplication Overflow
DeFi protocols use 18-decimal fixed-point arithmetic to represent fractional values. The standard representation stores 1.0 as 1e18. Multiplying two fixed-point numbers requires dividing by 1e18 to normalize: result = (a * b) / 1e18.
The intermediate value a * b can overflow even when both a and b are valid fixed-point numbers well below the maximum representable value.
// VULNERABLE: intermediate overflow when both operands are large
function mulFixed(uint256 a, uint256 b) internal pure returns (uint256) {
// a = 2e18 (represents 2.0)
// b = 1e18 (represents 1.0)
// a * b = 2e36 — overflows uint256 (max ~1.15e77, but practical limit
// for fixed-point ops is ~1.15e59 before normalization)
// More realistic: a = 1e58, b = 1e18 → a * b overflows
return (a * b) / 1e18;
}
With Solidity 0.8 overflow protection enabled, this reverts rather than silently corrupting state. But a revert in a core pricing or share-calculation function is still a denial-of-service vulnerability. If an attacker can cause a pool's internal price to reach a value where legitimate operations revert, they can freeze the protocol.
The fix is to use a mulDiv implementation that handles the intermediate 512-bit result, such as Uniswap's FullMath.mulDiv or Paul Razvan Berg's PRBMath:
import { FullMath } from "@uniswap/v3-core/contracts/libraries/FullMath.sol";
// SAFE: handles intermediate 512-bit product without overflow
function mulFixed(uint256 a, uint256 b) internal pure returns (uint256) {
return FullMath.mulDiv(a, b, 1e18);
}
FullMath.mulDiv computes floor(a * b / denominator) using assembly-level 512-bit arithmetic, avoiding overflow in the intermediate result while returning a correctly rounded uint256. Use it anywhere you multiply two potentially large numbers and then divide.
6. Comparison Signed/Unsigned Mismatch
Solidity does not perform implicit conversions between signed (int) and unsigned (uint) integer types. When you compare or mix them in arithmetic, the compiler requires an explicit cast. The dangerous case is when a negative int value is cast to uint, producing a very large positive number.
// VULNERABLE: int256 cast to uint256 produces a huge number
function canWithdraw(int256 creditLimit, uint256 requestedAmount) public pure returns (bool) {
// creditLimit = -1 (account is in arrears)
// uint256(-1) = type(uint256).max = 1.15e77
// type(uint256).max >= requestedAmount is almost always true
// This check never blocks withdrawal when creditLimit is negative
return uint256(creditLimit) >= requestedAmount;
}
This pattern is common in codebases that mix signed and unsigned types for economic values — for example, a signed int256 to represent a net position (positive = credit, negative = debt) alongside unsigned uint256 for token amounts. A function that is supposed to block withdrawals when the account is in deficit instead approves them because the negative int becomes a massive uint.
The fix requires explicit handling of the sign before comparing:
// SAFE: check sign before casting
function canWithdraw(int256 creditLimit, uint256 requestedAmount) public pure returns (bool) {
if (creditLimit < 0) {
return false; // negative credit limit — no withdrawals allowed
}
// Safe cast: creditLimit >= 0 is guaranteed
return uint256(creditLimit) >= requestedAmount;
}
When a function parameter could be negative and you need to compare it against an unsigned value, always gate the cast with a sign check. Avoid designing APIs that mix signed and unsigned types for the same conceptual quantity.
What ContractScan Detects
ContractScan analyzes Solidity source and bytecode for these arithmetic vulnerability patterns using a combination of data-flow analysis, taint tracking, and pattern matching across compilation units.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Unchecked loop counter with non-uint256 type | Static type analysis on unchecked blocks; flags uint8/uint16/etc. loop vars |
High |
| Unsafe downcast truncation | Taint tracking from large integer sources to explicit downcast sites without bounds checks | High |
| Division before multiplication | Arithmetic ordering analysis in expression trees; flags (a / b) * c patterns |
Medium |
| Subtraction in unchecked with unvalidated operands | Data-flow analysis for unchecked subtractions where subtrahend is externally influenced | Critical |
| Fixed-point multiplication without mulDiv | Identifies (a * b) / denom where operands may approach sqrt(type(uint256).max) |
High |
| Signed/unsigned comparison without sign guard | Control-flow analysis for int-to-uint casts in comparison paths |
High |
Scan your contracts at contractscan.io to detect these patterns before deployment. ContractScan surfaces the exact line, the data-flow path that creates the vulnerability, and a suggested remediation — not just a category label.
Related Posts
- Solidity Compiler Version Security: Known Bugs and Version Pinning
- Staking Contract Security: Reward Manipulation and Accounting Exploits
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.