Opcode Summary

PropertyValue
Opcode0x15
MnemonicISZERO
Gas3
Stack Inputa
Stack Outputa == 0 (1 if true, 0 if false)
BehaviorReturns 1 if the top-of-stack value is zero, 0 otherwise. Equivalent to EQ(a, 0) but with only one stack input.

Threat Surface

ISZERO is the most versatile single-operand opcode in the EVM’s comparison set. Despite its apparent simplicity, it is the Swiss Army knife of EVM control flow, serving roles far beyond zero-checking:

  1. Boolean negation: ISZERO(x) is the only way to compute logical NOT in the EVM. !condition in Solidity compiles to ISZERO(condition). Double negation ISZERO(ISZERO(x)) normalizes any non-zero value to 1.

  2. Comparison composition: Every >=, <=, and != operator in Solidity compiles to ISZERO wrapping a comparison:

    • a >= bISZERO(LT(a, b))
    • a <= bISZERO(GT(a, b))
    • a != bISZERO(EQ(a, b))
    • a != 0ISZERO(ISZERO(a))
  3. Call success verification: After low-level calls (.call(), .send(), .delegatecall()), the return value (0 for failure, 1 for success) is checked via ISZERO. Missing this check is the “unchecked return value” vulnerability — one of the oldest and most prevalent smart contract bugs.

  4. Overflow detection: ISZERO is used in the compiler-generated overflow check for MUL: ISZERO(a) short-circuits the a * b / a == b verification when a == 0.

  5. Division-by-zero guard: Solidity’s division-by-zero check uses ISZERO(divisor) with a conditional revert, even inside unchecked blocks.

Because ISZERO participates in virtually every control flow decision, a misuse or omission of ISZERO propagates errors through the entire logic of a contract. It’s not that ISZERO itself is dangerous — it’s that every safety check in the EVM passes through ISZERO, making it a linchpin of contract security.


Smart Contract Threats

T1: Unchecked Call Return Value — Silent Failure (Critical)

The most historically devastating ISZERO-related vulnerability. Low-level calls (.call(), .send(), .delegatecall(), .staticcall()) return a boolean success value on the stack. This value must be checked with ISZERO (and a conditional revert) to detect failures. If the return value is popped without checking (via POP instead of ISZERO + JUMPI), failed calls silently succeed from the caller’s perspective:

// VULNERABLE: return value not checked
payable(recipient).send(amount);  // Returns false on failure, but nobody checks
 
// CORRECT: check with require (compiles to ISZERO + JUMPI + REVERT)
require(payable(recipient).send(amount), "send failed");

When .send() or .call() fails:

  • The target contract may have reverted, run out of gas, or hit a stack depth limit
  • The ETH/tokens are not transferred but remain in the sending contract
  • State changes in the calling contract proceed as if the transfer succeeded
  • Balances become inconsistent: internal accounting says funds were sent, but they weren’t

This was ranked in the OWASP Smart Contract Top 10 and appeared in ~30% of Solidity audit findings pre-0.8.0.

T2: Incorrect Boolean Normalization (High)

ISZERO returns exactly 0 or 1, but many EVM operations can produce “truthy” values other than 1. The EVM’s conditional jump (JUMPI) treats any non-zero value as true, but EQ comparisons are strict:

// In assembly, a bitwise AND can produce 2, 4, 128, etc.
uint256 flag = someValue & 0x80;  // Result could be 0 or 128
 
// JUMPI(dest, flag) -- treats 128 as true. Fine.
// But: EQ(flag, 1) returns false for 128. Bug if used as "is true?"

ISZERO normalizes correctly: ISZERO(128) returns 0 (false, meaning “128 is not zero, therefore it’s truthy”). ISZERO(ISZERO(128)) returns 1 (true). But if a developer assumes ISZERO is checking “is this value falsy?” and uses the result in further arithmetic (not just branching), they may get unexpected results.

T3: Masking Overflow/Error via ISZERO-Based Zero Check (High)

A common DeFi pattern: check that a computed amount is non-zero before proceeding. ISZERO(amount) with a conditional revert serves as the guard. But if the amount was computed from arithmetic that overflowed to zero, the ISZERO check correctly identifies zero and reverts — this is actually a mitigation when present. The danger is when ISZERO is absent:

// Pre-0.8.0: no overflow check
uint256 totalCost = quantity * pricePerUnit;  // Overflows to 0
// Missing: require(totalCost > 0);  // ISZERO(totalCost) would catch the phantom zero
token.transfer(buyer, quantity);  // Buyer gets tokens for free

The BEC batchOverflow attack succeeded precisely because a require(amount > 0) check (ISZERO-based) was not applied to the overflowed multiplication result.

T4: Division-by-Zero Handling (Medium)

The EVM’s DIV and SDIV opcodes return 0 when the divisor is 0, rather than reverting. Solidity inserts an ISZERO(divisor) check before every division to revert on zero divisors. This check is present even in unchecked blocks (division by zero is always checked). However:

  • Assembly code: Raw div(a, 0) in Yul returns 0 with no revert. If the developer forgets the ISZERO guard, zero-division silently produces 0, which may propagate as a valid result.
  • Modular arithmetic: MOD(a, 0) also returns 0. A zero modulus in fee or rate calculations can zero out fees entirely.

T5: Double-Negation Pitfalls (Medium)

The pattern ISZERO(ISZERO(x)) normalizes any value to boolean (0 or 1). This is correct but subtle:

  • ISZERO(ISZERO(0)) = ISZERO(1) = 0
  • ISZERO(ISZERO(42)) = ISZERO(0) = 1
  • ISZERO(ISZERO(MAX_UINT256)) = ISZERO(0) = 1

But developers sometimes add an extra ISZERO accidentally (triple negation), inverting the logic:

  • ISZERO(ISZERO(ISZERO(success))) = ISZERO(success_normalized) = inverted!

In assembly-heavy code, tracking the parity of ISZERO applications is error-prone.

T6: Call Success Inversion in Proxy Patterns (Medium)

In proxy contracts, DELEGATECALL returns 0 (failure) or 1 (success). The proxy must check this with ISZERO and either revert or return based on the result. If the ISZERO logic is inverted (e.g., reverting on success instead of failure), the proxy reverts on all successful calls and succeeds on all failures — a catastrophic logic inversion.

let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// Correct: revert if result is 0
if iszero(result) { revert(0, returndatasize()) }
// BUG: if the condition were inverted, success reverts and failure continues

Protocol-Level Threats

P1: No DoS Vector (Low)

ISZERO costs a fixed 3 gas with no dynamic component. Purely stack-based.

P2: Consensus Safety (Low)

Zero comparison on a 256-bit value is trivially deterministic. All EVM implementations agree.

P3: No State Impact (None)

ISZERO modifies only the stack.

P4: Central Role in Compiler Safety Checks (Medium)

ISZERO appears in virtually every compiler-generated safety check:

  • Overflow detection for ADD, MUL
  • Division-by-zero guard
  • Array bounds checking
  • Call success verification
  • Comparison operator composition (>=, <=, !=)

Any EVM client implementation bug in ISZERO would cascade through all these patterns, potentially breaking every safety check simultaneously. While this is theoretical (ISZERO is trivially simple to implement), it underscores ISZERO’s critical role in the EVM’s safety architecture.


Edge Cases

Edge CaseBehaviorSecurity Implication
ISZERO(0)Returns 1 (true)Base case: zero is zero
ISZERO(1)Returns 0 (false)Standard boolean negation
ISZERO(MAX_UINT256)Returns 0 (false)Any non-zero value, no matter how large, is “not zero”
ISZERO(2^255)Returns 0 (false)Sign-agnostic: negative or positive, it’s not zero
ISZERO(ISZERO(42))Returns 1Double-negation normalizes to boolean: 42 → 0 → 1
ISZERO(ISZERO(0))Returns 0Double-negation: 0 → 1 → 0 (correct normalization)
ISZERO(call_result) where call to empty addressReturns 0 (false)Call to empty address returns success (1); ISZERO says “not zero” = don’t revert. Silent success trap!
ISZERO(call_result) where call revertedReturns 1 (true)Call failed (0); ISZERO says “zero” = true → should trigger revert path
ISZERO(div(a, 0))Returns 1 (true)DIV by zero returns 0; ISZERO catches it, but the real check should be on the divisor

Real-World Exploits

Exploit 1: King of the Ether Throne — Unchecked send() (February 2016)

Root cause: The contract used .send() to pay the previous king but never checked the return value. Failed sends silently continued execution.

Details: The King of the Ether Throne contract allowed users to claim the “throne” by sending more ETH than the current king. The contract then attempted to compensate the deposed king via previousKing.send(compensation). When the previous king was a contract address (e.g., a Mist wallet) that required more than 2300 gas to receive ETH, the .send() call failed and returned false. Without an ISZERO check on the return value followed by a revert, the contract continued execution: the new king was crowned, and the old king’s compensation was permanently lost.

ISZERO’s role: The missing safety pattern was:

CALL (send ETH)      → pushes 0 (failure) or 1 (success) onto stack
ISZERO               → 0 becomes 1 (true: failed), 1 becomes 0 (false: ok)
JUMPI to revert      → revert if ISZERO returned 1

Without this ISZERO + JUMPI pattern, the failed return value was simply discarded via POP.

Impact: Multiple users lost ETH in failed compensations during the “Turbulent Age” (February 6-8, 2016). The incident became the canonical example of the “unchecked send” vulnerability class.

References:


Exploit 2: Unchecked Return Value Epidemic — 30% of Audited Contracts (2016-2019)

Root cause: Systematic failure to check return values of low-level calls across the Ethereum ecosystem.

Details: A 2016 scan of live Ethereum contracts found that approximately 30% contained unchecked return value vulnerabilities. The Solidity Ethereum Foundation blog post from June 2016 specifically warned about this pattern. The vulnerability manifested in multiple contract types:

  • Multi-sig wallets: Failed ETH transfers to signers went unnoticed
  • Token contracts: Failed ERC-20 transfer() return values (especially for non-compliant tokens like USDT that return void instead of bool)
  • Payment splitters: Failed payments to one recipient didn’t halt distribution to others

The introduction of OpenZeppelin’s SafeERC20 library (which wraps ERC-20 calls with return value verification) and Solidity’s require() syntax significantly reduced the incidence of this bug class.

ISZERO’s role: Every unchecked return value bug is fundamentally a missing ISZERO check. The fix is always the same pattern: ISZERO(call_result) + JUMPI(revert_dest), expressed in Solidity as require(success).

References:


Exploit 3: BEC batchOverflow — Missing Non-Zero Check on Overflowed Amount (April 2018)

CVE: CVE-2018-10299

Root cause: Integer overflow in cnt * _value produced 0, and no ISZERO-based check caught the phantom zero before it was used in a balance comparison.

Details: The BEC batchTransfer() function computed uint256 amount = uint256(cnt) * _value, which overflowed to 0. The function then checked require(balances[msg.sender] >= amount) — which passes for any balance when amount == 0. A simple additional check require(amount > 0) (which compiles to ISZERO(amount) + conditional revert) would have caught the overflow-to-zero, since any legitimate batch transfer should have a non-zero total amount.

ISZERO’s role: The phantom zero could have been detected by an ISZERO(amount) check. More broadly, ISZERO-based zero checks serve as a cheap, effective canary for MUL overflow: if two non-zero inputs produce a zero product, overflow definitely occurred. This is not a complete overflow detection (overflow can produce non-zero results too), but it catches the most dangerous phantom-zero pattern.

References:


Exploit 4: Non-Compliant ERC-20 Tokens — USDT’s Missing Return Value

Root cause: USDT’s transfer() and approve() functions don’t return a boolean, violating the ERC-20 standard. Contracts that check the return value with ISZERO receive garbage data.

Details: Tether’s USDT is one of the most widely used ERC-20 tokens, but its transfer() and approve() functions were implemented without a return value (they return void). When a calling contract executes IERC20(usdt).transfer(to, amount) and the Solidity ABI decoder expects a boolean return, it reads from returndata that doesn’t exist or contains leftover memory. Depending on the Solidity version:

  • Solidity < 0.5.0: May not check return data length, succeeding on void returns
  • Solidity >= 0.5.0: Reverts if return data doesn’t match expected ABI encoding

This created a situation where USDT transfers reverted on modern contracts, and the ISZERO-based return value check was comparing against garbage data on older contracts.

ISZERO’s role: OpenZeppelin’s SafeERC20 library handles this by checking: (1) the low-level call succeeded (ISZERO(success) check), and (2) either the return data is empty OR it decodes to true (a second ISZERO-based check on the decoded boolean). This two-level ISZERO pattern became the standard for interacting with non-compliant tokens.

References:


Attack Scenarios

Scenario A: Unchecked send() Leads to Fund Loss

// Pre-0.8.0 pattern
contract VulnerablePayment {
    mapping(address => uint256) public pendingWithdrawals;
    
    function processPayment(address payable recipient, uint256 amount) internal {
        // BUG: send() return value not checked
        recipient.send(amount);  // Returns false if recipient is a contract that reverts
        // ISZERO check is missing! Execution continues even on failure
        pendingWithdrawals[recipient] = 0;  // Balance zeroed but funds not sent
    }
    
    // FIXED version:
    // require(recipient.send(amount), "Payment failed");
    // Or: (bool success, ) = recipient.call{value: amount}("");
    //     require(success, "Payment failed");
}

Scenario B: Phantom Zero Not Caught

// Pre-0.8.0
contract VulnerableToken {
    function batchMint(address[] calldata recipients, uint256 amount) external {
        uint256 total = recipients.length * amount;  // MUL overflow to 0
        
        // Missing ISZERO check on 'total':
        // require(total > 0);  ← This would catch phantom zero
        
        require(balances[msg.sender] >= total);  // 0 >= 0 passes
        balances[msg.sender] -= total;  // Subtracts 0
        
        for (uint i = 0; i < recipients.length; i++) {
            balances[recipients[i]] += amount;  // Mints enormous amounts
        }
    }
}

Scenario C: Division-by-Zero in Assembly

contract VulnerableCalc {
    function computeShare(uint256 amount, uint256 totalShares, uint256 totalAssets) 
        internal pure returns (uint256) 
    {
        uint256 result;
        assembly {
            // BUG: no ISZERO check on totalAssets before division
            result := div(mul(amount, totalShares), totalAssets)
            // If totalAssets == 0, div returns 0 silently
            // User gets 0 shares for a non-zero deposit
        }
        return result;
        
        // FIXED: 
        // if iszero(totalAssets) { revert(0, 0) }
        // result := div(mul(amount, totalShares), totalAssets)
    }
}

Scenario D: Inverted ISZERO in Proxy

contract VulnerableProxy {
    address public implementation;
    
    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            
            // BUG: inverted logic -- reverts on SUCCESS, returns on FAILURE
            if result { revert(0, returndatasize()) }
            return(0, returndatasize())
            
            // CORRECT:
            // if iszero(result) { revert(0, returndatasize()) }
            // return(0, returndatasize())
        }
    }
}

Mitigations

ThreatMitigationImplementation
T1: Unchecked return valueAlways check call/send/delegatecall return valuesrequire(success) after every low-level call
T1: Non-compliant ERC-20Use SafeERC20 for all token interactionsusing SafeERC20 for IERC20; token.safeTransfer(to, amount)
T2: Non-canonical booleansUse ISZERO(ISZERO(x)) to normalize to 0/1Standard pattern in compiler-generated code
T3: Phantom zero from overflowAdd non-zero checks on computed amountsrequire(amount > 0) after any multiplication
T4: Division by zero in assemblyAlways check divisor with ISZERO before DIVif iszero(divisor) { revert(0, 0) }
T5: Triple negationCount ISZERO applications; verify parityCode review; test with both zero and non-zero inputs
T6: Proxy ISZERO inversionStandard proxy pattern: revert if ISZERO(result)Use OpenZeppelin’s Proxy base contract

Compiler/EIP-Based Protections

  • Solidity 0.8.0+: High-level external calls (IERC20(addr).transfer(...)) revert if the call fails or returns unexpected data. This eliminates the most common unchecked return value patterns.
  • Solidity >= 0.4.22: require() was introduced, providing a cleaner syntax for ISZERO-based checks.
  • OpenZeppelin SafeERC20: Handles non-compliant tokens (USDT) with a dual ISZERO check pattern.
  • Address.sendValue(): OpenZeppelin’s wrapper around .call{value: amount}("") that checks the return value.
  • Slither: unchecked-send, unchecked-lowlevel, and unchecked-transfer detectors specifically flag missing ISZERO patterns on call return values.

ISZERO in Compiler-Generated Safety Patterns

ISZERO appears in virtually every compiler-generated safety check:

// ADD overflow detection (Solidity 0.8+)
ADD → DUP → LT(result, a) → ISZERO → JUMPI(safe)  // Revert if overflow

// MUL overflow detection  
MUL → DUP(a) → ISZERO → short_circuit  // Skip check if a == 0
            → DUP → DIV → EQ → ISZERO → JUMPI(revert)

// Division by zero
divisor → ISZERO → JUMPI(revert)  // Revert if divisor is 0

// Array bounds
index → LT(index, length) → ISZERO → JUMPI(revert)  // Revert if out of bounds

// Call success
CALL → ISZERO → JUMPI(revert)  // Revert if call failed

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractCriticalHigh (pre-0.8) / Low (post-0.8)King of Ether, USDT incompatibility, 30% of pre-0.8 contracts
T2Smart ContractHighLowAssembly-level boolean handling bugs
T3Smart ContractHighHigh (pre-0.8) / Low (post-0.8)BEC batchOverflow ($infinite tokens)
T4Smart ContractMediumMediumAssembly division-by-zero in DeFi math
T5Smart ContractMediumLowAssembly logic bugs
T6Smart ContractMediumLowProxy implementation errors
P1ProtocolLowN/A
P2ProtocolLowN/A
P4ProtocolMediumVery LowTheoretical: catastrophic if ISZERO had a client bug

OpcodeRelationship
EQ (0x14)ISZERO(a) is equivalent to EQ(a, 0). ISZERO is the specialized single-operand form
LT (0x10)a >= b compiles to ISZERO(LT(a, b)). ISZERO inverts LT for non-strict comparisons
GT (0x11)a <= b compiles to ISZERO(GT(a, b)). Same inversion pattern
SLT (0x12)a >= b (signed) compiles to ISZERO(SLT(a, b))
SGT (0x13)a <= b (signed) compiles to ISZERO(SGT(a, b))
CALL (0xF1)Returns success/failure; ISZERO is the standard check on CALL’s return value
DELEGATECALL (0xF4)Same return value pattern as CALL; ISZERO check critical in proxy contracts
JUMPI (0x57)ISZERO feeds into JUMPI for conditional branching; they form the core of EVM control flow
DIV (0x04)ISZERO guards against division by zero before DIV
MUL (0x02)ISZERO is used in MUL overflow detection pattern to short-circuit when multiplicand is 0