Integer Overflow & Underflow

Difficulty: Advanced

Integer overflow and underflow vulnerabilities occur when arithmetic operations produce results outside the range of the integer type. In Solidity, a uint256 can hold values from 0 to 2^256-1. Before Solidity 0.8.0, adding 1 to the maximum uint256 value would silently wrap around to 0 (overflow). Similarly, subtracting 1 from 0 would wrap to the maximum value (underflow). These silent wrapping behaviors led to numerous exploits where attackers manipulated balances, minted unlimited tokens, or bypassed critical checks.

Solidity 0.8.0 introduced built-in overflow and underflow checking for all arithmetic operations. Operations that would cause overflow or underflow now automatically revert with a Panic error (Panic(0x11)). This was a massive security improvement that made SafeMath libraries unnecessary for most cases. However, this protection comes with a gas cost - each arithmetic operation includes additional checks. For performance-critical code where overflow is mathematically impossible, Solidity provides the unchecked block to skip these checks and save gas.

While Solidity 0.8+ largely solved the overflow/underflow problem, several related arithmetic issues remain. Precision loss from integer division is a persistent concern - Solidity has no floating-point types, so 7 / 2 = 3 (truncated). In financial calculations, this can be exploited: if a fee is calculated as (amount * feeRate) / 10000 and the amount is tiny, the fee rounds to zero. The mitigation is to use large denominators (1e18) and perform multiplication before division. Type casting between different integer sizes (e.g., uint256 to uint128) can also silently truncate values even in Solidity 0.8+.

Historical exploits demonstrate the severity. The Beauty Chain (BEC) token exploit (2018) allowed an attacker to generate astronomically large token amounts by exploiting unchecked multiplication in a batch transfer function. The crafted value, when multiplied by the number of recipients, overflowed to a small number (passing the balance check), but each individual transfer used the original large value. Similarly, the Proof of Weak Hands Coin exploit used an underflow to mint unlimited tokens.

Best practices include: always use Solidity 0.8+ for new contracts; only use unchecked blocks when overflow is provably impossible (with comments explaining why); perform multiplication before division; use OpenZeppelin's Math.mulDiv for safe multiplication-then-division; validate all type casts; and consider fixed-point math libraries like PRBMath for fractional precision.

Code examples

Pre-0.8 vs Post-0.8 Behavior and Unchecked Blocks

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract IntegerSafety {
    // ======== Automatic Protection in 0.8+ ========

    function safeAdd() external pure returns (uint256) {
        uint256 max = type(uint256).max;
        // REVERTS with Panic(0x11) in 0.8+
        // In 0.7-, this would return 0 (wrapped)
        return max + 1;
    }

    function safeSubtract() external pure returns (uint256) {
        uint256 a = 0;
        // REVERTS - would return type(uint256).max in 0.7-
        return a - 1;
    }

    // ======== Unchecked Blocks (Gas Optimization) ========

    function gasEfficientLoop(
        uint256[] calldata data
    ) external pure returns (uint256 sum) {
        for (uint256 i = 0; i < data.length;) {
            sum += data[i];
            unchecked {
                // Safe: i < data.length <= type(uint256).max
                ++i;
            }
        }
    }

    function decrementSafe(
        uint256 balance,
        uint256 amount
    ) external pure returns (uint256) {
        require(balance >= amount, "Insufficient");
        unchecked {
            // Safe: we verified balance >= amount above
            return balance - amount;
        }
    }

    // ======== DANGER: Type Cast Truncation (NOT protected in 0.8+!) ========

    function unsafeCast() external pure returns (uint128) {
        uint256 bigNumber = type(uint256).max;
        // WARNING: Silently truncates! No revert in 0.8+!
        return uint128(bigNumber);
    }

    function safeCast(uint256 value) external pure returns (uint128) {
        require(value <= type(uint128).max, "Value exceeds uint128");
        return uint128(value);
    }
}

Solidity 0.8+ automatically reverts on overflow/underflow for standard arithmetic. Unchecked blocks opt out for gas savings - only use when overflow is provably impossible. Type casting truncation is NOT protected even in 0.8+.

The BEC Token Overflow Exploit

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// Demonstrates the BEC token overflow exploit
// In Solidity <0.8.0, this would compile without overflow protection

// The vulnerable batchTransfer function (simplified)
// In pre-0.8 Solidity:
// function batchTransfer(address[] receivers, uint256 value) public {
//     uint256 amount = receivers.length * value; // OVERFLOWS!
//     require(balances[msg.sender] >= amount);   // Passes with overflowed small amount
//     balances[msg.sender] -= amount;            // Subtracts small amount
//     for (uint i = 0; i < receivers.length; i++) {
//         balances[receivers[i]] += value;        // Adds huge original value!
//     }
// }

// ======== SECURE VERSION (Solidity 0.8+) ========
contract SecureBatchTransfer {
    mapping(address => uint256) public balances;

    constructor() {
        balances[msg.sender] = 10000;
    }

    function batchTransfer(
        address[] calldata receivers,
        uint256 value
    ) external {
        // In 0.8+, this multiplication REVERTS on overflow
        uint256 amount = receivers.length * value;

        require(balances[msg.sender] >= amount, "Insufficient");
        balances[msg.sender] -= amount;

        for (uint256 i = 0; i < receivers.length; i++) {
            require(receivers[i] != address(0), "Zero address");
            balances[receivers[i]] += value;
        }
    }
}

// Legacy SafeMath (no longer needed in 0.8+, shown for reference)
library SafeMath {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        uint256 c = a + b;
        require(c >= a, "SafeMath: addition overflow");
        return c;
    }

    function sub(uint256 a, uint256 b) internal pure returns (uint256) {
        require(b <= a, "SafeMath: subtraction underflow");
        return a - b;
    }

    function mul(uint256 a, uint256 b) internal pure returns (uint256) {
        if (a == 0) return 0;
        uint256 c = a * b;
        require(c / a == b, "SafeMath: multiplication overflow");
        return c;
    }
}

The BEC exploit used a crafted value that overflowed when multiplied by receiver count, passing the balance check with a tiny amount while crediting each receiver the huge original value. In 0.8+, the multiplication automatically reverts. SafeMath is shown for historical reference.

Precision Loss in DeFi Math

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/utils/math/Math.sol";

contract PrecisionExample {
    using Math for uint256;

    // BAD: Division before multiplication
    function badFee(uint256 amount, uint256 rateBps) external pure returns (uint256) {
        // amount=999, rateBps=30: (999/10000)*30 = 0*30 = 0
        return (amount / 10000) * rateBps;
    }

    // GOOD: Multiplication before division
    function goodFee(uint256 amount, uint256 rateBps) external pure returns (uint256) {
        // amount=999, rateBps=30: (999*30)/10000 = 29970/10000 = 2
        return (amount * rateBps) / 10000;
    }

    // BEST: Using mulDiv for overflow-safe mul-then-div
    function safeFee(uint256 amount, uint256 rateBps) external pure returns (uint256) {
        return amount.mulDiv(rateBps, 10000);
    }

    // Rounding direction matters in DeFi
    function depositShares(
        uint256 depositAmount,
        uint256 totalAssets,
        uint256 totalShares
    ) external pure returns (uint256) {
        // Round DOWN for deposits (protocol keeps dust)
        return depositAmount.mulDiv(totalShares, totalAssets, Math.Rounding.Floor);
    }

    function withdrawAssets(
        uint256 shareAmount,
        uint256 totalAssets,
        uint256 totalShares
    ) external pure returns (uint256) {
        // Round DOWN for withdrawals (protocol keeps dust)
        return shareAmount.mulDiv(totalAssets, totalShares, Math.Rounding.Floor);
    }
}

Division before multiplication causes truncation (fee becomes 0 for small amounts). Always multiply first, then divide. Use OpenZeppelin's mulDiv for overflow-safe calculations. In DeFi, rounding should always favor the protocol to prevent dust exploits.

Key points

Concepts covered

Integer Overflow, Integer Underflow, SafeMath, Unchecked Blocks, Type Casting