Skip to main content
Sigvex

Off-By-One

Detects off-by-one errors in loop boundaries that can lead to skipped elements or out-of-bounds array access.

Off-By-One

Overview

The off-by-one detector identifies loop boundary conditions that are likely incorrect: using length - 1 as an upper bound (skipping the last element), using <= with length (accessing beyond the array), or using negated conditions that obscure the true boundary. These errors cause missed processing, out-of-bounds access, or infinite loops at edge cases.

In DeFi, off-by-one errors can mean the last depositor in a reward distribution is skipped, or the last element in a whitelist is not checked, creating an economic exploit.

Why This Is an Issue

Off-by-one errors are among the most common programming mistakes. In smart contracts, they carry financial consequences:

  • Skipped last element: A loop over reward recipients using i < recipients.length - 1 skips the last recipient. With a 1000-recipient airdrop, one user permanently loses their claim.
  • Out-of-bounds access: A loop using i <= array.length accesses array[array.length], which in Solidity reverts with an out-of-bounds panic. If this function processes withdrawals, no one can withdraw.
  • Zero-length edge case: array.length - 1 underflows to type(uint256).max in Solidity <0.8.0, creating an unbounded loop. Solidity 0.8+ catches this with a panic, but the function still reverts.

How to Resolve

// Before: Vulnerable -- skips last element
for (uint256 i = 0; i < array.length - 1; i++) {
    process(array[i]);
}

// After: Fixed -- processes all elements
for (uint256 i = 0; i < array.length; i++) {
    process(array[i]);
}

Examples

Vulnerable (Inclusive Upper Bound)

function distributeRewards(address[] memory stakers) external {
    for (uint256 i = 0; i <= stakers.length; i++) {
        // Reverts on last iteration: array[length] is out of bounds
        _sendReward(stakers[i]);
    }
}

Fixed

function distributeRewards(address[] memory stakers) external {
    for (uint256 i = 0; i < stakers.length; i++) {
        _sendReward(stakers[i]);
    }
}

Vulnerable (Length Minus One)

function checkAllVoters(address[] memory voters) external view returns (bool) {
    for (uint256 i = 0; i < voters.length - 1; i++) {
        if (!isEligible(voters[i])) return false;
    }
    return true;  // Last voter is never checked
}

Fixed

function checkAllVoters(address[] memory voters) external view returns (bool) {
    for (uint256 i = 0; i < voters.length; i++) {
        if (!isEligible(voters[i])) return false;
    }
    return true;
}

Sample Sigvex Output

[MEDIUM] off-by-one
  Potential off-by-one error in 'distributeRewards'
  Location: distributeRewards @ block 0, instruction 3
  Confidence: 0.70

  Potential off-by-one error in 'distributeRewards': loop bound uses
  'length - 1' which may skip last element.

Detection Methodology

  1. Length variable tracking: Identifies variables loaded from memory or storage that contain array length values, based on naming heuristics.
  2. Subtraction detection: Tracks SUB operations where one operand is a length variable and the other is the constant 1.
  3. Comparison analysis: Flags comparisons (LT, GT, SLT, SGT) where one operand is a length - 1 variable or where GT/SGT is used with a length variable (indicating <= semantics).
  4. Negated condition detection: Identifies ISZERO applied to comparison results, creating !(i >= length) patterns that are error-prone.
  5. Compiler-aware adjustment: Solidity 0.8+ contracts receive reduced confidence because underflow on length - 1 when length == 0 triggers a panic, catching the zero-length edge case.

Limitations

False positives: Intentional length - 1 patterns exist in binary search, pair-wise comparison, and sorting algorithms. Audited library code (OpenZeppelin’s EnumerableSet) uses length - 1 correctly. These are suppressed by the detector. False negatives: Off-by-one errors in arithmetic other than loop bounds (e.g., reward calculations) are not targeted by this detector.

References