Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x10 |
| Mnemonic | LT |
| Gas | 3 |
| Stack Input | a, b |
| Stack Output | a < b (1 if true, 0 if false) |
| Behavior | Unsigned 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:
-
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. -
LT participates in critical safety patterns: The Solidity 0.8.0+ compiler emits
LTas the core of its ADD overflow detection: after computinga + b, it checksresult < a. If this check is absent (pre-0.8.0 code) or bypassed (uncheckedblocks), 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 as0xFFFF...FFFF(2^256 - 1 unsigned)LT(-1, 100)evaluates asLT(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 wrapsWhen 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 levelIf 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 Case | Behavior | Security 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 overflows | Returns 1 (true) | Overflow detection: wrapped result is smaller than operand |
LT(0, a) for any a > 0 | Returns 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:
- Trail of Bits: Avoiding Smart Contract Gridlock with Slither
- Neil M: Gridlock - A Smart Contract Bug
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
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Signed/unsigned confusion | Use SLT/SGT for signed comparisons; never use LT on int256 values | In Yul/assembly: slt(a, b) not lt(a, b) for signed data |
| T1: Cross-contract type mismatch | Validate type consistency at contract boundaries | Explicit type casting with range checks at ABI decode points |
| T2: Off-by-one in boundaries | Use <= / >= when boundary values should be included | Code review checklist: verify every < vs <= against specification |
| T2: Liquidation thresholds | Fuzz-test boundary values exhaustively | Test at threshold, threshold-1, threshold+1 for every comparison |
| T3: Overflow before comparison | Use Solidity >= 0.8.0; validate inputs before arithmetic | Checked arithmetic ensures comparison receives correct values |
| T4: Timestamp manipulation | Cap deadline values; avoid user-controlled timestamp arithmetic | require(deadline <= block.timestamp + MAX_DURATION) |
| T5: Array bounds | Use 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 < int256and LT foruint256 < uint256automatically. The risk is in assembly blocks and ABI boundary mismatches. - Slither: Detects dangerous strict equalities (
dangerous-strict-equalitydetector) and can flag comparisons that should use a different operator.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Assembly bugs in DeFi protocols |
| T2 | Smart Contract | High | High | Off-by-one in liquidation/boundary logic |
| T3 | Smart Contract | High | High (pre-0.8) / Low (post-0.8) | BEC ($infinite tokens), time-lock bypasses |
| T4 | Smart Contract | Medium | Medium | Timestamp overflow bypasses |
| T5 | Smart Contract | Medium | Low | Storage collision edge cases |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| 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 |