Smart Contract Security

Assume every external function is called by an adversary with unlimited creativity, flash-loaned capital, control over transaction ordering (MEV), and the ability to deploy malicious contracts that call back into yours.

33.1 The threat model

Assume every external function is called by an adversary with unlimited creativity, flash-loaned capital, control over transaction ordering (MEV), and the ability to deploy malicious contracts that call back into yours. Security resources: SWC Registry (historical), OWASP Smart Contract Top 10, SCSVS, Solodit (aggregated audit findings), rekt.news (post-mortems).

33.2 Reentrancy

An external call hands control to another contract, which can call back before your state is updated. (The DAO, 2016: ~3.6M ETH.)

Fixed — Checks-Effects-Interactions + guard: Variants: cross-function reentrancy (re-entering a different function sharing state), cross-contract, and read-only reentrancy (a view function returns stale/inconsistent values mid-update and another protocol relies on it — e.g., Curve LP price oracles). Hooks that hand over control: Ether transfers, ERC-721 _safeMint/safeTransferFrom, ERC-1155 transfers, ERC-777 hooks, flash-loan callbacks.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableBank {
    mapping(address => uint256) public balances;

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

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        (bool ok, ) = msg.sender.call{value: amount}("");   // INTERACTION before EFFECT
        require(ok);
        balances[msg.sender] = 0;                            // too late
    }
}

contract Attacker {
    VulnerableBank public bank;
    constructor(VulnerableBank b) { bank = b; }

    function attack() external payable {
        bank.deposit{value: msg.value}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= msg.value) bank.withdraw();   // re-enter
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

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

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];          // CHECKS
        require(amount > 0, "no balance");
        balances[msg.sender] = 0;                        // EFFECTS
        (bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTIONS
        require(ok, "transfer failed");
    }
}

33.3 Access control

Vulnerable: missing modifier on a privileged function, or an unprotected initializer.

Checklist: every state-changing external function has deliberate access control; use Ownable2Step or AccessControl; put admin keys in a multisig with a timelock; check initialize cannot be called by anyone else (Parity multisig 2017: an unprotected initWallet and later a library kill froze ~514k ETH).

contract VulnerableToken {
    mapping(address => uint256) public balanceOf;
    function mint(address to, uint256 amount) external {     // anyone can mint!
        balanceOf[to] += amount;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract FixedToken is Ownable {
    mapping(address => uint256) public balanceOf;
    constructor() Ownable(msg.sender) {}
    function mint(address to, uint256 amount) external onlyOwner { balanceOf[to] += amount; }
}

33.4 tx.origin authentication

If the owner interacts with a malicious contract, that contract can call transfer and tx.origin is still the owner. Fix: use msg.sender == owner. (require(tx.origin == msg.sender) to block contracts is also unreliable since EIP-7702 and smart accounts.)

contract TxOriginWallet {
    address public owner = msg.sender;
    function transfer(address payable to, uint256 amount) external {
        require(tx.origin == owner, "not owner");   // phishing contract called by owner passes!
        to.transfer(amount);
    }
}

33.5 Integer issues

Pre-0.8 overflow/underflow (BEC token 2018): use 0.8+.

unchecked blocks reintroduce wrapping — review every one.

Unsafe downcasts don't revert: uint128(x) silently truncates — use SafeCast.toUint128(x).

Precision loss: divide last: a * b / c, not a / c * b. Watch rounding direction — round in favor of the protocol (e.g., ERC-4626 rounds shares down on deposit, up on withdraw). Use Math.mulDiv(a, b, c) to avoid intermediate overflow.

uint256 bad = amount / 1000 * feeBps;     // amount < 1000 → fee 0
uint256 good = amount * feeBps / 1000;

33.6 Front-running and MEV

Pending transactions are visible in the public mempool; searchers can insert transactions before/after yours (sandwich attacks, front-running, back-running).

Vulnerable: a puzzle reward claimable by submitting the answer in plain text (anyone can copy it with a higher tip), or a swap without slippage protection.

Slippage limits and deadlines (amountOutMin, deadline).

Private transaction relays (Flashbots Protect, MEV Blocker), batch auctions (CoW Swap), and design that minimizes ordering dependence.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract CommitReveal {
    mapping(address => bytes32) public commitments;
    mapping(address => uint256) public commitBlock;

    function commit(bytes32 hash) external {        // hash = keccak256(abi.encode(answer, salt, msg.sender))
        commitments[msg.sender] = hash;
        commitBlock[msg.sender] = block.number;
    }

    function reveal(string calldata answer, bytes32 salt) external {
        require(block.number > commitBlock[msg.sender], "same block");
        require(keccak256(abi.encode(answer, salt, msg.sender)) == commitments[msg.sender], "bad reveal");
        delete commitments[msg.sender];
        // ... verify answer and reward
    }
}

33.7 Oracle manipulation

Vulnerable: using a DEX spot price (pool reserves) as a price oracle — a flash loan can move it massively within one transaction (bZx 2020, Harvest 2020, many lending exploits).

Fixes: use decentralized oracles (Chainlink) with staleness and sanity checks, TWAPs over sufficiently long windows (Uniswap v3 observe), multiple sources with deviation checks, and circuit breakers. See Chapter 39 for a safe Chainlink consumer.

// Vulnerable: price derived from current reserves
function getPrice() public view returns (uint256) {
    (uint112 r0, uint112 r1, ) = pair.getReserves();
    return uint256(r1) * 1e18 / r0;
}

33.8 Signature replay

Vulnerable: a signature that lacks a nonce, chain ID, or contract address can be reused.

Fixed: use EIP-712 (domain separator with chainId and verifyingContract), a per-user nonce, a deadline, and OpenZeppelin ECDSA (rejects malleable signatures) — exactly as in the Voucher contract in 31.2. Never use the signature itself as a "used" key (malleability).

// Vulnerable: same signature can be replayed forever, and on other chains / contracts
function withdrawWithSig(uint256 amount, bytes calldata sig) external {
    bytes32 h = keccak256(abi.encodePacked(msg.sender, amount));
    require(ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(h), sig) == signer);
    payable(msg.sender).transfer(amount);
}

33.9 Denial of service

Unbounded loops over growing arrays exceed the block gas limit → use pagination or pull patterns.

Revert in push payments: one recipient whose receive reverts blocks everyone (King of the Ether) → pull payments.

Griefing with dust: attackers fill arrays with tiny entries → minimum amounts, per-user limits.

Unexpected Ether/balance assumptions: require(address(this).balance == expected) can be broken by forced Ether.

Block-stuffing around deadlines.

// Vulnerable: refunds all bidders in a loop — a single reverting bidder or a long list blocks the auction
for (uint256 i; i < bidders.length; i++) payable(bidders[i]).transfer(bids[bidders[i]]);
// Fixed: record refunds; each bidder calls withdraw() (see 18.3)

33.10 selfdestruct changes

Pre-Cancun, selfdestruct could delete implementation contracts behind proxies (bricking them) and enabled "metamorphic" contracts (CREATE2 + selfdestruct to redeploy different code at the same address).

Post-Cancun (EIP-6780), it only deletes code when called in the creation transaction; otherwise it just sends the balance. Code relying on contract deletion is broken; forced-Ether-sending via selfdestruct still works.

33.11 delegatecall pitfalls

Vulnerable: storage collision between proxy and implementation, or delegatecall to a user-supplied address.

An attacker calls setNumber(uint256(uint160(attackerContract))) through the proxy, replacing the implementation with their own code. Fixes: ERC-1967 slots or ERC-7201 namespaces, identical layouts verified with tooling, never delegatecall to untrusted or user-supplied addresses, and lock implementation contracts (_disableInitializers).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableProxy {
    address public implementation;   // slot 0
    address public owner;            // slot 1

    constructor(address impl) { implementation = impl; owner = msg.sender; }

    fallback() external payable {
        (bool ok, ) = implementation.delegatecall(msg.data);
        require(ok);
    }
}

contract Lib {
    uint256 public someNumber;       // slot 0 — collides with `implementation`!
    function setNumber(uint256 n) external { someNumber = n; }   // via proxy: overwrites implementation
}

33.12 More vulnerability classes

Unchecked return values ignoring bool from low-level calls or non-reverting ERC-20s check success; SafeERC20

block.timestamp, blockhash, prevrandao are predictable/biasable Chainlink VRF, commit-reveal proposers can skew time slightly tolerate ~seconds of drift; don't use for randomness huge capital within one tx manipulates governance/prices snapshot voting power, TWAP, delay Price/rounding errors in vaults inflation attack, rounding toward user virtual shares, round toward protocol Missing input validation zero address, zero amount, array length mismatch validate all inputs flipped s gives a second valid signature OZ ECDSA, nonces Hash collisions with encodePacked multiple dynamic args Uninitialized proxy / implementation attacker initializes and takes ownership initialize atomically; _disableInitializers single admin key can rug or be compromised multisig, timelock, role separation, renounce Arbitrary external call user-controlled target.call(data) steals approvals whitelist targets/selectors msg.value in loops/multicall value reused across iterations track value spent; avoid payable multicall Cross-chain replay / bridge bugs messages verified incorrectly audited messaging (CCIP, LayerZero, Hyperlane), chain IDs Gas griefing / returnbomb callee returns huge data or burns gas limit gas and returndata copy sequencer downtime, block.number semantics Chainlink sequencer uptime feed

33.13 Security mindset checklist

Follow Checks-Effects-Interactions; add nonReentrant to functions with external calls.

Least privilege; multisig + timelock for admin.

Validate inputs; use custom errors with context.

Prefer pull over push; bound all loops.

Use battle-tested libraries; don't reinvent cryptography or token logic.

Emit events for all state changes (incident response depends on them).

Have an incident plan: pause switch, monitoring (OpenZeppelin Defender alternatives such as Tenderly alerts, Forta, Hypernative), bug bounty (Immunefi).

Test thoroughly: unit, fuzz, invariant, fork tests; static analysis; independent audits.