Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x07 |
| Mnemonic | SMOD |
| Gas | 5 |
| Stack Input | a, b |
| Stack Output | a % b (signed, two’s complement) |
| Behavior | Signed 256-bit modulus. The result has the same sign as the dividend a, not the divisor. If b == 0, returns 0 silently. The relationship a == (a / b) * b + (a % b) holds when paired with SDIV (truncation toward zero). |
Threat Surface
SMOD is the signed counterpart of MOD (0x06) and the remainder companion to SDIV (0x05). Its threat surface is more subtle than either, because SMOD’s dangers rarely produce dramatic exploits in isolation — instead they inject systematic numeric bias that accumulates into exploitable discrepancies over time.
The central issue is the sign convention of the remainder. In the EVM, SMOD returns a result whose sign matches the dividend (first operand), not the divisor. This means -7 % 5 = -2 and 7 % -5 = 2. This convention matches C, C++, Java, Rust, and Solidity, but differs from Python (-7 % 5 = 3), Ruby, and mathematical modular arithmetic (where the result is always non-negative or matches the divisor’s sign). Developers porting algorithms from these languages will silently get wrong results.
Beyond sign confusion, SMOD introduces three additional threat vectors:
- Silent mod-by-zero: Like MOD and DIV,
a % 0returns0with no exception. In signed contexts,0is a perfectly valid and meaningful remainder, making it impossible to distinguish “remainder is zero” from “divisor was zero” without an explicit check. - Signed/unsigned confusion: The EVM stack is untyped. SMOD and MOD interpret the same 256-bit word completely differently. A value like
0xFFFF...FFFFis2^256 - 1to MOD but-1to SMOD. Using the wrong opcode in inline assembly produces silently incorrect results. - Interaction with SDIV overflow: The identity
a == (a / b) * b + (a % b)means SMOD is mathematically tied to SDIV. Whena == int256.minandb == -1, SDIV overflows (returningint256.mininstead of+2^255), but SMOD correctly returns0. However, any contract that computes both the quotient and remainder for this input pair will have a correct remainder paired with an incorrect quotient — a subtle inconsistency that can corrupt reconstruction logic.
Smart Contract Threats
T1: Sign Convention Confusion (High)
The EVM’s SMOD follows the truncation-toward-zero (T-division) convention: the remainder’s sign matches the dividend. This differs from other common conventions:
| Expression | EVM SMOD (T-division) | Python % (F-division) | Mathematical mod |
|---|---|---|---|
-7 % 5 | -2 | 3 | 3 |
7 % -5 | 2 | -3 | 2 |
-7 % -5 | -2 | -2 | -2 (or 3) |
Developers porting algorithms from Python, Ruby, or mathematical specifications will silently get wrong results when negative values are involved. This is especially dangerous for:
- Cyclic index calculations:
array[index % length]whereindexcan be negative. SMOD produces a negative index, causing an out-of-bounds access or array underflow, while the developer expected wrapping to a valid positive index. - Time bucketing:
timestamp % intervalfor reward distribution epochs. Iftimestampis represented as a signed delta from some reference, negative timestamps produce negative bucket indices. - Hash-to-range mapping: Reducing a signed hash value to a range
[0, N)via SMOD yields negative results for negative hashes, breaking uniform distribution assumptions.
T2: Signed/Unsigned Modulus Confusion (High)
The EVM stack has no type system. SMOD interprets operands as two’s complement signed integers, while MOD treats them as unsigned. The same 256-bit value produces completely different results:
| Value (hex) | MOD interpretation | SMOD interpretation |
|---|---|---|
0xFFFF...FFFF | 2^256 - 1 (max uint) | -1 |
0x8000...0000 | 2^255 | -2^255 (int256.min) |
Using the wrong opcode — especially in inline assembly or Yul — produces silently incorrect results:
assembly {
// BUG: Using smod for unsigned values
// If a = 0xFFFF...FFFE (uint256.max - 1) and b = 3:
// MOD: (2^256 - 2) % 3 = 0
// SMOD: (-2) % 3 = -2 (0xFFFF...FFFE as signed)
let result := smod(a, b)
}This confusion is amplified by Solidity’s Yul IR code generation pipeline, where type information can be lost during optimization passes. A uint256 variable compiled through the Yul pipeline could theoretically end up in an smod instruction if the optimizer’s type tracking is incorrect.
T3: Silent Mod-by-Zero (Medium)
SMOD returns 0 when the divisor is zero, with no exception or revert. In signed arithmetic contexts, 0 is a valid remainder (e.g., 6 % 3 = 0), making it impossible to distinguish a legitimate zero remainder from a division-by-zero error without an explicit pre-check.
int256 bucket = value % int256(numBuckets);
// If numBuckets == 0: bucket = 0, silently
// Contract assigns everything to bucket 0 instead of revertingThis is identical to MOD’s and DIV’s behavior, but the signed context makes it more dangerous because contracts often use signed modulus in financial calculations where a zero result has a specific meaning (e.g., “position is perfectly hedged” or “no remainder to distribute”).
T4: Negative Remainder in Financial Math (High)
DeFi protocols using signed arithmetic for PnL calculations, funding rates, or fee distribution must handle negative remainders correctly. SMOD’s sign-follows-dividend behavior creates systematic bias:
- Fee distribution: When distributing
totalFeesamongNparticipants,totalFees % Ngives the undistributed remainder. IftotalFeesis negative (a refund), SMOD produces a negative remainder, which may be incorrectly added rather than subtracted, creating or destroying value. - Periodic settlement:
accruedFunding % settlementPeriodwhereaccruedFundingcan be negative produces a negative result. A contract expecting a positive “residual” will mishandle the negative case. - Rounding bias accumulation: The relationship
a == (a / b) * b + (a % b)means SDIV’s truncation-toward-zero combines with SMOD’s signed remainder to create asymmetric rounding. For positivea, the remainder is always non-negative. For negativea, the remainder is always non-positive. Over many iterations (e.g., per-block funding rate calculations across thousands of blocks), this bias accumulates into an exploitable imbalance between long and short positions.
T5: The int256.min % -1 Edge Case (Medium)
When a == int256.min (-2^255) and b == -1, SMOD correctly returns 0 (since -2^255 is exactly divisible by -1). However, this edge case is dangerous in context:
- The companion SDIV operation for the same inputs (
int256.min / -1) overflows, returningint256.mininstead of+2^255. - Any contract that computes both quotient and remainder (e.g., for Euclidean division reconstruction) gets
q = int256.min, r = 0, and reconstructsq * b + r = int256.min * (-1) + 0 = int256.min— which is “correct” only because the quotient itself is wrong. Code that validatesa == q * b + rwill pass, masking the SDIV overflow. - Solidity 0.8.0+ reverts on
int256.min / -1but does not revert onint256.min % -1(since the SMOD result itself is correct). Code paths that compute only the remainder bypass the SDIV overflow protection while still operating on the same dangerous input pair.
Protocol-Level Threats
P1: EVM Client Implementation Divergence (Medium)
SMOD’s signed arithmetic logic is a known source of implementation bugs in EVM clients. The opcode requires correct handling of two’s complement sign extraction, signed-aware modulus computation, and sign application to the result:
- Revm (2024): PR #1248 fixed the
i256_modfunction’s handling of divisor-is-zero. The div-by-zero check was originally in thesmodopcode handler rather than the underlyingi256_modfunction, creating an inconsistency withi256_div’s handling. The fix also removed misleading “overflow check” comments from the modulo code path. - LambdaClass Ethrex (2024): The
negate()overflow bug discovered by FuzzingLabs in the SDIV handler also affected SMOD, since both opcodes share signed arithmetic helpers for converting between signed and unsigned representations before performing the operation. - Solidity Formal Verification (2020): Issue #9802 revealed that the SMOD implementation in Solidity’s formal verification test suite (
test/formal/opcodes.py) incorrectly used SMT’ssmodfunction instead of the EVM’s SMOD semantics. The SMT convention returns a result matching the divisor’s sign, while EVM SMOD matches the dividend’s sign. This meant formal proofs of SMOD-using code were verified against wrong semantics.
Any client that returns a remainder with the wrong sign, mishandles the int256.min % -1 case, or panics on edge inputs would diverge from the canonical chain.
P2: Compiler-Level Sign Handling (Low)
Different Solidity compiler versions handle signed modulus edge cases differently:
- Pre-0.8.0: No overflow checks. SMOD is emitted directly. The companion SDIV overflow (
int256.min / -1) is also unchecked. - 0.8.0+: The compiler inserts overflow checks for SDIV but not for SMOD itself (since SMOD cannot overflow). However, the
int256.min % -1case may or may not trigger the SDIV overflow check depending on whether the compiler also generates a division for the same expression. - Signed Immutables Bug (0.6.5–0.8.8): Signed immutable values shorter than 256 bits lacked proper sign extension when read via inline assembly. A
int8(-2)immutable read as+254would produce wrong SMOD results:254 % 3 = 2instead of(-2) % 3 = -2.
P3: No DoS Vector (Low)
SMOD costs a fixed 5 gas regardless of operand values. It operates purely on the stack with no memory, storage, or external access. It cannot be used for gas griefing or computational DoS.
Edge Cases
| Edge Case | Input | Result | Security Implication |
|---|---|---|---|
| int256.min % -1 | -2^255 % -1 | 0 | Correct, but companion SDIV overflows. Masks SDIV bug in reconstruction checks. |
| Negative % Positive | -7 % 5 | -2 | Sign follows dividend. Differs from Python (3). Breaks ported algorithms. |
| Positive % Negative | 7 % -5 | 2 | Sign follows dividend (positive). Divisor sign ignored for result sign. |
| Negative % Negative | -7 % -5 | -2 | Both negative; result negative (matches dividend). |
| a % 0 | any % 0 | 0 | Silent failure. Indistinguishable from a legitimate zero remainder. |
| 0 % b | 0 % any | 0 | Correct, but EVM implementations must not apply sign to the zero result. (Ethrex negate(0) bug.) |
| int256.min % 1 | -2^255 % 1 | 0 | Correct. No overflow. |
| int256.min % int256.min | -2^255 % -2^255 | 0 | Correct. Self-modulus is always 0. |
| -1 % int256.min | -1 % -2^255 | -1 | Correct. -1 / -2^255 truncates to 0, remainder is -1. |
| int256.min % 2 | -2^255 % 2 | 0 | Correct. -2^255 is even. |
| Large negative % small positive | -2^255 % 3 | -2 or -1 (depends on exact value) | Sign is negative; developers expecting positive index get negative. |
Real-World Exploits
Exploit 1: Solidity Formal Verification — Wrong SMOD Semantics (2020)
Root cause: The SMOD implementation in Solidity’s formal verification test suite used SMT’s smod function, which has different sign semantics than the EVM’s SMOD opcode.
Details: GitHub issue #9802 reported that test/formal/opcodes.py in the Solidity repository implemented SMOD using the Z3 SMT solver’s built-in smod function. The SMT convention defines smod such that the result’s sign follows the divisor, while the EVM’s SMOD returns a result matching the dividend’s sign. For example:
- EVM SMOD:
-7 % 5 = -2(sign of dividend-7) - SMT smod:
-7 % 5 = 3(sign of divisor5)
This meant all formal proofs of Solidity code involving signed modulus were verifying against incorrect behavior. A contract proven “safe” by the formal verification toolchain could still produce unexpected negative remainders at runtime.
Impact: The severity was rated low because the bug was confined to the test infrastructure, not the compiler itself. However, it undermined the trustworthiness of formal verification for any contract using signed modulus — a tool that auditors and high-value DeFi protocols rely on for security guarantees.
References:
Exploit 2: LambdaClass Ethrex EVM — Signed Arithmetic Crash Affecting SMOD (2024)
Root cause: Arithmetic overflow in the negate() helper function shared by SDIV and SMOD code paths in the Ethrex EVM implementation.
Details: FuzzingLabs discovered that LambdaClass’s Ethrex EVM used a negate() function (!value + U256::one()) that panicked on specific inputs. While the primary trigger was through SDIV, the SMOD code path used the same negate() function for converting between signed and unsigned representations before computing the modulus. An input where the remainder was zero but the dividend was negative would invoke negate(0), computing !0 + 1 = U256::MAX + 1, causing a panic.
The initial fix using saturating_add introduced a second bug: negate(0) returned U256::MAX instead of 0, producing a non-zero remainder when the correct result was zero.
Impact: Denial of service against Ethrex nodes. A crafted transaction with SMOD inputs costing only 5 gas could crash the node. On a network using Ethrex as a consensus client, this would cause a chain split.
SMOD’s role: The shared negate() helper meant SMOD inherited the same crash vulnerability as SDIV. The zero-remainder edge case (a % b == 0 where a < 0) was the specific SMOD trigger.
References:
Exploit 3: Revm — Inconsistent Div-by-Zero Handling in i256_mod (2024)
Root cause: The division-by-zero check for SMOD was in the opcode handler rather than the underlying i256_mod function, creating an inconsistency with how i256_div handled the same case.
Details: In revm (the Rust EVM implementation used by reth), the smod opcode handler checked for a zero divisor before calling i256_mod, while the analogous sdiv handler delegated the zero check to i256_div itself. PR #1248 standardized the behavior by moving the check into i256_mod, ensuring that i256_mod(a, 0) == 0 regardless of call site. The PR also removed misleading comments about “overflow checks” in the modulo code, which described behavior that belonged to SDIV, not SMOD.
Impact: While not directly exploitable (the zero-check existed, just in the wrong layer), this architectural inconsistency risked introducing bugs during refactoring. If a new call site invoked i256_mod directly without the zero check, it would produce undefined behavior. Revm is used in reth, one of the major Ethereum execution clients.
References:
Exploit 4: Vyper — Signed/Unsigned Comparison Bug in Range Loops (2024)
CVE: CVE-2024-32481
Root cause: The Vyper compiler (versions 0.3.8 through 0.4.0b1) used unsigned comparison instructions (LE) instead of signed comparison instructions (SLE) when evaluating range loop bounds involving signed integers.
Details: When a Vyper contract used range(start, start + N) with a signed integer start, the compiler generated code that compared the loop variable against the bounds using unsigned less-than-or-equal (LE) instead of signed less-than-or-equal (SLE). A negative start value, whose two’s complement representation has the MSB set, appears as an extremely large unsigned integer. The unsigned comparison would always fail, causing the loop body to never execute or the transaction to revert unexpectedly.
While this vulnerability involves SLT/SGT comparison opcodes rather than SMOD directly, it illustrates the broader class of signed/unsigned confusion that SMOD is susceptible to: the same 256-bit value is interpreted completely differently by signed vs. unsigned opcodes, and choosing the wrong variant produces silently incorrect behavior.
Impact: CVSS 5.3 (Medium). Contracts compiled with affected Vyper versions that used signed integers in range loops would fail to execute the loop body when the start value was negative, potentially skipping critical state updates or distributions.
References:
Attack Scenarios
Scenario A: Negative Index from Signed Modulus
contract VulnerableRoundRobin {
address[] public validators;
function getValidator(int256 slot) external view returns (address) {
// BUG: If slot is negative, SMOD returns negative remainder
// e.g., slot = -7, validators.length = 5:
// -7 % 5 = -2 (EVM SMOD)
// Cast to uint256: 0xFFFF...FFFE (enormous index)
// Array access reverts or reads garbage
int256 index = slot % int256(uint256(validators.length));
return validators[uint256(index)];
}
}Attack: Any negative slot value produces a negative remainder, which wraps to an enormous uint256 index when cast, causing a revert or (in unchecked contexts) reading from an unintended storage slot.
Scenario B: Fee Distribution Rounding Exploit
contract VulnerableFeeDistributor {
int256 constant PRECISION = 1e18;
function distributeFunding(int256 totalFunding, int256 numPositions)
external
pure
returns (int256 perPosition, int256 residual)
{
// SDIV truncates toward zero; SMOD gives signed remainder
perPosition = totalFunding / numPositions;
residual = totalFunding % numPositions;
// When totalFunding = -1000, numPositions = 3:
// perPosition = -333 (truncated toward zero, not -334)
// residual = -1
// Reconstruction: -333 * 3 + (-1) = -1000 ✓
// But the protocol expects positive residual for redistribution
// residual is negative, so adding it to a pool SUBTRACTS value
}
}Attack: An attacker manipulates positions to ensure totalFunding is negative. The negative residual is added to a redistribution pool as if positive, draining 1 wei per settlement period. Over thousands of blocks, this accumulates.
Scenario C: Mod-by-Zero Bypass in Access Control
contract VulnerableTimeLock {
mapping(address => int256) public lastAction;
function canAct(address user, int256 cooldownPeriod) external view returns (bool) {
int256 elapsed = int256(block.timestamp) - lastAction[user];
// BUG: If cooldownPeriod == 0 (uninitialized or attacker-controlled):
// elapsed % 0 = 0
// 0 == 0 is true, so the check passes
return (elapsed % cooldownPeriod) == 0;
}
}Attack: If cooldownPeriod is zero (e.g., for an unconfigured action type), SMOD returns 0, and the equality check passes, bypassing the time-lock entirely.
Scenario D: Signed/Unsigned Confusion in Assembly
contract VulnerableHashBucket {
function getBucket(bytes32 hash, uint256 numBuckets) external pure returns (uint256) {
uint256 bucket;
assembly {
// BUG: smod treats hash as signed
// A hash with MSB set (50% of hashes) is interpreted as negative
// Negative % positive = negative in SMOD
// Result wraps to huge uint256 when returned
bucket := smod(hash, numBuckets)
}
return bucket;
}
}Attack: Half of all hash values (those with the MSB set) produce negative SMOD results, wrapping to enormous uint256 values. The function returns indices far outside the expected [0, numBuckets) range, breaking any bucketing or routing logic downstream.
Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Sign convention confusion | Convert SMOD result to non-negative when a positive remainder is needed | int256 r = a % b; if (r < 0) r += (b > 0 ? b : -b); for Euclidean modulus |
| T1: Ported algorithms | Use explicit Euclidean mod function | Implement modE(a, b) that always returns [0, abs(b)) |
| T2: Signed/unsigned confusion | Never use smod for unsigned values in assembly | Use mod for uint256, smod only for int256; audit all Yul/assembly |
| T2: Type system bypass | Avoid raw assembly for signed arithmetic | Let Solidity’s type system enforce correct opcode selection |
| T3: Mod-by-zero | Always validate divisor before modulus | require(divisor != 0, "mod by zero") |
| T4: Negative remainder in finance | Use absolute-value-then-sign pattern | Compute abs(a) % abs(b), then apply correct sign per protocol spec |
| T4: Rounding bias | Choose and document rounding convention | Implement modFloor(a, b) / modEuclidean(a, b) wrappers |
| T5: int256.min % -1 interaction | Guard both SDIV and SMOD inputs together | require(!(b == -1 && a == type(int256).min)) before any signed division/modulus pair |
| General: Assembly audits | Flag all smod usage in security reviews | Static analysis: Slither custom detectors, Mythril signed arithmetic checks |
| General: Compiler version | Use Solidity >= 0.8.9 | Fixes signed immutables bug; includes SDIV overflow checks |
Compiler/EIP-Based Protections
- Solidity 0.8.0+ (2020): Automatic revert on
int256.min / -1via SDIV overflow check. SMOD itself does not overflow, but the compiler’s protection on SDIV prevents the most dangerous companion operation. - Solidity 0.8.9+ (2021): Fixes the Signed Immutables Bug, ensuring signed values shorter than 256 bits are properly sign-extended when read, preventing corrupted SMOD inputs.
- EIP-6888 (Proposed): Would add arithmetic verification flags to the EVM state, including a signed overflow flag (
sovf) set whenb == 0 ∨ (a == INT_MIN ∧ b == -1)for SMOD/SDIV. This would allow contracts to branch on division-by-zero without compiler-inserted checks. - SafeInt256 libraries: Notional Finance’s
SafeInt256and OpenZeppelin’sSignedMathprovide safe wrappers that explicitly check edge cases before performing signed arithmetic. - Static analysis tools: Slither can detect signed/unsigned type confusion and missing zero-divisor checks. Mythril and Certora can formally verify that SMOD is never called with a zero divisor.
Euclidean Mod at EVM Level
When a protocol requires non-negative remainders (Euclidean modulus), the standard conversion from SMOD:
// Euclidean mod: result always in [0, |b|)
// Given: r = SMOD(a, b)
// If r < 0: result = r + |b|
// Else: result = r
PUSH a
PUSH b
SMOD // r = a smod b (sign of a)
DUP1 // r, r
PUSH 0
SLT // r < 0? (signed comparison)
ISZERO
jumpi @done // If r >= 0, we're done
DUP1 // r, r
PUSH b
DUP1 // b, b, r, r
PUSH 0
SLT // b < 0?
jumpi @neg_b
ADD // r + b (b is positive)
jump @done
@neg_b:
SUB // r - b (b is negative, so r - b = r + |b|)
@done:
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | High | Medium (any ported algorithm with negative inputs) | Solidity formal verification wrong semantics (#9802) |
| T2 | Smart Contract | High | Medium (assembly-heavy DeFi code) | Vyper CVE-2024-32481 (signed/unsigned confusion) |
| T3 | Smart Contract | Medium | Medium (same as MOD/DIV division-by-zero) | — |
| T4 | Smart Contract | High | Medium (any protocol using signed math for finance) | Funding rate rounding in perpetuals protocols |
| T5 | Smart Contract | Medium | Low (requires specific int256.min input with companion SDIV) | Notional SafeInt256 guard pattern |
| P1 | Protocol | Medium | Low (rare but catastrophic: consensus split) | LambdaClass Ethrex crash; revm i256_mod fix |
| P2 | Protocol | Low | Low | Solidity Signed Immutables Bug (0.6.5–0.8.8) |
| P3 | Protocol | Low | N/A | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| MOD (0x06) | Unsigned counterpart. Same division-by-zero behavior (returns 0), but no sign ambiguity since both operands and result are unsigned. Using MOD on signed values or SMOD on unsigned values produces wrong results. |
| SDIV (0x05) | Signed division, the quotient companion to SMOD. The identity a == (a / b) * b + (a % b) ties them together. SDIV’s int256.min / -1 overflow produces a wrong quotient that SMOD’s correct 0 remainder cannot compensate for. |
| SLT (0x12) | Signed less-than comparison. Essential for checking the sign of SMOD results and converting negative remainders to positive (Euclidean mod). Using unsigned LT on SMOD results causes the Vyper-class bug. |
| SGT (0x13) | Signed greater-than comparison. Paired with SLT for range-checking SMOD results in signed arithmetic guard patterns. |
| SIGNEXTEND (0x0B) | Sign-extends smaller signed integers to 256 bits. Critical for correct SMOD behavior when operating on values narrower than int256. The Signed Immutables Bug was a failure of sign extension that corrupted SMOD inputs. |
| SAR (0x1D) | Arithmetic shift right. Sometimes combined with SMOD in optimized signed division/remainder patterns. SAR preserves the sign bit, unlike SHR. |