EVM Smart Contract Security: Common Vulnerabilities and How to Prevent Them

Learn the top 10 EVM smart contract vulnerabilities including reentrancy, overflow, and delegatecall attacks. Includes code examples, fixes, and audit tools.

By Zig EVM Team |

TL;DR: EVM smart contracts face a distinct set of security vulnerabilities that have collectively led to billions of dollars in losses. This guide covers the ten most critical vulnerability classes — reentrancy, integer overflow, access control flaws, front-running, denial of service, timestamp dependence, tx.origin phishing, delegatecall injection, storage collisions, and unchecked return values. For each vulnerability, we provide an explanation, a vulnerable code example, the fix, and a reference to a real-world incident. We also cover defensive patterns, security tools, and an audit checklist.

Why EVM Security Is Different

Traditional software bugs crash programs. Smart contract bugs lose money — permanently and irreversibly. Once a contract is deployed on Ethereum, its code cannot be changed (unless it uses an upgradeable proxy, which introduces its own risks). Transactions cannot be reversed. There is no customer support.

Between 2016 and 2025, over $7 billion was lost to smart contract exploits, hacks, and vulnerabilities. The immutable, adversarial, and financially loaded nature of the EVM makes security the single most important aspect of smart contract development.

Vulnerability Severity and Frequency Overview

VulnerabilitySeverityFrequencyTotal Losses
ReentrancyCriticalCommonOver $500M
Access control flawsCriticalVery commonOver $1B
Integer overflow/underflowHighRare (post-0.8)Over $100M
Front-running / MEVHighVery commonOngoing
Unchecked return valuesHighCommonOver $50M
Delegatecall injectionCriticalUncommonOver $150M
Storage collision (proxies)CriticalUncommonOver $50M
Denial of serviceMediumCommonVaried
Timestamp dependenceMediumCommonVaried
tx.origin phishingMediumRareLimited

1. Reentrancy

Reentrancy is the most infamous smart contract vulnerability. It occurs when an external call allows the callee to call back into the vulnerable function before the first invocation completes.

How It Works

When a contract sends ETH via call, the recipient’s receive() or fallback() function executes. If the vulnerable contract has not yet updated its state, the attacker can call the withdrawal function again, draining funds repeatedly.

Vulnerable Code

// VULNERABLE: state update after external call
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // External call BEFORE state update
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        // State update AFTER external call -- too late!
        balances[msg.sender] = 0;
    }
}

The Fix: Checks-Effects-Interactions

// SAFE: state update before external call
contract SafeVault {
    mapping(address => uint256) public balances;

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");          // CHECK

        balances[msg.sender] = 0;                    // EFFECT

        (bool success, ) = msg.sender.call{value: amount}(""); // INTERACTION
        require(success, "Transfer failed");
    }
}

Alternatively, use OpenZeppelin’s ReentrancyGuard:

import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract GuardedVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        balances[msg.sender] = 0;
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
    }
}

Real-world incident: The 2016 DAO hack exploited reentrancy to drain 3.6 million ETH (approximately $60M at the time), leading to the Ethereum/Ethereum Classic hard fork.

2. Integer Overflow and Underflow

Before Solidity 0.8, arithmetic operations silently wrapped around on overflow and underflow. A uint256 at its maximum value plus 1 would become 0. A uint256 at 0 minus 1 would become 2^256 - 1.

Vulnerable Code (Solidity pre-0.8)

// VULNERABLE: Solidity < 0.8
contract VulnerableToken {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        // If balances[msg.sender] is 0 and amount is 1,
        // this underflows to 2^256 - 1 and the require passes!
        require(balances[msg.sender] - amount >= 0); // ALWAYS TRUE for uint256

        balances[msg.sender] -= amount;  // Underflows
        balances[to] += amount;          // Could overflow
    }
}

The Fix

Solidity 0.8 and later versions include built-in overflow and underflow checks. Operations that would wrap automatically revert:

// SAFE: Solidity >= 0.8 reverts on overflow/underflow automatically
contract SafeToken {
    mapping(address => uint256) public balances;

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

For pre-0.8 contracts, use OpenZeppelin’s SafeMath library. For 0.8+ code that uses unchecked blocks for gas optimization, manually verify that overflow is impossible.

Real-world incident: The 2018 BEC (Beauty Chain) token exploit used integer overflow to generate massive token quantities out of thin air, crashing the token price to zero.

3. Access Control Flaws

Missing or incorrect access control is the single largest category of smart contract losses by dollar value. This includes unprotected initialization functions, missing onlyOwner modifiers, and flawed role-based access.

Vulnerable Code

// VULNERABLE: anyone can call initialize
contract VulnerableProxy {
    address public owner;
    bool public initialized;

    // No access control on critical function
    function initialize(address _owner) external {
        // Missing: require(!initialized)
        owner = _owner;
        initialized = true;
    }

    function withdraw() external {
        require(msg.sender == owner);
        payable(owner).transfer(address(this).balance);
    }
}

The Fix

contract SafeProxy {
    address public owner;
    bool private initialized;

    function initialize(address _owner) external {
        require(!initialized, "Already initialized");
        initialized = true;
        owner = _owner;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    function withdraw() external onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
}

Use OpenZeppelin’s Initializable for proxy contracts and AccessControl or Ownable for role management.

Real-world incident: The Parity multisig library destruction (2017) occurred because anyone could call the initWallet function on the library contract, then call kill() to self-destruct it, freezing over $150M in ETH permanently.

4. Front-Running and MEV

Front-running occurs when an attacker observes a pending transaction in the mempool and submits their own transaction with a higher gas price to be executed first. This is a subset of Maximal Extractable Value (MEV).

Common Front-Running Scenarios

  • DEX trades: An attacker sees a large swap, buys before it, and sells after (sandwich attack)
  • NFT mints: An attacker detects a rare mint and front-runs it
  • Liquidations: Bots compete to execute profitable liquidations
  • Governance: Voting on proposals after seeing the outcome is likely

Mitigation Strategies

// Commit-reveal scheme to prevent front-running
contract CommitReveal {
    mapping(address => bytes32) public commits;
    mapping(address => uint256) public commitTimestamps;

    // Phase 1: commit a hash (front-runners cannot see the real value)
    function commit(bytes32 hash) external {
        commits[msg.sender] = hash;
        commitTimestamps[msg.sender] = block.timestamp;
    }

    // Phase 2: reveal the actual value after a delay
    function reveal(uint256 value, bytes32 salt) external {
        require(block.timestamp >= commitTimestamps[msg.sender] + 1 minutes);
        require(keccak256(abi.encodePacked(value, salt)) == commits[msg.sender]);
        // Process the value
    }
}

Other mitigations include using Flashbots Protect (private transaction submission), setting maximum slippage in DEX trades, and using batch auctions instead of continuous trading.

Real-world incidents: MEV extraction is ongoing and systemic. Research by Flashbots estimated over $600M in extracted MEV in 2022 alone.

5. Denial of Service (DoS)

DoS vulnerabilities prevent a contract from functioning normally. Common vectors include gas limit issues with unbounded loops and unexpected reverts blocking critical operations.

Vulnerable Code: Unbounded Loop

// VULNERABLE: loop can exceed block gas limit
contract VulnerableAirdrop {
    address[] public recipients;

    function airdrop(uint256 amount) external {
        // If recipients array grows too large, this exceeds gas limit
        for (uint256 i = 0; i < recipients.length; i++) {
            IERC20(token).transfer(recipients[i], amount);
        }
    }
}

The Fix: Pull Pattern

// SAFE: users withdraw individually
contract SafeAirdrop {
    mapping(address => uint256) public claimable;

    function setClaims(address[] calldata users, uint256 amount) external onlyOwner {
        for (uint256 i = 0; i < users.length; i++) {
            claimable[users[i]] = amount;
        }
    }

    function claim() external {
        uint256 amount = claimable[msg.sender];
        require(amount > 0, "Nothing to claim");
        claimable[msg.sender] = 0;
        IERC20(token).transfer(msg.sender, amount);
    }
}

Real-world incident: The GovernMental Ponzi scheme (2016) had a payout function with a loop over all participants. When the array grew too large, nobody could trigger the payout, locking 1,100 ETH permanently.

6. Timestamp Dependence

Using block.timestamp for critical logic is risky because miners (or validators in proof-of-stake) have some flexibility in setting the timestamp.

Vulnerable Code

// VULNERABLE: timestamp can be manipulated
contract VulnerableLottery {
    function draw() external {
        // Miner can adjust timestamp to influence outcome
        uint256 winner = uint256(keccak256(abi.encodePacked(block.timestamp))) % players.length;
        payable(players[winner]).transfer(address(this).balance);
    }
}

The Fix

Use Chainlink VRF or a commit-reveal scheme for randomness. For time-based logic, use block numbers instead of timestamps when possible, and build in tolerances of at least 15 seconds.

7. tx.origin Phishing

tx.origin returns the address of the EOA that initiated the transaction chain. Using it for authorization enables phishing attacks where a malicious contract tricks a user into sending a transaction that executes privileged operations on another contract.

Vulnerable Code

// VULNERABLE: uses tx.origin for authorization
contract VulnerableWallet {
    address public owner;

    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner, "Not owner"); // WRONG
        payable(to).transfer(amount);
    }
}

An attacker deploys a contract that calls VulnerableWallet.transfer(). If the owner interacts with the attacker’s contract, tx.origin is the owner, and the check passes.

The Fix

// SAFE: uses msg.sender
contract SafeWallet {
    address public owner;

    function transfer(address to, uint256 amount) external {
        require(msg.sender == owner, "Not owner"); // CORRECT
        payable(to).transfer(amount);
    }
}

Rule: Always use msg.sender for authorization, never tx.origin.

8. Delegatecall Injection

If a contract uses DELEGATECALL with an address that an attacker can control, the attacker can execute arbitrary code in the victim contract’s context, gaining full access to its storage and balance.

Vulnerable Code

// VULNERABLE: user-controlled delegatecall target
contract VulnerableDispatcher {
    address public owner;

    function dispatch(address target, bytes calldata data) external {
        // Attacker supplies a malicious target
        (bool success, ) = target.delegatecall(data);
        require(success);
    }
}

The Fix

Never allow user-controlled addresses in DELEGATECALL. Whitelist permitted targets:

contract SafeDispatcher {
    address public owner;
    mapping(address => bool) public trustedImplementations;

    function dispatch(address target, bytes calldata data) external {
        require(trustedImplementations[target], "Untrusted target");
        (bool success, ) = target.delegatecall(data);
        require(success);
    }
}

For a deep dive into DELEGATECALL mechanics and the proxy pattern, see our EVM Call Types Explained guide.

Real-world incident: The Parity multisig hack (2017) exploited an unprotected delegatecall to take ownership of wallets containing $30M+ in ETH.

9. Storage Collision in Proxies

When using DELEGATECALL-based proxies, the implementation contract’s storage layout must exactly match the proxy’s. Mismatched layouts cause variables to overwrite each other.

Vulnerable Code

// Proxy stores admin at slot 0
contract Proxy {
    address public admin;  // slot 0

    function upgrade(address newImpl) external {
        require(msg.sender == admin);
        // ...
    }
}

// Implementation also uses slot 0 -- COLLISION
contract Implementation {
    uint256 public totalSupply;  // slot 0 -- overwrites admin!

    function setSupply(uint256 s) external {
        totalSupply = s;  // This actually overwrites the admin address
    }
}

The Fix

Use EIP-1967 standard storage slots for proxy admin and implementation addresses. These slots are derived from hashes and placed far outside the normal storage range:

// EIP-1967: implementation slot
bytes32 constant IMPL_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

// EIP-1967: admin slot
bytes32 constant ADMIN_SLOT = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);

Use OpenZeppelin’s proxy contracts, which implement these standards correctly.

10. Unchecked Return Values

Low-level calls (call, delegatecall, staticcall) and certain token transfers return a boolean success flag. Ignoring it means failures go unnoticed.

Vulnerable Code

// VULNERABLE: ignoring return value
function withdrawToken(address token, uint256 amount) external {
    IERC20(token).transfer(msg.sender, amount);
    // Some ERC-20 tokens return false instead of reverting
    // This line executes even if the transfer failed
    balances[msg.sender] = 0;
}

The Fix

// SAFE: checking return value
function withdrawToken(address token, uint256 amount) external {
    bool success = IERC20(token).transfer(msg.sender, amount);
    require(success, "Transfer failed");
    balances[msg.sender] = 0;
}

// BETTER: use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

function withdrawToken(address token, uint256 amount) external {
    IERC20(token).safeTransfer(msg.sender, amount);
    balances[msg.sender] = 0;
}

Real-world incident: The King of the Ether (2016) and numerous DeFi protocols have lost funds due to unchecked transfer return values, particularly with non-standard ERC-20 tokens that return false instead of reverting.

Defensive Patterns and Best Practices

The Checks-Effects-Interactions Pattern

This is the single most important defensive pattern in Solidity:

  1. Checks: Validate all inputs, permissions, and preconditions
  2. Effects: Update all state variables
  3. Interactions: Make external calls last

Security Tools

ToolTypeStrengths
SlitherStatic analysisFast, catches common patterns, low false positives
MythrilSymbolic executionDeep analysis, finds complex bugs
Foundry fuzzingProperty-based fuzzingTests with random inputs, integrated with tests
EchidnaProperty-based fuzzingAdvanced fuzzing strategies, coverage guided
CertoraFormal verificationMathematical proofs of correctness
AderynStatic analysisSolidity-focused, quick scans

Audit Checklist

Before deploying any contract handling significant value:

  • Run Slither and fix all high/medium findings
  • Run Mythril analysis for deeper vulnerability detection
  • Write Foundry fuzz tests for all critical functions
  • Verify access control on every external/public function
  • Check all external calls for return value handling
  • Review storage layout for proxy compatibility
  • Test with Foundry’s forge test --fuzz-runs 10000
  • Get at least one independent security audit for contracts handling significant value
  • Implement a bug bounty program post-deployment

Exploring Vulnerabilities in a Safe Environment

The Zig EVM project provides a transparent, from-scratch EVM implementation where you can study exactly how each opcode executes. Understanding vulnerabilities at the bytecode level — seeing how SSTORE is sequenced relative to CALL, or how storage slots map to variables — gives you deeper insight than working only at the Solidity level.

Try stepping through vulnerable patterns in the Zig EVM Playground to see exactly how reentrancy exploits work at the opcode level.

# Clone and build the Zig EVM
git clone https://github.com/cryptuon/zig-evm.git
cd zig-evm
zig build test

Key Takeaways

  • Reentrancy remains dangerous. Always follow checks-effects-interactions and consider using ReentrancyGuard on any function that makes external calls.
  • Solidity 0.8+ eliminates integer overflow/underflow by default, but unchecked blocks and pre-0.8 contracts are still vulnerable.
  • Access control errors account for the largest dollar losses. Every public/external function needs explicit authorization checks.
  • Front-running is inherent to public mempools. Use commit-reveal schemes or private transaction submission where ordering matters.
  • Never use tx.origin for authorization. Always use msg.sender.
  • Never DELEGATECALL to untrusted addresses. Whitelist all delegatecall targets.
  • Always check return values from low-level calls and use SafeERC20 for token transfers.
  • Storage layout in proxies must be append-only between upgrades. Use EIP-1967 for proxy-specific slots.
  • Use multiple security tools — no single tool catches everything. Combine static analysis, fuzzing, and manual review.
  • The checks-effects-interactions pattern is the single most effective defense against the majority of smart contract vulnerabilities.

Further Reading

Frequently Asked Questions

What is the most common smart contract vulnerability?
Reentrancy remains one of the most common and damaging smart contract vulnerabilities. It occurs when an external call allows the callee to re-enter the calling function before it finishes executing. The 2016 DAO hack exploited reentrancy to drain 3.6 million ETH. Prevention involves using the checks-effects-interactions pattern, reentrancy guards, or pull-based payment designs.
Does Solidity 0.8 prevent integer overflow?
Yes. Solidity 0.8 and later versions include built-in arithmetic overflow and underflow checks. Operations that would wrap around automatically revert instead. However, you can opt out using an unchecked block for gas optimization, and contracts compiled with earlier Solidity versions remain vulnerable unless they use SafeMath or similar libraries.
What tools can I use to audit smart contract security?
Popular security tools include Slither (static analysis by Trail of Bits), Mythril (symbolic execution by ConsenSys), Foundry's built-in fuzzing, Echidna (property-based fuzzing), and Certora (formal verification). A comprehensive audit uses multiple tools plus manual review.
What is the checks-effects-interactions pattern?
Checks-effects-interactions is a defensive coding pattern that orders operations as: first validate all conditions (checks), then update contract state (effects), and finally make external calls (interactions). By updating state before making external calls, you prevent reentrancy attacks because the contract's state already reflects the completed operation.
Can storage collisions happen in upgradeable proxy contracts?
Yes. Storage collisions are a critical vulnerability in proxy contracts. Because DELEGATECALL executes the implementation's code against the proxy's storage, any mismatch in storage variable ordering between proxy and implementation contracts will cause variables to overwrite each other, corrupting state and potentially enabling exploits.

Try Zig EVM

Explore the interactive playground or dive into the source code.