Opcode Summary

PropertyValue
Opcode0x06
MnemonicMOD
Gas5
Stack Inputa, b
Stack Outputa % b
BehaviorUnsigned 256-bit modulus. Returns the remainder of a divided by b. If b == 0, the result is 0 (NOT a revert).

Threat Surface

MOD occupies a unique threat niche among EVM arithmetic opcodes. Unlike ADD and MUL, which threaten through overflow, MOD threatens through silent failure on zero divisors and predictability when used as a randomness primitive.

The EVM Yellow Paper specifies that a % 0 == 0 for all values of a. This is mathematically undefined behavior that the EVM resolves by returning zero — silently, with no exception, no revert, and no flag. Any contract that uses MOD to compute an index, distribute funds, or enforce access control without first validating the divisor can produce a zero result that bypasses downstream logic. A mapping index of 0, a fee calculation that yields 0, or a round-robin selector that always returns 0 can all be catastrophic depending on context.

The second major threat vector is MOD’s pervasive use as a range-reduction primitive: value % N maps a large value into the range [0, N). This pattern appears in pseudo-random number generation (block.timestamp % N), round-robin scheduling, slot allocation, and sharding logic. When the input to MOD is predictable (as all on-chain values are), the output is equally predictable, destroying any randomness assumption. Miners and validators can manipulate block.timestamp within the allowed tolerance (~15 seconds on Ethereum) to influence timestamp % N outcomes directly.

The third threat is modulus bias: when 2^256 is not evenly divisible by N (which is almost always the case), value % N produces a non-uniform distribution. Lower remainders appear slightly more often than higher ones. While the bias is negligible for small N relative to 2^256, it becomes a real concern in adversarial settings where even tiny statistical edges translate to extractable value over millions of transactions.


Smart Contract Threats

T1: Mod-by-Zero Silent Failure (High)

At the EVM level, a % 0 returns 0 for any value of a. This is mathematically undefined, but the EVM does not revert — it silently produces zero. Any contract relying on MOD where the divisor can be zero (or can be manipulated to zero) will receive 0 instead of a meaningful result.

Solidity >= 0.8.0 inserts a check that reverts on mod-by-zero (even inside unchecked {} blocks — this check cannot be disabled). However, the vulnerability persists in:

  • Pre-0.8.0 contracts: No automatic check. a % 0 silently returns 0.
  • Inline assembly: mod(a, b) in Yul/assembly bypasses Solidity’s check and uses the raw EVM behavior.
  • Raw bytecode: Contracts deployed from other languages (Vyper < certain versions, Huff, raw bytecode) may not check.

Dangerous patterns:

// Pre-0.8.0: if totalShares is somehow 0, index is always 0
uint256 index = someValue % totalShares;
assets[index] += reward;  // All rewards go to slot 0
 
// Assembly bypass even in 0.8+
assembly {
    let result := mod(a, b)  // Returns 0 when b == 0, no revert
}

When b derives from user input, external calls, or state that can be drained to zero (e.g., totalSupply after a total withdrawal), this becomes exploitable.

T2: Predictable Randomness via Modulus (Critical)

The most widely exploited misuse of MOD is as a range-reduction step in pseudo-random number generation:

uint256 "random" = uint256(keccak256(abi.encodePacked(
    block.timestamp, block.prevrandao, msg.sender
))) % N;

Every input to this hash is known or predictable:

  • block.timestamp: Validators choose the timestamp within a ~15-second tolerance window. They can iterate over valid timestamps to find one that yields a favorable % N result before producing the block.
  • block.prevrandao: Known to the block proposer before the block is finalized. Under proof-of-stake, the proposer knows prevrandao at least one slot in advance.
  • block.number: Completely deterministic and known in advance.
  • msg.sender: Known to the caller. An attacker can deploy contracts at pre-computed addresses (using CREATE2) to control msg.sender.

An attacker contract can replicate the randomness formula, compute the result, and only proceed if the outcome is favorable — reverting otherwise at no cost beyond gas. This is the standard attack pattern against on-chain lotteries, NFT rarity assignment, and game mechanics.

T3: Modulus Bias in Range Reduction (Medium)

When mapping a uniform 256-bit value into range [0, N) via value % N, the distribution is biased unless N is a power of 2 or divides 2^256 evenly.

For a 256-bit random value r, the number of values mapping to remainder k is:

  • floor(2^256 / N) + 1 for k < (2^256 % N)
  • floor(2^256 / N) for k >= (2^256 % N)

The bias magnitude is (2^256 % N) / 2^256, which is vanishingly small for small N relative to 2^256. For N = 100, the bias is approximately 1 / 2^256 — negligible. However:

  • For N close to 2^255, bias can approach 50%.
  • In adversarial settings with millions of samples, even small biases become exploitable.
  • Gambling/lottery contracts that claim “provably fair” are technically incorrect if they use raw % N.

The correct approach is rejection sampling: discard values r >= N * floor(2^256 / N) and resample. In practice, Chainlink VRF and well-designed randomness systems handle this internally.

T4: Off-by-One in Range Calculations (Medium)

MOD produces values in [0, N-1]. Developers who confuse this with [1, N] or [0, N] introduce off-by-one errors:

// BUG: slot 0 is never used; slot N is out of bounds
uint256 slot = (value % N) + 1;  // Range: [1, N] instead of [0, N-1]
 
// BUG: expects range [1, N] but gets [0, N-1]; 0 may be invalid
uint256 winnerId = randomHash % participantCount;
// If participant IDs are 1-indexed, winnerId=0 is invalid

In DeFi, an off-by-one in a round-robin or slot-selection mechanism can exclude one participant from ever receiving rewards, or can direct funds to a non-existent slot (typically mapping to the zero address or an uninitialized storage slot).

T5: Storage Slot Collision via Modulus-Based Mapping (Medium)

Contracts that use key % N to map keys into a fixed number of storage slots create intentional hash collisions. If N is small or if an attacker can choose keys, they can force multiple distinct logical entries into the same physical slot:

mapping(uint256 => uint256) public buckets;
 
function store(uint256 key, uint256 value) external {
    uint256 bucket = key % NUM_BUCKETS;
    buckets[bucket] = value;  // Overwrites previous entry in same bucket
}

An attacker can compute keys that collide modulo NUM_BUCKETS, overwriting or corrupting data. If NUM_BUCKETS is known (it usually is, since it’s in the bytecode), generating colliding keys is trivial: any k and k + NUM_BUCKETS map to the same bucket.

T6: Modular Arithmetic in Access Control (High)

Contracts that use MOD for time-based access control or scheduling are vulnerable to manipulation:

// Only allow withdrawals in "even" epochs
require(block.number % 2 == 0, "odd epoch");
 
// Rotating admin: current admin is determined by block time
uint256 adminIndex = block.timestamp % numAdmins;
address currentAdmin = admins[adminIndex];
require(msg.sender == currentAdmin);

Validators can choose when to include transactions (or produce blocks) to satisfy these conditions. On proof-of-stake Ethereum, the block proposer has full control over which transactions to include and can choose timestamps within the allowed range.


Protocol-Level Threats

P1: No DoS Vector (Low)

MOD costs a fixed 5 gas regardless of operand size. It cannot be used for gas griefing. It operates purely on the stack with no memory or storage access.

P2: Consensus Safety (Low)

MOD is deterministic: a mod b (with 0 for b == 0) is unambiguous for any pair of 256-bit unsigned integers. All EVM client implementations (geth, Nethermind, Besu, Erigon, reth) agree on its behavior, including the b == 0 edge case. No known consensus divergence has occurred due to MOD.

P3: No Direct State Impact (None)

MOD modifies only the stack. It cannot cause state bloat, storage writes, or memory expansion.

P4: Compiler-Generated Zero-Divisor Checks (Low)

Solidity 0.8+ inserts a zero-divisor check before every MOD operation:

// Compiler-generated check for a % b
PUSH b
DUP1
ISZERO       // b == 0?
PUSH @panic
JUMPI        // If b == 0, jump to panic handler
SWAP1
PUSH a
MOD          // Safe: b is known non-zero

Unlike overflow checks, this cannot be disabled with unchecked {}. Solidity considers mod-by-zero a distinct safety category from overflow. The only way to reach the raw EVM a % 0 == 0 behavior is through inline assembly. Different compiler versions may emit slightly different check patterns, but all versions >= 0.8.0 revert on mod-by-zero in high-level Solidity code.


Edge Cases

Edge CaseBehaviorSecurity Implication
a % 0Returns 0Silent failure; mathematically undefined. Bypasses any logic expecting a meaningful remainder.
0 % bReturns 0Correct behavior (0 divided by anything has remainder 0). Safe, but downstream code checking result == 0 as an error condition may false-positive.
a % 1Returns 0Always returns 0 regardless of a. If an attacker can set the divisor to 1, all values collapse to 0.
a % aReturns 0Trivially correct. An attacker who knows a can set b = a to force a zero result.
a % (a + 1)Returns aReturns the dividend unchanged. If b is just above a, MOD is a no-op.
large % smallReturns value in [0, small-1]Range reduction. Correct, but distribution is biased if 2^256 % small != 0.
small % largeReturns small unchangedMOD is a no-op when a < b. If code assumes the result is always less than a, this can confuse range assumptions.
MAX_UINT256 % 2Returns 12^256 - 1 is odd. Correct, but shows that MAX_UINT256 is not even — relevant for parity checks.
MAX_UINT256 % MAX_UINT256Returns 0Any value modulo itself is 0.
(2^128) % (2^128 + 1)Returns 2^128Dividend returned unchanged because a < b is false but a / b == 0… wait, 2^128 / (2^128 + 1) == 0 with remainder 2^128. Correct.

Real-World Exploits

Exploit 1: Fomo3D Airdrop — Predictable Modulus-Based Randomness (July 2018)

Root cause: The airdrop lottery used block.timestamp, block.difficulty, msg.sender, and other on-chain data, reduced via modulus to determine winners. All inputs were predictable or manipulable.

Details: Fomo3D was a wildly popular Ethereum game that included an airdrop lottery mechanism. The contract computed a “random” seed from on-chain data including block.timestamp, block.difficulty, sender address, and game state, then used modulus to determine if the caller won an airdrop prize.

The critical flaw: an attacker could deploy a contract that replicated the randomness formula, pre-computed whether a call would win, and only proceeded if the result was favorable. Using CREATE2, attackers could also pre-calculate contract addresses to control the msg.sender component of the seed. PeckShield documented attackers using iterative contract creation to cycle through addresses until finding one that would produce a winning modulus result.

// Simplified Fomo3D airdrop logic
function airdrop() internal view returns (bool) {
    uint256 seed = uint256(keccak256(abi.encodePacked(
        (block.timestamp).add(block.difficulty).add(
            uint256(keccak256(abi.encodePacked(block.coinbase)))
        ).add(block.gaslimit).add(
            uint256(keccak256(abi.encodePacked(msg.sender)))
        ).add(block.number),
        airDropTracker_
    )));
    // Modulus determines win condition
    if ((seed - ((seed / 1000) * 1000)) < airDropPot_) {
        return true;
    }
    return false;
}

The expression seed - ((seed / 1000) * 1000) is equivalent to seed % 1000. The attacker’s exploit contract computed this in advance and only called the game when the result guaranteed a win.

MOD’s role: MOD (or its equivalent floor-division pattern) was the range-reduction step that converted the predictable seed into a win/lose decision. The predictability of all inputs combined with the deterministic modulus operation made the “lottery” completely exploitable.

Impact: Attackers systematically drained airdrop prizes totaling significant ETH. The exploit demonstrated that on-chain modulus-based randomness is fundamentally broken, catalyzing industry adoption of Chainlink VRF and commit-reveal schemes.

References:


Exploit 2: SmartBillions Lottery — On-Chain Modulus Randomness Drained (October 2017)

Root cause: Lottery outcome was determined by blockhash(block.number) % range, which was predictable and manipulable by miners.

Details: SmartBillions was an Ethereum-based lottery that claimed provably fair outcomes. The contract used blockhash of a recent block combined with participant data, reduced via modulus, to select winning numbers. The lottery drew numbers in the range [0, N) using hash % N.

The vulnerability: blockhash is known to everyone once a block is mined, and the block proposer (miner) knows it before broadcasting. A miner could compute blockhash % N for their candidate block and choose whether to publish it based on whether the lottery outcome was favorable. Even non-miners could predict outcomes by watching the mempool and submitting entries in the same block as the draw, where blockhash was already determined.

An attacker exploited this to win multiple consecutive lottery draws, draining over 400 ETH (~$120,000 at the time).

MOD’s role: The modulus operation was the final step that reduced the hash into a lottery number. Since the hash input was predictable, the modulus output was equally predictable. MOD itself behaved correctly — the vulnerability was in trusting it to produce “random” results from deterministic inputs.

Impact: 400+ ETH stolen. SmartBillions paused the lottery and redesigned the randomness mechanism. The incident became a canonical example of SWC-120 (Weak Sources of Randomness from Chain Attributes).

References:


Exploit 3: Meebits NFT — Predictable Token ID via Modulus (May 2021)

Root cause: NFT token ID assignment used on-chain data reduced via modulus, allowing attackers to predict which token ID they would receive and cherry-pick rare NFTs.

Details: Meebits, created by Larva Labs (the CryptoPunks team), assigned token IDs to minters using a pseudo-random algorithm. The randomIndex was computed from on-chain data and reduced via modulus to select from remaining unminted IDs. Because all inputs were on-chain and visible, an attacker contract could:

  1. Simulate the randomness formula
  2. Determine which Meebit would be minted in the current block
  3. Proceed only if the resulting Meebit was rare (checking against the known metadata)
  4. Revert the transaction if the Meebit was common (costing only gas)

A user known as “0xNietzsche” executed over 300 test transactions, eventually minting the rare Meebit #16647, which was sold for 200 ETH (~$700,000). Multiple other attackers ran similar contracts to extract remaining rare Meebits.

MOD’s role: The modulus operation was used to map the pseudo-random value into the range of available token IDs (hash % remainingSupply). The predictability of the hash combined with the deterministic modulus reduction meant attackers could preview exactly which token they’d receive.

Impact: ~$700,000+ in value extracted through cherry-picked rare mints. The exploit undermined the project’s fairness guarantees and led to industry-wide reassessment of NFT randomness mechanisms.

References:


Exploit 4: Multiple ERC-20 Tokens — Division/Modulus by Zero in Fee Distributions (2018-2020)

Root cause: Fee or reward distribution contracts computed per-holder shares as totalReward % holderCount or totalReward / holderCount without checking that holderCount > 0. On pre-0.8.0 Solidity, this silently returned 0 rather than reverting.

Details: Multiple DeFi and token contracts used modular arithmetic in their reward distribution logic to handle remainders. A common pattern:

// Pre-0.8.0: no automatic check
uint256 perHolder = totalReward / holderCount;
uint256 remainder = totalReward % holderCount;
// Remainder distributed to first 'remainder' holders
 
// If holderCount == 0 (all holders exited):
// perHolder = 0 (DIV by zero returns 0)
// remainder = 0 (MOD by zero returns 0)
// totalReward is silently lost -- stuck in contract forever

When all holders withdrew, holderCount dropped to zero. The next reward distribution computed reward % 0 == 0, distributing nothing and trapping the reward tokens permanently. While this was more of a fund-locking bug than a direct theft, it represented permanent loss for the protocol.

In more adversarial scenarios, attackers could deliberately drain holder count to zero, then deposit new tokens knowing the unclaimed rewards would accumulate, waiting for the modulus to produce favorable distributions after new holders joined.

MOD’s role: The % 0 == 0 behavior directly caused the silent fund loss. The EVM’s choice to return 0 instead of reverting meant the contract continued execution as if the distribution succeeded, when in fact all rewards were silently discarded.

Impact: Individually small (typically < $100K per incident), but widespread across dozens of contracts. The pattern was identified as a systemic issue by audit firms and contributed to Solidity 0.8.0’s decision to make mod-by-zero always revert.

References:


Attack Scenarios

Scenario A: Mod-by-Zero in Reward Distribution

// Solidity < 0.8.0, no SafeMath on MOD
contract VulnerableRewards {
    uint256 public totalStaked;
    mapping(address => uint256) public stakes;
 
    function distribute(uint256 reward) external {
        // If all stakers withdrew, totalStaked == 0
        uint256 perStaker = reward / totalStaked;     // Returns 0 (DIV by zero)
        uint256 remainder = reward % totalStaked;      // Returns 0 (MOD by zero)
        // 'reward' tokens are silently trapped in the contract forever
    }
}

Attack: Wait for or cause all stakers to withdraw (e.g., via a panic-inducing price crash). The next reward distribution computes % 0 == 0, permanently locking all distributed rewards.

Scenario B: Predictable Lottery via Modulus

contract VulnerableLottery {
    uint256 public ticketPrice = 0.1 ether;
    address[] public players;
 
    function draw() external {
        require(players.length > 0);
        // "Random" winner selection using on-chain data
        uint256 index = uint256(keccak256(abi.encodePacked(
            block.timestamp, block.prevrandao, players.length
        ))) % players.length;
        
        payable(players[index]).transfer(address(this).balance);
        delete players;
    }
}
 
// Attacker contract
contract LotteryExploit {
    VulnerableLottery public target;
    
    function attack() external payable {
        // Pre-compute the winner index
        uint256 index = uint256(keccak256(abi.encodePacked(
            block.timestamp, block.prevrandao, target.players.length + 1
        ))) % (target.players.length + 1);
        
        // Only enter if we'll be the winner
        require(index == target.players.length, "won't win");
        target.enter{value: 0.1 ether}();
    }
}

Attack: Deploy the exploit contract. Call attack() repeatedly across blocks until one block’s timestamp/prevrandao combination produces a favorable modulus result. The attacker only enters the lottery when they’ve pre-verified they’ll win.

Scenario C: Modulus-Based Access Control Bypass

contract VulnerableTimeLock {
    mapping(address => uint256) public deposits;
    uint256 public constant LOCK_PERIOD = 30 days;
    uint256 public numEpochs;
 
    function withdraw() external {
        require(deposits[msg.sender] > 0);
        // Intended: only allow withdrawal in specific epochs
        uint256 currentEpoch = block.timestamp % numEpochs;
        require(currentEpoch == 0, "not withdrawal epoch");
        
        // BUG: if numEpochs is set to 0 (e.g., by admin error or exploit),
        // block.timestamp % 0 == 0 at the EVM level (in assembly/pre-0.8)
        // The require always passes -- anyone can withdraw anytime
        
        uint256 amount = deposits[msg.sender];
        deposits[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

Attack: If numEpochs can be set to 0 (through admin error, governance manipulation, or uninitialized state), the modulus operation returns 0, and the withdrawal condition currentEpoch == 0 always passes, bypassing the time lock entirely.

Scenario D: NFT Rarity Sniping via Modulus Prediction

contract VulnerableNFTMint {
    uint256 public nextTokenId;
    uint256 public constant MAX_SUPPLY = 10000;
    mapping(uint256 => uint8) public rarity;  // Pre-assigned rarity per ID
 
    function mint() external payable {
        require(msg.value >= 0.05 ether);
        // "Random" assignment from remaining IDs
        uint256 seed = uint256(keccak256(abi.encodePacked(
            block.timestamp, msg.sender, nextTokenId
        )));
        uint256 assignedId = seed % MAX_SUPPLY;
        // ... assign token ...
    }
}
 
// Attacker: deploy via CREATE2 to control msg.sender,
// compute seed % MAX_SUPPLY, only mint if assignedId is rare

Attack: The attacker knows block.timestamp (within tolerance), controls msg.sender via CREATE2, and knows nextTokenId from on-chain state. They compute seed % MAX_SUPPLY off-chain and submit the mint transaction only when the modulus maps to a rare token ID.


Mitigations

ThreatMitigationImplementation
T1: Mod-by-zeroUse Solidity >= 0.8.0 (automatic revert on mod-by-zero)Default behavior; cannot be disabled even in unchecked {} blocks
T1: Assembly bypassValidate divisor before assembly modrequire(b != 0) before any assembly { mod(a, b) }
T1: Zero-state divisorGuard against states where divisor can reach zerorequire(totalStakers > 0) before distribution; handle empty-pool edge case explicitly
T2: Predictable randomnessUse Chainlink VRF or commit-reveal schemesChainlink VRF v2.5; 2-transaction commit-reveal pattern
T2: Miner/validator manipulationNever use block.timestamp, block.number, block.prevrandao as sole entropy sourceCombine with off-chain entropy; use VRF for any financially meaningful randomness
T3: Modulus biasUse rejection sampling instead of raw % NDiscard values >= N * floor(2^256 / N); or use Chainlink VRF which handles this internally
T4: Off-by-oneVerify range boundaries with tests and formal specsFuzz-test that result is always in expected range [min, max]; use [0, N) convention consistently
T5: Slot collisionUse cryptographic hashing (keccak256) for bucketing instead of % Nuint256 bucket = uint256(keccak256(abi.encode(key))) % N — still biased, but collision-resistant
T6: Access control via modulusUse explicit time windows or role-based access instead of modular schedulingReplace block.timestamp % N == 0 with block.timestamp >= startTime && block.timestamp <= endTime
General: Randomness auditFlag all uses of % with block variables as high-riskStatic analysis: Slither’s weak-prng detector; manual audit checklist for all % N patterns

Compiler/EIP-Based Protections

  • Solidity 0.8.0+ (2020): Automatic revert on mod-by-zero. Unlike overflow checks, this cannot be disabled with unchecked {} — it is a separate safety class. The compiler inserts ISZERO + JUMPI to a panic handler before every MOD instruction.
  • Chainlink VRF (2020+): Off-chain verifiable random function that provides provably fair, unpredictable randomness. Eliminates the need for on-chain modulus-based pseudo-randomness entirely. VRF v2.5 handles modulus bias internally via rejection sampling.
  • EIP-4399 / PREVRANDAO (2022): Replaced block.difficulty with block.prevrandao post-Merge. While stronger than difficulty (which was always 0 post-Merge), prevrandao is still known to the block proposer in advance and is not suitable for high-value randomness without additional entropy sources.
  • Static analysis tools: Slither detects weak-prng (modulus with block variables). Mythril and Certora can verify that divisors are never zero.

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractHighMedium (pre-0.8) / Low (post-0.8)Multiple ERC-20 fee distribution bugs
T2Smart ContractCriticalHighFomo3D airdrops, SmartBillions (700K+)
T3Smart ContractMediumLowTheoretical; relevant for provably-fair gambling contracts
T4Smart ContractMediumMediumNumerous audit findings (off-by-one in index calculations)
T5Smart ContractMediumLowTheoretical; custom bucketing contracts
T6Smart ContractHighMediumFomo3D (time-based game mechanics manipulated by miners)
P1ProtocolLowN/A
P2ProtocolLowN/A

OpcodeRelationship
SMOD (0x07)Signed modulus variant. Same b == 0 returns 0 behavior, but result takes the sign of a (two’s complement). Misuse of SMOD when unsigned MOD is intended can flip result signs.
DIV (0x04)Unsigned division. DIV also returns 0 for b == 0. MOD and DIV are complementary: a == (a / b) * b + (a % b) for b != 0.
ADDMOD (0x08)Computes (a + b) % N with a 512-bit intermediate addition, avoiding overflow. Returns 0 when N == 0. Safer for modular addition in cryptographic contexts.
MULMOD (0x09)Computes (a * b) % N with a 512-bit intermediate product, avoiding overflow. Returns 0 when N == 0. Essential for elliptic curve and modular arithmetic.
MUL (0x02)Multiplication and modulus are inverse operations in many DeFi patterns (amount * rate / PRECISION vs amount % PRECISION for remainders). MUL overflow can produce values that make subsequent MOD behave unexpectedly.