Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x06 |
| Mnemonic | MOD |
| Gas | 5 |
| Stack Input | a, b |
| Stack Output | a % b |
| Behavior | Unsigned 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 % 0silently 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% Nresult before producing the block.block.prevrandao: Known to the block proposer before the block is finalized. Under proof-of-stake, the proposer knowsprevrandaoat 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 (usingCREATE2) to controlmsg.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) + 1fork < (2^256 % N)floor(2^256 / N)fork >= (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
Nclose to2^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 invalidIn 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 Case | Behavior | Security Implication |
|---|---|---|
a % 0 | Returns 0 | Silent failure; mathematically undefined. Bypasses any logic expecting a meaningful remainder. |
0 % b | Returns 0 | Correct behavior (0 divided by anything has remainder 0). Safe, but downstream code checking result == 0 as an error condition may false-positive. |
a % 1 | Returns 0 | Always returns 0 regardless of a. If an attacker can set the divisor to 1, all values collapse to 0. |
a % a | Returns 0 | Trivially correct. An attacker who knows a can set b = a to force a zero result. |
a % (a + 1) | Returns a | Returns the dividend unchanged. If b is just above a, MOD is a no-op. |
large % small | Returns value in [0, small-1] | Range reduction. Correct, but distribution is biased if 2^256 % small != 0. |
small % large | Returns small unchanged | MOD is a no-op when a < b. If code assumes the result is always less than a, this can confuse range assumptions. |
MAX_UINT256 % 2 | Returns 1 | 2^256 - 1 is odd. Correct, but shows that MAX_UINT256 is not even — relevant for parity checks. |
MAX_UINT256 % MAX_UINT256 | Returns 0 | Any value modulo itself is 0. |
(2^128) % (2^128 + 1) | Returns 2^128 | Dividend 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:
- PeckShield: Pwning Fomo3D — Iterative Pre-Calculated Contract Creation
- Apriorit: Fomo3D Vulnerability Explained
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:
- Simulate the randomness formula
- Determine which Meebit would be minted in the current block
- Proceed only if the resulting Meebit was rare (checking against the known metadata)
- 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:
- iphelix: Meebit NFT Exploit Analysis
- CryptoPotato: Exploit in Larva Labs Meebits
- Code4rena: Meebits Audit Report
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 foreverWhen 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 rareAttack: 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
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Mod-by-zero | Use Solidity >= 0.8.0 (automatic revert on mod-by-zero) | Default behavior; cannot be disabled even in unchecked {} blocks |
| T1: Assembly bypass | Validate divisor before assembly mod | require(b != 0) before any assembly { mod(a, b) } |
| T1: Zero-state divisor | Guard against states where divisor can reach zero | require(totalStakers > 0) before distribution; handle empty-pool edge case explicitly |
| T2: Predictable randomness | Use Chainlink VRF or commit-reveal schemes | Chainlink VRF v2.5; 2-transaction commit-reveal pattern |
| T2: Miner/validator manipulation | Never use block.timestamp, block.number, block.prevrandao as sole entropy source | Combine with off-chain entropy; use VRF for any financially meaningful randomness |
| T3: Modulus bias | Use rejection sampling instead of raw % N | Discard values >= N * floor(2^256 / N); or use Chainlink VRF which handles this internally |
| T4: Off-by-one | Verify range boundaries with tests and formal specs | Fuzz-test that result is always in expected range [min, max]; use [0, N) convention consistently |
| T5: Slot collision | Use cryptographic hashing (keccak256) for bucketing instead of % N | uint256 bucket = uint256(keccak256(abi.encode(key))) % N — still biased, but collision-resistant |
| T6: Access control via modulus | Use explicit time windows or role-based access instead of modular scheduling | Replace block.timestamp % N == 0 with block.timestamp >= startTime && block.timestamp <= endTime |
| General: Randomness audit | Flag all uses of % with block variables as high-risk | Static 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 insertsISZERO+JUMPIto 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.difficultywithblock.prevrandaopost-Merge. While stronger thandifficulty(which was always 0 post-Merge),prevrandaois 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 ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | High | Medium (pre-0.8) / Low (post-0.8) | Multiple ERC-20 fee distribution bugs |
| T2 | Smart Contract | Critical | High | Fomo3D airdrops, SmartBillions (700K+) |
| T3 | Smart Contract | Medium | Low | Theoretical; relevant for provably-fair gambling contracts |
| T4 | Smart Contract | Medium | Medium | Numerous audit findings (off-by-one in index calculations) |
| T5 | Smart Contract | Medium | Low | Theoretical; custom bucketing contracts |
| T6 | Smart Contract | High | Medium | Fomo3D (time-based game mechanics manipulated by miners) |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| 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. |