Skip to content

Inline Assembly

Solidity's assembly { ... } blocks expose Yul, a low-level language one step above raw EVM opcodes. Developers reach for it to save gas, to read data the language does not expose, or to build libraries that would otherwise require a compiler change. Inside such a block, almost every guarantee the compiler makes elsewhere is suspended. There is no checked arithmetic, no type system beyond the 256-bit word, no bounds checking, no memory allocator, and no ABI encoder validating that the incoming data has the shape the code assumes.

The resulting bugs are not language-lawyer curiosities. They are the ordinary mistakes of low-level programming, off-by-one offsets and missing masks and unchecked lengths, landing in code that moves money. Roughly 11.63% of Solidity compiler defects are themselves triggered by inline assembly, which gives a sense of how thin the ice is.

Yul is not the EVM

An assembly block contains Yul, not raw bytecode. Yul offers structured control flow, named variables, and helpers such as x.slot and x.offset for reaching Solidity state. It compiles down to opcodes, and the abstraction is thin enough that the underlying machine shows through everywhere: wrapping arithmetic, zero-padded out-of-range reads, unmanaged memory.

Unchecked Arithmetic

Solidity 0.8 introduced checked arithmetic, and the guarantee stops at the boundary of an assembly block. Yul's add, sub, and mul wrap modulo 2^256 regardless of the pragma, so any pointer derived from user-supplied numbers is an arbitrary read or write primitive waiting to be triggered.

Consider a settlement contract locating a trailing data structure by walking past a variable-length section of calldata:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pragma solidity ^0.8.17;

contract Settlement {
    function settle(bytes calldata order) external {
        address resolver;
        assembly {
            let interactionLength := calldataload(add(order.offset, 0x20))
            // @audit attacker-controlled length wraps the offset around
            let suffix := add(add(order.offset, 0x40), interactionLength)
            resolver := shr(96, calldataload(suffix))
        }
        // the resolver is now trusted to receive the settlement callback
    }
}

The value of interactionLength is a full 32-byte word taken straight from calldata, and the caller decides what it contains. A value close to 2^256 makes the addition wrap, so suffix points not behind the interaction data but in front of it, into a region the attacker has filled with bytes of their own choosing. The resolver address read from that position is whatever they decided it should be.

On March 5th, 2025, an attacker drained roughly five million dollars from a market maker using the deprecated 1inch Fusion v1 settlement contract by doing exactly this. The _settleOrder function computed a suffix offset as add(add(ptr, interactionOffset), interactionLength), and the attacker passed an interactionLength equivalent to -512, relocating the suffix 512 bytes earlier into their own padding. The suffix carries the resolver address authorizing the settlement callback, so the corrupted read let them substitute a contract they controlled and swap a few wei for millions in USDC and WETH.

A bug with a long fuse

The 1inch flaw was introduced in November 2022, when the contract was refactored from Solidity into Yul for gas reasons, and survived review by nine separate audit firms. The vulnerable version had been superseded but never removed from the resolvers still referencing it. Rewriting working Solidity into assembly re-opens every question the compiler had already answered, and audit coverage rarely follows deprecated code.

The same pattern reaches memory pointer arithmetic. A finding in the wagmi-leverage protocol involved an external-call helper computing a write location as add(add(ptr, 0x24), mul(swapAmountInDataIndex, 0x20)) with an unbounded, user-supplied index. A large enough index wrapped the pointer backwards over the four-byte function selector of the prepared call, letting the attacker invoke arbitrary functions on targets a whitelist was supposed to protect.

Memory Corruption

Solidity maintains a small set of memory conventions that the rest of the generated code depends on. The first 64 bytes are scratch space, used freely by hashing and encoding helpers. The word at 0x40 is the free memory pointer, which every allocation reads and advances. The word at 0x60 is the zero slot, expected to remain zero forever because empty dynamic memory arrays point at it for their length. Allocatable memory begins at 0x80.

None of these conventions are enforced. An assembly block writing memory without allocating it produces a buffer that the next allocation writes over:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
pragma solidity ^0.8.17;

contract Encoder {
    function pack(uint256 a, uint256 b) internal pure returns (bytes memory out) {
        assembly {
            out := mload(0x40)
            mstore(out, 0x40)
            mstore(add(out, 0x20), a)
            mstore(add(out, 0x40), b)
            // @audit the free memory pointer is never advanced past the buffer
        }
    }
}

The returned bytes looks correct for exactly as long as nothing else allocates. The moment the caller performs an abi.encode, constructs an array, or hashes a memory range, the compiler reads the same stale free memory pointer and writes over out. What ends up hashed, signed, or sent to another contract is a mix of two unrelated values, and the attacker frequently controls the second one.

Two further variants are worth internalizing. Writing to 0x40 directly corrupts every subsequent allocation in the transaction. Writing a non-zero value to 0x60 makes every empty dynamic array created afterwards report a bogus length, which turns an innocuous loop into an out-of-bounds read. Memory is never zero-initialized and never freed, so a buffer that is allocated but only partially written leaks whatever the previous occupant left behind into whatever consumes it.

The memory-safe promise

Annotating a block as assembly ("memory-safe") { ... } states that it only touches memory it allocated, memory Solidity allocated, the scratch space, or space beyond the free memory pointer that it does not claim. This is a promise, not a check. The IR pipeline uses it to relocate stack variables into memory it believes unused, so a block that lies about its behavior produces, in the words of the Solidity documentation, "incorrect and undefined behavior that cannot easily be discovered by testing". A review treating the annotation as documentation rather than as a claim to verify has skipped the interesting part.

Dirty Bits and Missing Masks

The EVM stack is 256 bits wide, while an address is 160 and a uint64 is 64. Under normal circumstances the ABI decoder guarantees that the unused bits of an argument are zero, and the compiler cleans values before they matter. Assembly bypasses the decoder, and the Solidity documentation is explicit that no assumption about those bits survives the boundary: "you cannot make any assumptions about bits not part of the encoding of the type. Especially, do not assume them to be zero."

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pragma solidity ^0.8.17;

contract Registry {
    mapping(address => uint256) public credits;

    fallback() external {
        address user;
        assembly {
            // @audit the top twelve bytes are never cleared
            user := calldataload(4)
        }
        credits[user] += 1;
    }
}

Because the high twelve bytes of that word are never cleared, an attacker submits 2^96 different values that all render as the same address in a block explorer while hashing to entirely different mapping slots. The reverse is equally useful: a comparison such as require(user == owner) fails for a caller who genuinely is the owner but whose word carries garbage, or succeeds against a value masked somewhere else in the call path. Reading an address correctly means masking with and(x, 0xffffffffffffffffffffffffffffffffffffffff) or, for a left-aligned twenty-byte read, shr(96, ...).

Signed types have their own version of the problem. Widening a negative int128 requires signextend, not shr, and the wrong one turns a negative balance into an enormous positive one. The compiler has its own entry for this class: the SignedImmutables bug, present from 0.6.5 to 0.8.9, left signed immutable variables zero-extended instead of sign-extended, and the documentation notes that the only way to observe the unclean value was through inline assembly.

Manual Calldata Decoding

Manual decoding introduces a failure mode with no equivalent in high-level Solidity. Reading past the end of calldata does not revert. calldataload beyond calldatasize() returns zeros, quietly, so a value that a check depends on simply evaporates.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
pragma solidity ^0.8.17;

contract Airdrop {
    mapping(address => bool) claimed;

    fallback() external {
        address recipient;
        uint256 amount;
        uint256 proofLength;
        assembly {
            recipient := shr(96, calldataload(0))
            amount := calldataload(20)
            // @audit no comparison against calldatasize() precedes this load
            proofLength := calldataload(52)
        }
        require(!claimed[recipient], "already claimed");
        for (uint256 i; i < proofLength; ++i) {
            // verify the i-th element of the Merkle proof
        }
        claimed[recipient] = true;
        _transfer(recipient, amount);
    }
}

A caller sending exactly 52 bytes gets a well-formed recipient and amount, and a proofLength of zero. The verification loop runs no iterations, the claim is recorded, and the transfer executes. The guard did not fail, it was never evaluated. Any hand-written decoder needs a comparison against calldatasize() before every load, and the same caution applies to assigning .offset or .length on calldata arrays, where the documentation warns that "no validation is performed to ensure that the variable will not point beyond calldatasize()".

Positional Calldata Reads

A related class of bug arises where assembly reads data by position rather than by structure. The meta-transaction standard ERC-2771 has a trusted forwarder append the original sender's address to the end of the calldata, and the receiving contract recovers it like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function _msgSender() internal view returns (address sender) {
    if (msg.sender == trustedForwarder) {
        assembly {
            // @audit the last twenty bytes are trusted without knowing who wrote them
            sender := shr(96, calldataload(sub(calldatasize(), 20)))
        }
    } else {
        sender = msg.sender;
    }
}

The code is correct as long as the forwarder is the only party deciding what sits in the last twenty bytes. Combining it with a Multicall implementation breaks that assumption. Multicall executes each element of a user-supplied bytes[] through delegatecall, which preserves msg.sender, so the forwarder branch stays active for the inner call while the attacker controls the full inner calldata including its final twenty bytes.

The result is unrestricted spoofing of _msgSender(), and with it a complete bypass of any access control built on top. Disclosed in December 2023 through thirdweb's pre-built contracts, the issue affected more than eight thousand deployed contracts across several chains and led to roughly a million dollars in losses, including about $190,000 drained from the TIME token on mainnet. OpenZeppelin's fix made the expected suffix length explicit so that Multicall accounts for it, rather than leaving the tail of calldata as an unguarded convention.

Low-level Call Handling

Writing an external call in assembly means reimplementing everything the compiler normally does around it, and there are more steps than most implementations remember.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
pragma solidity ^0.8.17;

contract Payer {
    function pay(address token, address to, uint256 amount) internal {
        assembly {
            let p := mload(0x40)
            mstore(p, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
            mstore(add(p, 0x04), to)
            mstore(add(p, 0x24), amount)
            // @audit success alone proves neither that code exists nor that the token returned true
            if iszero(call(gas(), token, 0, p, 0x44, 0, 0)) { revert(0, 0) }
        }
    }
}

The success flag is checked, which already puts this ahead of a good deal of production code, and two exploitable gaps remain. The CALL opcode returns success when the target account holds no code at all, so a token address that was never deployed, or that lives only on another chain, makes this function report a completed transfer that never happened. That behavior was the mechanism behind the qBridge incident in January 2022, and it remains the reason OpenZeppelin's SafeERC20 performs a code-size check where the assembly-optimized alternatives in solmate and Solady delegate that responsibility to the caller. The second gap is the return data: a token returning false rather than reverting produces a successful call with a 32-byte return value of zero, indistinguishable from success unless the code looks.

Return data cuts the other way as well. Copying it without an upper bound hands the callee control over the caller's memory expansion, which grows quadratically:

1
2
3
let ok := call(gas(), target, 0, add(data, 0x20), mload(data), 0, 0)
// @audit the callee decides how much memory the caller must pay to expand
returndatacopy(0, 0, returndatasize())

A malicious callee ending in revert(0, 10000) forces the caller to pay for expanding memory to fit ten thousand bytes it never asked for, and with a carefully chosen gas budget the caller runs out of gas while copying rather than while executing. This return bomb is a griefing primitive that costs the attacker very little and censors relayers, keepers, and any loop iterating over untrusted targets. The mitigation caps the copy length explicitly, the pattern popularized by Nomad's ExcessivelySafeCall, and writes the truncated data somewhere actually allocated. The naive form is also memory-unsafe by the compiler's own definition, since returndatasize() readily exceeds the 64 bytes of scratch space.

Assembly also changes what return and revert mean. Both opcodes terminate the entire external call rather than the enclosing Solidity function, so any post-condition, guard release, refund, or event emission written after the assembly block never executes. A contract releasing a reentrancy lock after calling into a helper that ends in stop() never releases it.

Storage and Transient Storage

Storage in assembly is a flat array of slots addressed by numbers the developer computes, and every mistake in that computation lands on some other variable. Packed fields are the most common casualty, because updating one requires reading the slot, clearing the target bits, and writing the merged result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
pragma solidity ^0.8.17;

contract Timelock {
    // slot 0: owner [0..159] | unlockTime [160..223] | paused [224]
    address owner;
    uint64 unlockTime;
    bool paused;

    function setUnlockTime(uint64 t) external onlyOwner {
        assembly {
            // @audit or() sets bits without clearing the previous value
            sstore(owner.slot, or(sload(owner.slot), shl(160, t)))
        }
    }
}

The mask is missing, and or only ever sets bits. The stored unlockTime becomes the bitwise union of every value ever written to it, drifting monotonically upward until the timelock can no longer be shortened and withdrawals are frozen for good. A shift a few bits off rather than an absent mask would spill into paused and disable the contract outright. Computed slots demand the same care: mapping values live at keccak256(abi.encode(key, slot)) and dynamic array data begins at keccak256(slot), and a preimage assembled incorrectly writes into a neighboring variable rather than reverting.

Hard-coded slot constants deserve particular suspicion in proxied systems. ERC-1967 places the implementation and admin pointers at fixed positions chosen so the Solidity compiler never allocates them, and the standard notes the limitation directly: contracts written in other languages, or directly in assembly, still collide. A stray sstore to a constant slot overwrites an implementation pointer or writes into the __gap region a future upgrade intends to use. The article on upgradeability covers the layout rules these systems depend on.

Transient storage, introduced by EIP-1153 and reachable from Solidity mostly through tstore and tload, adds a location with the ergonomics of memory and the lifetime of a transaction. The combination is easy to misjudge:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
uint256 constant CALLBACK_SLOT = 0x1;

function mint(address pool, uint256 amount) external {
    assembly { tstore(CALLBACK_SLOT, pool) }
    IUniswapV3Pool(pool).swap(/* ... */);
    // @audit the slot is reused for a second purpose and never cleared
    assembly { tstore(CALLBACK_SLOT, amount) }
}

function uniswapV3SwapCallback(int256, int256, bytes calldata) external {
    address expected;
    assembly { expected := tload(CALLBACK_SLOT) }
    require(msg.sender == expected, "unauthorized");
}

One slot does two jobs, and nothing clears it in between. Transient storage persists for the whole transaction, so a later callback expecting a pool address reads back the leftover amount instead. On March 30th, 2025, SIR.trading lost roughly $355,000 to precisely this shape. The attacker used CREATE2 to deploy a contract at 0x00000000001271551295307acc16ba1e7e0d4281, then chose mint parameters producing an amount of exactly 95759995883742311247042417521410689, the same number read as an integer. The stale value in the transient slot matched their contract, the caller check passed, and the vault transferred tokens on their instruction.

The lifetime interacts badly with untrusted callbacks in general. ChainSecurity has documented that transient-storage reentrancy guards are cheap enough that a reentrant call arriving on a 2300-gas stipend now accomplishes real work, which invalidates an assumption a good deal of older code still relies on. The reentrancy article covers the broader pattern.

Manual Dispatch and Selector Handling

Fallback functions routing calls in assembly are a small dispatcher, and dispatchers fail in familiar ways: an unknown selector falling through to a privileged branch instead of reverting, an authorization check placed inside the branch the attacker never reaches, or a comparison examining too few bytes.

Selectors are only a four-byte truncation of a keccak hash, which makes collisions cheap to find wherever an attacker influences the function name being called. The Poly Network exploit of August 2021, which moved $611 million, worked because a cross-chain handler built a low-level call from a user-supplied method string. Brute-forcing f1121318093(bytes,bytes,uint64) yielded the selector 0x41973cd9, colliding with putCurEpochConPubKeyBytes(bytes) and allowing the attacker to replace the protocol's keeper set. The mechanics are covered in ABI hash collisions.

The logging opcodes belong in the same breath. log0 through log4 accept raw topics, so an incorrect event signature hash or a transposed topic order produces events that indexers, subgraphs, and monitoring pipelines misattribute, which is an attractive way to hide activity from everything downstream of the chain.

Compiler-level Defects

Assembly is over-represented in Solidity's own defect history, so reviewing a contract includes reviewing the pragma against the list of known bugs.

The most consequential example remains the memory side effects bug in 0.8.13 and 0.8.14, where the Yul optimizer removed memory writes in an assembly block that were never read back within that same block, even when a later block or the surrounding Solidity depended on them. A closely related defect in 0.8.13 through 0.8.16 removed storage writes preceding a conditional return or stop in assembly. More recently, the TSTORE Poison bug affecting 0.8.28 through 0.8.33 caused a name collision between the compiler's transient and persistent clearing helpers, so delete on a transient variable emitted sstore instead of tstore and silently corrupted persistent state. It required the via-IR pipeline, which is not the default, and was rated the first high-severity compiler bug in roughly a decade.

Assembly that is correct as written is therefore still miscompilable, and pinning a convenient old compiler version is a finding in its own right.

Review Heuristics

Reading an assembly block productively means asking a fixed set of questions of every line rather than following the logic.

Every arithmetic operation on a pointer, offset, or length needs tracing back to establish whether any operand originates in calldata, because the result wraps. Every mload and mstore needs the address shown to be allocated, the free memory pointer shown to be advanced, and 0x60 shown to be zero when control returns to Solidity. Every calldataload needs a preceding comparison against calldatasize(), since the alternative is a check that silently reads zero. Every value narrower than a word crossing the boundary needs a mask or a signextend.

Every call, staticcall, and delegatecall needs four separate confirmations: that the success flag is checked, that the target has code, that the return data length is validated before decoding, and that returndatacopy is bounded. Every sstore and tstore needs its slot recomputed by hand, checked against reserved proxy positions, and checked for transient slots that are reused rather than cleared. A ("memory-safe") annotation is a claim to verify, not documentation.

Testing mirrors the same suspicion. Differential fuzzing against a plain-Solidity reference implementation catches most decoding and masking errors outright. Decoders deserve fuzzing with truncated, oversized, and misaligned calldata specifically, because the interesting inputs are the malformed ones. External calls deserve hostile mocks that revert empty, revert with enormous payloads, return false, return short data, and consume all available gas. The memory conventions themselves make good invariants: the free memory pointer only ever increases, and mload(0x60) is zero after any operation claiming to be harmless. The testing chapter covers the tooling.