Opcode Summary

PropertyValue
Opcode0x10
MnemonicLT
Gas3
Stack Inputa, b
Stack Outputa < b (1 if true, 0 if false)
BehaviorUnsigned 256-bit less-than comparison. Both operands are treated as unsigned integers. Returns 1 or 0.

Threat Surface

LT is the primary unsigned comparison opcode in the EVM, used pervasively in range checks, bounds validation, loop termination, access control thresholds, and overflow detection patterns. Its threat surface stems from two properties:

  1. LT is purely unsigned: It interprets all 256-bit values as non-negative integers in the range [0, 2^256 - 1]. A value like 0xFFFFFFFF...FFFF (which represents -1 in two’s complement signed arithmetic) is treated as the maximum possible value by LT. If a contract stores signed data (int256) but compares it with LT instead of SLT, negative values appear astronomically large, completely inverting the intended comparison logic.

  2. LT participates in critical safety patterns: The Solidity 0.8.0+ compiler emits LT as the core of its ADD overflow detection: after computing a + b, it checks result < a. If this check is absent (pre-0.8.0 code) or bypassed (unchecked blocks), overflow goes undetected. LT is also the backbone of array bounds checking, loop guards, and require-based threshold validation. Any misuse of LT in these patterns has cascading security consequences.

The combination of unsigned-only semantics with its role in safety-critical checks makes LT a deceptively dangerous opcode — not because it’s complex, but because developers frequently misapply it to signed data or construct off-by-one comparisons that leave exploitable gaps.


Smart Contract Threats

T1: Unsigned Comparison on Signed Values (Critical)

When a contract stores or computes values that should be interpreted as signed (int256 in Solidity) but compares them using unsigned comparison logic (which compiles to LT/GT), negative values are misinterpreted as enormous positive values. This completely inverts comparison results:

  • int256(-1) is stored as 0xFFFF...FFFF (2^256 - 1 unsigned)
  • LT(-1, 100) evaluates as LT(2^256 - 1, 100) = 0 (false)
  • The contract believes -1 is greater than 100

This occurs in:

  • Inline assembly: Yul and raw assembly default to unsigned operations. Writing if lt(signedValue, threshold) uses LT (unsigned) when SLT was needed.
  • Cross-contract ABI mismatches: Contract A sends an int256; Contract B decodes it as uint256 and compares with LT.
  • Price feeds with negative values: Oracle prices, funding rates, or PnL calculations that can go negative but are compared with unsigned LT.

T2: Off-By-One in Boundary Checks (High)

Comparison opcodes are the most common source of off-by-one errors in smart contracts. The difference between < and <= (which is LT vs NOT(GT) or LT(SUB(b,1), a) at the opcode level) determines whether boundary values are included or excluded:

// Off-by-one: allows exactly MAX_SUPPLY tokens, but intent was to cap below it
require(totalSupply < MAX_SUPPLY);  // LT: totalSupply == MAX_SUPPLY - 1 is the last allowed
 
// Correct if intent is "at most MAX_SUPPLY"
require(totalSupply <= MAX_SUPPLY);  // Compiles to: NOT(GT(totalSupply, MAX_SUPPLY))

In liquidation logic, the difference between < and <= on the collateralization ratio determines whether positions at exactly the threshold are liquidated. Getting this wrong either allows undercollateralized positions to persist or incorrectly liquidates healthy positions.

T3: Comparison with Overflow-Wrapped Results (High)

LT is used in the standard ADD overflow detection pattern: (a + b) < a implies overflow. If this pattern is applied incorrectly — for example, checking the wrong operand, or applying it after further arithmetic has modified the result — overflow can go undetected:

// Correct overflow detection
uint256 result = a + b;
require(result >= a);  // Equivalent to: require(!LT(result, a))
 
// WRONG: checking against b instead of a when b could be 0
uint256 result = a + b;
require(result >= b);  // Fails to detect overflow when b == 0 and a wraps

When overflow wraps a value to something small, subsequent LT comparisons against thresholds (e.g., require(amount < MAX_AMOUNT)) pass trivially, because the wrapped value is tiny. The overflow effectively bypasses the range check.

T4: Time and Block Number Comparisons (Medium)

Contracts frequently use LT to compare timestamps or block numbers for time-locking, vesting schedules, and auction deadlines:

require(block.timestamp < deadline);  // LT at the opcode level

If deadline is set from user input or calculated with unchecked arithmetic, it could overflow to a small value (in pre-0.8.0 code), making the deadline appear to have already passed. Conversely, setting deadline to type(uint256).max creates a lock that never expires.

T5: Array Bounds with User-Controlled Index (Medium)

Array access bounds checking uses LT: require(index < array.length). If array.length is 0 (empty array), any unsigned index fails the check — this is correct. But if the array length itself is manipulated (e.g., via storage collision or delegatecall corruption), the bounds check becomes unreliable.


Protocol-Level Threats

P1: No DoS Vector (Low)

LT costs a fixed 3 gas, operates purely on the stack, and has no dynamic gas component. It cannot be used for gas griefing.

P2: Consensus Safety (Low)

Unsigned less-than comparison on 256-bit integers is trivially deterministic. All EVM client implementations agree on LT’s behavior. No known consensus divergence has occurred.

P3: No State Impact (None)

LT modifies only the stack. No memory, storage, or state changes.

P4: Compiler-Generated Comparison Patterns (Low)

Different Solidity versions emit different patterns for <=, >=, and combined comparisons. For example, a <= b may compile to ISZERO(GT(a, b)) or NOT(GT(a, b)) depending on context. Auditors reviewing bytecode must recognize these equivalences to correctly identify the intended comparison.


Edge Cases

Edge CaseBehaviorSecurity Implication
LT(0, 0)Returns 0 (false)Correct: 0 is not less than 0
LT(0, 1)Returns 1 (true)Correct; base case
LT(MAX_UINT256, 0)Returns 0 (false)Correct for unsigned; but if value represents -1 (signed), comparison is inverted
LT(0, MAX_UINT256)Returns 1 (true)Correct for unsigned
LT(2^255, 2^255 - 1)Returns 0 (false)For signed interpretation, 2^255 is the most negative number (-2^255); LT says it’s bigger
LT(a, a)Returns 0 (false)Strict less-than; equal values return false
LT(a + b, a) where a + b overflowsReturns 1 (true)Overflow detection: wrapped result is smaller than operand
LT(0, a) for any a > 0Returns 1 (true)Used in ISZERO-equivalent pattern

Real-World Exploits

Exploit 1: Edgeware Lockdrop “Gridlock” — $900M at Risk (July 2019)

Root cause: Strict equality check (==) on contract balance where a comparison operator (>=) was needed. While this is an EQ issue, the fix required replacing the strict check with a comparison (GE, which internally uses LT/GT).

Details: Edgeware’s Lockdrop contract managed over $900M in ETH. The lock() function asserted assert(address(lockAddr).balance == msg.value) — strict equality that fails when any extra wei is present. An attacker could pre-calculate the Lock contract’s address (deterministic via CREATE) and send 1 wei to it before the legitimate lock() call. The assertion would then fail, permanently bricking the lock function.

LT/GT’s role: The fix replaced == with >= (compiled to ISZERO(LT(balance, msg.value))), demonstrating that comparison operators are the correct tool for balance validation, not equality. This is the canonical example of why thresholds should use LT/GT family opcodes rather than EQ.

References:


Exploit 2: BEC batchOverflow — Comparison Bypassed by Overflow (April 2018)

CVE: CVE-2018-10299

Root cause: Integer overflow in MUL caused the total to wrap to 0, which then trivially passed a >= comparison check. The comparison itself (compiled to LT/GT) was correct, but the value it was comparing had already been destroyed by overflow.

Details: The BEC batchTransfer() function computed uint256 amount = uint256(cnt) * _value, which overflowed to 0 when cnt = 2 and _value = 2^255. The subsequent check require(balances[msg.sender] >= amount) (which uses LT internally: ISZERO(LT(balance, amount))) passed trivially because balance >= 0 is always true.

LT’s role: The LT-based comparison was the security gate that was supposed to prevent unauthorized transfers. It functioned correctly on its inputs, but those inputs had already been corrupted by the upstream overflow. This demonstrates that comparison opcodes are only as reliable as the values they’re comparing — overflow before comparison is the classic bypass pattern.

References:


Exploit 3: Integer Overflow Bypasses Time-Lock Comparison (Generic Pattern, Multiple Incidents)

Root cause: Overflow in timestamp arithmetic causes a time-lock comparison (block.timestamp < unlockTime) to pass prematurely.

Details: A pattern seen in multiple contracts: users can extend a lock by adding seconds to their unlock time. If the addition overflows (pre-0.8.0), the unlock time wraps to a small value, and the LT comparison block.timestamp < unlockTime becomes block.timestamp < smallValue, which fails — effectively unlocking the funds immediately. The OWASP Smart Contract Top 10 documents this as a canonical exploit pattern with the “TimeWrapVault” example.

function extendLock(uint256 _additionalSeconds) public {
    withdrawalUnlockTime[msg.sender] += _additionalSeconds;  // Overflow wraps to small value
}
function releaseFunds() public {
    require(block.timestamp > withdrawalUnlockTime[msg.sender]);  // GT passes immediately
}

LT’s role: The time-lock check uses GT (the complement of LT) to enforce that the current time exceeds the unlock time. When overflow corrupts the unlock time to a small value, the comparison becomes meaningless. The security gate (GT/LT) was correct, but the time value it guarded was corrupted.

References:


Attack Scenarios

Scenario A: Signed/Unsigned Confusion in Assembly

contract VulnerableOracle {
    function isNegativeReturn(int256 value) external pure returns (bool) {
        bool isNegative;
        assembly {
            // BUG: lt() is unsigned -- int256(-1) looks like MAX_UINT256
            // This returns false because MAX_UINT256 is not less than 0
            isNegative := lt(value, 0)
        }
        // isNegative is always false for negative values!
        // Should use: isNegative := slt(value, 0)
        return isNegative;
    }
    
    function processReturn(int256 pnl) external {
        // Attacker passes negative PnL; contract thinks it's a massive positive number
        if (!isNegativeReturn(pnl)) {
            // Contract treats negative loss as enormous profit
            _distributeProfit(uint256(pnl));  // Distributes ~2^256 tokens
        }
    }
}

Scenario B: Off-By-One in Liquidation Threshold

contract VulnerableLending {
    uint256 constant LIQUIDATION_THRESHOLD = 150;  // 150% collateralization
    
    function isLiquidatable(address user) public view returns (bool) {
        uint256 ratio = getCollateralRatio(user);
        // BUG: uses < instead of <=
        // Position at exactly 150% cannot be liquidated
        return ratio < LIQUIDATION_THRESHOLD;  // LT at opcode level
        // Should be: return ratio <= LIQUIDATION_THRESHOLD;
    }
    
    // Attacker maintains position at exactly 150%, which is
    // undercollateralized by protocol intent but immune to liquidation
}

Scenario C: Overflow Bypasses Amount Check

// Solidity < 0.8.0
contract VulnerableVault {
    mapping(address => uint256) public balances;
    uint256 constant MAX_DEPOSIT = 100 ether;
    
    function deposit() external payable {
        uint256 newBalance = balances[msg.sender] + msg.value;  // Can overflow
        require(newBalance < MAX_DEPOSIT);  // LT: passes if overflow wraps to small value
        balances[msg.sender] = newBalance;
    }
    
    // Attack: if balances[attacker] = 99 ether and msg.value = (2^256 - 99 ether + 1 wei),
    // newBalance wraps to 1 wei, passing the < MAX_DEPOSIT check
}

Mitigations

ThreatMitigationImplementation
T1: Signed/unsigned confusionUse SLT/SGT for signed comparisons; never use LT on int256 valuesIn Yul/assembly: slt(a, b) not lt(a, b) for signed data
T1: Cross-contract type mismatchValidate type consistency at contract boundariesExplicit type casting with range checks at ABI decode points
T2: Off-by-one in boundariesUse <= / >= when boundary values should be includedCode review checklist: verify every < vs <= against specification
T2: Liquidation thresholdsFuzz-test boundary values exhaustivelyTest at threshold, threshold-1, threshold+1 for every comparison
T3: Overflow before comparisonUse Solidity >= 0.8.0; validate inputs before arithmeticChecked arithmetic ensures comparison receives correct values
T4: Timestamp manipulationCap deadline values; avoid user-controlled timestamp arithmeticrequire(deadline <= block.timestamp + MAX_DURATION)
T5: Array boundsUse Solidity’s built-in bounds checking (default for arrays)Avoid unchecked around array indexing

Compiler/EIP-Based Protections

  • Solidity 0.8.0+: Automatic overflow checks on arithmetic ensure that values reaching LT comparisons are correct. The compiler also emits LT as part of its overflow detection pattern.
  • Solidity type system: The compiler uses SLT for int256 < int256 and LT for uint256 < uint256 automatically. The risk is in assembly blocks and ABI boundary mismatches.
  • Slither: Detects dangerous strict equalities (dangerous-strict-equality detector) and can flag comparisons that should use a different operator.

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractCriticalMediumAssembly bugs in DeFi protocols
T2Smart ContractHighHighOff-by-one in liquidation/boundary logic
T3Smart ContractHighHigh (pre-0.8) / Low (post-0.8)BEC ($infinite tokens), time-lock bypasses
T4Smart ContractMediumMediumTimestamp overflow bypasses
T5Smart ContractMediumLowStorage collision edge cases
P1ProtocolLowN/A
P2ProtocolLowN/A

OpcodeRelationship
GT (0x11)Unsigned greater-than; GT(a, b) == LT(b, a). Same threat class, mirrored operands
SLT (0x12)Signed less-than; must be used instead of LT when comparing int256 values
SGT (0x13)Signed greater-than; the signed counterpart to GT
EQ (0x14)Equality; strict equality where LT/GT comparison operators should often be used instead
ISZERO (0x15)Used to negate comparison results: a >= b compiles to ISZERO(LT(a, b))
ADD (0x01)LT is used in ADD overflow detection: (a + b) < a implies overflow
SUB (0x03)LT is used in SUB underflow detection: a < b before computing a - b