Back to Blog

EVM Smart Contract Security: The Developer's Practical Guide

|Odin Scan Team
EVM Smart Contract Security: The Developer's Practical Guide

Since 2020, more than $5 billion has been stolen from EVM smart contracts. Not from one exotic attack, but from patterns repeated across hundreds of protocols, many of which had audits, many of which had tests, and most of which had developers who thought they understood what they were doing.

The EVM is a mature environment. The tooling is excellent. The vulnerability classes are well-documented. Protocols still get drained.

This guide covers the most impactful EVM vulnerability patterns: what they are, why they happen, and how to prevent each one.


1. Reentrancy

Reentrancy is the oldest bug in DeFi. The DAO hack in 2016 was a reentrancy. Protocols are still getting drained by it in 2025.

How it works: A contract makes an external call to a user-controlled address before updating its own state. The malicious contract receiving the call re-enters the original function, which still sees the old state, and executes the same logic again.

// Vulnerable: state update happens after external call
function withdraw() external {
    uint256 amount = balances[msg.sender];
    // The external call can re-enter before balances[msg.sender] is set to 0
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
    balances[msg.sender] = 0; // too late
}
// Safe: update state before external call (checks-effects-interactions)
function withdraw() external nonReentrant {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0; // state update first
    (bool success,) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Two defenses, both required:

Checks-Effects-Interactions (CEI): Update all state before making any external call. If you need to call out to an unknown address, your state must already reflect the post-call world before you make that call.

ReentrancyGuard: Use OpenZeppelin's nonReentrant modifier on all functions that make external calls or transfer ETH. It is a cheap insurance policy.

Read-only reentrancy is the modern variant. A function that only reads state can still cause problems if it is called during another function's execution that has not yet updated state. Price oracles and view functions that derive values from mid-execution state are the common targets.


2. Access Control Failures

The most common critical finding in audits. A function that should be restricted is callable by anyone, or the restriction can be bypassed.

// Vulnerable: no access control on admin function
function setFee(uint256 newFee) external {
    fee = newFee; // anyone can call this
}

// Vulnerable: access control that can be bypassed during initialization
function initialize(address admin) external {
    require(!initialized, "Already initialized");
    owner = admin;
    initialized = true;
}
// If initialized is not set atomically, a frontrunning attack can insert a
// different admin before the deployer's initialize transaction confirms
// Safe: use OpenZeppelin's Ownable or AccessControl
import "@openzeppelin/contracts/access/Ownable.sol";

contract Protocol is Ownable {
    function setFee(uint256 newFee) external onlyOwner {
        fee = newFee;
    }
}

Common access control mistakes:

  • Functions marked public that should be external or internal
  • Missing onlyOwner or role checks on privileged functions
  • Ownership transfer functions that do not require the new owner to accept (two-step transfer is safer)
  • Functions that can be called by any address during an initialization window
  • tx.origin used instead of msg.sender (allows malicious contracts to execute actions on behalf of users)

For complex role systems, use OpenZeppelin's AccessControl. For simple ownership, Ownable2Step (two-step ownership transfer) is safer than Ownable.


3. Oracle Manipulation

A protocol reads a price from an on-chain source that can be manipulated cheaply. The attacker moves the price, executes a profitable action at the fake price, and exits.

Flash loan oracle attacks:

// Vulnerable: reads price from a Uniswap V2 spot price
function getPrice(address token) internal view returns (uint256) {
    (uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
    return uint256(reserve1) * 1e18 / uint256(reserve0);
    // Flash loan can move reserve0/reserve1 within a single transaction
}
// Better: use Chainlink for external price reference
function getPrice(address token) internal view returns (uint256) {
    (, int256 price, , uint256 updatedAt,) = AggregatorV3Interface(feed).latestRoundData();
    require(price > 0, "Invalid price");
    require(block.timestamp - updatedAt <= MAX_STALENESS, "Stale price");
    return uint256(price);
}

For AMM-based pricing that cannot be replaced with a Chainlink feed, use TWAP (Time Weighted Average Price) over a meaningful window (30 minutes minimum, ideally several hours for high-value decisions). Spot prices are manipulable within a single transaction.

Also validate oracle staleness. A Chainlink feed that has not updated in 24 hours is returning potentially wrong data. Always check updatedAt against block.timestamp.

For LSTs and LRTs (cbETH, wstETH, rETH), never use the exchange rate feed directly as a USD price. Compose it with an ETH/USD feed. See our dedicated LST oracle vulnerability guide for the full pattern.


4. Flash Loan Attacks

Flash loans let anyone borrow massive amounts of capital with no collateral, execute arbitrary code, and repay in the same transaction. They amplify any economic vulnerability by making capital constraints irrelevant.

Flash loans do not create vulnerabilities. They amplify existing ones: price manipulation, governance attacks, liquidity drain. If your protocol is vulnerable without a flash loan, it is more vulnerable with one.

Governance flash loan attacks:

// Vulnerable: snapshot voting power at current block
function castVote(uint256 proposalId, bool support) external {
    uint256 votes = token.balanceOf(msg.sender); // spot balance, flashloanable
    _castVote(msg.sender, proposalId, support, votes);
}
// Safe: use snapshotted historical balances (OpenZeppelin Governor)
// Votes counted at the block when the proposal was created, not at voting time
function castVote(uint256 proposalId, bool support) external {
    uint256 votes = token.getPastVotes(msg.sender, proposalSnapshot(proposalId));
    _castVote(msg.sender, proposalId, support, votes);
}

Design your protocol so that every action that matters economically uses values from a point in time that cannot be changed within a single transaction: TWAP prices, historical balance snapshots, time-locked actions.


5. Integer Overflow and Underflow

Before Solidity 0.8, arithmetic wrapped silently. After 0.8, it reverts on overflow by default. This sounds like a solved problem. It is not.

Unsafe unchecked blocks:

// Vulnerable: unchecked block disables Solidity 0.8 overflow protection
function distributeRewards(uint256 totalRewards, uint256 shares) internal pure returns (uint256) {
    unchecked {
        return totalRewards * shares / TOTAL_SHARES; // can overflow if totalRewards is large
    }
}

unchecked is useful for gas optimization in loops where overflow is impossible by construction. It should never be used on user-supplied values or amounts that grow over time without explicit bounds proof.

Precision loss:

// Vulnerable: integer division truncates, order matters
uint256 fee = amount / 10000 * feeRate; // divides first, truncates, then multiplies
// For amount = 9999, fee = 0 regardless of feeRate

// Safe: multiply before divide
uint256 fee = amount * feeRate / 10000;

For financial calculations involving percentages, always multiply before dividing. Consider using fixed-point libraries like PRBMath or FixedPointMathLib for complex math.


6. Signature Replay Attacks

A valid signature is re-used in a context the signer did not intend: on a different chain, in a different contract, in a different time period, or for a different user's benefit.

// Vulnerable: signature contains no context
function permit(address owner, uint256 amount, bytes memory signature) external {
    bytes32 hash = keccak256(abi.encodePacked(owner, amount));
    address signer = ECDSA.recover(hash, signature);
    require(signer == owner, "Invalid signature");
    allowances[owner][msg.sender] = amount;
}
// Safe: EIP-712 typed signatures with chain ID, contract address, nonce, and deadline
function permit(
    address owner,
    address spender,
    uint256 value,
    uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    require(deadline >= block.timestamp, "Expired");
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH,
        owner,
        spender,
        value,
        _useNonce(owner), // increments after use, prevents replay
        deadline
    ));
    bytes32 hash = _hashTypedDataV4(structHash); // includes chainId and contract address
    address signer = ECDSA.recover(hash, v, r, s);
    require(signer == owner, "Invalid signature");
}

Use EIP-712 for all off-chain signatures. Include: chain ID (prevents cross-chain replay), contract address (prevents cross-contract replay), nonce (prevents same-context replay), deadline (limits validity window).

OpenZeppelin's EIP712 and ERC20Permit provide correct implementations. Do not roll your own.


7. Proxy Storage Collisions

Upgradeable contracts using the transparent or UUPS proxy pattern can have storage collisions between the proxy's admin variables and the implementation's state variables.

// Vulnerable: implementation's slot 0 collides with proxy's owner slot
contract VulnerableImplementation {
    address public owner; // slot 0 - same slot as proxy's admin address
    uint256 public value; // slot 1
}
// Safe: use ERC-1967 standardized storage slots for proxy admin variables
// OpenZeppelin's ProxyAdmin uses: keccak256("eip1967.proxy.admin") - 1
// Implementation contracts should use OpenZeppelin's Initializable and avoid slot 0

Always use OpenZeppelin's upgradeable contract library (@openzeppelin/contracts-upgradeable). Never add state variables to a proxy contract. When upgrading, only append new state variables at the end of existing storage layout. Never reorder or remove variables.

Run the OpenZeppelin Upgrades plugin validation before every upgrade deployment: npx hardhat run scripts/upgrade.js --network mainnet. It catches storage layout conflicts automatically.


8. Frontrunning and MEV

Transactions in the mempool are visible before they are included in a block. Searchers and block builders can insert, reorder, or copy transactions to extract value.

Sandwich attacks:

A user sends a large DEX swap. A searcher sees it, sends a buy before it (pushing the price up), lets the user's swap execute at a worse price, then sells immediately after. The user receives less than expected. The searcher captures the difference.

Mitigation: Use slippage protection on every swap. Set amountOutMinimum to a value you will actually accept, not zero.

// Vulnerable: accepts any output amount
function swap(address tokenIn, uint256 amountIn) external {
    ISwapRouter(router).exactInputSingle(
        ISwapRouter.ExactInputSingleParams({
            ...
            amountOutMinimum: 0 // accepts complete loss to MEV
        })
    );
}

// Safe: enforce minimum output
function swap(address tokenIn, uint256 amountIn, uint256 minAmountOut) external {
    ISwapRouter(router).exactInputSingle(
        ISwapRouter.ExactInputSingleParams({
            ...
            amountOutMinimum: minAmountOut // reverts if MEV extracts too much
        })
    );
}

For protocols that must be MEV-resistant, use commit-reveal schemes (reveal the action after commitment is finalized), time-locks, or private mempool services like Flashbots Protect.


The Security Baseline for EVM Contracts

Before deploying any EVM contract, verify:

  • All external calls follow checks-effects-interactions and use nonReentrant
  • Every privileged function has an explicit caller check
  • Every price source is a Chainlink feed with staleness validation, or a TWAP with an adequate window
  • No arithmetic uses unchecked without a documented proof that overflow is impossible
  • All off-chain signatures use EIP-712 with nonce, deadline, chain ID, and contract address
  • Upgradeable contracts use OpenZeppelin's upgradeable library and have had storage layout validated
  • All DEX interactions have amountOutMinimum set to a meaningful value

Audits are necessary. They are not sufficient. Code changes after audits. Governance proposals add new assets. Integrations add new dependencies. Continuous scanning catches what point-in-time reviews miss.


Odin Scan runs all of these checks on every PR automatically, integrating directly into your GitHub Actions pipeline. Start your free trial.