12 KiB
Solidity Security & Audit Patterns
Security vulnerabilities, defensive patterns, and Foundry testing strategies for smart contract audits.
Critical Vulnerabilities
Reentrancy
Risk: External calls allow recursive entry before state updates, draining contracts.
// VULNERABLE: State update after external call
function withdraw(uint256 amount) public {
require(balances[msg.sender] >= amount);
(bool sent,) = msg.sender.call{value: amount}("");
require(sent);
balances[msg.sender] -= amount; // Too late!
}
// SECURE: CEI pattern + reentrancy guard
function withdraw(uint256 amount) public nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // Effects first
(bool sent,) = msg.sender.call{value: amount}("");
require(sent); // Interactions last
}
Foundry Test:
contract ReentrancyAttacker {
Vault vault;
uint256 count;
receive() external payable {
if (count++ < 5 && address(vault).balance > 0) {
vault.withdraw(1 ether);
}
}
function attack() external {
vault.withdraw(1 ether);
}
}
function test_reentrancy_protected() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(vault);
deal(address(attacker), 2 ether);
attacker.deposit{value: 1 ether}();
vm.expectRevert("ReentrancyGuard");
attacker.attack();
}
Access Control
Risk: Missing permission checks allow unauthorized privileged operations.
// VULNERABLE: No access control
function drain() public {
payable(owner).transfer(address(this).balance);
}
// VULNERABLE: Using tx.origin
function authorize(address user) public {
require(tx.origin == admin); // Phishing vulnerable!
}
// SECURE: Role-based access
function drain() external onlyRole(ADMIN_ROLE) {
payable(msg.sender).transfer(address(this).balance);
}
Foundry Test:
function test_accessControl_unauthorized() public {
vm.prank(attacker);
vm.expectRevert(
abi.encodeWithSelector(
AccessControl.AccessControlUnauthorizedAccount.selector,
attacker,
vault.ADMIN_ROLE()
)
);
vault.drain();
}
Oracle Manipulation
Risk: Manipulated price feeds cause unfair liquidations or loan exploits.
// VULNERABLE: Spot price (flash-loanable)
function getPrice() public view returns (uint256) {
return dex.spotPrice(token);
}
// SECURE: Chainlink with staleness check
function getPrice() public view returns (int256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
require(answeredInRound >= roundId, "Stale round");
require(block.timestamp - updatedAt < 3600, "Price too old");
return price;
}
Foundry Test:
function test_oracle_staleness() public {
vm.warp(block.timestamp + 4000); // Past staleness threshold
vm.expectRevert("Price too old");
oracle.getPrice();
}
Integer Overflow/Underflow
Solidity 0.8+ has built-in checks, but watch for:
// VULNERABLE: Unchecked block disables protection
unchecked {
balance -= amount; // Can underflow!
}
// VULNERABLE: Type casting
uint8 small = uint8(largeNumber); // Truncates silently
// SECURE: Explicit checks
require(balance >= amount, "Insufficient balance");
unchecked { balance -= amount; }
Front-Running / MEV
Risk: Attackers see pending transactions and extract value.
Mitigations:
- Commit-reveal schemes for sensitive operations
- Flashbots for transaction privacy
- Slippage protection in DEX trades
- Batch auctions instead of first-come-first-served
// Commit-reveal pattern
function commit(bytes32 hash) external {
commits[msg.sender] = hash;
commitTime[msg.sender] = block.timestamp;
}
function reveal(uint256 value, bytes32 salt) external {
require(block.timestamp >= commitTime[msg.sender] + 1 hours);
require(commits[msg.sender] == keccak256(abi.encode(value, salt)));
// Process value
}
Flash Loan Attacks
Risk: Attackers borrow large amounts to manipulate protocol state within single transaction.
Mitigations:
- Use TWAP instead of spot prices
- Require multi-block operations
- Over-collateralization requirements
- Block flash loans if not needed
Signature Malleability
Risk: Attackers modify signatures to replay transactions.
// VULNERABLE: No nonce or deadline
function permit(address owner, uint256 value, bytes calldata sig) external {
// Can be replayed!
}
// SECURE: EIP-712 with nonce and deadline
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
require(block.timestamp <= deadline, "Expired");
// Verify EIP-712 signature with nonce
nonces[owner]++;
}
Storage Collision (Proxies)
Risk: Proxy and implementation have different storage layouts.
// WRONG: Storage mismatch
contract Proxy {
address implementation; // slot 0
address owner; // slot 1
}
contract Implementation {
address owner; // slot 0 - COLLISION!
}
// CORRECT: Use EIP-1967 storage slots
bytes32 constant IMPL_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
Denial of Service
Patterns:
- Unbounded loops exhausting gas
- External calls that always revert
- Block gas limit exceeded
// VULNERABLE: Unbounded loop
function distributeAll() external {
for (uint256 i = 0; i < users.length; i++) {
payable(users[i]).transfer(rewards[i]);
}
}
// SECURE: Paginated distribution
function distributeBatch(uint256 start, uint256 count) external {
require(count <= 100, "Batch too large");
uint256 end = start + count;
if (end > users.length) end = users.length;
for (uint256 i = start; i < end; i++) {
payable(users[i]).transfer(rewards[i]);
}
}
Foundry Security Testing
Fuzz Testing
Generate random inputs to find edge cases:
function testFuzz_transfer(address to, uint256 amount) public {
vm.assume(to != address(0));
vm.assume(to != address(token));
amount = bound(amount, 0, token.balanceOf(address(this)));
uint256 balanceBefore = token.balanceOf(to);
token.transfer(to, amount);
assertEq(token.balanceOf(to), balanceBefore + amount);
}
Configuration:
[fuzz]
runs = 10000
seed = "0x1234"
max_test_rejects = 65536
Invariant Testing
Test properties that must always hold:
contract VaultInvariant is Test {
Vault vault;
VaultHandler handler;
function setUp() public {
vault = new Vault();
handler = new VaultHandler(vault);
targetContract(address(handler));
}
// Total deposits must equal total shares value
function invariant_solvency() public view {
assertGe(
vault.totalAssets(),
vault.totalSupply(),
"Vault insolvent"
);
}
// Sum of balances equals total supply
function invariant_balanceSum() public view {
uint256 sum = 0;
address[] memory users = handler.getUsers();
for (uint256 i = 0; i < users.length; i++) {
sum += vault.balanceOf(users[i]);
}
assertEq(sum, vault.totalSupply());
}
}
Handler Pattern
Control fuzz inputs and track state:
contract VaultHandler is Test {
Vault vault;
address[] public users;
uint256 public ghost_totalDeposited;
constructor(Vault _vault) {
vault = _vault;
users.push(makeAddr("alice"));
users.push(makeAddr("bob"));
}
function deposit(uint256 userSeed, uint256 amount) external {
address user = users[bound(userSeed, 0, users.length - 1)];
amount = bound(amount, 1, 1e24);
deal(address(vault.asset()), user, amount);
vm.startPrank(user);
vault.asset().approve(address(vault), amount);
vault.deposit(amount, user);
vm.stopPrank();
ghost_totalDeposited += amount;
}
function withdraw(uint256 userSeed, uint256 shares) external {
address user = users[bound(userSeed, 0, users.length - 1)];
shares = bound(shares, 0, vault.balanceOf(user));
if (shares == 0) return;
vm.prank(user);
uint256 assets = vault.redeem(shares, user, user);
ghost_totalDeposited -= assets;
}
}
Configuration:
[invariant]
runs = 256
depth = 100
fail_on_revert = false
Fork Testing
Test against real mainnet state:
function setUp() public {
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 18000000);
}
function testFork_usdcIntegration() public {
IERC20 usdc = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
address whale = 0x47ac0Fb4F2D84898e4D9E7b4DaB3C24507a6D503;
uint256 balanceBefore = usdc.balanceOf(address(this));
vm.prank(whale);
usdc.transfer(address(this), 1_000_000e6);
assertEq(usdc.balanceOf(address(this)), balanceBefore + 1_000_000e6);
}
Symbolic Execution (Halmos)
Prove properties mathematically:
function check_transferPreservesSupply(address from, address to, uint256 amount) public {
uint256 supplyBefore = token.totalSupply();
token.transfer(from, to, amount);
assert(token.totalSupply() == supplyBefore);
}
Run with: halmos --contract MyTest
Pre-Audit Checklist
Code Quality
- NatSpec documentation on all public functions
- No console.log or debug statements
- All TODO/FIXME resolved
- Consistent naming conventions
Security Patterns
- Reentrancy guards on external-calling functions
- CEI pattern followed
- Access control on privileged functions
- No tx.origin for authentication
- SafeERC20 for token transfers
- Input validation on all parameters
Testing
- >95% code coverage
- Fuzz tests with 10,000+ runs
- Invariant tests for core properties
- Edge case tests (zero, max values)
- Fork tests for integrations
Static Analysis
- Slither: no HIGH/CRITICAL issues
- Manual review of MEDIUM issues
Documentation
- README with security assumptions
- Threat model documented
- Known limitations listed
- Admin functions documented
Common Audit Findings
Missing Return Value Check
// BAD
token.transfer(user, amount);
// GOOD
require(token.transfer(user, amount), "Transfer failed");
// BEST
IERC20(token).safeTransfer(user, amount);
Uninitialized Proxy
// BAD: Anyone can initialize
function initialize(address _owner) external {
owner = _owner;
}
// GOOD: Use initializer modifier
function initialize(address _owner) external initializer {
owner = _owner;
}
Precision Loss
// BAD: Division before multiplication
uint256 share = (amount / total) * balance; // Rounds to 0
// GOOD: Multiplication before division
uint256 share = (amount * balance) / total;
Unchecked Array Access
// BAD
return users[index]; // Can revert
// GOOD
require(index < users.length, "Out of bounds");
return users[index];
Security Tools
Static Analysis:
- Slither:
slither . --json report.json - Mythril:
myth analyze contracts/Vault.sol
Fuzzing:
- Forge fuzz: Built-in
- Echidna: Property-based fuzzing
Formal Verification:
- Certora Prover
- Halmos
Foundry Security Commands
# Run all tests with coverage
forge coverage --report lcov
# Fuzz with high iterations
forge test --fuzz-runs 50000
# Invariant testing
forge test --match-contract Invariant -vv
# Fork testing
forge test --fork-url $RPC_URL
# Gas analysis
forge test --gas-report
# Debug failing test
forge test --match-test testName -vvvv