Look at this token transfer helper — it seems reasonable at first glance:
function safeTransferETH(address to, uint256 amount) internal {
assembly {
let success := call(gas(), to, amount, 0, 0, 0, 0)
}
}
The ETH is sent. The call goes out. But if it fails — if to is a contract that reverts, or if gas runs out — nothing happens. No revert. No event. The caller continues executing as if the transfer succeeded. This is one of six inline assembly vulnerabilities that consistently appear in production Solidity code.
Assembly is powerful and sometimes necessary. It's also the place where the compiler stops protecting you.
When and Why Assembly Is Used in Solidity
Inline assembly (Yul) appears in Solidity code for a handful of legitimate reasons:
- Gas optimization: Avoiding redundant checks, packing operations, using
mstore/mloaddirectly to avoid memory allocation overhead - Direct memory access: Reading calldata at specific offsets, building ABI-encoded payloads manually, copying memory regions with
mcopy - Low-level calls:
staticcall,delegatecall,callwith custom gas limits or return data handling that the Solidity compiler won't emit - Signature parsing: Extracting
r,s,vfrom a packed 65-byte signature without creating intermediate variables - Precompile interaction: Calling
ecrecover,sha256,bn128precompiles with exact memory layouts
The OpenZeppelin library, Uniswap V3, and most high-performance DeFi protocols use assembly in hot paths. When used correctly, it's fine. The problem is that assembly removes every safety check Solidity provides — and each one that's removed is a vulnerability that now needs to be manually reimplemented.
Vulnerability 1: Missing Return Value Check on call()
In Solidity, a failed external call reverts automatically (when using addr.call{value: v}("") with the return value checked, or when using high-level calls). In assembly, call() returns a success flag. If you ignore it, failed calls become silent no-ops.
// VULNERABLE: success flag ignored
function withdrawETH(address to, uint256 amount) external onlyOwner {
assembly {
let success := call(gas(), to, amount, 0, 0, 0, 0)
// success is never checked
}
}
If to is a contract with a reverting receive(), success is 0. Execution continues. The accounting state updates but no ETH moves. In a vault contract, this means a withdrawal is marked complete while funds stay locked.
// SECURE: check and propagate the failure
function withdrawETH(address to, uint256 amount) external onlyOwner {
assembly {
let success := call(gas(), to, amount, 0, 0, 0, 0)
if iszero(success) {
revert(0, 0)
}
}
}
For most ETH transfer cases, skip assembly entirely. Solidity's (bool ok,) = to.call{value: amount}(""); with require(ok) compiles to nearly the same bytecode and is harder to get wrong.
Vulnerability 2: Memory Corruption from Wrong Offsets
Solidity's memory layout is not arbitrary. The EVM reserves specific locations:
0x00–0x3f: Scratch space (safe for hashing, temporary use)0x40–0x5f: Free memory pointer — this value must always point to the next available slot0x60–0x7f: Zero slot — should never be written
When assembly writes to 0x00 without saving and restoring the scratch space, it can corrupt hash inputs mid-computation. When it writes to 0x40, it breaks the allocator. Subsequent new, abi.encode, or memory array allocations will overwrite your data.
// VULNERABLE: clobbers free memory pointer
function encodeAndHash(uint256 a, uint256 b) internal pure returns (bytes32 result) {
assembly {
mstore(0x40, a) // ← overwrites the free memory pointer
mstore(0x60, b)
result := keccak256(0x40, 64)
}
}
Any memory allocation after this call writes to whatever a happened to be, which is almost certainly not a valid memory address.
// SECURE: read free pointer, allocate above it
function encodeAndHash(uint256 a, uint256 b) internal pure returns (bytes32 result) {
assembly {
let ptr := mload(0x40) // read current free pointer
mstore(ptr, a) // write at safe location
mstore(add(ptr, 0x20), b)
result := keccak256(ptr, 64)
mstore(0x40, add(ptr, 64)) // advance free pointer
}
}
The rule: always read mload(0x40) first. Never write directly to 0x40 or 0x60. If you use scratch space (0x00–0x3f), restore it before calling anything that might hash or allocate memory.
Vulnerability 3: delegatecall in Assembly Bypassing Safety Checks
Solidity's high-level delegatecall runs in the caller's storage context — the called contract's code executes but reads and writes the calling contract's storage. The compiler catches some obvious storage slot collisions when using inheritance, but it cannot reason about collisions when the target is specified at runtime or when slots are computed manually.
In assembly, all safety rails are gone:
// VULNERABLE: no slot collision analysis possible
contract Proxy {
address public implementation; // stored at slot 0
function _delegate(address impl) internal {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
If the implementation contract also declares address public implementation at slot 0, both the proxy and the implementation write to the same storage slot. An attacker calling a function on the implementation that writes to its slot 0 will overwrite the proxy's implementation address — a full contract takeover.
// SECURE: use EIP-1967 unstructured storage
// slot = keccak256("eip1967.proxy.implementation") - 1
bytes32 private constant IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
function _getImplementation() internal view returns (address impl) {
assembly {
impl := sload(IMPLEMENTATION_SLOT)
}
}
EIP-1967 uses a slot derived from a hash — astronomically unlikely to collide with any slot a normal implementation contract would use. Always use standardized slot locations for proxy storage, and document every manual slot assignment.
Vulnerability 4: extcodesize Zero Does Not Mean EOA
A common pattern tries to block contract callers by checking if the caller has code:
// VULNERABLE: flawed EOA check
function onlyEOA() internal view {
assembly {
if gt(extcodesize(caller()), 0) {
revert(0, 0)
}
}
}
This fails in two cases:
Case 1: Contracts during construction. A contract's constructor runs before its code is stored at the address. During construction, extcodesize(address(this)) returns 0. An attacker deploys a malicious contract whose constructor calls your function — extcodesize returns 0 and the check passes.
Case 2: CREATE2 counterfactual addresses. An attacker can predict a future address (using CREATE2 with known parameters), call your function from an EOA, then deploy a contract to that address to exploit whatever state change the call created.
There is no reliable on-chain way to distinguish an EOA from a contract. If your protocol requires EOA-only interaction, that constraint belongs in your system design, not in an extcodesize check. If you need to prevent flash loan exploitation specifically, consider reentrancy guards or commitment schemes instead.
Vulnerability 5: Calldata Slicing Errors
Manual calldata parsing is common in assembly-heavy contracts — particularly in routers, multicall implementations, and function dispatchers. Offset mistakes are easy to make and hard to test exhaustively.
// VULNERABLE: loads wrong data, address truncation risk
function dispatch(bytes calldata data) external {
assembly {
// ABI: function sig (4 bytes) + address (32 bytes) + uint256 (32 bytes)
let target := calldataload(0) // ← loads sig + first 28 bytes of address
let amount := calldataload(4) // ← loads address + first 28 bytes of amount
// target is garbage; amount is garbage
}
}
The correct offsets:
// SECURE: proper offsets for ABI-encoded calldata
function dispatch(bytes calldata data) external {
assembly {
// skip 4-byte selector; address is right-padded in 32-byte slot, so load at offset 4
// address occupies the rightmost 20 bytes of a 32-byte word
let target := and(calldataload(4), 0xffffffffffffffffffffffffffffffffffffffff)
let amount := calldataload(36) // 4 (selector) + 32 (address slot)
}
}
Also watch for this when parsing packed (non-ABI) calldata — e.g., a 20-byte address followed by a 32-byte uint256 with no padding:
// Packed calldata: [20 bytes addr][32 bytes amount]
assembly {
// calldataload always reads 32 bytes; mask to 20 bytes for address
let target := shr(96, calldataload(0)) // right-align by shifting 12 bytes = 96 bits
let amount := calldataload(20) // starts at byte 20, exactly 32 bytes
}
When in doubt, use abi.decode at the Solidity level. Assembly parsing is only justified when the gas savings are measured and significant.
Vulnerability 6: Gas Stipend Assumptions in call()
Solidity's .transfer() and .send() forward exactly 2300 gas — enough for a simple ETH receipt but not enough to write to storage. This behavior is baked into those methods. When you write call() in assembly and pass gas(), you forward all remaining gas with no such limit.
// MISLEADING COMMENT, WRONG BEHAVIOR
function sendETH(address to, uint256 amount) internal {
assembly {
// Intentionally limiting to 2300 gas stipend like .transfer()
let success := call(sub(gas(), 2300), to, amount, 0, 0, 0, 0)
if iszero(success) { revert(0, 0) }
}
}
sub(gas(), 2300) does not forward 2300 gas. It forwards gasleft() - 2300 — nearly all remaining gas. The receiver gets orders of magnitude more than intended, enabling reentrancy via storage writes in the recipient's receive() function.
// SECURE: explicitly cap at 2300 if that's the intent
function sendETHWithStipend(address to, uint256 amount) internal {
assembly {
let success := call(2300, to, amount, 0, 0, 0, 0)
if iszero(success) { revert(0, 0) }
}
}
// Or, forward all gas intentionally but guard with reentrancy lock
// (preferred for modern contracts — fixed 2300 breaks smart wallets)
function sendETHFull(address to, uint256 amount) internal nonReentrant {
assembly {
let success := call(gas(), to, amount, 0, 0, 0, 0)
if iszero(success) { revert(0, 0) }
}
}
Note: hard-coding 2300 gas breaks compatibility with smart contract wallets (ERC-4337 accounts, Safe multisigs) that need more than 2300 gas to execute their receive() logic. The current best practice for ETH transfers is to forward all gas and protect against reentrancy with a guard — not to rely on gas limits for security.
When to Use Assembly vs. When to Avoid It
Before reaching for assembly, work through this checklist:
- Is there a Solidity built-in that does this?
abi.encode,abi.decode,keccak256,ecrecover,address.call— if yes, use that. - Is there an OpenZeppelin utility?
SafeERC20,Address.sendValue,ECDSA.recover— audited, battle-tested, use them. - Have you measured the gas difference? If the savings are under 200 gas per call, the added complexity is rarely worth it.
- Can you isolate the assembly to a small, pure function? Assembly is safest in small, stateless helpers. Assembly spread across stateful logic is a liability.
- Do you have a test for each assembly branch? Every
if iszero(...)andswitchcase needs a test. Assembly has no compiler enforcement of branch coverage. - Has a second engineer reviewed the offsets? Off-by-one errors in
calldataload,mstore, andsloadare common and silent.
Assembly is appropriate for: ERC-2771 context forwarding, signature extraction, proxy dispatch, precompile calls, bitwise packing of multi-value return data.
Assembly is not appropriate for: basic token transfers, access control logic, arithmetic, anything where a Solidity equivalent exists.
What ContractScan Detects
| Vulnerability | Pattern-Based Tools | ContractScan AI |
|---|---|---|
Unchecked call() return value in assembly |
Partial (Slither) | Full detection with context |
mstore to 0x40 free memory pointer |
Rarely | Yes |
delegatecall storage slot collision |
No | Yes — cross-contract slot analysis |
extcodesize EOA bypass |
No | Yes |
Calldata offset errors (calldataload misalignment) |
No | Yes |
Gas stipend arithmetic error (sub(gas(), 2300)) |
No | Yes |
Assembly success flag declared but never branched |
No | Yes |
| Inline assembly in reentrancy-sensitive context | No | Yes |
Pattern-based tools catch some assembly issues when they match known signatures. Offset errors, memory layout bugs, and gas arithmetic mistakes require understanding the surrounding Solidity context — which values are in scope, what the free pointer holds, whether a reentrancy guard is active. That context-aware analysis is what the ContractScan engine applies.
Scan your assembly code at https://contract-scanner.raccoonworld.xyz — paste your contract and get a full inline assembly analysis alongside the rest of your security review.
Related: Solidity Security Best Practices 2026
Related: Delegatecall Vulnerabilities in Solidity
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.