← Course home Module 13 / 19 Blockchain — Part 2

Smart Contract Security

A smart contract deployed on-chain is immutable — there is no patch Tuesday, no hotfix, no rollback. A single vulnerability can drain millions in seconds. This module teaches you to think like an attacker so you can build like a defender: the most common vulnerabilities, how they are exploited, and the patterns and tools that prevent them.

~75 min read Module 13 ⟠ Part 2

Recap: Why Security Matters

In Module 12 you built a DApp frontend that connects to smart contracts. But a beautiful UI is worthless if the contract behind it can be drained. Smart contracts hold real money. Unlike traditional software, you cannot push a patch after deployment — the code is immutable on-chain. Security must be baked in from the start.

Immutability = No Patches

Traditional software can be patched after a vulnerability is found. Smart contracts cannot. Once deployed, the bytecode lives on-chain forever. If there is a bug, attackers can exploit it repeatedly until the contract is drained or the protocol team deploys a new contract and migrates users. Upgradeable proxy patterns exist, but they introduce their own risks.

Biggest Smart Contract Hacks

Year
Protocol
Loss
Vulnerability
2016
The DAO
$60M
Reentrancy — recursive call drained ETH before balance update.
2017
Parity Multisig
$280M
Unprotected initialize() — anyone could become owner and self-destruct the library.
2022
Wormhole
$326M
Signature verification bypass — forged guardian signatures to mint wrapped ETH.
2022
Ronin Bridge
$625M
Compromised validator keys — 5 of 9 multisig keys stolen via social engineering.
2022
Nomad Bridge
$190M
Improper initialization — zero-value Merkle root accepted any message as valid.

These five hacks alone lost over $1.48 billion. And there are hundreds more. Smart contract security is not optional — it is existential.

The Attacker's Mindset

To write secure contracts, you must think like an attacker. For every function, ask: Who can call this? (access control), What happens if it is called multiple times? (reentrancy), What if inputs are extreme? (overflow, zero address), Can the order of transactions be manipulated? (front-running). This module covers each of these attack vectors systematically.

Reentrancy

Reentrancy is the most infamous smart contract vulnerability. It occurs when a contract makes an external call before updating its own state. The called contract can re-enter the calling function and repeat the action — withdrawing funds multiple times before the balance is set to zero.

How Reentrancy Works

Consider a simple vault: the user calls withdraw(), the contract checks the balance, sends ETH, then updates the balance. The problem is the send happens before the update. If the recipient is a malicious contract, its receive() function calls withdraw() again — and the balance check still passes because it has not been updated yet.

Vulnerable Contract

InsecureVault.sol Solidity
// VULNERABLE — DO NOT USE
contract InsecureVault {
    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");

        // BUG: external call BEFORE state update
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");

        // This line runs too late — attacker already re-entered
        balances[msg.sender] = 0;
    }
}

The Attack Contract

Attacker.sol Solidity
contract Attacker {
    InsecureVault public vault;

    constructor(address _vault) {
        vault = InsecureVault(_vault);
    }

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

    // Re-enter withdraw() on every ETH receive
    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw();
        }
    }
}
⚠️

The DAO Hack (2016)

The DAO was an investment fund holding $150M in ETH. An attacker exploited a reentrancy bug to drain $60M. The Ethereum community hard-forked the chain to reverse the theft — creating Ethereum (ETH) and Ethereum Classic (ETC). One bug literally split a blockchain in two.

The Fix: Checks-Effects-Interactions

The Checks-Effects-Interactions (CEI) pattern is the standard defense against reentrancy. Checks: validate all conditions (require statements). Effects: update all state variables. Interactions: make external calls last. By updating the balance before sending ETH, a re-entrant call sees a zero balance and fails.

Fixed Contract (CEI Pattern)

SecureVault.sol Solidity
contract SecureVault {
    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");

        // EFFECT: update state FIRST
        balances[msg.sender] = 0;

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

OpenZeppelin ReentrancyGuard

For extra safety, use OpenZeppelin's ReentrancyGuard. The nonReentrant modifier uses a mutex (lock variable) that prevents any function from being re-entered while it is still executing. Combine CEI and ReentrancyGuard for defense in depth.

SafeVault.sol Solidity
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeVault 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];
        require(amount > 0, "Nothing to withdraw");

        balances[msg.sender] = 0;

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

Always follow the CEI pattern: Checks first, Effects second, Interactions last. Use ReentrancyGuard as an additional safety net. Never send ETH or call external contracts before updating your own state.

Access Control Vulnerabilities

Access control bugs are the second most common vulnerability class. They occur when a function that should be restricted to the owner or a specific role is callable by anyone. The result: unauthorized minting, draining admin funds, or bricking the contract entirely.

tx.origin Phishing

tx.origin is the original external account that initiated the transaction, while msg.sender is the immediate caller. If you check tx.origin == owner, an attacker can trick the owner into calling a malicious contract, which then calls your contract — passing the tx.origin check. Always use msg.sender, never tx.origin for authentication.

tx.origin Phishing Solidity
// VULNERABLE — tx.origin phishing
contract UnsafeWallet {
    address public owner;

    constructor() { owner = msg.sender; }

    function withdraw() external {
        // BUG: tx.origin can be the owner even when
        // msg.sender is a malicious contract
        require(tx.origin == owner, "Not owner");
        payable(msg.sender).transfer(address(this).balance);
    }
}

// FIX: use msg.sender
function withdraw() external {
    require(msg.sender == owner, "Not owner");
    payable(msg.sender).transfer(address(this).balance);
}

Missing Role Checks

The most basic access control bug: a sensitive function with no require or modifier. If mint() has no onlyOwner check, anyone can mint tokens. If pause() is unprotected, anyone can freeze the protocol. Always ask: who should be allowed to call this function?

Missing Access Control Solidity
// VULNERABLE — anyone can mint
function mint(address to, uint256 amount) external {
    _mint(to, amount);
}

// FIX — restrict to owner
function mint(address to, uint256 amount) external onlyOwner {
    _mint(to, amount);
}

Unprotected initialize()

Proxy patterns use initialize() instead of a constructor. If this function lacks an initializer modifier, anyone can call it and set themselves as the owner. The Parity Wallet hack ($280M frozen) happened because the library contract's initialize() was unprotected — an attacker became the owner and self-destructed it.

Unprotected Initializer Solidity
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

// VULNERABLE — can be called by anyone, multiple times
function initialize(address _owner) external {
    owner = _owner;
}

// FIX — use OpenZeppelin Initializable
function initialize(address _owner) external initializer {
    owner = _owner;
}

Default Visibility Pitfalls

In older Solidity versions (< 0.5), functions without an explicit visibility keyword defaulted to public. This led to internal helper functions being accidentally exposed. In Solidity 0.8+, the compiler requires explicit visibility — but always double-check that helper functions are marked internal or private.

Best Practice: Role-Based Access Control

For complex protocols, use OpenZeppelin's AccessControl instead of a single owner. Define granular roles (MINTER_ROLE, PAUSER_ROLE, ADMIN_ROLE) and assign them independently. This follows the principle of least privilege — each account gets only the permissions it needs.

Role-Based Access Control Solidity
import "@openzeppelin/contracts/access/AccessControl.sol";

contract MyToken is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor() ERC20("MyToken", "MTK") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
}

Integer & Logic Bugs

Solidity 0.8+ includes built-in overflow and underflow protection — arithmetic operations revert on overflow instead of silently wrapping. But this does not make integer bugs extinct. unchecked blocks, precision loss, and rounding errors still catch developers off guard.

Overflow & Underflow (Pre-0.8)

Before Solidity 0.8, a uint256 at its maximum value (2^256 - 1) would silently wrap to 0 on increment. Similarly, uint256(0) - 1 would wrap to the maximum value. The SafeMath library was required to prevent this. In Solidity 0.8+, these operations revert automatically.

Overflow Example Solidity
// Solidity < 0.8 — DANGEROUS silent overflow
uint8 x = 255;
x += 1; // x is now 0, not 256!

// Solidity >= 0.8 — SAFE, reverts on overflow
uint8 y = 255;
y += 1; // REVERTS with "Arithmetic overflow"

The unchecked Block Risk

Solidity 0.8 introduced the unchecked block for gas optimization — code inside it skips overflow checks. This is useful for loop counters that cannot realistically overflow, but dangerous if used carelessly on user-controlled values.

unchecked Blocks Solidity
// Safe use of unchecked — loop counter
for (uint256 i = 0; i < array.length;) {
    processItem(array[i]);
    unchecked { i++; } // safe: i < array.length guarantees no overflow
}

// DANGEROUS use of unchecked — user input
function withdraw(uint256 amount) external {
    unchecked {
        // BUG: if amount > balances[msg.sender], wraps to huge number
        balances[msg.sender] -= amount;
    }
    payable(msg.sender).transfer(amount);
}

Precision Loss in Division

Solidity has no floating-point numbers. Integer division truncates: 7 / 2 = 3, not 3.5. This causes precision loss, especially in financial calculations. The fix is to multiply before dividing and use a high-precision base (e.g., 1e18).

Precision Loss Solidity
// BAD — precision loss
uint256 reward = totalReward / totalStakers * userStake;
// If totalReward = 10, totalStakers = 3: 10/3 = 3, not 3.33

// GOOD — multiply first, use high precision
uint256 PRECISION = 1e18;
uint256 rewardPerToken = (totalReward * PRECISION) / totalStakers;
uint256 userReward = (userStake * rewardPerToken) / PRECISION;

Rounding Errors in Token Math

When distributing tokens proportionally, rounding errors can leave dust amounts stuck in the contract or allow attackers to extract extra tokens via repeated small operations. Always round in favor of the protocol (round down for withdrawals, round up for deposits). Use mulDiv from OpenZeppelin's Math library for safe rounding.

Front-Running & MEV

When you send a transaction, it does not go directly into a block. It sits in the mempool — a public waiting room visible to everyone. Miners and validators can see pending transactions and reorder them for profit. This is called Maximal Extractable Value (MEV).

The Public Mempool

Every pending transaction is broadcast to the network and visible in the mempool before being included in a block. Anyone can watch the mempool: bots scan for profitable opportunities, validators choose which transactions to include and in what order. Your transaction is not private until it is mined.

Sandwich Attacks

A sandwich attack targets DEX swaps. When a bot sees your large swap in the mempool, it: 1. Places a buy order before yours (front-run), moving the price up. 2. Your swap executes at a worse price. 3. The bot sells after you (back-run), pocketing the difference. You lose value; the bot profits.

Example: You submit a swap of 10 ETH for DAI at market price. A bot front-runs you with 50 ETH, pushing the price up. Your 10 ETH now buys fewer DAI. The bot immediately sells its DAI, profiting from the inflated price. You get a worse exchange rate without realizing it.

Commit-Reveal Schemes

A commit-reveal scheme hides the content of your transaction in two phases. Commit phase: submit a hash of your action (e.g., keccak256(bid, secret)). Reveal phase: after the commit deadline, reveal the actual values. Nobody can front-run what they cannot see.

CommitRevealAuction.sol Solidity
contract CommitRevealAuction {
    mapping(address => bytes32) public commits;
    mapping(address => uint256) public bids;
    uint256 public commitDeadline;
    uint256 public revealDeadline;

    // Phase 1: submit hash of (bid + secret)
    function commit(bytes32 hash) external {
        require(block.timestamp < commitDeadline, "Commit phase over");
        commits[msg.sender] = hash;
    }

    // Phase 2: reveal actual bid
    function reveal(uint256 bid, bytes32 secret) external payable {
        require(block.timestamp >= commitDeadline, "Still in commit phase");
        require(block.timestamp < revealDeadline, "Reveal phase over");
        require(
            keccak256(abi.encodePacked(bid, secret)) == commits[msg.sender],
            "Hash mismatch"
        );
        require(msg.value == bid, "Must send bid amount");
        bids[msg.sender] = bid;
    }
}

Flashbots Protect

Flashbots Protect is a private transaction relay. Instead of broadcasting to the public mempool, your transaction goes directly to block builders via a private channel. MEV bots cannot see it, so they cannot front-run or sandwich it. Users can add the Flashbots Protect RPC to MetaMask as a custom network endpoint.

MEV Defense Summary

Defense
Description
Commit-Reveal
Hide transaction content until a reveal phase. Good for auctions and votes.
Flashbots Protect
Private transaction submission. Bypasses the public mempool entirely.
Slippage Limits
Set maximum acceptable price impact on DEX swaps. Revert if exceeded.
Batch Auctions
Execute all orders at a single clearing price (e.g., CoW Protocol). No ordering advantage.

Common Pitfalls

Beyond the major vulnerability classes, there are several common pitfalls that trip up even experienced developers. These are subtle bugs that often pass code review but fail catastrophically in production.

Denial of Service (DoS)

If your contract loops over an unbounded array, an attacker can add enough entries to make the function exceed the block gas limit — permanently bricking it. Similarly, if you push payments to users in a loop, one failing recipient (a contract that reverts on receive) blocks everyone else.

DoS & Pull Pattern Solidity
// VULNERABLE — unbounded loop
function distributeRewards() external {
    for (uint256 i = 0; i < stakers.length; i++) {
        // If stakers array grows too large, this exceeds gas limit
        payable(stakers[i]).transfer(rewards[stakers[i]]);
    }
}

// FIX — pull pattern (users claim individually)
mapping(address => uint256) public pendingRewards;

function claimReward() external {
    uint256 amount = pendingRewards[msg.sender];
    require(amount > 0, "Nothing to claim");
    pendingRewards[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
}

Force-Sending ETH

A contract that relies on address(this).balance == 0 as a condition can be broken. ETH can be force-sent to any contract via selfdestruct (deprecated but still functional) or by setting the contract as a mining/validator reward recipient. Never use address(this).balance for logic — track balances manually.

Force-Sent ETH Solidity
// VULNERABLE — relies on contract balance
contract Game {
    function isGameOver() public view returns (bool) {
        return address(this).balance == 10 ether;
        // Attacker can force-send 0.1 ETH via selfdestruct,
        // making balance 10.1 and breaking the game
    }
}

// FIX — track deposits manually
contract SafeGame {
    uint256 public totalDeposits;

    function deposit() external payable {
        totalDeposits += msg.value;
    }

    function isGameOver() public view returns (bool) {
        return totalDeposits == 10 ether;
    }
}

Storage Collision in Proxies

Upgradeable contracts use a proxy that delegatecalls to an implementation. Both contracts share the same storage layout. If the implementation adds a new variable at the beginning of storage instead of the end, it overwrites existing data. Use OpenZeppelin's upgradeable contracts and always append new variables at the end.

delegatecall Dangers

delegatecall executes another contract's code in the context of the calling contract — using the caller's storage, msg.sender, and msg.value. If you delegatecall to an untrusted contract, it can overwrite any storage slot, steal funds, or self-destruct your contract. Only delegatecall to trusted, audited implementations.

delegatecall Danger Solidity
// How delegatecall works:
// Contract A delegatecalls Contract B
// B's code runs, but reads/writes A's storage

contract Proxy {
    address public implementation;
    address public owner; // slot 1

    fallback() external payable {
        // DANGER: implementation code can overwrite
        // 'owner' if it writes to slot 1
        (bool ok, ) = implementation.delegatecall(msg.data);
        require(ok);
    }
}

Pitfall Checklist

  • No unbounded loops — use pull patterns for distributions.
  • Never rely on address(this).balance — track deposits manually.
  • Append-only storage layout in upgradeable contracts.
  • Only delegatecall to trusted, audited contracts.
  • Test with adversarial inputs: zero address, max uint256, empty arrays.

Security Tools & Audits

Manual code review catches many bugs, but automated tools catch the ones humans miss. A robust security pipeline combines static analysis, symbolic execution, fuzz testing, and professional audits.

Slither (Static Analysis)

Slither, developed by Trail of Bits, analyzes Solidity source code without running it. It detects common vulnerabilities (reentrancy, unchecked return values, dangerous delegatecalls), code quality issues, and optimization opportunities. Run it on every commit.

Slither Commands Bash
# Install Slither
pip3 install slither-analyzer

# Run on your project
slither .

# Run a specific detector
slither . --detect reentrancy-eth

# Output to JSON for CI integration
slither . --json output.json

Mythril (Symbolic Execution)

Mythril, developed by ConsenSys, uses symbolic execution — it explores all possible execution paths mathematically, finding bugs that static analysis misses. It can detect integer overflows, unprotected self-destructs, and complex reentrancy patterns. Slower than Slither but deeper.

Mythril Commands Bash
# Install Mythril
pip3 install mythril

# Analyze a single contract
myth analyze contracts/MyToken.sol

# Increase exploration depth for complex contracts
myth analyze contracts/MyToken.sol --execution-timeout 300

Foundry Fuzz Testing

Foundry's built-in fuzzer generates thousands of random inputs for your test functions. Any function prefixed with testFuzz_ receives randomized parameters. Foundry automatically shrinks failing inputs to the minimal reproduction case. Fuzzing is the fastest way to find edge cases you never thought of.

VaultFuzzTest.t.sol Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SecureVault.sol";

contract VaultFuzzTest is Test {
    SecureVault vault;

    function setUp() public {
        vault = new SecureVault();
    }

    // Foundry generates random amounts
    function testFuzz_depositWithdraw(uint256 amount) public {
        vm.assume(amount > 0 && amount < 100 ether);
        vm.deal(address(this), amount);

        vault.deposit{value: amount}();
        assertEq(vault.balances(address(this)), amount);

        vault.withdraw();
        assertEq(vault.balances(address(this)), 0);
    }
}

Professional Audits

Automated tools catch known patterns, but a professional audit is the gold standard. Auditors manually review every line, understand the business logic, and look for protocol-level vulnerabilities that no tool can detect. Top firms include Trail of Bits, OpenZeppelin, Cyfrin, Spearbit, and Consensys Diligence.

Stage
What Happens
Scoping
Auditors review the codebase size, complexity, and documentation to estimate effort.
Review
Line-by-line manual review. Auditors write proof-of-concept exploits for findings.
Report
Detailed report with severity ratings (Critical, High, Medium, Low, Informational).
Remediation
Development team fixes issues. Auditors verify fixes in a follow-up review.

Bug Bounties (Immunefi)

Immunefi is the leading bug bounty platform for smart contracts. Protocols post bounties (often $50K-$10M) for critical vulnerabilities. White-hat hackers find bugs and get paid instead of exploiting them. Over $100M has been paid out to researchers. If you build a protocol, run a bug bounty. If you want to learn security, start hunting.

Security Pipeline

  1. Development: Write tests with 100% branch coverage. Use CEI pattern. Add ReentrancyGuard.
  2. Pre-commit: Run Slither on every commit. Fix all high-severity findings.
  3. Pre-deploy: Run Mythril. Run Foundry fuzzing with 10,000+ runs. Internal review.
  4. Audit: Engage 1-2 professional audit firms. Fix all critical and high findings.
  5. Post-deploy: Launch bug bounty on Immunefi. Monitor with Forta or OpenZeppelin Defender.

Exercise & Self-Check

Exercises

  1. Spot the bug: Given the following contract, identify all security vulnerabilities. List each bug, explain how it can be exploited, and write the fix.
    contract BuggyToken {
        mapping(address => uint256) public balances;
        function deposit() external payable { balances[msg.sender] += msg.value; }
        function withdraw(uint256 amount) external {
            require(balances[msg.sender] >= amount);
            (bool ok, ) = msg.sender.call{value: amount}("");
            require(ok);
            balances[msg.sender] -= amount;
        }
        function mint(address to, uint256 amount) external {
            balances[to] += amount;
        }
    }
  2. Fix the reentrancy: Rewrite the withdraw() function above using the Checks-Effects-Interactions pattern. Then add the nonReentrant modifier from OpenZeppelin's ReentrancyGuard.
  3. Write a CEI withdraw: Write a complete vault contract from scratch with deposit() and withdraw() functions. Follow the CEI pattern strictly. Add access control for an emergencyWithdrawAll() function that only the owner can call. Include events for all state changes.
  4. Analyze with Slither: Install Slither on your machine. Run it on your Module 9 (ERC-20) or Module 11 (testing) contract. List all findings, categorize them by severity, and explain whether each is a true positive or false positive.
  5. Design a commit-reveal auction: Write a smart contract for a sealed-bid auction using the commit-reveal pattern. Users commit a hash of their bid in phase 1, reveal the actual bid in phase 2, and the highest bidder wins. Handle edge cases: what if a user commits but never reveals? What if two bids are equal?

Self-Check Questions

  1. Explain the reentrancy vulnerability in your own words. Why does updating state after an external call create a vulnerability?
  2. What is the difference between msg.sender and tx.origin? Why should you never use tx.origin for authentication?
  3. Why does Solidity 0.8+ still have integer-related bugs despite built-in overflow protection? Give two examples.
  4. What is a sandwich attack? How does Flashbots Protect mitigate it?
  5. Name three automated security tools and explain what type of analysis each performs.
🎯

Module Summary

Smart contract security is not optional — it is existential. You now understand the major vulnerability classes: reentrancy (CEI pattern + ReentrancyGuard), access control (msg.sender, roles, initializer), integer bugs (unchecked blocks, precision loss), front-running (commit-reveal, Flashbots), and common pitfalls (DoS, force-sent ETH, storage collisions). You know the tools: Slither, Mythril, Foundry fuzzing, and professional audits. Build like a defender. Think like an attacker.

Next module

Your contracts are now secure. But they still live on your local machine. Module 14 covers deployment to real networks — testnets, mainnet, Layer 2 chains — and contract verification on block explorers.

Module 14: Deployment & L2 →
Course Project

Ready to apply everything you've learned? The end-of-course project combines smart contracts, AI, and Node.js into a real decentralised academic assistant.

View Project Brief →