Skip to main content
Sigvex

Unchecked Subtraction

Detects subtraction operations without underflow protection that can wrap around to a large value, bypassing balance checks and enabling fund theft.

Unchecked Subtraction

Overview

Remediation Guide: How to Fix Unchecked Subtraction

The unchecked subtraction detector identifies integer subtraction operations that can underflow — wrapping from zero to the maximum uint256 value — without any prior balance check or overflow protection. In Solidity versions prior to 0.8.0, all arithmetic is unchecked by default: 0 - 1 produces 2^256 - 1 rather than reverting. In Solidity 0.8+, code inside unchecked {} blocks intentionally disables this protection and reintroduces the risk.

Sigvex detects this by identifying SUB opcodes in the bytecode where the subtrahend is user-controlled (derived from function parameters or SLOAD) and no preceding comparison instruction validates that the minuend is sufficient. Pre-0.8 contracts are always analyzed for this pattern; 0.8+ contracts are analyzed specifically for unchecked {} scopes.

Why This Is an Issue

An underflow wraps a zero balance to 2^256 - 1 — approximately 1.16 × 10^77. An attacker who achieves this can withdraw unlimited funds, transfer unlimited tokens, or bypass any downstream balance check. The vulnerability is binary: either the subtraction is protected or it is not. There is no partial exploitation — a single underflowing subtraction gives the attacker an astronomical balance.

The attack is low cost and does not require special privileges. Any user-accessible transfer or withdrawal function that subtracts from a balance before checking is sufficient.

The BEC Token incident (April 2018) demonstrated the full impact: an integer underflow in batchTransfer allowed an attacker to generate an astronomical token quantity, collapsing the token’s market value to near zero. Numerous DEX and DeFi protocols using pre-0.8 Solidity without SafeMath suffered similar attacks between 2018 and 2020.

How to Resolve

Use Solidity 0.8+ (which checks arithmetic by default) and avoid putting user-controlled subtraction inside unchecked {} blocks. For pre-0.8 contracts, use OpenZeppelin’s SafeMath or add explicit require guards before every subtraction.

// Before: Vulnerable — no underflow protection (pre-0.8 Solidity)
pragma solidity ^0.6.0;
function transfer(address to, uint256 amount) external {
    balances[msg.sender] -= amount; // Wraps to 2^256 - 1 if balance < amount
    balances[to] += amount;
}

// After: Fixed — explicit guard before subtraction
pragma solidity ^0.6.0;
function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount; // Safe — require above prevents underflow
    balances[to] += amount;
}

Examples

Vulnerable Code

// Pre-0.8 Solidity — always vulnerable to underflow
pragma solidity ^0.6.0;

contract VulnerableToken {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // VULNERABLE: no balance check — underflows to 2^256 - 1
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

// Solidity 0.8+ with unchecked block — explicitly vulnerable
pragma solidity ^0.8.0;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdrawWithFee(uint256 amount, uint256 fee) external {
        unchecked {
            // VULNERABLE: attacker passes fee > balance — underflows
            balances[msg.sender] -= amount + fee;
        }
        payable(msg.sender).transfer(amount);
    }
}

Fixed Code

pragma solidity ^0.8.0;

// Solidity 0.8+ checks by default — no special action needed
contract SafeToken {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // In 0.8+, this reverts automatically on underflow
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

// Safe use of unchecked — only for provably safe arithmetic
contract GasOptimizedToken {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        unchecked {
            // SAFE: require above guarantees balances[msg.sender] >= amount
            balances[msg.sender] -= amount;
            balances[to] += amount; // Also safe: addition cannot overflow here
        }
    }

    // SAFE: loop counter increment cannot overflow uint256 in practice
    function processAll() external {
        for (uint256 i = 0; i < items.length; ) {
            _process(items[i]);
            unchecked { ++i; }
        }
    }
}

Sample Sigvex Output

{
  "detector_id": "unchecked-subtraction",
  "severity": "critical",
  "confidence": 0.88,
  "description": "SUB opcode at offset 0x3e in function transfer() subtracts user-controlled parameter (stack position 1) from storage slot 0x00. No preceding comparison validates that the storage value is >= the subtrahend. Underflow to 2^256 - 1 is possible.",
  "location": {
    "function": "transfer(address,uint256)",
    "offset": 62
  }
}

Detection Methodology

Sigvex identifies unchecked subtraction through dataflow analysis of the decompiled bytecode:

  1. Compiler version detection: Identifies whether the contract was compiled with Solidity <0.8 (all arithmetic is unchecked) or ≥0.8 (arithmetic is checked by default).
  2. Unchecked scope identification: For 0.8+ contracts, identifies unchecked {} scopes by detecting the compiler’s generated pattern (which skips overflow checks by using raw SUB instead of the checked variant).
  3. User-controlled operand detection: Taint-tracks function parameters and SLOAD-derived values. Marks any subtraction where the subtrahend is tainted as a candidate.
  4. Preceding guard check: For each candidate SUB, checks whether a LT, GT, or EQ comparison on the same operands appears in a preceding basic block with a conditional branch to a revert path.
  5. Finding emission: Reports any SUB where the subtrahend is user-controlled and no guard is found.

Limitations

False positives:

  • Subtraction where the operand is constrained by prior arithmetic to be provably smaller than the minuend (e.g., a modulo result) may be flagged if the constraint is not explicitly expressed as a comparison.
  • Library calls that internally protect against underflow but are inlined at the bytecode level may produce false positives if the protection pattern is not recognized.

False negatives:

  • Underflows that require multiple transactions to construct (e.g., depositing a small amount then subtracting a larger amount through a different path) may not be detected if the multi-path constraint is not inferred.
  • Integer Overflow — detects overflow in addition and multiplication operations
  • Unchecked Call — detects external call return values that are not checked

References