Security

Smart Contract Security

Smart Contract Security

Module 1 of Security


Why Smart Contract Security Is Different

In crypto, code is money. Unlike traditional software:

Traditional BugSmart Contract Bug
Crash → RestartLoss of funds
Patch → DeployImmutable (often)
Internal reviewAdversarial environment
Limited exposure$100B+ at risk

A single vulnerability can drain millions instantly. There are no do-overs.


The Attacker's Mindset

Thinking like an attacker:

For every function:
1. What if I call this 1000 times?
2. What if I call this with max/min values?
3. What if I call this from another contract?
4. What if I call functions in unexpected order?
5. What if I'm both sender AND receiver?
6. What if I manipulate external data?

Vulnerability: Reentrancy

The Classic Attack

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

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount);

        // 1. Send ETH (EXTERNAL CALL!)
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // 2. Update balance
        balances[msg.sender] -= amount;  // TOO LATE!
    }
}

// ATTACKER
contract Attacker {
    VulnerableBank bank;

    receive() external payable {
        // Re-enter before balance is updated!
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

    function attack() public {
        bank.withdraw(1 ether);
    }
}

The Attack Flow

Attacker.attack()
    └── Bank.withdraw(1 ETH)
            ├── Check: balance >= 1 ETH ✓
            ├── Send 1 ETH to Attacker
            │       └── Attacker.receive()
            │               └── Bank.withdraw(1 ETH)  // RE-ENTER!
            │                       ├── Check: balance >= 1 ETH ✓ (not updated!)
            │                       ├── Send 1 ETH
            │                       │       └── ... (repeat)
            │                       └── Update balance
            └── Update balance

Prevention: Checks-Effects-Interactions

// SAFE VERSION
function withdraw(uint256 amount) public {
    // 1. CHECKS
    require(balances[msg.sender] >= amount);

    // 2. EFFECTS (update state FIRST)
    balances[msg.sender] -= amount;

    // 3. INTERACTIONS (external call LAST)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

Additional Protection: Reentrancy Guard

bool private locked;

modifier nonReentrant() {
    require(!locked, "Reentrancy detected");
    locked = true;
    _;
    locked = false;
}

function withdraw(uint256 amount) public nonReentrant {
    // Now safe from reentrancy
}

Vulnerability: Access Control

Missing Access Control

// VULNERABLE
contract Token {
    function mint(address to, uint256 amount) public {
        // Anyone can mint unlimited tokens!
        _mint(to, amount);
    }
}

// SAFE
contract Token {
    address public owner;

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

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

tx.origin Vulnerability

// VULNERABLE - uses tx.origin
function withdraw() public {
    require(tx.origin == owner);  // BAD!
    payable(owner).transfer(balance);
}

// Attack: Trick owner to call malicious contract
// Malicious contract calls withdraw()
// tx.origin = owner, but msg.sender = attacker

// SAFE - uses msg.sender
function withdraw() public {
    require(msg.sender == owner);
    payable(owner).transfer(balance);
}

Vulnerability: Integer Issues

Overflow/Underflow (Pre-0.8.0)

// Solidity < 0.8.0: VULNERABLE
uint8 balance = 255;
balance += 1;  // Wraps to 0!

uint8 balance = 0;
balance -= 1;  // Wraps to 255!

// Solidity >= 0.8.0: Safe by default
// Uses checked arithmetic, reverts on overflow

Precision Loss

// VULNERABLE - division before multiplication
uint256 fee = amount / 100 * feePercent;  // Precision lost!

// SAFE - multiply first
uint256 fee = amount * feePercent / 100;

// Example:
// amount = 150, feePercent = 3
// Bad:  150 / 100 * 3 = 1 * 3 = 3
// Good: 150 * 3 / 100 = 450 / 100 = 4

Vulnerability: Oracle Manipulation

Spot Price Manipulation

// VULNERABLE - uses spot price
function getPrice() public view returns (uint256) {
    // Attacker can manipulate in same block!
    return tokenA.balanceOf(pool) / tokenB.balanceOf(pool);
}

// ATTACK FLOW
// 1. Flash loan huge amount of tokenA
// 2. Dump into pool (manipulates ratio)
// 3. Call vulnerable function (gets wrong price)
// 4. Profit from mispricing
// 5. Restore pool, repay flash loan

Safe Oracle Patterns

// SAFE - Use TWAP (Time-Weighted Average Price)
function getPrice() public view returns (uint256) {
    // Average price over time, resistant to manipulation
    return oracle.consult(token, 1 hours);
}

// SAFE - Use Chainlink
function getPrice() public view returns (uint256) {
    (, int256 price,,,) = priceFeed.latestRoundData();
    require(price > 0, "Invalid price");
    return uint256(price);
}

Vulnerability: Flash Loan Attacks

The Pattern

1. Borrow huge sum (no collateral needed)
2. Manipulate something (price, governance, etc.)
3. Profit from manipulation
4. Repay loan + fee
5. Keep profit

All in ONE transaction - no capital at risk!

Example: Governance Attack

// Vulnerable governance
function propose(bytes calldata action) public {
    require(token.balanceOf(msg.sender) >= threshold);
    // Attacker flash loans tokens, proposes, returns tokens
}

// Safe governance
function propose(bytes calldata action) public {
    require(token.getPastVotes(msg.sender, block.number - 1) >= threshold);
    // Uses HISTORICAL balance - can't flash loan
}

Vulnerability: Signature Issues

Missing Nonce (Replay Attack)

// VULNERABLE
function executeWithSig(
    address to,
    uint256 amount,
    bytes calldata signature
) public {
    bytes32 hash = keccak256(abi.encodePacked(to, amount));
    address signer = ECDSA.recover(hash, signature);
    require(signer == owner);
    // Same signature can be replayed!
}

// SAFE
mapping(bytes32 => bool) public usedHashes;

function executeWithSig(
    address to,
    uint256 amount,
    uint256 nonce,
    bytes calldata signature
) public {
    bytes32 hash = keccak256(abi.encodePacked(to, amount, nonce));
    require(!usedHashes[hash], "Already used");
    usedHashes[hash] = true;

    address signer = ECDSA.recover(hash, signature);
    require(signer == owner);
}

Signature Malleability

// ECDSA signatures have two valid forms: (v, r, s) and (v', r, -s mod n)
// Use OpenZeppelin's ECDSA library which handles this
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

Vulnerability: Logic Errors

Incorrect Comparison

// VULNERABLE
function isWhitelisted(address user) public view returns (bool) {
    return whitelist[user] = true;  // ASSIGNMENT, not comparison!
}

// SAFE
function isWhitelisted(address user) public view returns (bool) {
    return whitelist[user] == true;
}

Missing Validation

// VULNERABLE
function transfer(address to, uint256 amount) public {
    balances[msg.sender] -= amount;
    balances[to] += amount;
    // What if to == address(0)?
    // What if amount > balance?
}

// SAFE
function transfer(address to, uint256 amount) public {
    require(to != address(0), "Invalid recipient");
    require(balances[msg.sender] >= amount, "Insufficient balance");
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

Security Tools

Static Analysis

ToolTypeUse
SlitherStatic analyzerFind common bugs
MythrilSymbolic executionDeep analysis
SecurifyPattern matchingSecurity properties

Testing

// Foundry fuzz testing
function testFuzz_Transfer(address to, uint256 amount) public {
    vm.assume(to != address(0));
    vm.assume(amount <= balanceOf(address(this)));

    uint256 balanceBefore = balanceOf(to);
    transfer(to, amount);
    assertEq(balanceOf(to), balanceBefore + amount);
}

Formal Verification

Prove properties mathematically:

Property: "Total supply never changes"
Prove: For all possible function calls,
       totalSupply_before == totalSupply_after

Audit Process

Pre-Audit Checklist

  1. Documentation: Explain what the code should do
  2. Tests: 90%+ coverage
  3. Comments: Explain non-obvious logic
  4. Specification: Define invariants

During Audit

Auditors will:
1. Manual line-by-line review
2. Run automated tools
3. Test edge cases
4. Review economic incentives
5. Check external dependencies

Audit ≠ Guaranteed Security

  • Auditors are human, miss things
  • Code can change after audit
  • Economic attacks may not be caught
  • Still valuable, but not a silver bullet

Best Practices

Development

  1. Use battle-tested libraries (OpenZeppelin)
  2. Keep contracts simple - complexity = bugs
  3. Test extensively - fuzzing, invariant tests
  4. Document everything - future you will thank you

Deployment

  1. Start with limits - cap deposits initially
  2. Use timelocks - delay sensitive operations
  3. Have a pause mechanism - for emergencies
  4. Monitor continuously - detect anomalies

Emergency Response

// Emergency pause
bool public paused;

modifier whenNotPaused() {
    require(!paused, "Contract paused");
    _;
}

function pause() external onlyOwner {
    paused = true;
}

Key Takeaways

  1. Reentrancy - Follow checks-effects-interactions, use guards
  2. Access control - Never forget authorization, use msg.sender not tx.origin
  3. Oracle manipulation - Use TWAPs or Chainlink, never spot prices
  4. Integer issues - Use Solidity 0.8+, multiply before divide
  5. Audits help but aren't guarantees
  6. Test extensively - fuzzing finds what you miss
  7. Start limited - you can always increase limits
  8. Have emergency plans - pause mechanisms, monitoring