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.
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
| Vulnerability | Severity | Frequency | Total Losses |
|---|---|---|---|
| Reentrancy | Critical | Common | Over $500M |
| Access control flaws | Critical | Very common | Over $1B |
| Integer overflow/underflow | High | Rare (post-0.8) | Over $100M |
| Front-running / MEV | High | Very common | Ongoing |
| Unchecked return values | High | Common | Over $50M |
| Delegatecall injection | Critical | Uncommon | Over $150M |
| Storage collision (proxies) | Critical | Uncommon | Over $50M |
| Denial of service | Medium | Common | Varied |
| Timestamp dependence | Medium | Common | Varied |
| tx.origin phishing | Medium | Rare | Limited |
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:
- Checks: Validate all inputs, permissions, and preconditions
- Effects: Update all state variables
- Interactions: Make external calls last
Security Tools
| Tool | Type | Strengths |
|---|---|---|
| Slither | Static analysis | Fast, catches common patterns, low false positives |
| Mythril | Symbolic execution | Deep analysis, finds complex bugs |
| Foundry fuzzing | Property-based fuzzing | Tests with random inputs, integrated with tests |
| Echidna | Property-based fuzzing | Advanced fuzzing strategies, coverage guided |
| Certora | Formal verification | Mathematical proofs of correctness |
| Aderyn | Static analysis | Solidity-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
uncheckedblocks 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
- EVM Call Types Explained — deep dive into CALL, DELEGATECALL, and STATICCALL
- EVM Opcodes Explained — complete opcode reference
- How the EVM Works — foundational EVM architecture
- Zig EVM on GitHub — explore the source code
- Zig EVM Playground — test bytecode interactively
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.