Smart Contract Security
Module 1 of Security
Why Smart Contract Security Is Different
In crypto, code is money. Unlike traditional software:
| Traditional Bug | Smart Contract Bug |
|---|---|
| Crash → Restart | Loss of funds |
| Patch → Deploy | Immutable (often) |
| Internal review | Adversarial 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
| Tool | Type | Use |
|---|---|---|
| Slither | Static analyzer | Find common bugs |
| Mythril | Symbolic execution | Deep analysis |
| Securify | Pattern matching | Security 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
- Documentation: Explain what the code should do
- Tests: 90%+ coverage
- Comments: Explain non-obvious logic
- 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
- Use battle-tested libraries (OpenZeppelin)
- Keep contracts simple - complexity = bugs
- Test extensively - fuzzing, invariant tests
- Document everything - future you will thank you
Deployment
- Start with limits - cap deposits initially
- Use timelocks - delay sensitive operations
- Have a pause mechanism - for emergencies
- 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
- Reentrancy - Follow checks-effects-interactions, use guards
- Access control - Never forget authorization, use msg.sender not tx.origin
- Oracle manipulation - Use TWAPs or Chainlink, never spot prices
- Integer issues - Use Solidity 0.8+, multiply before divide
- Audits help but aren't guarantees
- Test extensively - fuzzing finds what you miss
- Start limited - you can always increase limits
- Have emergency plans - pause mechanisms, monitoring