Opcode Summary

PropertyValue
Opcode0x51
MnemonicMLOAD
Gas3 (static) + memory expansion cost (dynamic)
Stack Inputoffset (byte offset into memory)
Stack Outputmem[offset:offset+32] (32-byte word read from memory)
BehaviorReads a 32-byte (256-bit) word from memory starting at byte position offset. If offset + 32 exceeds the current memory size, memory is expanded to accommodate the read, and the caller pays the quadratic expansion cost. The read is not required to be word-aligned — any byte offset is valid. All newly expanded memory bytes are initialized to zero.

Threat Surface

MLOAD is the EVM’s primary memory read primitive. Every Solidity function that decodes calldata, constructs return values, hashes data, or manipulates dynamic types (arrays, bytes, strings) compiles to sequences of MLOAD and MSTORE operations. Despite its apparent simplicity, MLOAD introduces a threat surface that spans gas economics, data integrity, and compiler-level memory safety.

The threat surface centers on four properties:

  1. Memory expansion has quadratic gas cost. The EVM charges memory gas using the formula (words² / 512) + (3 * words), where words = ceil((offset + 32) / 32). This means an MLOAD at offset 0 costs 3 gas total, but an MLOAD at offset 1 MB costs ~3 million gas, and at ~3.5 MB the cost exceeds the 30M block gas limit entirely. Any contract that allows an external caller to influence the offset parameter of an MLOAD (directly via assembly, or indirectly through array indexing, ABI decoding, or memory copy operations) exposes itself to gas griefing. The attacker doesn’t need the data — they just need the expansion to happen.

  2. Uninitialized memory reads zero, masking bugs silently. The EVM guarantees that freshly expanded memory is zero-filled. This means an MLOAD from an unwritten region returns 0x0000...0000 rather than reverting. While this prevents undefined behavior in the traditional sense, it creates a class of silent failures: contracts that forget to write data before reading it get zero instead of an error, producing incorrect but non-reverting execution. Zero is a valid value for addresses (address(0)), balances, lengths, and boolean false — all of which can cause downstream logic failures that are extremely difficult to detect in testing.

  3. Non-aligned reads are valid and can cross data boundaries. Unlike many architectures, the EVM places no alignment requirement on MLOAD. Reading from offset 1 returns bytes [1:33], which overlaps with two 32-byte words. In inline assembly, this allows developers to read partial or overlapping data structures, but it also means that an off-by-one error in offset calculation reads a completely different (and partially overlapping) value than intended. High-level Solidity restricts memory access to aligned offsets, but any use of assembly { ... } or Yul bypasses this safety net.

  4. Solidity’s free memory pointer creates a single point of failure. Solidity stores the next free memory address at the fixed location 0x40. Every memory allocation reads this pointer via mload(0x40), writes data at that address, and then updates 0x40 to point past the new allocation. If inline assembly corrupts the value at 0x40 — by writing directly to it, by failing to update it after manual memory use, or by a compiler optimizer bug that removes a necessary mstore(0x40, ...) — all subsequent Solidity memory allocations will overwrite live data. The corruption is invisible until the overwritten data is read, potentially many operations later.


Smart Contract Threats

T1: Memory Expansion Gas Griefing via Attacker-Controlled Offsets (High)

An MLOAD with a large offset forces memory expansion whose gas cost grows quadratically. If any external input influences the memory offset — directly in assembly, or indirectly through array length, ABI-decoded size fields, or loop bounds — an attacker can craft inputs that consume most or all of the transaction’s gas on memory expansion alone, leaving insufficient gas for subsequent operations.

  • Unbounded array or bytes decoding. When a contract ABI-decodes a dynamic bytes or array parameter, the compiler allocates memory proportional to the declared length. A malicious caller can pass a length field of 2^20 (1 MB) in calldata while providing minimal actual data. The decoder allocates length bytes in memory via MLOAD/MSTORE at high offsets, triggering massive expansion costs. The caller pays gas for the transaction, but if the contract forwards the call (e.g., via a relayer pattern or meta-transaction), the relayer pays the gas and the attacker’s transaction reverts after consuming it.

  • Return data bombing (indirect MLOAD). When a contract calls an external address using .call() and captures return data with returndatacopy, the EVM copies the returned bytes into memory. A malicious callee can revert with megabytes of data, forcing the caller’s memory to expand dramatically. While returndatacopy (not MLOAD) performs the actual write, the resulting memory expansion makes subsequent MLOADs at high offsets cheap (expansion already paid) — but the damage is done: the caller’s gas is consumed.

  • Assembly loops with user-controlled bounds. Inline assembly that iterates with mload(add(ptr, mul(i, 0x20))) where i is derived from calldata will expand memory proportionally to the maximum index accessed. Without bounds checking, the last iteration’s MLOAD determines the total memory cost.

Why it matters: Memory expansion griefing appears in approximately 20% of smart contract audits. It can freeze withdrawals, block liquidations, and degrade protocol liveness when the griefed operation is time-critical.

T2: Uninitialized Memory Reads Returning Zero (Medium)

The EVM zero-initializes all memory, so an MLOAD from an address that was never written to returns 32 zero bytes rather than reverting. This is safe from an undefined-behavior standpoint but dangerous from a correctness standpoint:

  • Missing writes silently produce zero. If a code path skips a mstore due to a conditional branch, optimizer bug, or logic error, the subsequent mload returns zero. In Solidity, zero is address(0), false, empty bytes, and the integer 0 — all of which are semantically meaningful values that can pass downstream checks.

  • Struct fields default to zero. When Solidity allocates a struct in memory, it zero-fills the region. If the developer forgets to initialize a field before passing the struct to an external call or hash function, the field reads as zero. For an address field, this means address(0), which may have special semantics (burn address, default admin, unset sentinel).

  • Conditional memory writes with fallthrough. Assembly code like if condition { mstore(ptr, value) } followed by let x := mload(ptr) returns zero when condition is false. The absence of a revert means the contract continues with corrupted (zero) data.

Why it matters: Silent zero-reads are a class of bug that passes all “happy path” tests because zero is often a valid value. They surface only when specific code paths are taken, making them difficult to detect without formal verification or comprehensive fuzzing.

T3: Free Memory Pointer Corruption and Overwrites (Critical)

Solidity uses mload(0x40) as the canonical free memory pointer (FMP). Every dynamic memory allocation reads the FMP, writes data at that location, then advances the FMP. Corruption of the value at address 0x40 causes all subsequent allocations to overlap with existing data:

  • Assembly blocks that don’t update 0x40. When inline assembly manually allocates memory by writing past the current FMP without updating 0x40, the next Solidity allocation (e.g., new bytes(...), abi.encode(...), string concatenation) overwrites the assembly-allocated data. The MLOAD that reads the FMP returns the stale value, and the new allocation clobbers live data.

  • Assembly blocks that write an incorrect value to 0x40. Writing a value to 0x40 that points into already-used memory (e.g., mstore(0x40, 0x80) to “reset” the pointer) causes every subsequent allocation to overwrite data from the start of the heap. This can corrupt function arguments, return values, ABI-encoded data, and event parameters — all without reverting.

  • Compiler optimizer removing necessary writes. The Solidity optimizer bug in versions 0.8.13-0.8.14 (CVE-2022-05-18) removed mstore operations from isolated inline assembly blocks when the optimizer determined the write had no in-block readers. If the removed write was mstore(0x40, newPtr), subsequent code read a stale FMP via mload(0x40), allocating into already-occupied memory.

  • Cross-function pointer staling. When an internal function modifies memory and advances the FMP, but the caller has a cached copy of the old FMP (stored in a local variable via let ptr := mload(0x40) before the call), the caller’s subsequent writes using the cached ptr will collide with the callee’s allocations.

Why it matters: The FMP is the sole memory management mechanism in Solidity. Its corruption is the memory-safety equivalent of a heap buffer overflow in C. The 1inch Fusion v1 exploit ($5M, March 2025) demonstrated that assembly-level memory corruption can bypass multiple audits and remain undetected for years.

T4: Reading Scratch Space and Reserved Memory (0x00-0x3f) (Medium)

The EVM reserves memory addresses 0x00 through 0x3f as scratch space for hashing operations, and 0x40-0x5f for the free memory pointer. Solidity uses 0x00-0x1f as scratch space for keccak256 on short values and for ecrecover return values. These regions are deliberately not tracked by the FMP and can be overwritten at any time:

  • Stale scratch space reads. An mload(0x00) in inline assembly reads whatever was last written to scratch space — which could be a hash intermediate, an ecrecover result, or zero if nothing has written there yet. The value is non-deterministic from the developer’s perspective and depends on the compiler’s internal scratch space usage.

  • Hash collision via scratch space reuse. If two different code paths write different data to 0x00-0x3f and then read it, the second read sees the second write regardless of which “logical” variable it was supposed to contain. In security-sensitive contexts (e.g., computing storage slot keys for mappings), reading stale scratch space can cause reads/writes to the wrong storage slot.

  • Solidity compiler upgrades change scratch usage. The compiler’s use of 0x00-0x3f is an implementation detail, not a specification guarantee. Code that relies on scratch space containing specific values may break across Solidity versions.

Why it matters: Scratch space is a shared mutable resource with no access control. Assembly code that reads from it without first writing to it is reading ambient state left by compiler-generated code — a fragile and version-dependent pattern.

T5: Assembly Memory Safety Violations — Incorrect Offset Arithmetic (High)

Inline assembly and Yul allow arbitrary MLOAD at any offset, bypassing Solidity’s type system and bounds checking. Incorrect offset calculations are the assembly equivalent of buffer over-reads:

  • Off-by-one in struct field reads. A struct with fields at offsets 0x00, 0x20, 0x40 requires reading mload(add(ptr, 0x20)) for the second field. An off-by-one like mload(add(ptr, 0x1f)) reads a 32-byte window shifted by one byte, mixing bytes from two adjacent fields. The result is a valid 256-bit value that appears correct but contains corrupted data.

  • Dynamic array length vs. data confusion. Solidity stores dynamic memory arrays as [length][element0][element1].... Reading mload(arrayPtr) returns the length, while mload(add(arrayPtr, 0x20)) returns the first element. Confusing the two reads the length as data or data as length, producing nonsensical values that do not revert.

  • ABI-decoded data with crafted offsets. Hand-written ABI decoding in assembly trusts calldata offset fields to point to valid data regions. A malicious caller can provide offsets that cause calldataload and subsequent mload operations to read from unintended memory locations, as demonstrated in the 1inch Fusion v1 exploit where a negative offset caused a buffer overread into attacker-controlled calldata.

  • Misaligned MLOAD producing overlapping reads. Since MLOAD allows any byte offset, mload(ptr) and mload(add(ptr, 1)) return overlapping 32-byte windows. Code that incorrectly increments by 1 instead of 32 when iterating over words reads shifting, overlapping windows of data rather than sequential words.

Why it matters: Assembly memory bugs bypass all of Solidity’s safety guarantees. They produce valid-looking values rather than reverts, making them extremely difficult to detect without byte-level fuzzing or formal verification.


Protocol-Level Threats

P1: Quadratic Memory Pricing Enables Asymmetric Gas Griefing (Medium)

The EVM’s memory gas formula (words² / 512) + (3 * words) creates a nonlinear cost curve where the marginal cost of each additional word increases with total memory size. At the protocol level, this means:

  • Attackers pay linearly in calldata, victims pay quadratically in memory. A 32-byte calldata value specifying a large array length costs the attacker ~16 gas (calldata cost), but forces the victim contract to pay thousands or millions of gas for memory expansion. This asymmetry is inherent to the EVM’s memory model.

  • Nested call frames multiply memory capacity. Each CALL creates a fresh memory space. An attacker contract can use ~30M gas across multiple nested call frames to allocate up to ~26.8 MB of total memory (across frames), far exceeding what a single frame allows. This affects protocols that aggregate data from multiple external calls.

  • EIP-7686 proposes linear memory pricing. The proposed change to linear pricing (3 gas per word with a hard memory limit equal to the gas limit) would eliminate quadratic griefing but hasn’t been adopted yet. Until then, quadratic pricing remains the griefing vector.

P2: Consensus Safety of MLOAD (Low)

MLOAD is deterministic: it reads 32 bytes from a well-defined memory offset and expands memory if needed. All EVM client implementations agree on its semantics. No consensus bugs have been attributed to MLOAD itself. Memory is execution-context-local (not persisted across transactions), so there are no state synchronization concerns.

P3: MLOAD Behavior Across Hard Forks (Low)

MLOAD semantics have never changed across any Ethereum hard fork. The memory expansion gas formula has remained constant since Ethereum’s launch. The only related evolution is EIP-5656 (MCOPY, Cancun), which added a dedicated memory-to-memory copy opcode but did not alter MLOAD’s behavior or gas cost.


Edge Cases

Edge CaseBehaviorSecurity Implication
mload(0x00) — scratch spaceReads the 32-byte word at offset 0, which Solidity uses as scratch space for hashingReturns whatever the compiler last wrote there. Value is non-deterministic from the developer’s perspective; relying on it is fragile and version-dependent.
mload(0x40) — free memory pointerReads the free memory pointer itself (a 32-byte word encoding the next free address)Core mechanism for Solidity memory allocation. Corruption of this value (via mstore(0x40, ...) in assembly) causes all subsequent allocations to overwrite live data.
mload(0x60) — zero slotReads the “zero slot” which Solidity reserves as a zero-value sourceShould always contain 0. If assembly writes to 0x60, it corrupts Solidity’s zero-value assumption, potentially affecting abi.encode of empty dynamic types.
Very large offset (e.g., mload(0x100000))Expands memory to offset + 32 bytes, paying quadratic gasAt offset ~3.5 MB, gas cost exceeds the 30M block gas limit. Any user-controlled offset without bounds checking enables gas griefing or guaranteed revert.
Offset not word-aligned (e.g., mload(1))Reads bytes [1:33], straddling two 32-byte wordsReturns a valid value composed of bytes from two adjacent words. Off-by-one errors in offset math produce silently incorrect data instead of reverting.
mload at the current memory boundaryTriggers expansion by exactly 32 bytes; returns zero for the newly allocated bytesThe read succeeds with zero-valued data. No revert, no signal that the data was never written.
mload after mstore to same offsetReturns the exact value previously storedExpected behavior, but if an intervening external call or assembly block overwrites the same location, MLOAD returns the latest write, not the “expected” value.
mload in STATICCALL contextWorks identically; memory is execution-context-local and not a state mutationNo additional restrictions in static context. Memory reads are always permitted.
mload with offset = 2^256 - 1Would require 2^256 + 31 bytes of memory; reverts with out-of-gas since expansion cost is astronomicalEffectively an immediate revert. Not exploitable beyond consuming the transaction’s gas.

Real-World Exploits

Exploit 1: 1inch Fusion v1 — Assembly Memory Corruption via Calldata Offset Manipulation ($5M, March 2025)

Root cause: A buffer overflow in hand-written Yul assembly within the _settleOrder() function allowed an attacker to corrupt memory by providing a negative interaction length value, causing calldata to be read from attacker-controlled locations.

Details: On March 6, 2025, 1inch Network’s deprecated Fusion v1 order settlement contract was exploited for $5 million. The _settleOrder() function used inline Yul assembly to decode and process order data from calldata. The attacker crafted calldata containing a negative interaction length value (0xFFFF...FE00, or -512 in two’s complement). When the assembly code calculated the write position for the suffix data by adding this length to a base pointer, the result underflowed, pointing the write destination into a memory region controlled by the attacker’s calldata.

The corrupted memory caused the DynamicSuffix library’s subsequent mload operations to read a forged resolver address from the attacker-controlled region. The attacker could then impersonate a legitimate market maker, swapping minimal wei for millions in user tokens that had outstanding approvals to the settlement contract.

MLOAD’s role: The exploit chain worked because mload faithfully reads whatever is in memory at the specified offset. After the buffer overflow corrupted memory, mload operations in the DynamicSuffix library read the attacker’s forged resolver address as if it were legitimate data. MLOAD has no concept of “this memory region belongs to that data structure” — it just reads 32 bytes. The lack of memory bounds checking at the opcode level means that all memory safety must be enforced by the compiler or by the developer’s assembly code.

Impact: $5 million stolen from users who had token approvals to the deprecated contract. The vulnerability survived multiple audits because it required understanding signed-vs-unsigned integer semantics in assembly-level calldata offset calculations.

References:


Exploit 2: Return Data Bombing — RAI Protocol Liquidation Engine (Critical Bug, Disclosed January 2026)

Root cause: A malicious contract could return (or revert with) megabytes of data during a callback, forcing the caller’s memory expansion to consume all available gas and prevent liquidation completion.

Details: RAI (Reflexer Finance) allowed users to register “savior” contracts that were called during liquidation to attempt to save underwater positions. The liquidation engine used Solidity’s try/catch to handle savior failures gracefully. However, a malicious savior contract could revert with an enormous payload (megabytes of error data). Solidity’s try/catch mechanism copies the revert data into memory via returndatacopy, triggering massive memory expansion with quadratic gas cost.

Under the 63/64 gas rule, the external call to the savior received at most 63/64 of the remaining gas. The savior used minimal gas for its own execution but returned megabytes of revert data. The remaining 1/64 of gas in the liquidation engine was insufficient to complete the liquidation after paying for the memory expansion caused by copying the revert data. The result: the position became unliquidatable, threatening protocol solvency.

MLOAD’s role: While returndatacopy (not MLOAD) performs the initial memory write, the memory expansion it triggers is the same expansion mechanism that MLOAD uses. After the expansion, any MLOAD at a high offset within the expanded region costs only 3 gas (expansion already paid). The core vulnerability is the EVM’s shared memory expansion cost model: any operation that touches high memory offsets — MLOAD, MSTORE, RETURNDATACOPY, CALLDATACOPY, CODECOPY — pays the same quadratic expansion cost, and this cost is borne by the calling contract, not the callee.

Impact: Critical severity bug bounty. No funds were lost (disclosed responsibly). Demonstrated that memory expansion griefing can break protocol invariants (liquidation liveness) without directly stealing funds.

References:


Exploit 3: Solidity Optimizer Bug — Inline Assembly Memory Side Effects Removed (CVE-2022-05-18, June 2022)

Root cause: The Solidity optimizer in versions 0.8.13-0.8.14 incorrectly removed mstore operations from inline assembly blocks when the optimizer determined the writes had no in-block readers, even though subsequent code read the memory via mload.

Details: A new Yul optimizer step (“UnusedStoreEliminator”) introduced in Solidity 0.8.13 analyzed each inline assembly block in isolation. If an mstore within the block had no corresponding mload within the same block, the optimizer classified the write as unused and removed it. This was incorrect when a subsequent assembly block or high-level Solidity code read the written memory location:

function vulnerable() external pure returns (uint256 x) {
    assembly {
        mstore(0, 0x42)  // Optimizer removes this "unused" write
    }
    assembly {
        x := mload(0)    // Reads stale/zero memory instead of 0x42
    }
}

The bug affected contracts compiled with solc --optimize (legacy pipeline, not --via-ir). If the removed mstore wrote to address 0x40 (the free memory pointer), all subsequent Solidity memory allocations would use a stale pointer, causing heap corruption.

MLOAD’s role: The mload in the second assembly block was the operation that surfaced the bug — it returned zero (or stale data) instead of the value that should have been written by the removed mstore. MLOAD itself behaved correctly (it read what was in memory), but the optimizer’s incorrect removal of the preceding write meant the memory contents were wrong. This illustrates that MLOAD’s security depends entirely on the correctness of prior writes, and compiler bugs that affect writes manifest as incorrect MLOAD results.

Impact: Medium severity. Fixed in Solidity 0.8.15. In practice, the bug pattern was unlikely to occur frequently because most assembly blocks either reference Solidity variables (which the optimizer tracked correctly) or are self-contained. No known exploits in production, but the bug class is particularly insidious because it produces silently wrong values rather than reverts.

References:


Exploit 4: Vyper Compiler Memory Corruption Bugs (CVE-2024-22419, CVE-2024-24564, 2024)

Root cause: Multiple bugs in the Vyper compiler’s memory management caused buffer overflows, dirty memory reads, and out-of-bounds access when compiling certain patterns.

Details: During 2023-2024, several critical memory corruption vulnerabilities were discovered in the Vyper compiler:

  • CVE-2024-22419 (concat buffer overflow, CVSS 9.8): The concat built-in could write past the allocated memory buffer when concatenating multiple byte strings. The compiler miscalculated the required buffer size, and the overflow corrupted adjacent memory data structures. Subsequent MLOAD operations on the corrupted data returned incorrect values.

  • CVE-2024-24564 (extract32 dirty memory read, CVSS 3.7): The extract32(b, start) function could read uninitialized memory when the start parameter had side effects that modified the byte array. The compiler evaluated start after allocating but before fully writing the buffer, leaving a window where MLOAD would read zero-filled or stale memory instead of the expected data.

  • CVE-2024-26149 (_abi_decode memory overflow, CVSS 3.7): Excessively large starting indices in ABI decode operations caused read-position overflow, making MLOAD read from unintended memory locations outside the decoded array bounds.

MLOAD’s role: In all three CVEs, the vulnerability manifested through MLOAD reading corrupted, uninitialized, or out-of-bounds memory. The EVM executed correctly — MLOAD faithfully returned whatever was at the requested address — but the compiler had placed incorrect data (or no data) at that address. These bugs demonstrate that MLOAD’s security is only as good as the compiler’s memory layout correctness.

Impact: No production exploits were confirmed, but CVE-2024-22419 was rated Critical (CVSS 9.8). All issues were fixed in Vyper 0.4.0.

References:


Attack Scenarios

Scenario A: Memory Expansion Gas Griefing via Unbounded Array Decoding

contract VulnerableRelayer {
    function relay(address target, bytes calldata payload) external {
        // Relayer pays gas on behalf of the user.
        // The user controls `payload`, which is ABI-decoded by `target`.
        (bool success,) = target.call(payload);
        require(success, "relay failed");
    }
}
 
contract VulnerableTarget {
    function processData(uint256[] memory data) external {
        // ABI decoding `data` allocates memory proportional to data.length.
        // Attacker passes length = 2^18 (262144 elements = 8 MB) with
        // minimal actual calldata. The decoder expands memory to 8 MB,
        // costing ~30M gas (quadratic formula), consuming the relayer's
        // entire gas budget before reaching this line.
        uint256 sum;
        for (uint256 i = 0; i < data.length; i++) {
            sum += data[i];
        }
    }
 
    // Attack: Craft calldata with length field = 262144 but only
    // provide a few bytes of actual data. The ABI decoder allocates
    // memory for the full declared length, paying quadratic expansion
    // gas. The relayer's transaction reverts with out-of-gas.
}

Scenario B: Free Memory Pointer Corruption via Assembly

contract VulnerableFMPCorruption {
    function processWithAssembly(bytes calldata input) external pure returns (bytes32) {
        bytes32 result;
 
        assembly {
            let ptr := mload(0x40)
 
            // Copy calldata into memory at ptr
            calldatacopy(ptr, input.offset, input.length)
 
            result := keccak256(ptr, input.length)
 
            // BUG: Forgot to update the free memory pointer.
            // Should be: mstore(0x40, add(ptr, input.length))
            // Without this, Solidity's next allocation overwrites
            // the calldata copy region.
        }
 
        // Solidity allocates memory for abi.encode here, using the
        // stale free memory pointer. It writes over the region that
        // assembly used, but since we already hashed, `result` is
        // correct in THIS function. However, if this pattern occurs
        // before a subsequent memory operation that depends on the
        // data at `ptr`, the data is silently corrupted.
        bytes memory encoded = abi.encode(result);
 
        // In a more complex contract, the corruption manifests later:
        // a struct field is overwritten, an address becomes 0, or
        // a length field is zeroed -- all without reverting.
        return abi.decode(encoded, (bytes32));
    }
}

Scenario C: Uninitialized Memory Read Masking a Missing Write

contract VulnerableUninitialized {
    struct Withdrawal {
        address recipient;
        uint256 amount;
        bool approved;
    }
 
    function processWithdrawal(uint256 id) external {
        Withdrawal memory w;
 
        // Intended: load withdrawal data from storage into memory.
        // BUG: Developer forgot the else branch -- if id is not
        // in the active set, `w` remains zero-initialized.
        if (_isActive(id)) {
            w.recipient = _getRecipient(id);
            w.amount = _getAmount(id);
            w.approved = true;
        }
        // Missing: else { revert("invalid id"); }
 
        // w.approved is false (zero) for invalid IDs, so this check
        // catches SOME cases. But if the approval check is removed
        // in a future refactor, or if a different struct layout puts
        // a non-zero field first, the zero-initialized data passes.
        require(w.approved, "not approved");
 
        // With approved == true, this sends `w.amount` (0) to
        // `w.recipient` (address(0)). The transfer to address(0)
        // succeeds (burns ETH) with amount 0 -- a no-op that
        // silently swallows the withdrawal without error.
        payable(w.recipient).transfer(w.amount);
    }
 
    function _isActive(uint256) internal pure returns (bool) { return false; }
    function _getRecipient(uint256) internal pure returns (address) { return address(0); }
    function _getAmount(uint256) internal pure returns (uint256) { return 0; }
}

Scenario D: Return Data Bomb Preventing Liquidation

interface ILiquidationEngine {
    function liquidate(address position) external;
}
 
contract MaliciousSavior {
    // Called by the liquidation engine during liquidation.
    // Returns megabytes of data to consume the caller's gas
    // via memory expansion.
    fallback() external payable {
        assembly {
            // Revert with 4 MB of data.
            // The caller's try/catch copies this into memory,
            // paying ~30M gas for quadratic memory expansion.
            // The liquidation engine has insufficient gas remaining
            // to complete the liquidation.
            revert(0, 0x400000)
        }
    }
}
 
contract VulnerableLiquidationEngine {
    mapping(address => address) public saviors;
 
    function liquidate(address position) external {
        address savior = saviors[position];
 
        if (savior != address(0)) {
            // try/catch captures revert data into memory
            try ISavior(savior).save(position) {
                return; // Position saved
            } catch (bytes memory /* errorData */) {
                // BUG: `errorData` is copied into memory via
                // returndatacopy. If errorData is 4 MB, memory
                // expansion here consumes all remaining gas.
                // Execution never reaches the liquidation logic below.
            }
        }
 
        // Unreachable if savior returned a bomb -- position is
        // now permanently unliquidatable.
        _executeLiquidation(position);
    }
 
    function _executeLiquidation(address) internal { /* ... */ }
}
 
interface ISavior {
    function save(address position) external;
}

Mitigations

ThreatMitigationImplementation
T1: Memory expansion gas griefingBound all user-influenced memory offsets and array lengthsrequire(data.length <= MAX_LENGTH) before ABI decoding; cap dynamic array sizes in function signatures
T1: Return data bombingLimit return data copied into memoryUse assembly-level returndatacopy with explicit size limits; use Nomad’s ExcessivelySafeCall library for external calls
T1: Relayer gas griefingValidate payload size before forwardingrequire(payload.length <= MAX_PAYLOAD) in relayer contracts; estimate gas off-chain before submission
T2: Uninitialized memory readsAlways initialize memory structs explicitly; add revert for unexpected pathselse { revert(...) } for all conditional writes; use Solidity’s default initialization and avoid raw assembly reads of unwritten memory
T3: Free memory pointer corruptionAlways update 0x40 after assembly memory usemstore(0x40, add(ptr, size)) at the end of every assembly block that allocates memory; use memory-safe assembly annotation in Solidity >= 0.8.13
T3: Compiler optimizer bugsPin Solidity version; audit assembly blocks for optimizer interactionsUse solc >= 0.8.15 to avoid the assembly memory side effects bug; test with both --optimize and --no-optimize
T4: Scratch space readsNever read 0x00-0x3f without writing firstIn assembly, always mstore(0x00, value) before mload(0x00); treat scratch space as write-before-read only
T5: Offset arithmetic errorsUse named constants for struct field offsets; add bounds checkslet FIELD_OFFSET := 0x20 instead of magic numbers; require(offset + 32 <= maxOffset) for dynamic reads
General: Assembly memory safetyUse Solidity’s assembly ("memory-safe") { ... } annotationSignals to the compiler that the block only accesses memory within Solidity’s allocation model; enables better optimizer behavior and catches violations
General: Compiler-level protectionsUse latest stable compiler; run multiple compilers for critical codeCompile with both Solidity and Vyper >= 0.4.0 where applicable; use formal verification tools (Certora, Halmos) for memory-sensitive code

Compiler/EIP-Based Protections

  • Solidity >= 0.8.13 memory-safe assembly annotation: Allows developers to mark inline assembly blocks as memory-safe, asserting that they only allocate memory beyond the free memory pointer and update 0x40 correctly. The compiler can optimize more aggressively around these blocks and may emit warnings for non-annotated blocks in future versions.
  • Solidity >= 0.8.15: Fixes the optimizer bug (CVE-2022-05-18) that removed necessary mstore operations from assembly blocks. All production contracts should use 0.8.15 or later.
  • EIP-5656 (MCOPY, Cancun 2024): Adds a dedicated memory-to-memory copy opcode that is cheaper and simpler than the mload/mstore loop pattern. Reduces the likelihood of off-by-one errors in manual memory copy routines.
  • EIP-7686 (Proposed — Linear Memory Pricing): Would replace the quadratic memory cost formula with linear pricing (3 gas per word) plus a hard memory limit. This would eliminate the quadratic gas griefing vector but has not been adopted.
  • Vyper >= 0.4.0: Fixes all known memory corruption bugs (CVE-2024-22419, CVE-2024-24564, CVE-2024-26149). Contracts compiled with earlier Vyper versions should be audited for memory safety.

Severity Summary

Threat IDCategorySeverityLikelihoodReal-World Precedent
T1Smart ContractHighHighRAI return data bomb (critical bug bounty), ~20% of audit findings involve memory expansion griefing
T2Smart ContractMediumMediumVyper extract32 dirty memory read (CVE-2024-24564); pattern common in assembly-heavy contracts
T3Smart ContractCriticalMedium1inch Fusion v1 ($5M stolen, March 2025); Solidity optimizer bug (CVE-2022-05-18); Vyper concat overflow (CVE-2024-22419)
T4Smart ContractMediumLowNo direct exploit, but scratch space misuse is a recurring audit finding in assembly-heavy code
T5Smart ContractHighMedium1inch Fusion v1 ($5M) — calldata offset manipulation caused MLOAD to read from attacker-controlled memory
P1ProtocolMediumMediumQuadratic memory pricing is an active area of EIP discussion (EIP-7686); griefing is structural
P2ProtocolLowN/ANo consensus bugs attributed to MLOAD
P3ProtocolLowN/AMLOAD semantics unchanged across all hard forks

OpcodeRelationship
MSTORE (0x52)Writes a 32-byte word to memory. The write counterpart to MLOAD. Every MLOAD vulnerability (FMP corruption, uninitialized reads) traces back to a missing, incorrect, or optimizer-removed MSTORE. They share the same memory expansion cost model.
MSTORE8 (0x53)Writes a single byte to memory. Can cause subtle bugs when developers use MSTORE8 to write but MLOAD to read — the MLOAD returns 32 bytes where only 1 byte was written, with the remaining 31 bytes being zero or stale.
MSIZE (0x59)Returns the current memory size (highest accessed offset, rounded up to 32-byte words). Sometimes misused as a source of entropy or as a proxy for “how much memory has been used.” MSIZE increases when MLOAD triggers expansion, creating a side effect that can be observed by subsequent code.
MCOPY (0x5E)Memory-to-memory copy (EIP-5656, Cancun 2024). Replaces the mload/mstore loop pattern for bulk memory copies. Cheaper and less error-prone than manual copy loops, reducing the risk of off-by-one errors in memory offset arithmetic.
CALLDATACOPY (0x37)Copies calldata into memory. Shares MLOAD’s memory expansion cost model. The 1inch Fusion v1 exploit used calldatacopy to write attacker-controlled data into memory, which was then read by MLOAD. The two opcodes form a pipeline where CALLDATACOPY writes and MLOAD reads.
RETURNDATACOPY (0x3E)Copies return data from the last external call into memory. The return data bomb attack vector works through RETURNDATACOPY triggering massive memory expansion, which is the same expansion mechanism MLOAD uses.
KECCAK256 (0x20)Hashes a region of memory. Reads memory (like MLOAD) and triggers expansion if the region hasn’t been accessed. Commonly used immediately after MLOAD-based data preparation. Shares the same memory expansion cost and scratch space usage patterns.