Skip to the content.

Module 03 — Smart Contract Vulnerabilities

Difficulty: Intermediate → Advanced

This is the core module. Every vulnerability class known to affect smart contracts is documented here with: technical explanation, vulnerable code, fixed code, real-world exploit reference, and detection method. This is your primary reference during audits.


3.1 Reentrancy

Difficulty: Intermediate

Reentrancy occurs when a contract makes an external call before updating its state, allowing the called contract to re-enter the original function and exploit the stale state.

Types of Reentrancy

Type Description Difficulty
Single-function Re-entering the same function Beginner
Cross-function Re-entering a different function that shares state Intermediate
Cross-contract Re-entering via a different contract in the same protocol Advanced
Read-only reentrancy Re-entering a view function that returns stale state used by another protocol Advanced

Vulnerable Code — Single-Function Reentrancy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// VULNERABLE: State update AFTER external call
contract VulnerableBank {
    mapping(address => uint256) public balances;

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

        // [NO] External call BEFORE state update
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");

        // State update happens AFTER the call
        // If msg.sender is a contract, its receive() can call withdraw() again
        balances[msg.sender] = 0;
    }

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

Attack Contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract ReentrancyAttacker {
    VulnerableBank public target;

    constructor(address _target) {
        target = VulnerableBank(_target);
    }

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

    receive() external payable {
        if (address(target).balance >= target.balances(address(this))) {
            target.withdraw(); // Re-enter!
        }
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// FIXED: Checks-Effects-Interactions pattern + reentrancy guard
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

    function withdraw() external nonReentrant {
        uint256 balance = balances[msg.sender];
        require(balance > 0, "No balance");

        // [YES] Effect BEFORE interaction
        balances[msg.sender] = 0;

        // Interaction AFTER effect
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Transfer failed");
    }
}

Read-Only Reentrancy

This subtle variant doesn’t steal from the re-entered contract directly. Instead, the attacker re-enters a view function that returns stale state, and a different protocol reads that stale value.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Protocol A — Vulnerable to read-only reentrancy
contract CurvePool {
    // This view function reads balances that haven't been updated yet
    // during the callback in remove_liquidity
    function get_virtual_price() external view returns (uint256) {
        // Returns price based on current balances
        // During reentrancy, balances are stale → price is manipulated
        return _calculate_price();
    }

    function remove_liquidity(uint256 _amount) external {
        // Burns LP tokens → sends ETH → THEN updates balances
        // During the ETH send, get_virtual_price() returns stale value
        _burn(msg.sender, _amount);
        payable(msg.sender).call{value: ethAmount}(""); // Callback here!
        _update_balances(); // Too late — attacker already read stale price
    }
}

// Protocol B — Relies on Protocol A's view function
contract LendingProtocol {
    function getCollateralValue(address user) public view returns (uint256) {
        uint256 lpBalance = lpToken.balanceOf(user);
        uint256 price = curvePool.get_virtual_price(); // Reads stale value!
        return lpBalance * price / 1e18;
    }
}

Real-world exploit: Curve/Vyper reentrancy (July 2023) — Multiple Curve pools exploited through reentrancy in Vyper 0.2.15-0.3.0, with the compiler’s reentrancy lock being buggy. ~$70M lost across multiple pools.

Detection Methods


3.2 Integer Overflow / Underflow

Difficulty: Beginner

Prior to Solidity 0.8.0, arithmetic operations silently wrapped on overflow/underflow. Post-0.8.0, they revert by default — but unchecked {} blocks and inline assembly bypass this.

Vulnerable Code (Pre-0.8.x)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Solidity < 0.8.0 — No automatic overflow checks
pragma solidity 0.7.6;

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

    function transfer(address to, uint256 amount) external {
        // [NO] If balances[msg.sender] < amount, this UNDERFLOWS to a huge number
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    function batchTransfer(address[] memory recipients, uint256 value) external {
        // [NO] If recipients.length * value overflows, totalAmount becomes small
        uint256 totalAmount = recipients.length * value;
        require(balances[msg.sender] >= totalAmount);
        balances[msg.sender] -= totalAmount;
        for (uint i = 0; i < recipients.length; i++) {
            balances[recipients[i]] += value;
        }
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// FIXED: Use Solidity >= 0.8.0 (automatic checks) or SafeMath for older versions
pragma solidity ^0.8.20;

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

    function transfer(address to, uint256 amount) external {
        // [YES] Reverts on underflow in Solidity 0.8+
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

// For older versions:
// import "@openzeppelin/contracts/utils/math/SafeMath.sol";
// using SafeMath for uint256;
// balances[msg.sender] = balances[msg.sender].sub(amount);

Post-0.8.x Bypass — unchecked Blocks

1
2
3
4
5
6
// Watch out for developers using unchecked for "gas optimization"
function riskyDecrement(uint256 x) internal pure returns (uint256) {
    unchecked {
        return x - 1; // [NO] Wraps if x == 0!
    }
}

Real-world exploit: BEC Token (April 2018) — batchTransfer function had an integer overflow allowing attackers to create tokens from nothing. The recipients.length * value multiplication overflowed, bypassing the balance check.

Detection Methods


3.3 Access Control Flaws

Difficulty: Beginner

Missing or improper access control is the most common vulnerability class in competitive audits. It ranges from missing onlyOwner modifiers to subtle role-based access control logic errors.

Vulnerable Code — Missing Modifier

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
contract VulnerableVault {
    address public owner;
    uint256 public feeRate;

    constructor() { owner = msg.sender; }

    // [NO] No access control — anyone can change the fee rate!
    function setFeeRate(uint256 _newRate) external {
        feeRate = _newRate;
    }

    // [NO] No access control — anyone can steal all funds!
    function emergencyWithdraw(address to) external {
        payable(to).transfer(address(this).balance);
    }
}

Vulnerable Code — tx.origin Authentication

1
2
3
4
5
6
7
8
9
10
11
contract VulnerableWallet {
    address public owner;

    // [NO] tx.origin checks the original transaction sender, not the immediate caller
    // An attacker can trick the owner into calling a malicious contract that then
    // calls this function — tx.origin will still be the owner!
    function transfer(address to, uint256 amount) external {
        require(tx.origin == owner, "Not owner"); // [NO] Use msg.sender!
        payable(to).transfer(amount);
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureVault is AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant FEE_MANAGER_ROLE = keccak256("FEE_MANAGER_ROLE");

    uint256 public feeRate;

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(FEE_MANAGER_ROLE, msg.sender);
    }

    // [YES] Proper role-based access control
    function setFeeRate(uint256 _newRate) external onlyRole(FEE_MANAGER_ROLE) {
        require(_newRate <= 1000, "Fee too high"); // Max 10%
        feeRate = _newRate;
    }

    function emergencyWithdraw(address to) external onlyRole(ADMIN_ROLE) {
        payable(to).transfer(address(this).balance);
    }
}

Real-world exploit: Parity Multisig Wallet (Nov 2017) — An unprotected initWallet() function allowed anyone to take ownership of the library contract and then self-destruct it, freezing ~$150M across 587 wallets.

Detection Methods


3.4 Front-Running / MEV

Difficulty: Intermediate

Front-running exploits the public mempool: an attacker observes a pending transaction and submits their own transaction with a higher gas price to execute first.

Types of Front-Running

Type Mechanism Example
Displacement Attacker’s tx replaces victim’s Front-running a name registration
Insertion (Sandwich) Attacker places txs before AND after victim DEX sandwich attacks
Suppression Attacker fills blocks to delay victim’s tx Delaying liquidations

Vulnerable Code — Front-Runnable Approval

1
2
3
4
5
6
7
8
9
10
11
12
13
// [NO] ERC20 approve() is front-runnable
// If Alice approves Bob from 100 to 50 tokens:
// 1. Bob sees the pending approve(50) tx in mempool
// 2. Bob front-runs with transferFrom(Alice, Bob, 100) — uses old allowance
// 3. Alice's approve(50) executes
// 4. Bob calls transferFrom(Alice, Bob, 50) — uses new allowance
// Result: Bob steals 150 instead of 50

// FIXED: Use increaseAllowance/decreaseAllowance or set to 0 first
function safeApprove(IERC20 token, address spender, uint256 amount) internal {
    token.approve(spender, 0); // [YES] Set to 0 first
    token.approve(spender, amount);
}

Sandwich Attack on DEX

1
2
3
4
5
6
7
8
9
10
11
12
// Attacker monitors mempool for large swap:
// Victim: swap 100 ETH → USDC on Uniswap

// Step 1: Attacker FRONT-RUNS — buys USDC before victim
// This raises the USDC price

// Step 2: Victim's swap executes at WORSE price

// Step 3: Attacker BACK-RUNS — sells USDC after victim
// Locks in profit from the price impact

// Defense: Use Flashbots Protect, set tight slippage, deadline parameter

Real-world impact: MEV bots extract millions daily. In 2023, over $1.4 billion was extracted via MEV across Ethereum alone.

Detection Methods


3.5 Flash Loan Attacks

Difficulty: Advanced

Flash loans allow borrowing millions in tokens with zero collateral — repayment happens in the same transaction. They enable attackers to temporarily wield massive capital for price manipulation, governance attacks, and collateral manipulation.

Attack Anatomy

1
2
3
4
5
6
7
8
Single Transaction:
1. Borrow $100M via flash loan (Aave, dYdX, etc.)
2. Use funds to manipulate price oracle or governance
3. Exploit the manipulated state for profit
4. Repay flash loan + fee
5. Keep profit

Total cost: Gas fee + flash loan fee (~0.05%)

Vulnerable Code — Flash Loan Oracle Manipulation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
contract VulnerableLending {
    IUniswapV2Pair public priceFeed;
    IERC20 public collateralToken;
    IERC20 public borrowToken;

    // [NO] Uses spot price from Uniswap — manipulable via flash loan
    function getPrice() public view returns (uint256) {
        (uint112 reserve0, uint112 reserve1, ) = priceFeed.getReserves();
        return (uint256(reserve1) * 1e18) / uint256(reserve0);
    }

    function borrow(uint256 collateralAmount, uint256 borrowAmount) external {
        collateralToken.transferFrom(msg.sender, address(this), collateralAmount);
        uint256 collateralValue = collateralAmount * getPrice() / 1e18;
        require(collateralValue >= borrowAmount * 150 / 100, "Undercollateralized");
        borrowToken.transfer(msg.sender, borrowAmount);
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
contract SecureLending {
    AggregatorV3Interface public chainlinkOracle;

    // [YES] Uses Chainlink price feed — not manipulable by flash loans
    function getPrice() public view returns (uint256) {
        (, int256 price,, uint256 updatedAt,) = chainlinkOracle.latestRoundData();
        require(price > 0, "Invalid price");
        require(block.timestamp - updatedAt < 3600, "Stale price"); // 1 hour
        return uint256(price);
    }
}

Real-world exploit: Beanstalk (April 2022) — $182M stolen. Attacker took a flash loan, acquired enough governance tokens to pass a malicious proposal that drained the treasury, and repaid the loan — all in one transaction.

Detection Methods


3.6 Oracle Manipulation

Difficulty: Advanced

Oracles feed external or cross-contract data into smart contracts. Manipulating this data is one of the most profitable exploit categories.

Oracle Types & Risks

Oracle Type Manipulation Risk Cost to Manipulate
Spot price (AMM reserves) Very High Flash loan (near-zero)
TWAP (time-weighted average) Medium Sustained manipulation over time blocks
Chainlink Low Would require compromising majority of nodes
Band Protocol Low-Medium Similar decentralized oracle network
Custom oracle (centralized) Very High Compromise the oracle operator

Vulnerable Code — Spot Price Oracle

1
2
3
4
5
6
// [NO] Using Uniswap spot reserves as price oracle
function getTokenPrice() external view returns (uint256) {
    (uint112 r0, uint112 r1, ) = uniswapPair.getReserves();
    return uint256(r1) * 1e18 / uint256(r0);
    // Manipulable: a single large swap changes reserves instantly
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// [YES] Using Chainlink with proper validation
function getTokenPrice() external view returns (uint256) {
    (
        uint80 roundId,
        int256 price,
        ,
        uint256 updatedAt,
        uint80 answeredInRound
    ) = priceFeed.latestRoundData();

    require(price > 0, "Negative price");
    require(updatedAt > 0, "Round not complete");
    require(answeredInRound >= roundId, "Stale price");
    require(block.timestamp - updatedAt < 3600, "Price too old");

    return uint256(price);
}
Issue Description
Staleness Price hasn’t been updated in too long (oracle may be down)
L2 Sequencer down On L2s, check sequencer uptime before using Chainlink prices
Deviation threshold Chainlink only updates if price deviates >X% — stale in low-volatility
minAnswer/maxAnswer Some feeds have circuit breakers — price clamps at min/max

Real-world exploit: Mango Markets (Oct 2022) — $114M. Attacker manipulated the price of MNGO token on a spot oracle by inflating it through coordinated trading, then borrowed against inflated collateral.


3.7 Delegatecall Vulnerabilities

Difficulty: Advanced

DELEGATECALL executes the callee’s code in the context of the caller — the caller’s storage, msg.sender, and msg.value are preserved. This is the foundation of proxy patterns, but misuse leads to devastating exploits.

Vulnerable Code — Storage Collision

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Proxy contract
contract Proxy {
    address public implementation;  // Storage slot 0
    address public owner;           // Storage slot 1

    function upgrade(address _impl) external {
        require(msg.sender == owner);
        implementation = _impl;
    }

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

// Implementation contract
contract Implementation {
    // [NO] STORAGE COLLISION: 'admin' occupies slot 0 — same as 'implementation' in Proxy!
    address public admin;        // Slot 0 — overwrites proxy.implementation!
    uint256 public value;        // Slot 1 — overwrites proxy.owner!

    function setAdmin(address _admin) external {
        admin = _admin;  // Actually writes to proxy.implementation → breaks proxy!
    }
}

Fixed Code — EIP-1967 Storage Slots

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// [YES] Fixed: Use EIP-1967 random storage slots to avoid collision
contract SecureProxy {
    // Slot = keccak256("eip1967.proxy.implementation") - 1
    bytes32 private constant IMPL_SLOT =
        0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

    bytes32 private constant ADMIN_SLOT =
        0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;

    function _setImplementation(address impl) internal {
        assembly { sstore(IMPL_SLOT, impl) }
    }

    function _getImplementation() internal view returns (address impl) {
        assembly { impl := sload(IMPL_SLOT) }
    }
}

Real-world exploit: Parity Wallet Hack #1 (July 2017) — The initialization function of a library contract was callable by anyone via delegatecall, allowing the attacker to take ownership and drain ~$30M.


3.8 Uninitialized Storage Pointers

Difficulty: Intermediate

In older Solidity versions (< 0.5.0), local variables of struct type defaulted to storage — creating a pointer to slot 0, which could overwrite critical state.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pragma solidity 0.4.25;

contract UninitializedStorage {
    address public owner;     // Slot 0
    uint256 public totalDeposit;  // Slot 1

    struct User {
        address addr;         // Would point to slot 0
        uint256 balance;      // Would point to slot 1
    }

    mapping(uint256 => User) public users;

    function register(uint256 id) external {
        // [NO] In Solidity < 0.5.0, this creates a storage pointer to slot 0!
        User user;
        user.addr = msg.sender;     // Overwrites owner (slot 0)!
        user.balance = 0;           // Overwrites totalDeposit (slot 1)!
        users[id] = user;
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pragma solidity ^0.8.20;

contract FixedStorage {
    address public owner;
    uint256 public totalDeposit;

    struct User {
        address addr;
        uint256 balance;
    }

    mapping(uint256 => User) public users;

    function register(uint256 id) external {
        // [YES] In 0.8+, explicit storage/memory keyword is required
        User memory user = User({addr: msg.sender, balance: 0});
        users[id] = user;
    }
}

Detection Methods


3.9 Signature Replay Attacks

Difficulty: Intermediate

Off-chain signatures enable gasless transactions and meta-transactions. Without proper replay protection, a valid signature can be reused.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
12
13
contract VulnerableRelay {
    mapping(address => uint256) public balances;

    // [NO] No replay protection! Same signature can be submitted multiple times
    function withdraw(uint256 amount, bytes memory signature) external {
        bytes32 hash = keccak256(abi.encodePacked(amount));
        address signer = ECDSA.recover(hash, signature);

        require(balances[signer] >= amount, "Insufficient balance");
        balances[signer] -= amount;
        payable(msg.sender).transfer(amount);
    }
}

Fixed Code — With EIP-712

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract SecureRelay is EIP712 {
    mapping(address => uint256) public balances;
    mapping(address => uint256) public nonces;  // [YES] Replay protection

    bytes32 public constant WITHDRAW_TYPEHASH =
        keccak256("Withdraw(address owner,uint256 amount,uint256 nonce,uint256 deadline)");

    constructor() EIP712("SecureRelay", "1") {}

    function withdraw(
        uint256 amount,
        uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) external {
        require(block.timestamp <= deadline, "Expired"); // [YES] Deadline

        bytes32 structHash = keccak256(abi.encode(
            WITHDRAW_TYPEHASH,
            msg.sender,
            amount,
            nonces[msg.sender]++, // [YES] Nonce incremented
            deadline
        ));

        bytes32 hash = _hashTypedDataV4(structHash); // [YES] Includes chainId
        address signer = ECDSA.recover(hash, v, r, s);

        require(signer == msg.sender, "Invalid signature");
        require(balances[signer] >= amount, "Insufficient");
        balances[signer] -= amount;
        payable(msg.sender).transfer(amount);
    }
}

Replay Attack Vectors

Missing Protection Attack
No nonce Replay same signature multiple times
No chainId Replay signature on different chains (mainnet sig used on Polygon)
No deadline Signature valid forever — attacker can submit later when conditions favor them
No contract address Signature meant for Contract A replayed on Contract B

Real-world exploit: Wintermute Op token theft (June 2022) — 20M OP tokens were stolen because the Gnosis Safe deployment process on Optimism allowed replay of mainnet deployment signatures.


3.10 Unsafe External Calls

Difficulty: Beginner

Low-level calls (call, delegatecall, staticcall) return a boolean success indicator. Failing to check this return value means silent failures.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
contract UnsafeCaller {
    // [NO] Return value of call is not checked
    function sendETH(address to, uint256 amount) external {
        payable(to).call{value: amount}(""); // May fail silently!
    }

    // [NO] Return value of ERC20 transfer not checked
    function sendToken(IERC20 token, address to, uint256 amount) external {
        token.transfer(to, amount); // Some tokens return false instead of reverting
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SafeCaller {
    using SafeERC20 for IERC20;

    // [YES] Check return value
    function sendETH(address to, uint256 amount) external {
        (bool success, ) = payable(to).call{value: amount}("");
        require(success, "ETH transfer failed");
    }

    // [YES] Use SafeERC20 for tokens
    function sendToken(IERC20 token, address to, uint256 amount) external {
        token.safeTransfer(to, amount); // Reverts on failure
    }
}

Detection Methods


3.11 Denial of Service (DoS)

Difficulty: Intermediate

DoS attacks prevent legitimate users from interacting with a contract. Unlike traditional web DoS, on-chain DoS can be permanent.

Types of DoS

Type Mechanism Permanence
Gas griefing Force expensive operations in callbacks Per-transaction
Unbounded loops Iterate over growing arrays until gas limit exceeded Permanent (as array grows)
Forced ETH via selfdestruct Break address(this).balance == 0 checks Permanent
Push vs Pull Send loop fails for one recipient → blocks all Permanent (until removed)
Block gas limit Array too large: no single tx can process it Permanent

Vulnerable Code — Unbounded Loop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract VulnerableAirdrop {
    address[] public recipients;

    function addRecipient(address r) external {
        recipients.push(r);
    }

    // [NO] Loops over unbounded array — will exceed gas limit as array grows
    function distribute(IERC20 token, uint256 amountEach) external {
        for (uint i = 0; i < recipients.length; i++) {
            token.transfer(recipients[i], amountEach); // What if one reverts?
        }
    }
}

Fixed Code — Pull Pattern + Batched Processing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract SecureDistribution {
    mapping(address => uint256) public claimable;

    // [YES] Push: Admin sets claimable amounts (bounded by admin's gas budget)
    function setClaimable(address[] calldata users, uint256[] calldata amounts) external onlyOwner {
        require(users.length == amounts.length);
        require(users.length <= 100, "Batch too large"); // [YES] Bounded
        for (uint i = 0; i < users.length; i++) {
            claimable[users[i]] += amounts[i];
        }
    }

    // [YES] Pull: Users claim their own tokens
    function claim(IERC20 token) external {
        uint256 amount = claimable[msg.sender];
        require(amount > 0, "Nothing to claim");
        claimable[msg.sender] = 0;
        token.safeTransfer(msg.sender, amount);
    }
}

Force-Feeding ETH

1
2
3
4
5
6
7
8
9
10
11
// [NO] This check can be broken by selfdestruct force-feeding ETH
contract VulnerableGame {
    function isGameOver() public view returns (bool) {
        return address(this).balance == 7 ether; // Can be bypassed!
    }
}

// Attacker can force-send ETH via:
// 1. selfdestruct — sends remaining balance to target (pre-Dencun only in same tx)
// 2. Mining reward / validator reward (set target as coinbase)
// 3. Pre-calculated CREATE2 address — send ETH before contract deployment

Real-world exploit: GovernMental Ponzi (2016) — A growing array of investors eventually made the withdraw() function exceed the block gas limit, permanently locking 1,100 ETH.


3.12 Self-Destruct / Force-Feeding ETH

Difficulty: Beginner

SELFDESTRUCT destroys a contract and forcefully sends its remaining ETH to a target address, bypassing any receive() or fallback() function. Post-Dencun (EIP-6780), SELFDESTRUCT only works within the same transaction as contract creation.

1
2
3
4
5
6
7
8
// Attacker contract (pre-Dencun):
contract ForceFeeder {
    constructor(address payable target) payable {
        selfdestruct(target); // [YES] Still works in same tx as creation (EIP-6780)
    }
}

// Now ETH can be forcefully sent even post-Dencun by creating + destructing in one tx

What Breaks


3.13 Timestamp Dependence

Difficulty: Beginner

block.timestamp is set by the block proposer and can be manipulated within the ~12-second slot window on PoS Ethereum.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
12
13
// [NO] Using timestamp for randomness or precise timing
contract TimestampVulnerable {
    function isWinner() external view returns (bool) {
        return block.timestamp % 15 == 0; // Manipulable!
    }

    function isExpired(uint256 deadline) external view returns (bool) {
        // ! Validator can slightly manipulate this
        // Acceptable for deadlines with large windows (hours/days)
        // Not acceptable for precise sub-minute timing
        return block.timestamp >= deadline;
    }
}

Guidance


3.14 Block Number Dependence

Difficulty: Beginner

Using block.number for time estimation is unreliable across chains with different block times (L2s often have variable block times).

1
2
3
4
5
// [NO] Assuming 12-second block time for duration estimation
uint256 public constant BLOCKS_PER_DAY = 7200; // Only valid on mainnet post-Merge

// On Arbitrum, Optimism, or other L2s, block time varies significantly
// This could be off by hours or days

3.15 Randomness Manipulation

Difficulty: Intermediate

On-chain randomness is a fundamental challenge. Block variables are predictable to miners/validators.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
// [NO] Blockhash-based randomness — predictable to validators
function getRandomNumber() external view returns (uint256) {
    return uint256(keccak256(abi.encodePacked(
        block.timestamp,
        block.prevrandao,     // PREVRANDAO replaces DIFFICULTY post-Merge
        msg.sender
    )));
}
// Validators know block.prevrandao and block.timestamp before block production
// They can reorder txs or skip proposing to influence the outcome

Secure Randomness

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// [YES] Use Chainlink VRF (Verifiable Random Function)
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";

contract SecureRandom is VRFConsumerBaseV2 {
    function requestRandom() external returns (uint256 requestId) {
        requestId = COORDINATOR.requestRandomWords(
            keyHash,
            subscriptionId,
            requestConfirmations,
            callbackGasLimit,
            numWords
        );
    }

    function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
        // Use randomWords[0] — cryptographically secure and verifiable
    }
}

Note: PREVRANDAO (post-Merge) provides 256 bits of “randomness,” but the proposer knows the value before proposing the block and can choose not to propose (1-bit bias attack). It’s acceptable for low-value outcomes but not for high-value lotteries.


3.16 Precision Loss & Rounding Errors

Difficulty: Intermediate

Solidity has no floating-point arithmetic. All math uses integer division, which truncates (rounds toward zero). This creates exploitable rounding errors, especially in DeFi.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
// [NO] Division before multiplication — precision loss
function calculateFee(uint256 amount, uint256 feePercent) external pure returns (uint256) {
    return amount / 100 * feePercent; // If amount = 99, result = 0 regardless of fee!
}

// [NO] Small deposits can round to 0 shares
function deposit(uint256 assets) external returns (uint256 shares) {
    shares = assets * totalSupply / totalAssets; // If totalAssets >> totalSupply * assets, shares = 0
    // User deposits tokens but gets 0 shares — funds lost
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// [YES] Multiply before divide
function calculateFee(uint256 amount, uint256 feePercent) external pure returns (uint256) {
    return amount * feePercent / 100; // 99 * 5 / 100 = 4 (correct)
}

// [YES] Add minimum share check + round up for deposits
function deposit(uint256 assets) external returns (uint256 shares) {
    shares = assets * totalSupply / totalAssets;
    require(shares > 0, "Deposit too small");
}

// [YES] Round up against the user for withdrawals (protocol-favorable rounding)
function previewRedeem(uint256 shares) public view returns (uint256 assets) {
    assets = shares * totalAssets / totalSupply; // Round down — user gets less
}

Detection Methods


3.17 Rug Pull Mechanics & Honeypot Patterns

Difficulty: Beginner

Rug pulls are malicious contracts designed to steal funds from users. Honeypots attract buyers but prevent selling.

Common Rug Pull Patterns

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Pattern 1: Hidden mint function
contract RugToken {
    // Owner can mint unlimited tokens and dump on users
    function _mint(address to, uint256 amount) internal { /* ... */ }
    function secretMint() external onlyOwner {
        _mint(owner, totalSupply() * 100); // 100x inflation
    }
}

// Pattern 2: Blacklist prevents selling
contract HoneypotToken {
    mapping(address => bool) private _isBlacklisted;
    function transfer(address to, uint256 amount) public override returns (bool) {
        require(!_isBlacklisted[msg.sender], ""); // Users can buy but not sell
        return super.transfer(to, amount);
    }
}

// Pattern 3: Fee manipulation
contract FeeRugToken {
    uint256 public sellFee = 0; // Starts at 0%
    function setSellFee(uint256 _fee) external onlyOwner {
        sellFee = _fee; // Owner sets to 99% after launch
    }
}

// Pattern 4: Proxy rug — owner upgrades implementation to drain
// Uses upgradeable proxy pattern — swaps implementation to a drainer contract

Detection Red Flags


3.18 Governance Attack Vectors

Difficulty: Advanced

DeFi governance can be exploited through flash loans to temporarily acquire voting power.

Vulnerable Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract VulnerableGovernor {
    IERC20Votes public token;

    function propose(/* ... */) external returns (uint256) {
        // [NO] No minimum holding period — flash loan can meet threshold
        require(token.getVotes(msg.sender) >= proposalThreshold, "Below threshold");
        // ... create proposal
    }

    function castVote(uint256 proposalId, uint8 support) external {
        // [NO] Uses CURRENT balance, not snapshot
        uint256 weight = token.balanceOf(msg.sender);
        // ... record vote with weight
    }
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract SecureGovernor {
    function propose(/* ... */) external returns (uint256) {
        // [YES] Uses past snapshot — flash loan doesn't affect historical balances
        require(
            token.getPastVotes(msg.sender, block.number - 1) >= proposalThreshold,
            "Below threshold"
        );
    }

    function castVote(uint256 proposalId, uint8 support) external {
        // [YES] Uses snapshot at proposal creation time
        uint256 weight = token.getPastVotes(msg.sender, proposals[proposalId].startBlock);
    }
}

Real-world exploit: Beanstalk (April 2022) — Attacker flash-loaned governance tokens, passed a malicious BIP (Beanstalk Improvement Proposal), drained $182M, and repaid the flash loan — all in a single transaction.


3.19 Proxy Storage Collisions

Difficulty: Advanced

When a transparent proxy delegates to an implementation, both share the same storage space. If the proxy and implementation define variables in overlapping slots, state corruption occurs.

Function Selector Clash

1
2
3
4
5
6
7
// If proxy has: function admin() — selector 0xf851a440
// And implementation has: function clashingfunction() — selector 0xf851a440
// The proxy intercepts the call instead of forwarding to implementation!

// OpenZeppelin TransparentUpgradeableProxy prevents this by:
// - Restricting admin functions to admin-only
// - Non-admin calls always delegate to implementation

EIP-1967 Gap Pattern

1
2
3
4
5
6
7
8
9
10
// In OpenZeppelin's upgradeable contracts:
contract UpgradeableBase {
    // Reserve storage slots to prevent future versions from colliding
    uint256[50] private __gap; // [YES] Subclasses can add variables above the gap
}

contract UpgradeableChild is UpgradeableBase {
    uint256 public newVariable; // Uses slot immediately after parent's variables
    uint256[49] private __gap; // Gap shrinks by 1 for each new variable
}

3.20 Upgradeable Contract Pitfalls

Difficulty: Advanced

Initialization Flaws

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// [NO] Constructor doesn't run on implementation in proxy pattern!
contract VulnerableImplementation {
    address public owner;

    // This constructor only runs during deployment, NOT when used as proxy implementation
    constructor() {
        owner = msg.sender;
    }
}

// [YES] Use initializer pattern
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract SecureImplementation is Initializable {
    address public owner;

    function initialize(address _owner) external initializer {
        owner = _owner; // Runs once when proxy calls it
    }
}

Uninitialized Implementation Risk

1
2
3
4
5
6
7
8
9
// If the implementation contract is NOT initialized directly,
// an attacker can call initialize() on the implementation itself (not the proxy).
// Then they own the implementation and can call selfdestruct (pre-Dencun)
// or other privileged functions.

// [YES] Fix: Call _disableInitializers() in implementation constructor
constructor() {
    _disableInitializers(); // Prevents initialization of the implementation itself
}

Real-world exploit: Wormhole Uninitialized Proxy (2022) — The implementation contract was not initialized, potentially allowing an attacker to take ownership and upgrade it maliciously.


3.21 Non-Standard Token Issues

Difficulty: Intermediate

Not all ERC-20 tokens behave identically. Assumptions about standard behavior lead to vulnerabilities.

Token Type Behavior Risk
Fee-on-transfer (USDT, STA) Recipient receives less than amount Accounting mismatch — protocol credits more than received
Rebasing (stETH, AMPL) Balances change without transfers Share calculations break
ERC-777 (imBTC) tokensReceived callback on transfer Reentrancy vector
Double-entry tokens (some bridged TUSD) Two addresses point to same balance Double-counting in protocols
Pausable (USDC, USDT) Transfers can be frozen DoS for protocols relying on the token
Blacklistable (USDC, USDT) Specific addresses blocked Frozen funds in DeFi contracts
Non-bool-returning (USDT on mainnet) transfer() returns nothing Reverts if caller expects bool return
Decimals ≠ 18 (USDC = 6, WBTC = 8) Arithmetic assumptions break Precision errors in price calculations

Handling Fee-on-Transfer Tokens

1
2
3
4
5
6
7
8
9
10
11
12
13
// [NO] Assumes amount received == amount sent
function deposit(IERC20 token, uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    balances[msg.sender] += amount; // Credits full amount, but less was received!
}

// [YES] Measure actual balance change
function deposit(IERC20 token, uint256 amount) external {
    uint256 balanceBefore = token.balanceOf(address(this));
    token.transferFrom(msg.sender, address(this), amount);
    uint256 received = token.balanceOf(address(this)) - balanceBefore;
    balances[msg.sender] += received; // Credits actual received amount
}

3.22 Sandwich Attacks & AMM Price Impact

Difficulty: Advanced

Sandwich attacks exploit the deterministic price impact of AMM swaps. The attacker:

  1. Detects a large pending swap in the mempool
  2. Front-runs with a buy (pushing the price up)
  3. Victim’s swap executes at a worse price
  4. Attacker back-runs with a sell (capturing the squeezed profit)

Defense in Smart Contracts

1
2
3
4
5
6
7
8
9
10
11
// [YES] Require slippage protection in swap functions
function swap(
    uint256 amountIn,
    uint256 amountOutMin, // [YES] Minimum acceptable output
    uint256 deadline      // [YES] Transaction expiry
) external {
    require(block.timestamp <= deadline, "Expired");
    uint256 amountOut = _calculateSwap(amountIn);
    require(amountOut >= amountOutMin, "Slippage exceeded");
    // ... execute swap
}

3.23 Price Impact Manipulation in Lending Protocols

Difficulty: Advanced

Lending protocols that use collateral value based on AMM prices are vulnerable to flash-loan-powered price manipulation.

1
2
3
4
5
6
Attack Flow:
1. Flash loan large amount of Token A
2. Dump Token A on DEX → price of Token A crashes
3. Liquidate users who have Token A as collateral (now undercollateralized)
4. Buy Token A back at crashed price
5. Repay flash loan

Defense


3.24 Donation / Inflation Attack on Vault Shares (ERC-4626)

Difficulty: Advanced

This attack exploits the share-based accounting of ERC-4626 vaults. The attacker inflates the share price by “donating” assets directly to the vault, making subsequent depositors’ shares round down to zero.

Attack Flow

1
2
3
4
5
6
7
1. Attacker deposits 1 wei of asset → receives 1 share
2. Attacker donates 1e18 tokens directly to vault (transfer, not deposit)
3. Now: totalAssets = 1e18 + 1, totalSupply = 1
4. Share price: 1 share = ~1e18 tokens
5. Victim deposits 5e17 tokens (0.5 ETH):
   shares = 5e17 * 1 / (1e18 + 1) = 0 shares (rounds down!)
6. Victim's 0.5 ETH is now owned by the attacker's 1 share

Vulnerable Code

1
2
3
4
5
6
7
// Standard ERC-4626 deposit without protection
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    shares = assets * totalSupply() / totalAssets();
    // [NO] If totalAssets is inflated and totalSupply is 1, shares rounds to 0
    _mint(receiver, shares);
    asset.transferFrom(msg.sender, address(this), assets);
}

Fixed Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// [YES] Defense 1: Virtual shares and assets offset (OpenZeppelin approach)
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view returns (uint256) {
    return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding);
}

// [YES] Defense 2: Require minimum initial deposit
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
    if (totalSupply() == 0) {
        require(assets >= MIN_DEPOSIT, "Below minimum");
        shares = assets - DEAD_SHARES; // Burn some shares to address(dead)
        _mint(address(0xdead), DEAD_SHARES); // Dead shares prevent inflation
    } else {
        shares = assets * totalSupply() / totalAssets();
    }
    require(shares > 0, "Zero shares");
    _mint(receiver, shares);
    asset.transferFrom(msg.sender, address(this), assets);
}

Real-world exploit: Multiple ERC-4626 vaults have been exploited via this pattern. The Yearn V3 and OpenZeppelin libraries now include built-in mitigations.


Summary — Vulnerability Detection Quick Reference

# Vulnerability Slither Detector Key Pattern
1 Reentrancy reentrancy-eth External call before state update
2 Integer overflow (manual for unchecked) unchecked {}, pre-0.8.x
3 Access control unprotected-upgrade Missing modifiers on state-changing functions
4 Front-running (manual) Order-dependent operations
5 Flash loan (manual) Spot price oracles
6 Oracle manipulation (manual) AMM reserve reads
7 Delegatecall controlled-delegatecall Storage layout mismatches
8 Uninitialized storage uninitialized-storage Pre-0.5.0 struct locals
9 Signature replay (manual) Missing nonce/chainId/deadline
10 Unsafe calls unchecked-lowlevel .call without return check
11 DoS (manual) Unbounded loops, push pattern
12 Self-destruct suicidal address(this).balance checks
13 Timestamp timestamp block.timestamp in conditionals
14 Block number (manual) Block-time assumptions
15 Randomness weak-prng blockhash, prevrandao
16 Precision loss (manual) Division before multiplication
17 Rug pulls (manual) Unverified, mint, blacklist
18 Governance (manual) Flash-loan-able voting
19 Proxy collision (manual) Storage slot overlap
20 Upgradeable uninitialized-state Missing initializer
21 Token quirks (manual) Fee-on-transfer, rebasing
22 Sandwich (manual) Missing slippage checks
23 Lending manipulation (manual) Spot price collateral valuation
24 Vault inflation (manual) First depositor share rounding

Key Takeaway: Most high-severity findings in competitive audits are NOT reentrancy or overflow — those are well-known and often caught by tools. The highest-paying findings in 2024–2025 are: logic errors in economic models, cross-contract interaction issues, incorrect assumptions about external token behavior, and oracle manipulation. Train your eye to think about economic invariants, not just code patterns.


*← Previous: Recon & OSINT Next: Audit Methodology →*