Smart Contract Security

This page is a practical handbook for Ethereum smart contract security: real-world vulnerabilities, secure patterns, testing strategies, and how to think like an attacker.

Focus: Solidity + EVM, Hardhat & Foundry workflows, production-ready protocols (ERC20, ERC721, vaults, DeFi).

Introduction

Smart contract security is not a “nice to have” – it is the core of Web3. Once deployed, contracts are usually immutable and manage real value. Every bug is public, permanent, and potentially catastrophic.

This document is written as if you already know Solidity and basic tools (Hardhat, Foundry), and now want to build or audit contracts without stepping on the same mines that destroyed many DeFi projects.

Security mindset

When writing or reviewing contracts, you are not just “adding features”. You are designing a system that must survive adversarial, creative, patient attackers who may invest weeks into understanding how to break your code.

Core security principles

  • Least privilege — give each contract and role the minimum power needed.
  • Simple beats clever — attack surface scales with complexity.
  • Explicit over implicit — make invariants obvious in code.
  • Defense in depth — multiple layers: checks, limits, roles, monitoring.
  • Fail safe — in doubt, revert; never silently continue in broken state.
Design for review

Write code in a way that is easy to review: small modules, clear naming, simple math, well-documented invariants and assumptions. If you cannot explain a function clearly in one or two sentences, it is probably too complex.

Threat model

Before you can “defend” anything, you must know what you are defending against. A minimal threat model includes:

  • External attackers calling functions with arbitrary calldata and ETH.
  • Malicious contracts that can reenter, revert, manipulate callbacks.
  • Compromised privileged keys (owner, admin, guardian, multisig signers).
  • Miners / validators controlling transaction ordering and inclusion (MEV).
  • Users making mistakes (approving wrong address, front-end phishing).

Reentrancy

Reentrancy happens when a contract makes an external call before it has finished updating its own state, and the callee calls back into the vulnerable contract.

Classic vulnerable pattern

mapping(address => uint256) public balances;

function deposit() external payable {
    balances[msg.sender] += msg.value;
}

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

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

    balances[msg.sender] = 0;
}

Exploit idea

Attacker creates a contract with a fallback that calls withdraw() again before balances[msg.sender] is set to zero, draining funds.

Safe version (Checks-Effects-Interactions)

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

    // ✅ Effects first
    balances[msg.sender] = 0;

    // ✅ Interactions last
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}
Extra defenses
  • Use ReentrancyGuard from OpenZeppelin.
  • Avoid writing complex logic after external calls.
  • Consider pull patterns instead of pushing ETH in loops.

Arithmetic & overflow

Since Solidity 0.8, arithmetic overflow and underflow revert by default. However:

  • unchecked { ... } disables checks.
  • Inline assembly math has no checks.
  • Some gas-optimization tricks using smaller integer types can behave unexpectedly.

Unsafe pattern

function dec(uint256 x) external pure returns (uint256) {
    unchecked {
        return x - 1; // underflow won't revert if x == 0
    }
}
Rule of thumb

Only use unchecked when you have a proven invariant that guarantees safety. Add comments explaining the reasoning.

Access control bugs

Many high-profile hacks are just “anyone can call this function and drain funds”. Access control must be explicit and simple.

Bad example

function emergencyWithdraw() external {
    // supposed to be onlyOwner, but missing modifier
    token.transfer(msg.sender, token.balanceOf(address(this)));
}

Safer approach

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract Vault is Ownable {
    function emergencyWithdraw(address to) external onlyOwner {
        uint256 bal = token.balanceOf(address(this));
        token.transfer(to, bal);
    }
}

For bigger protocols, prefer AccessControl with roles instead of a single owner.

Denial of Service

DoS in smart contracts is often about a function becoming unusable due to gas or external dependency failures.

Gas-based DoS: unbounded loop

address[] public users;

function airdrop(uint256 amount) external {
    // ❌ will eventually run out of gas as users grows
    for (uint256 i; i < users.length; i++) {
        token.transfer(users[i], amount);
    }
}

Better: chunked processing, or pull model where users claim by themselves.

DoS via reverting receiver

function pay(address to, uint256 amount) external {
    // If `to` is a contract and reverts, entire tx fails
    (bool ok, ) = to.call{value: amount}("");
    require(ok, "Payment failed");
}

Sometimes this is acceptable, sometimes you want to avoid relying on the success of every external call, and instead track debts on-chain.

tx.origin misuse

tx.origin is the original EOA that started the transaction. It is not safe for authorization, because contracts can trick users into calling them.

function auth() external {
    require(tx.origin == owner, "Not owner"); // ❌ vulnerable
}

Always use msg.sender and proper access control instead.

delegatecall & proxy risks

delegatecall runs code of another contract in the context (storage, balance, msg.sender) of the caller. It is the basis of many proxy patterns, but also the source of numerous hacks.

  • Storage layout mismatch can corrupt state.
  • Uninitialized logic contracts can be taken over.
  • Any bug in implementation affects all proxies.

Front-running & MEV

Every transaction is public before it is finalized. Attackers (or MEV bots) can:

  • Sandwich trades by buying before and selling after your trade.
  • Race you to claim rewards, liquidations, or arbitrage.
  • Manipulate prices if your protocol relies on on-chain DEX spot prices.

Mitigations (high-level)

  • Avoid “first come, first served” rewards with predictable triggers.
  • Use commit-reveal schemes for sensitive actions when possible.
  • Use TWAP or oracle-based prices instead of raw pool spot price.

Oracle manipulation

If your contract relies on a price or other external data, manipulating that data can be equivalent to taking control over your protocol.

  • A single DEX pair as a price source can be manipulated via flashloans.
  • Thin liquidity pools are easy to move.
  • Using block.timestamp or block.number as “randomness” is unsafe.

Flashloan attacks

Flashloans let attackers borrow huge amounts of capital for one transaction, as long as they repay by the end of the tx. Any invariant that assumes “no one can suddenly have X amount of capital” is probably wrong.

Typical pattern: manipulate price in a DEX, trigger under-collateralized loans, drain pools, then repay the flashloan.

Signature issues

Off-chain signatures (EIP-712, permits, meta-transactions) are powerful but come with risks:

  • Replay on other chains (no chain id in domain).
  • Replay across contracts (missing contract-specific domain).
  • Using ecrecover incorrectly.

Always bind signatures to:

  • chain id
  • verifying contract address
  • unique nonce
  • clear, human-readable intent

Timestamp dependence

Miners can slightly manipulate timestamps. Do not use block.timestamp for randomness, and be careful when logic is extremely sensitive to exact time.

Checks-Effects-Interactions

The CEI pattern is a core tool against reentrancy and inconsistent state:

  1. Checks — validate input and conditions.
  2. Effects — update internal state.
  3. Interactions — call external contracts, transfer ETH.
function safeWithdraw(uint256 amount) external {
    // Checks
    require(balance[msg.sender] >= amount, "Too much");

    // Effects
    balance[msg.sender] -= amount;

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

Pull payments

Instead of sending ETH or tokens in a loop (push pattern), record how much each user is owed and let them withdraw (pull pattern). This avoids DoS and reentrancy in loops.

mapping(address => uint256) public pending;

function reward(address user, uint256 amount) internal {
    pending[user] += amount;
}

function claim() external {
    uint256 amount = pending[msg.sender];
    require(amount > 0, "Nothing to claim");

    pending[msg.sender] = 0;
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
}

Rate limiting

Rate limits reduce damage from compromised keys and griefing. Examples include:

  • Max total withdrawals per block or per day.
  • Cooldowns between large actions (e.g. parameter changes).
  • Gradual parameter changes instead of instant ones.
mapping(address => uint256) public lastAction;

function guardedAction() external {
    require(block.timestamp > lastAction[msg.sender] + 1 hours, "Too frequent");
    lastAction[msg.sender] = block.timestamp;
    // ...
}

Pausable flows

A pause mechanism lets you temporarily stop critical operations when something goes wrong (suspicious activity, oracle failure, discovered bug).

import {Pausable} from "@openzeppelin/contracts/security/Pausable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract Vault is Pausable, Ownable {
    function pause() external onlyOwner {
        _pause();
    }

    function unpause() external onlyOwner {
        _unpause();
    }

    function deposit() external payable whenNotPaused {
        // ...
    }

    function withdraw(uint256 amount) external whenNotPaused {
        // ...
    }
}
Design pause carefully

Pausing should stop risky flows (like withdrawals or swaps), but ideally still allow safe operations like viewing balances or performing admin recovery actions.

Ownership patterns

Ownership determines who can change parameters, pause the system, upgrade contracts, etc. Bad ownership setups are as dangerous as bugs.

Two-step ownership transfer

address public owner;
address public pendingOwner;

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

function transferOwnership(address newOwner) external onlyOwner {
    pendingOwner = newOwner;
}

function acceptOwnership() external {
    require(msg.sender == pendingOwner, "Not pending owner");
    owner = pendingOwner;
    pendingOwner = address(0);
}

This pattern prevents accidentally setting an invalid owner and gives the new owner a chance to confirm control.

Multisig & timelocks

For serious protocols, a single EOА owner is not acceptable. Use:

  • Multisig as the admin/owner address of critical contracts.
  • Timelock for upgrades and parameter changes, to give users time to react.

Typical pattern: core contracts are owned by a timelock, which is controlled by a multisig. Timelock enforces a delay on sensitive operations.

Upgradeable contracts

Upgradeable designs let you fix bugs or add features, but massively increase complexity and risk. You must handle:

  • Storage layout compatibility.
  • Upgrade admin security.
  • Initialization and reinitialization.

When using OpenZeppelin's proxy patterns, follow their guidelines strictly and avoid changing the order or type of existing storage variables.

Proxy pitfalls

  • Initializing logic contracts only once (otherwise anyone can take them over).
  • Leaving implementation contracts unprotected and upgradable.
  • Forgetting to disable selfdestruct in implementation.

Admin key risks

Admins can:

  • Upgrade implementations.
  • Change parameters.
  • Pause or unpause systems.

Treat admin accounts as critical attack targets. Use:

  • Hardware wallets.
  • Multisigs.
  • Timelocks for upgrades.
  • Clear policies and on-chain transparency for admin actions.

Emergency strategy

When something goes wrong, you need a plan:

  • How to pause / disable dangerous flows quickly.
  • How to communicate with users (website, socials, on-chain messages).
  • How to coordinate with other protocols that integrate you.

Fuzzing

Fuzzing means generating many random inputs to functions and checking properties (no reverts, invariants hold, no funds lost).

Foundry fuzz example

function test_DepositWithdraw_fuzz(uint256 amount) public {
    amount = bound(amount, 1, 1000 ether);
    uint256 balBefore = address(this).balance;

    vm.deal(address(this), amount);
    vault.deposit{value: amount}();

    vault.withdraw(amount);

    assertEq(address(this).balance, balBefore);
}

Invariant testing

Invariants are properties that must always hold, no matter what sequence of calls an attacker makes. Example: total supply of a token must never decrease, or a vault's total assets must be >= sum of user shares (modulo fees).

Foundry invariant example

contract Invariants is Test {
    Vault vault;
    Handler handler;

    function setUp() public {
        vault = new Vault();
        handler = new Handler(vault);
        targetContract(address(handler));
    }

    function invariant_totalAssetsNotNegative() public {
        assertGe(vault.totalAssets(), 0);
    }
}

Property-based tests

Instead of testing specific values, define properties:

  • “Transfers conserve total supply.”
  • “Users cannot withdraw more than they deposited + yield.”
  • “Paused functions revert with specific error.”

Then fuzz or randomly generate inputs to test those properties across many cases.

Static analysis

Static analyzers scan your code for common patterns and potential bugs without executing it.

  • Detect reentrancy patterns.
  • Uninitialized variables.
  • Dangerous uses of delegatecall and call.
  • Shadowed variables, unused code, dead branches.

Differential testing

Differential testing compares two implementations of the same idea:

  • New version vs old one.
  • Your implementation vs a reference implementation.

Feed them the same inputs and assert that outputs and state changes match (or differ only in expected ways, like better gas usage).

Code review workflow

A simple, effective review flow:

  1. Read docs / README: understand protocol goals.
  2. Draw a high-level architecture diagram (contracts, roles, flows).
  3. List assets and critical invariants.
  4. Review contract by contract:
    • State variables & storage layout.
    • Constructor & initialization.
    • Access control and modifiers.
    • External calls and transfers.
  5. Write or extend tests to cover discovered risks.
  6. Run static analysis & fuzzing.

Security checklist

Use this quick checklist before mainnet:

  • Compiler:
    • Using a recent Solidity version (0.8.x).
    • No accidental use of old pragma (<0.8) in any file.
  • Access control:
    • All sensitive functions have explicit access control.
    • No use of tx.origin for auth.
  • External calls:
    • State updated before external calls (CEI).
    • ReentrancyGuard applied where needed.
  • Math:
    • No unsafe unchecked sections without comments.
    • Division by zero and rounding behavior considered.
  • Upgrades:
    • Proxy admin is multisig + timelock.
    • Storage layout audited for changes.
  • Testing:
    • Unit tests cover happy & failure paths.
    • Fuzzing and invariants run on CI.

Tools & scanners

  • Slither — static analysis for Solidity.
  • Foundry — testing, fuzzing, invariants.
  • Hardhat — JS/TS tests, scripts, plugins.
  • Mythril / Echidna — symbolic execution & fuzzing.
  • OpenZeppelin Contracts — audited building blocks.

Learning path

If you want to become “security native”:

  1. Master Solidity & EVM fundamentals.
  2. Read public post-mortems of big hacks.
  3. Reproduce simple exploits in local testnets.
  4. Write your own vulnerable contracts and try to break them.
  5. Practice writing fuzz and invariant tests for real protocols.