Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x1B |
| Mnemonic | SHL |
| Gas | 3 |
| Stack Input | shift, value |
| Stack Output | value << shift |
| Behavior | Logical left shift. Shifts value left by shift bits. Bits shifted beyond bit 255 are discarded. If shift >= 256, the result is 0. Equivalent to (value * 2^shift) % 2^256. |
Threat Surface
SHL was introduced in the Constantinople hard fork (EIP-145, February 2019) alongside SHR and SAR. Before EIP-145, left shifts had to be emulated using MUL (value * 2^shift), which cost 5 gas compared to SHL’s 3 gas. More importantly, SHL and MUL-as-shift have identical overflow behavior: both silently discard bits shifted beyond bit 255.
SHL’s threat surface centers on three properties:
-
Silent bit loss on shift >= 256:
SHL(256, anything) == 0. There is no error, no flag, no revert. Any shift amount >= 256 zeroes the entire result. This is different from MUL-based overflow where the result wraps modulo 2^256 — SHL’s “overflow” is always zero. -
Overflow equivalence with MUL:
SHL(n, x)is mathematically identical tox * 2^nmodulo 2^256. Developers often use SHL as a “gas-optimized multiplication by power of 2,” but the overflow risk is unchanged. SHL does not add safety — it merely changes the gas cost. -
Mask construction: SHL is heavily used to construct bitmasks (
1 << n), position fields for packed storage (value << offset), and build composite values. Errors in the shift amount produce masks with bits in the wrong position, leading to the same class of vulnerabilities described in the AND and OR threat models.
The stack ordering is also a subtle trap: SHL takes shift as the first (top of stack) argument and value as the second. This is the reverse of x86 and most assembly conventions where the value comes first. Getting the order wrong means the value is treated as the shift amount and vice versa.
Smart Contract Threats
T1: Shift >= 256 Returns Zero (Critical)
Any shift amount >= 256 produces 0. If the shift amount is user-controlled or computed from external data, an attacker can force a zero result:
assembly {
let result := shl(shift, value)
// If shift >= 256, result is always 0 regardless of value
}In DeFi contexts, this can zero out prices, balances, or fee calculations. If the shift amount comes from an oracle, a timestamp delta, or a configuration parameter, an attacker who can influence that value to 256+ can nullify any SHL-based computation.
T2: SHL for Multiplication by Power-of-2 — Overflow Still Possible (High)
Developers use SHL as a gas-optimized replacement for multiplication by powers of 2:
// These are equivalent (both overflow the same way):
uint256 doubled = value * 2; // 5 gas (MUL)
uint256 doubled = value << 1; // 3 gas (SHL)The optimization saves 2 gas, but developers sometimes assume SHL is “safer” than MUL because it’s a bitwise operation. It is not. SHL(1, MAX_UINT256) produces MAX_UINT256 - 1 (the lowest bit is lost), just as MAX_UINT256 * 2 would. Both discard the overflowed bit.
In unchecked blocks in Solidity 0.8+, SHL-based “multiplication” bypasses overflow checks entirely because the compiler’s overflow detection only applies to the * operator, not to <<:
unchecked {
uint256 a = value * 2; // Compiler MAY optimize to SHL, but checks apply to source-level *
}
uint256 b = value << 1; // No overflow check regardless of unchecked/checked context!Solidity’s << operator does not have overflow checking even in checked arithmetic mode. This is a critical distinction from *.
T3: Bitmask Construction Errors (High)
SHL is the standard way to construct bitmasks: uint256 mask = 1 << bitPosition. Errors in the bit position produce masks with the wrong bit set:
// Constructing a role bitmap
uint256 constant ADMIN = 1 << 0; // 0x01
uint256 constant MINTER = 1 << 1; // 0x02
uint256 constant BURNER = 1 << 2; // 0x04
// Bug: off-by-one -- PAUSER overlaps with BURNER
uint256 constant PAUSER = 1 << 2; // 0x04 -- same as BURNER!For packed storage field positioning, value << offset where offset is wrong by even 1 bit causes the field to overlap with adjacent fields, producing the clobbering bugs described in the OR threat model.
T4: Stack Ordering Confusion (Medium)
SHL’s stack order is (shift, value), meaning the shift amount is popped first. In Solidity’s inline assembly, this matches the Yul shl(shift, value) syntax. But developers coming from x86 assembly or C-like bit shift notation (value << shift) may accidentally swap the arguments:
// Correct Yul: shl(shift, value)
let result := shl(8, 1) // 1 << 8 = 256
// Bug: arguments swapped -- shifting 8 by 1 position
let result := shl(1, 8) // 8 << 1 = 16 (not the intended 1 << 8)T5: Solidity Compiler Shift Optimization Bugs (Medium)
The Solidity compiler has had bugs in shift optimization. Issue #6246 reported that the optimizer incorrectly handled nested shifts (SHR(B, SHR(A, X)) optimized to SHR(min(A+B, 256), X)) when the addition of shift amounts overflowed u256. The fix required checking for u256 overflow in shift size addition.
This class of bug can produce incorrect bytecode where the compiled result of chained shift operations differs from the source-level semantics.
Protocol-Level Threats
P1: No DoS Vector (Low)
SHL costs a fixed 3 gas regardless of operand values. Even SHL(MAX_UINT256, MAX_UINT256) costs 3 gas and returns 0.
P2: Consensus Safety (Low)
SHL is deterministic: value << shift (with shift capped at 255 for non-zero results) is unambiguous. All EVM implementations agree. The EIP-145 specification is simple and well-tested.
P3: Pre-Constantinople Emulation Differences (Low)
Before Constantinople, shifts were emulated via MUL(value, EXP(2, shift)). The EXP opcode has variable gas cost based on the exponent, making pre-Constantinople shift emulation more expensive and gas-variable. Contracts targeting pre-Constantinople compatibility may still use MUL-based shifts, which have different gas characteristics but identical arithmetic behavior.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
SHL(0, x) | Returns x | No shift; identity |
SHL(1, x) | Returns x * 2 (mod 2^256) | Equivalent to doubling; MSB lost if x >= 2^255 |
SHL(255, 1) | Returns 2^255 (sign bit in two’s complement) | Sets only the most significant bit |
SHL(256, x) | Returns 0 | All bits shifted out; complete information loss |
SHL(257, x) | Returns 0 | Same as above; any shift >= 256 returns 0 |
SHL(MAX_UINT256, x) | Returns 0 | Maximum shift; still just returns 0 |
SHL(n, 0) | Returns 0 | Zero shifted by any amount is zero |
SHL(128, 2^128) | Returns 0 | 2^128 << 128 = 2^256 ≡ 0 (mod 2^256) — phantom zero |
SHL(n, MAX_UINT256) | Returns MAX_UINT256 << n | Lower n bits become 0; upper n bits lost |
Real-World Exploits
Exploit 1: Solidity Compiler Shift Optimization Overflow (Issue #6246, 2019)
Root cause: The Solidity compiler’s optimizer incorrectly handled nested shift operations when shift amounts added together overflowed u256.
Details: The optimizer rule for combining nested shifts (SHR(B, SHR(A, X)) → SHR(min(A+B, 256), X)) failed when A + B overflowed u256. For example, with A = NOT(0) (MAX_UINT256) and B = 1, the addition wraps to 0, causing the optimizer to produce SHR(0, X) instead of SHR(256, X). The former returns X unchanged; the latter returns 0. This is a complete semantic inversion.
While this specific bug was in SHR optimization, the same pattern applies to SHL optimization rules. Any optimizer that combines shift amounts via addition must handle the overflow case.
SHL’s role: SHL optimization follows the same combinatorial pattern: SHL(A, SHL(B, X)) should optimize to SHL(min(A+B, 256), X), with the same overflow risk in A+B.
Impact: Incorrect bytecode generation for contracts using chained shifts. Fixed in Solidity compiler.
References:
Exploit 2: CVE-2024-45056 — zksolc SHL/XOR Optimization Bug (August 2024)
Root cause: The zksolc compiler folded (xor (shl 1, x), -1) incorrectly, producing rotl 2^64-1, x instead of rotl 2^256-1, x.
Details: The LLVM-based zksolc compiler recognized the pattern XOR(SHL(1, x), -1) as a rotate-and-complement operation and optimized it. However, the complement value ~1 was generated as a 64-bit unsigned integer (2^64 - 1) instead of a 256-bit value (2^256 - 1). On the EraVM target (256-bit words), this value should have been sign-extended to 256 bits but was zero-extended instead.
SHL’s role: SHL was a direct component of the misoptimized expression. The compiler’s pattern matching for SHL(1, x) within the XOR chain triggered the faulty optimization.
Impact: CVSS 5.9 (Medium). All zksolc versions < 1.5.3 affected. No exploited contracts found at disclosure.
References:
Exploit 3: Shift-Based Overflow in DeFi Price Calculations (Recurring Pattern)
Root cause: Using SHL as an “optimized multiplication” without overflow protection, particularly in fixed-point math libraries.
Details: Gas-optimized DeFi math libraries (used in AMMs, lending protocols, and options pricing) frequently use SHL for multiplication by powers of 2. Common patterns include:
price << 96for Q96 fixed-point formatting (Uniswap v3 style)amount << 18for WAD scalingvalue << 128for Q128 fixed-point
When the base value is large enough, these shifts overflow silently. Unlike * in Solidity 0.8+, << is never checked for overflow. A price of 2^160 shifted left by 96 bits produces 0 (since 160 + 96 = 256), which can be exploited to obtain free assets from AMMs or lending pools that use the zeroed price.
SHL’s role: SHL is the direct cause of the silent overflow. The fix is either to check that the value fits within 256 - shift bits before shifting, or to use checked multiplication (value * 2^shift) which reverts on overflow in Solidity 0.8+.
Attack Scenarios
Scenario A: Shift Amount >= 256 Zeroes Price
contract VulnerableOracle {
function scalePrice(uint256 rawPrice, uint256 decimals) external pure returns (uint256) {
// Scale price to 18-decimal format
uint256 scaleFactor = 18 - decimals;
// Bug: if decimals > 18, scaleFactor underflows to huge number
// SHL with shift >= 256 returns 0
return rawPrice << (scaleFactor * 4); // Multiply by 16^scaleFactor
// If scaleFactor underflowed to ~2^256, shift >= 256, result = 0
}
}Attack: Pass a token with > 18 decimals. scaleFactor underflows. SHL returns 0. Price is zero. Buy tokens for free.
Scenario B: Unchecked Shift “Multiplication” Overflow
contract VulnerablePool {
// Q96 fixed-point price: price * 2^96
function computeQ96Price(uint256 price) external pure returns (uint256) {
// No overflow check on <<
return price << 96; // Silent overflow if price >= 2^160
// Solidity 0.8+ does NOT check << for overflow!
}
}Attack: Flash-loan to temporarily inflate price above 2^160. Q96 conversion overflows to a small value (or zero). Use the incorrect price to extract value from the pool.
Scenario C: Off-by-One Bitmask Collision
contract RoleManager {
uint256 constant ROLE_A = 1 << 4; // 0x10
uint256 constant ROLE_B = 1 << 5; // 0x20
// Bug: copy-paste error, ROLE_C should be 1 << 6 but is 1 << 5
uint256 constant ROLE_C = 1 << 5; // 0x20 -- collides with ROLE_B!
function hasRole(address user, uint256 role) external view returns (bool) {
return userRoles[user] & role != 0;
}
// Any user with ROLE_B automatically has ROLE_C and vice versa
}Scenario D: Stack Order Swap
function positionField(uint256 value, uint256 offset) internal pure returns (uint256) {
uint256 result;
assembly {
// Bug: shl(value, offset) shifts 'offset' left by 'value' positions
// Should be: shl(offset, value)
result := shl(value, offset)
}
return result;
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Shift >= 256 | Validate shift amount before SHL | require(shift < 256) or if (shift >= 256) return 0; (explicit) |
| T1: Computed shifts | Bounds-check intermediate shift calculations | require(decimals <= 18) before computing scale factors |
| T2: Overflow via SHL | Use checked multiplication instead of SHL for arithmetic | value * (1 << n) in Solidity 0.8+ reverts on overflow; value << n does not |
| T2: Unchecked shifts | Add explicit overflow checks for SHL | require(value <= type(uint256).max >> shift) before shifting |
| T3: Mask construction | Define all masks as named constants; verify no bit collisions | Unit test: assert(ROLE_A & ROLE_B == 0) for all role pairs |
| T3: Field positioning | Validate field widths vs offset positions | assert(FIELD_WIDTH + FIELD_OFFSET <= 256) |
| T4: Stack ordering | Use Solidity << operator instead of assembly shl() | Solidity handles argument ordering correctly |
| T5: Compiler bugs | Pin compiler version; verify bytecode | Compare output across compiler versions for shift-heavy code |
Key Insight: << Is Never Overflow-Checked
In Solidity 0.8+, the << operator is not subject to overflow checking, even outside unchecked blocks. This is by design (per the Solidity specification), but it means:
// This REVERTS on overflow:
uint256 a = value * 2;
// This DOES NOT revert on overflow:
uint256 b = value << 1;
// These are arithmetically equivalent but have different safety properties!Developers who replace * 2 with << 1 for gas optimization silently remove overflow protection.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Shift >= 256 zeroing in price calculations |
| T2 | Smart Contract | High | High | Unchecked shift overflow in DeFi math |
| T3 | Smart Contract | High | Medium | Bitmask construction errors |
| T4 | Smart Contract | Medium | Low | Stack ordering confusion in assembly |
| T5 | Smart Contract | Medium | Low | Solidity Issue #6246, CVE-2024-45056 |
| P1 | Protocol | Low | N/A | — |
| P2 | Protocol | Low | N/A | — |
Related Opcodes
| Opcode | Relationship |
|---|---|
| SHR (0x1C) | Inverse operation; SHR undoes SHL (with bit loss). SHR(n, SHL(n, x)) may not equal x if bits were shifted out |
| SAR (0x1D) | Arithmetic right shift; preserves sign. No arithmetic left shift exists — SHL serves for both signed and unsigned |
| MUL (0x02) | SHL(n, x) is equivalent to x * 2^n with identical overflow behavior. MUL costs 5 gas vs SHL’s 3 |
| EXP (0x0A) | SHL(n, 1) is equivalent to 2^n but vastly cheaper (3 gas vs 10 + 50*bytes) |
| AND (0x16) | SHL positions values for AND-based extraction: AND(SHR(offset, packed), mask) |
| OR (0x17) | SHL positions values for OR-based packing: OR(existing, SHL(offset, newValue)) |
| XOR (0x18) | Combined with SHL in compiler optimizations (CVE-2024-45056) |