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.
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.
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.
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");
}
- 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
}
}
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:
- Checks — validate input and conditions.
- Effects — update internal state.
- 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 {
// ...
}
}
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:
- Read docs / README: understand protocol goals.
- Draw a high-level architecture diagram (contracts, roles, flows).
- List assets and critical invariants.
- Review contract by contract:
- State variables & storage layout.
- Constructor & initialization.
- Access control and modifiers.
- External calls and transfers.
- Write or extend tests to cover discovered risks.
- 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”:
- Master Solidity & EVM fundamentals.
- Read public post-mortems of big hacks.
- Reproduce simple exploits in local testnets.
- Write your own vulnerable contracts and try to break them.
- Practice writing fuzz and invariant tests for real protocols.