Opcode Summary
| Property | Value |
|---|---|
| Opcode | 0x54 |
| Mnemonic | SLOAD |
| Gas | 100 (warm access) / 2100 (cold access) |
| Stack Input | key (32-byte storage slot index) |
| Stack Output | value (32-byte word read from persistent storage at key) |
| Behavior | Reads a 256-bit word from the executing contract’s persistent storage at the given slot index. If the slot has never been written, returns 0. In a DELEGATECALL context, reads from the calling (proxy) contract’s storage, not the implementation’s. Gas cost depends on whether the slot has been accessed earlier in the current transaction (warm) or not (cold). |
Threat Surface
SLOAD is the EVM’s persistent state read primitive. Every mapping lookup, every state variable read, every balanceOf() check, and every access control verification compiles down to one or more SLOAD operations. This makes it foundational to smart contract security — and a rich target for exploitation.
The threat surface centers on four properties:
-
SLOAD is one of the most expensive opcodes. A cold SLOAD costs 2100 gas — more than most computational opcodes combined. Before EIP-2929 (Berlin hardfork, April 2021), SLOAD cost a flat 200 gas regardless of access pattern, which was dramatically underpriced relative to the disk I/O required to traverse the state trie. The 2016 Shanghai DoS attacks exploited this mispricing to bring the network to its knees. Even post-EIP-2929, the 21x cost differential between cold (2100) and warm (100) access creates gas griefing vectors where attackers force contracts to touch many cold storage slots.
-
SLOAD in DELEGATECALL reads the proxy’s storage, not the implementation’s. This is the fundamental mechanism behind all proxy/upgrade patterns, but it means the implementation contract’s code interprets the proxy’s raw storage slots according to the implementation’s variable layout. If the layouts disagree — due to inheritance changes, variable reordering, or upgrade mistakes — SLOAD returns semantically wrong values. The EVM doesn’t know or care that slot 3 “should” be an
owneraddress; it just returns whatever 32 bytes are there. -
Uninitialized storage always returns zero. Unlike memory (which also defaults to zero but is transaction-scoped), storage zeros are permanent defaults for any slot never written. Contracts that interpret zero as a meaningful value (e.g., “no restriction”, “unlimited allowance”, or “address(0) is admin”) create exploitable logic when reading truly uninitialized slots.
-
SLOAD during reentrancy returns stale state. When a contract makes an external call mid-execution, a re-entrant callback reads storage that hasn’t been updated yet. The classic checks-effects-interactions violation is fundamentally an SLOAD problem: the re-entrant SLOAD returns a pre-update value, and the contract’s logic proceeds as if the state is current. Read-only reentrancy extends this to view functions that external protocols depend on for pricing and valuations.
Smart Contract Threats
T1: Storage Collision in Proxy/Upgrade Patterns (Critical)
When a proxy uses DELEGATECALL to execute implementation logic, every SLOAD in the implementation reads from the proxy’s storage. The implementation’s Solidity-declared variables map to sequential storage slots, and these must align perfectly with the proxy’s actual storage layout. Collisions occur when:
-
Proxy admin slot overlaps implementation variables. Early proxy designs stored the admin address at slot 0, colliding with the implementation’s first state variable. The Audius exploit (July 2022, $6M) occurred because the proxy admin address in slot 0 collided with OpenZeppelin’s
Initializableflags (initializedandinitializing). The last byte of the admin address (0xac) was interpreted asinitialized = trueand the second-to-last byte (0xab) was read asinitializing = true, causing theinitializer()modifier to always pass. The attacker re-initialized governance contracts to steal 18M AUDIO tokens. -
Inheritance changes across upgrades shift slot assignments. Adding a new state variable to a base contract shifts all derived variables down by one slot. Every subsequent SLOAD returns the value from the wrong slot. The implementation reads
totalSupplyfrom what is actuallyowner, orbalances[addr]from an unrelated mapping. -
Diamond/EIP-2535 patterns with hardcoded slot constants. Multiple facets sharing the same storage namespace can write to overlapping slots. An SLOAD in one facet returns data written by a completely different facet.
Why it matters: Storage collisions are silent — SLOAD returns 32 bytes regardless of what those bytes “should” represent. No revert, no warning. Research (Proxion, 2024) found ~1.5M contracts on Ethereum with collision risks, and 54.2% of analyzed contracts were proxies.
T2: Gas Griefing via Cold Storage Access (High)
Cold SLOAD costs 2100 gas per unique slot accessed in a transaction. An attacker can force a contract to perform many cold SLOADs by:
-
Calling functions that iterate over unbounded storage. A function that loops over a dynamic array or iterates mapping keys forces one cold SLOAD per element on first access. An attacker inflates the array (if they can append), then calls the iteration function, consuming
2100 * ngas fornelements. -
Triggering access to many distinct storage slots in a single call. Contracts with complex structs spread across multiple slots (mappings of structs, nested mappings) require one SLOAD per field. An attacker crafting inputs that touch many distinct keys forces cold access for each.
-
Exploiting gas limits in external integrations. If contract A calls contract B with a fixed gas stipend, and B’s function requires multiple cold SLOADs, the attacker can ensure B’s slots are cold (by not pre-warming them with access list transactions), causing B to run out of gas and revert — even though the same call would succeed with warm slots.
Why it matters: The Shanghai DoS attacks (2016) demonstrated that underpriced state access can cripple the entire network. Post-EIP-2929, the risk shifts to application-level gas griefing where individual contracts or protocols can be DoS’d.
T3: Reading Stale Storage During Reentrancy (High)
When a contract performs an external call before updating storage, a re-entrant call reads the pre-update values via SLOAD:
-
Classic reentrancy.
balances[msg.sender]is read (SLOAD) before being zeroed (SSTORE). The re-entrant call reads the same non-zero balance and withdraws again. -
Read-only reentrancy. A view function (e.g.,
get_virtual_price(),totalAssets()) computes a value from storage that is mid-update. External protocols that call this view function during the reentrancy window receive a manipulated value. The attacker doesn’t re-enter the same function — they trigger a callback that causes a different protocol to read stale state. -
Cross-contract reentrancy. Contract A updates its storage, calls contract B, and B calls contract C. C reads A’s storage, which may or may not be updated depending on the call ordering. If C reads state that A intends to update after B returns, C sees stale data.
Why it matters: Read-only reentrancy is especially dangerous because the victim contract’s code may be flawless — the vulnerability exists in how external protocols interpret its stale SLOAD results.
T4: Uninitialized Storage Slot Reads (Medium)
SLOAD returns 0x0000...0000 for any slot that has never been written. This creates vulnerabilities when:
-
Zero is a valid or privileged value. If
owner == address(0)is treated as “no owner set, anyone can claim”, an uninitialized owner slot grants universal access. If a mapping returns 0 for absent keys, and 0 means “no limit” or “approved”, the contract grants unintended permissions. -
Upgrade introduces new variables without initialization. A V2 implementation adds a
maxWithdrawalvariable. After upgrade, SLOAD for this slot returns 0 because no initializer set it. If the contract interprets 0 as “unlimited”, all withdrawal limits are bypassed. -
Uninitialized storage pointers (pre-Solidity 0.5.0). In older Solidity versions, local
storagevariables declared without assignment defaulted to slot 0, causing SLOAD/SSTORE operations on unintended slots. Writing to such a pointer could overwrite critical state variables likeowner. This class of bug is prevented by the compiler since Solidity 0.5.0.
Why it matters: The zero-default is a well-known property, but upgrade patterns continuously introduce new uninitialized slots. Every SLOAD of a new variable after an upgrade returns zero until explicitly initialized.
T5: Storage Layout Assumptions Across Upgrades (Medium)
Beyond outright collisions (T1), subtler SLOAD issues arise from assumptions about storage layout:
-
Packed storage slot reinterpretation. Solidity packs multiple small variables (e.g.,
uint128,bool,address) into a single 32-byte slot. An SLOAD returns the packed word, and the compiler generates bit-shift/mask operations to extract fields. If an upgrade changes the packing (e.g., changes auint128touint256), the extraction logic reads wrong bit ranges from the same slot. -
Mapping slot calculation changes. Solidity computes mapping slot positions as
keccak256(key . slot). If the base slot shifts due to added variables, all mapping entries effectively move — but the old data remains in the old slots. SLOAD with the new slot calculation returns zero (uninitialized), while the actual data sits in now-orphaned slots. -
Enum expansion. Adding values to an enum doesn’t change storage, but if the new enum variant’s integer value coincides with an existing stored value’s meaning, SLOAD returns the same bits with different semantic interpretation.
Why it matters: These bugs are subtle enough to pass audits and testing on fresh deployments, manifesting only after upgrades when SLOAD reads real production storage.
Protocol-Level Threats
P1: Shanghai DoS Attacks — Underpriced SLOAD (Historical, Mitigated)
In September 2016, during the Ethereum Devcon2 conference in Shanghai, attackers exploited the flat 200-gas cost of SLOAD (and EXTCODESIZE at 20 gas) to force nodes into excessive disk I/O. Each SLOAD requires traversing the Merkle Patricia Trie, involving multiple disk reads. At 200 gas, an attacker could pack ~150,000 SLOADs into a single block (at 30M gas), each targeting a different cold storage slot, forcing nodes to perform hundreds of thousands of random disk reads per block.
Block processing times spiked from seconds to over a minute. Miners struggled to validate blocks within the expected 15-second slot time, causing chain instability, peer disconnections, and effective network degradation for weeks.
Resolution: EIP-150 (Tangerine Whistle, October 2016) increased SLOAD cost to 200 gas and other state-access opcodes proportionally. EIP-2929 (Berlin, April 2021) introduced the warm/cold model: 2100 gas for first access, 100 gas for subsequent access within the same transaction. This reduced worst-case block processing time by ~3x.
P2: EIP-2929 Warm/Cold Pricing Model
EIP-2929 fundamentally changed SLOAD economics. A transaction maintains an accessed_storage_keys set. The first SLOAD of a (address, slot) pair costs 2100 gas (cold) and adds it to the set. Subsequent SLOADs of the same pair cost 100 gas (warm).
Security implications:
- Contract breakage. Contracts deployed pre-Berlin with hardcoded gas assumptions (e.g., forwarding exactly
Ngas to a subcall) may fail when cold SLOADs consume 2100 instead of the expected 200 or 800 gas. EIP-2930 access lists partially mitigate this by allowing transactions to pre-declare accessed slots at 200 gas each. - Access list manipulation. Miners/validators constructing blocks see the access list and know which slots will be accessed. This is a minor MEV vector for front-running.
- Gas estimation instability. The same function call costs dramatically different gas depending on whether slots were accessed earlier in the transaction.
eth_estimateGasmay return values that are wrong if the execution context changes between estimation and submission.
P3: State Trie Access Cost and State Growth
Every SLOAD requires reading from the Ethereum state trie, which grows as more contracts are deployed and more storage slots are written. As state size increases:
- Disk I/O per SLOAD grows. The trie depth increases logarithmically with the number of accounts/slots, but the constant factor matters when millions of SLOADs execute per block.
- State expiry proposals. EIP-4444, Verkle tries, and state expiry schemes would change how SLOAD resolves slots, potentially introducing “witness” requirements where SLOAD gas depends on proof size. Future protocol upgrades may further increase cold SLOAD costs or require explicit state access proofs.
- Light client implications. Light clients serving SLOAD results need witnesses (Merkle proofs) for each slot. Large numbers of SLOAD operations in a transaction increase the witness size, impacting light client performance.
Edge Cases
| Edge Case | Behavior | Security Implication |
|---|---|---|
| Cold vs warm access | First SLOAD of a slot in a transaction costs 2100 gas; subsequent reads cost 100 gas | Functions relying on gas limits may fail on first call but succeed on retry within the same transaction; attackers can exploit this asymmetry |
| Uninitialized slot | Returns 0x0000...0000 (32 zero bytes) | Zero may be a privileged or meaningful value; contracts must distinguish “never written” from “intentionally set to zero” |
| Slot read during constructor | Storage written by the constructor is readable via SLOAD within the same constructor; slots not yet written return 0 | Constructors that check storage before writing it (e.g., require(initialized == false)) always pass on first deployment since the slot is uninitialized |
| Slot in DELEGATECALL context | SLOAD reads from the calling (proxy) contract’s storage, not the implementation’s | Storage layout must match between proxy and implementation; mismatches silently return wrong data |
| Packed storage slots | SLOAD returns the full 32-byte word; the compiler generates bit masks to extract packed fields | Incorrect packing assumptions (e.g., after upgrade) cause SLOAD to return the right word but extract the wrong bits |
| Slot of a mapping | Slot is computed as keccak256(abi.encode(key, baseSlot)); reading a non-existent key returns 0 | Indistinguishable from “key exists with value 0”; protocols must track key existence separately if zero is a valid value |
| Slot during STATICCALL | SLOAD works normally; only SSTORE is blocked | Read-only reentrancy can exploit SLOAD in STATICCALL contexts to read stale state without triggering write restrictions |
| Self-destruct + SLOAD (same tx) | Pre-Dencun: storage is accessible until end of transaction. Post-Dencun (EIP-6780): SELFDESTRUCT only sends ETH, storage persists | Post-Dencun, SLOAD after SELFDESTRUCT returns the same values as before; no storage clearing occurs except in creation-transaction self-destructs |
Real-World Exploits
Exploit 1: Audius Governance Takeover — $6M via Storage Collision (July 2022)
Root cause: Storage slot 0 collision between the proxy admin address and OpenZeppelin’s Initializable flags, causing SLOAD to misinterpret the admin address bytes as initialization state.
Details: Audius used an AudiusAdminUpgradabilityProxy that stored the proxy admin address at storage slot 0. OpenZeppelin’s Initializable contract also uses slot 0 for two boolean flags: initialized (byte 0) and initializing (byte 1). When the implementation’s initializer() modifier executed SLOAD on slot 0, it read the proxy admin address 0x4deca517d6817b6510798b7328f2314d3003abac instead of the expected boolean flags. The last byte (0xac, nonzero) was interpreted as initialized = true, and the adjacent byte (0xab, nonzero) was interpreted as initializing = true. Due to the modifier’s logic require(initializing || !initialized), this combination (true || !true = true) always passed, allowing unlimited re-initialization.
The attacker called initialize() on the Governance, Staking, and DelegateManagerV2 contracts, setting themselves as guardian and creating fraudulent delegations with 10 trillion fake AUDIO tokens. They then passed a malicious governance proposal to transfer 18M AUDIO (~$6M) from the community treasury.
SLOAD’s role: Every access to the initialized flag compiled to SLOAD(0), which returned the proxy admin address. The bit-level reinterpretation of address bytes as boolean flags was the direct exploit mechanism. If SLOAD had returned the expected 0x01 or 0x00, the modifier would have correctly blocked re-initialization.
Impact: $6M stolen. The contracts had been audited by both OpenZeppelin and Kudelski without detecting the collision.
References:
Exploit 2: Sturdy Finance — $800K via Read-Only Reentrancy / Stale SLOAD (June 2023)
Root cause: Sturdy Finance’s collateral valuation logic read Balancer LP token prices via SLOAD during a reentrancy window when Balancer’s storage was in an inconsistent state.
Details: The attacker took a flash loan of 50,000 wstETH and 60,000 WETH from Aave, then added liquidity to Balancer’s B-stETH-STABLE pool to inflate the LP token price. During the liquidity operation, Balancer’s pool contract made an external call (ETH transfer) before finalizing its internal accounting. In this callback window, the attacker triggered Sturdy Finance to evaluate collateral. Sturdy’s valuation function called Balancer’s getRate() or equivalent view function, which performed SLOAD on Balancer’s pool storage — storage that hadn’t been updated yet because the liquidity operation was still mid-execution.
The stale SLOAD returned pre-update reserve values while the LP token supply had already changed, inflating the apparent price by ~3x. The attacker deposited the overvalued collateral into Sturdy, borrowed 513 WETH against it, then let the Balancer operation complete (normalizing prices) and withdrew their original collateral. The process was repeated across five Sturdy contracts.
SLOAD’s role: The entire exploit hinged on SLOAD reading Balancer’s storage during mid-execution. The view function was “read-only” (no state changes), but the SLOAD values it returned were stale because Balancer’s SSTORE hadn’t executed yet. Sturdy’s code was technically correct — the vulnerability was in trusting SLOAD results from an external contract during a reentrancy window.
Impact: ~$800K (442 ETH) stolen.
References:
Exploit 3: Shanghai DoS Attacks — Network Degradation via Mass SLOAD (September 2016)
Root cause: SLOAD was priced at 200 gas but required disk-level trie traversal costing orders of magnitude more in wall-clock time.
Details: Over several weeks starting in September 2016, attackers crafted transactions that maximized the number of unique storage slot accesses per block. Each SLOAD forced the EVM to traverse the Merkle Patricia Trie from root to leaf, requiring multiple LevelDB lookups (random disk reads). At 200 gas per SLOAD, a single 4.7M gas-limit block could contain ~23,500 cold storage reads, each requiring 5-7 disk seeks.
The attack was not limited to SLOAD alone — EXTCODESIZE (20 gas at the time) and BALANCE were also exploited — but SLOAD was a major component because storage slots have the deepest trie paths (account trie + storage trie). Block processing times exceeded 60 seconds on commodity hardware, causing miners to fall behind, peers to disconnect, and the network to operate at degraded capacity for weeks. The attacker also created ~19M empty accounts via cheap SELFDESTRUCT, inflating state size and making subsequent SLOAD operations even slower.
SLOAD’s role: SLOAD was one of the primary opcodes weaponized for the attack. Its low gas cost relative to actual I/O cost meant the attacker could force enormous disk I/O for minimal economic cost. The attack directly motivated the gas repricing in EIP-150 and later the warm/cold model in EIP-2929.
Impact: Weeks of network degradation. No funds stolen, but the attack exposed fundamental gas pricing flaws that took two hard forks (Tangerine Whistle, Spurious Dragon) and eventually a third (Berlin) to fully address.
References:
- ethos.dev: Ethereum’s Shanghai Attacks
- Ethereum Foundation: DoS Attack Blog Post
- EIP-2929: Gas cost increases for state access opcodes
Exploit 4: dForce — $3.6M via Read-Only Reentrancy on Curve’s get_virtual_price (February 2023)
Root cause: dForce used Curve’s get_virtual_price() as a pricing oracle. During a reentrancy window in Curve’s pool, get_virtual_price() performed SLOAD on stale reserves, returning a manipulated price.
Details: Curve’s get_virtual_price() computes the LP token value by reading pool reserves from storage (SLOAD) and dividing by the total LP supply. During certain pool operations (add/remove liquidity), Curve makes external calls before updating its reserve accounting. An attacker triggered this callback, then called dForce’s functions, which in turn called get_virtual_price(). The SLOAD on Curve’s reserves returned pre-update values while the LP supply had already changed, producing an inflated virtual price.
dForce’s lending protocol used this inflated price to value collateral, allowing the attacker to borrow against overvalued positions and extract $3.6M.
SLOAD’s role: get_virtual_price() is a pure read function that performs only SLOADs — no SSTOREs. The vulnerability is that those SLOADs returned stale data because the corresponding SSTOREs hadn’t executed yet in Curve’s incomplete transaction. This is the canonical example of how SLOAD in a view function can be weaponized.
Impact: $3.6M stolen from dForce.
References:
Attack Scenarios
Scenario A: Storage Collision in Proxy Upgrade
// V1 Implementation -- deployed and working
contract ImplementationV1 {
address public owner; // slot 0
uint256 public totalSupply; // slot 1
mapping(address => uint256) public balances; // slot 2 (base)
}
// V2 Implementation -- developer adds a variable to the base contract
contract ImplementationV2 {
address public owner; // slot 0
address public feeRecipient; // slot 1 -- NEW variable inserted here
uint256 public totalSupply; // slot 2 -- SHIFTED from slot 1
mapping(address => uint256) public balances; // slot 3 -- SHIFTED from slot 2
function withdraw(uint256 amount) external {
// SLOAD(slot 3 base) -- but actual balance data is still at slot 2 base!
// This reads an empty mapping, returning 0 for all users.
require(balances[msg.sender] >= amount, "insufficient");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
function totalAssets() external view returns (uint256) {
// SLOAD(slot 2) -- but slot 2 now holds old balances mapping base,
// which may contain a non-zero hash, returning a garbage totalSupply
return totalSupply;
}
}Scenario B: Gas Griefing via Unbounded Cold SLOAD
contract VulnerableRegistry {
address[] public registrants;
function register() external {
registrants.push(msg.sender);
}
// Attacker registers thousands of addresses, then calls isRegistered()
// with a non-existent address, forcing iteration over all cold slots.
function isRegistered(address user) external view returns (bool) {
for (uint256 i = 0; i < registrants.length; i++) {
// Each iteration: SLOAD on registrants[i] -- cold on first access
// Cost: 2100 gas * registrants.length for cold reads
if (registrants[i] == user) return true;
}
return false;
}
// Worst case: attacker inflates registrants to 14,000 entries
// isRegistered() costs 14,000 * 2100 = 29.4M gas (near block limit)
// Any protocol calling this function with a gas limit will revert
}Scenario C: Read-Only Reentrancy Exploiting Stale SLOAD
contract LiquidityPool {
uint256 public totalReserves;
uint256 public totalShares;
function removeLiquidity(uint256 shares) external {
uint256 amount = (shares * totalReserves) / totalShares;
totalShares -= shares; // SSTORE: shares updated
// External call BEFORE updating totalReserves
// During this call, totalReserves is stale (still includes `amount`)
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
totalReserves -= amount; // SSTORE: reserves updated AFTER call
}
// View function called by external lending protocol
function getSharePrice() external view returns (uint256) {
// SLOAD(totalReserves) / SLOAD(totalShares)
// During reentrancy: totalReserves is NOT yet decremented,
// but totalShares IS decremented -> inflated price
return (totalReserves * 1e18) / totalShares;
}
}
contract Attacker {
LiquidityPool immutable pool;
LendingProtocol immutable lender;
constructor(LiquidityPool _pool, LendingProtocol _lender) {
pool = _pool;
lender = _lender;
}
function attack() external {
pool.removeLiquidity(myShares);
}
receive() external payable {
// Re-entrant callback: pool.getSharePrice() returns inflated value
// because totalReserves hasn't been decremented yet but totalShares has
lender.depositCollateral(address(pool)); // lender reads inflated price
lender.borrow(maxAmount); // borrow against overvalued collateral
}
}Scenario D: Uninitialized Storage After Upgrade
// V1: No withdrawal limit
contract VaultV1 {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}
// V2: Adds withdrawal limit -- but forgot to initialize it
contract VaultV2 {
mapping(address => uint256) public balances;
uint256 public maxWithdrawal; // SLOAD returns 0 (never initialized)
function withdraw(uint256 amount) external {
// maxWithdrawal == 0 after upgrade because no initializer set it
// If the check is `amount <= maxWithdrawal`, all withdrawals are blocked
// If the check is `maxWithdrawal == 0 || amount <= maxWithdrawal`,
// then 0 means "no limit" -- potentially dangerous if that's unintended
require(maxWithdrawal == 0 || amount <= maxWithdrawal, "exceeds limit");
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}Mitigations
| Threat | Mitigation | Implementation |
|---|---|---|
| T1: Storage collision in proxies | Use EIP-1967 standardized storage slots | Store proxy admin/implementation at keccak256('eip1967.proxy.admin') - 1 to avoid sequential slot collisions; use OpenZeppelin’s TransparentUpgradeableProxy or ERC1967Proxy |
| T1: Layout shift across upgrades | Run storage layout diff tools before every upgrade | Use hardhat-upgrades or foundry-upgrades which automatically check storage compatibility; maintain uint256[50] private __gap in base contracts |
| T1: Diamond pattern collisions | Namespace storage per facet using struct hashing | Use keccak256("diamond.storage.FacetName") as storage base per EIP-2535; never use sequential slots in shared proxy storage |
| T2: Gas griefing via cold SLOAD | Avoid unbounded iteration over storage | Use mappings for O(1) lookups instead of arrays for O(n) scans; if iteration is needed, bound it with pagination or off-chain indexing |
| T2: Gas estimation instability | Use EIP-2930 access lists | Pre-declare accessed storage slots in the transaction to ensure warm pricing; test with both warm and cold access patterns |
| T3: Stale SLOAD during reentrancy | Checks-Effects-Interactions + reentrancy guards | Update all storage (SSTORE) before making external calls; use OpenZeppelin ReentrancyGuard or EIP-1153 transient storage locks |
| T3: Read-only reentrancy | Never trust view functions during callbacks | Use reentrancy-aware oracle designs; check for reentrancy locks before reading external protocol state; Chainlink-style oracle snapshots over raw SLOAD-based view functions |
| T4: Uninitialized slot reads | Always run initializers after upgrade | Use OpenZeppelin’s reinitializer(version) for V2+ upgrades; never treat zero as a privileged or permissive value without explicit initialization |
| T4: Zero-default semantics | Distinguish “unset” from “zero” explicitly | Use sentinel values (e.g., type(uint256).max for “not set”) or boolean existence flags alongside value storage |
| T5: Packed storage reinterpretation | Never change variable sizes in upgrades | Append-only storage layout; if a type must change, add a new variable and deprecate the old one |
| General: State access cost | Minimize cold SLOAD in hot paths | Cache storage reads in memory variables; batch reads at function entry; use immutable/constant for values that don’t change |
Protocol-Level Protections
- EIP-2929 (Berlin, 2021): Cold/warm pricing model. Increased cold SLOAD from 200 to 2100 gas, making mass-SLOAD DoS attacks 10.5x more expensive.
- EIP-2930 (Berlin, 2021): Optional access lists allow transactions to pre-declare storage slots at 200 gas per slot (vs 2100 cold), mitigating gas surprises for contracts with known access patterns.
- EIP-1153 (Dencun, 2024): Transient storage (
TLOAD/TSTORE) at 100 gas provides gas-efficient reentrancy guards without permanent state writes, directly mitigating T3. - Solidity >= 0.5.0: Uninitialized storage pointers no longer compile, eliminating T4’s pointer variant at the language level.
- Solidity >= 0.8.0: Storage layout is deterministic and documented; tools like
forge inspectandhardhat-storage-layoutcan diff layouts between upgrade versions.
Severity Summary
| Threat ID | Category | Severity | Likelihood | Real-World Precedent |
|---|---|---|---|---|
| T1 | Smart Contract | Critical | Medium | Audius ($6M, July 2022); widespread proxy collision risk (1.5M+ contracts) |
| T2 | Smart Contract | High | Medium | Shanghai DoS (2016, network-level); application-level gas griefing ongoing |
| T3 | Smart Contract | High | High | Sturdy Finance (3.6M, Feb 2023); Curve-dependent protocols |
| T4 | Smart Contract | Medium | Medium | Honeypot contracts (pre-0.5.0); upgrade-related zero-default bugs |
| T5 | Smart Contract | Medium | Low | Detected in audits; fewer public exploits but high latent risk in upgradeable contracts |
| P1 | Protocol | Critical (Historical) | Mitigated | Shanghai DoS (Sept 2016, weeks of network degradation) |
| P2 | Protocol | Medium | Low | Contract breakage post-Berlin; gas estimation edge cases |
| P3 | Protocol | Low | Low | Future risk from state growth; relevant to Verkle tree / state expiry proposals |
Related Opcodes
| Opcode | Relationship |
|---|---|
| SSTORE (0x55) | The write counterpart to SLOAD. SSTORE sets the value that SLOAD reads. Gas refund mechanics on SSTORE interact with SLOAD’s warm/cold model (writing a slot also warms it for subsequent SLOAD). |
| TLOAD (0x5C) | EIP-1153 transient storage read. Like SLOAD but transaction-scoped (cleared after tx). Costs 100 gas always (no cold/warm distinction). Preferred for reentrancy guards and temporary state that doesn’t need persistence. |
| DELEGATECALL (0xF4) | Executes code in the caller’s storage context. Every SLOAD in delegatecalled code reads from the calling contract’s storage, which is the fundamental mechanism behind proxy patterns and the root cause of storage collision vulnerabilities. |
| MLOAD (0x51) | Memory read counterpart. MLOAD is transaction-scoped and costs 3 gas (+ memory expansion). Caching SLOAD results in memory via MLOAD avoids repeated cold storage access. |