name: audit description: Deep EVM security audit system. 500+ checklist items across 19 domains. Systematic process for AI agents to audit Solidity contracts — access control through economic game theory. metadata: author: Andre Ginja version: 1.0.0
Audit: Deep EVM Security System
A systematic audit framework for Solidity smart contracts. This is not a summary of common bugs. This is a 19-domain, 500+ item checklist that an AI agent can execute methodically to find vulnerabilities before attackers do.
What You Probably Got Wrong
You skimmed the contract and said "looks good." An audit is not a code review. It is a systematic adversarial analysis. You must check every domain, every function, every state transition. If you did not run through a checklist, you did not audit.
You only checked for reentrancy. Reentrancy is the vulnerability everyone knows. The ones that drain protocols are oracle manipulation, flash loan attacks, governance exploits, and economic game theory failures. Those require domain-specific knowledge, not just pattern matching.
You did not use tools. Reading code is necessary but insufficient. Slither catches things humans miss. Foundry fuzz tests find edge cases you would never think of. Mythril explores execution paths symbolically. Use all three.
You reported severity wrong. A missing event emission is not "High." An unprotected
selfdestructis not "Low." Severity must reflect actual impact and exploitability, not how scary the code looks.You did not write a proof of concept. "This function might be vulnerable" is not a finding. A finding includes a PoC -- Foundry test code that demonstrates the exploit. If you cannot write the PoC, you do not understand the vulnerability well enough to report it.
Audit Process
Phase 1: Reconnaissance
Before reading a single line of Solidity:
# Clone and build
git clone <repo> && cd <repo>
forge build
# Get the lay of the land
find src -name "*.sol" | head -30
cloc src/
# Run static analysis
slither . --print human-summary
slither . --print contract-summary
# Check for known vulnerabilities in dependencies
forge inspect <Contract> storage-layout
Document:
- Total lines of Solidity
- Number of contracts
- External dependencies (OpenZeppelin version, other libraries)
- Upgrade pattern (transparent proxy, UUPS, diamond, none)
- Token standard(s) implemented
- Oracle dependencies
- Governance mechanism (if any)
Phase 2: Automated Analysis
# Slither -- static analysis
slither . --json slither-output.json
slither . --print inheritance-graph
slither . --detect reentrancy-eth,reentrancy-no-eth,reentrancy-benign
# Mythril -- symbolic execution (run on critical contracts)
myth analyze src/Vault.sol --solc-json mythril.config.json
# Foundry -- run existing tests and check coverage
forge test -vvv
forge coverage
If coverage is below 80%, note it as a finding. Low coverage means untested paths, which means undiscovered bugs.
Phase 3: Manual Review by Domain
Work through each of the 19 domains below. For every item, mark PASS, FAIL, or N/A with evidence.
Phase 4: Write Report
Use the report format at the bottom of this skill. Every finding needs severity, description, impact, PoC, and recommendation.
Domain 1: Access Control
The most common source of critical vulnerabilities. If the wrong address can call a privileged function, the protocol is owned.
Checklist
- All admin/owner functions have explicit access control modifiers (
onlyOwner,onlyRole, custom modifier) -
initialize()functions can only be called once (check forinitializermodifier or manual guard) - No unprotected
selfdestructordelegatecall - Role-based access uses OpenZeppelin
AccessControlor equivalent with proper role hierarchy - Ownership transfer uses two-step pattern (
Ownable2Step) -- not single-steptransferOwnership - Timelock on critical parameter changes (fee changes, oracle updates, pause)
- Multi-sig or governance required for irreversible actions (not a single EOA)
- Default admin role is not left as
address(0)or deployer EOA in production - Modifier ordering is correct -- state changes after access checks, not before
- Functions that should be
externalare notpublic(reduces attack surface) - No functions missing access control that modify sensitive state
-
msg.senderchecks cannot be bypassed viadelegatecallcontext - Constructor/initializer sets the correct initial owner/admin
- No backdoor functions (functions that bypass normal access control under certain conditions)
- Emergency functions (pause, emergency withdraw) have appropriate access control AND cannot be abused
- Access control roles are documented -- who holds what role and why
- Admin cannot upgrade to a malicious implementation without timelock
- Admin cannot drain user funds directly
- Admin cannot pause and then drain
- Fee parameters have maximum bounds
- Minting functions have proper authorization
- No reliance on
tx.originfor authentication (phishing vector)
Common Vulnerabilities
Unprotected initializer:
// VULNERABLE -- anyone can call initialize after deployment
function initialize(address _owner) public {
owner = _owner;
}
// SAFE -- uses OpenZeppelin initializer
function initialize(address _owner) public initializer {
__Ownable_init(_owner);
}
Single-step ownership transfer:
// VULNERABLE -- typo in address = permanent loss of ownership
function transferOwnership(address newOwner) public onlyOwner {
owner = newOwner;
}
// SAFE -- two-step: new owner must accept
// Use OpenZeppelin Ownable2Step
Missing access control on state-changing function:
// VULNERABLE -- anyone can set the fee
function setFee(uint256 _fee) external {
fee = _fee;
}
// SAFE
function setFee(uint256 _fee) external onlyOwner {
require(_fee <= MAX_FEE, "Fee too high");
fee = _fee;
}
Domain 2: Arithmetic
Solidity 0.8+ has built-in overflow protection, but that does not eliminate all arithmetic bugs.
Checklist
- Solidity version >= 0.8.0 for built-in overflow/underflow checks
- If
uncheckedblocks are used, verify overflow/underflow is genuinely impossible - Division before multiplication identified and fixed (precision loss)
- Rounding direction is correct and consistent (round down for withdrawals, round up for deposits in vaults)
- Decimal handling is correct for all tokens (6, 8, 18 decimals)
- No phantom overflow in intermediate calculations (even with 0.8+, intermediate uint256 can overflow before the final result fits)
-
type(uint256).maxused safely -- multiplication or addition with max values - Percentage calculations use basis points (10000 = 100%) not raw percentages
- Fee calculations cannot result in zero fees due to rounding (dust amounts bypass fees)
- Exchange rate calculations handle edge cases (zero supply, zero assets)
- No loss of precision in price calculations that compounds over time
- Casting between types (uint256 to uint128, int256 to uint256) checked for overflow/sign
- Modular arithmetic used correctly (if any)
- Exponentiation does not overflow
- Negative values handled correctly in signed integers
- Multiplication of large numbers does not overflow
uint256
Common Vulnerabilities
Division before multiplication:
// VULNERABLE -- loses precision
uint256 result = amount / totalSupply * price;
// SAFE -- multiply first
uint256 result = amount * price / totalSupply;
Rounding in the wrong direction:
// VULNERABLE -- vault rounds down on deposit, user gets free shares
function deposit(uint256 assets) external returns (uint256 shares) {
shares = assets * totalSupply / totalAssets; // rounds down
}
// SAFE -- round down shares on deposit (fewer shares for depositor)
// Round up assets on withdraw (more assets required from withdrawer)
function deposit(uint256 assets) external returns (uint256 shares) {
shares = assets.mulDivDown(totalSupply, totalAssets);
}
function withdraw(uint256 shares) external returns (uint256 assets) {
assets = shares.mulDivUp(totalAssets, totalSupply);
}
Unsafe casting:
// VULNERABLE -- silently truncates if value > type(uint128).max
uint128 smallAmount = uint128(largeAmount);
// SAFE -- use SafeCast
uint128 smallAmount = largeAmount.toUint128(); // reverts on overflow
Domain 3: Reentrancy
The classic vulnerability. Solidity's checks-effects-interactions pattern is the defense. But reentrancy now comes in cross-function and cross-contract variants that CEI alone does not prevent.
Checklist
- All functions follow Checks-Effects-Interactions (CEI) pattern
- State updates happen BEFORE external calls
- ReentrancyGuard (
nonReentrant) on all functions that make external calls AND modify state - Cross-function reentrancy: function A calls external contract, which calls function B on the same contract -- are both protected?
- Cross-contract reentrancy: contract A calls contract B, which calls back to contract A via a different entry point
- Read-only reentrancy: external call returns stale state that another function reads (common with Balancer, Curve pools)
- ERC-721/1155
safeTransferFromtriggersonERC721Received/onERC1155Receivedcallbacks -- reentrancy vector - ERC-777
tokensReceivedhook -- reentrancy vector - ETH transfers via
callwith callback -- reentrancy vector - Flash loan callbacks -- reentrancy vector
- Governance
executefunctions -- can proposals trigger reentrancy? - No nested
nonReentrantcalls (will revert unexpectedly) - View functions that return state used by other protocols -- is the state consistent during external calls?
-
safeTransfervariants trigger receiver hooks -- treated as external calls -
receive()andfallback()cannot be used for reentrancy
Common Vulnerabilities
Classic reentrancy:
// VULNERABLE -- state update after external call
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool success,) = msg.sender.call{value: amount}(""); // external call
require(success);
balances[msg.sender] -= amount; // state update AFTER call
}
// SAFE -- CEI pattern
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount; // state update BEFORE call
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
Read-only reentrancy:
// VULNERABLE -- reads pool state during a callback when pool is in inconsistent state
function getPrice() external view returns (uint256) {
return pool.getRate(); // returns stale/manipulated rate during reentrancy
}
// SAFE -- use reentrancy lock check
function getPrice() external view returns (uint256) {
require(!pool.reentrancyLocked(), "Pool locked");
return pool.getRate();
}
Domain 4: Oracle Manipulation
If your contract reads a price from anywhere, an attacker will try to manipulate that price.
Checklist
- Price oracle source identified -- Chainlink, Uniswap TWAP, custom, or spot price
- Spot prices (current pool reserves) are NOT used for any value calculation -- always use TWAP or Chainlink
- Chainlink feeds:
latestRoundData()return values fully validated (price > 0, updatedAt recent, answeredInRound >= roundId) - Chainlink feeds: stale price threshold configured (heartbeat varies per feed)
- Chainlink feeds: L2 sequencer uptime checked for Arbitrum/Optimism deployments
- Chainlink feeds: grace period after sequencer restart before trusting prices
- Chainlink feeds: correct decimals handling (most are 8, some are 18)
- Chainlink feeds: correct feed address for the target chain
- TWAP window is long enough to resist manipulation (minimum 30 minutes, prefer 1-4 hours)
- TWAP uses geometric mean, not arithmetic (Uniswap V3)
- Oracle price cannot be manipulated within a single transaction (flash loan + oracle read)
- Multiple oracle sources with fallback (primary fails, secondary activates)
- Price deviation circuit breaker (if price moves >X% in one update, pause or revert)
- Oracle decimals handled correctly (Chainlink ETH/USD = 8 decimals, not 18)
- No hardcoded prices that become stale
- Custom oracle update functions have access control
- Oracle manipulation would be unprofitable (manipulation cost > exploit profit)
- Donation attacks: sending tokens to a pool to manipulate
balanceOf-based pricing - Decimal mismatch between oracle and token
Chainlink Validation Pattern
// WRONG -- no validation
(, int256 price,,,) = priceFeed.latestRoundData();
return uint256(price);
// RIGHT -- full validation
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
require(updatedAt > block.timestamp - STALENESS_THRESHOLD, "Stale price");
require(answeredInRound >= roundId, "Stale round");
return uint256(price);
}
Chainlink Heartbeat Reference
| Feed | Mainnet Heartbeat | Deviation Threshold |
|---|---|---|
| ETH/USD | 3600s (1hr) | 0.5% |
| BTC/USD | 3600s (1hr) | 0.5% |
| USDC/USD | 86400s (24hr) | 0.25% |
| DAI/USD | 3600s (1hr) | 0.25% |
Set STALENESS_THRESHOLD to at least 2x the heartbeat.
Domain 5: Flash Loan Attacks
Flash loans let anyone borrow unlimited capital for one transaction with zero collateral. Any protocol state that can be manipulated with capital is vulnerable.
Checklist
- All price calculations resistant to single-block manipulation
- No spot price reads from AMM pools (use TWAP or oracles)
- Governance voting power cannot be flash-loaned (snapshot at prior block, not current)
- Vault share price cannot be inflated via direct token transfer (virtual shares defend against this)
- Lending protocol liquidation thresholds cannot be manipulated via flash-borrowed collateral
- Reward distribution calculations resistant to flash-staking (require minimum stake duration)
- Access control gated by token balance uses historical balance, not current
- Any function that reads
balanceOf(address(this))can be manipulated via direct transfer -- is this safe? - Flash loan fee calculations are correct
- Flash loan callback validation checks the initiator
- LP token pricing cannot be manipulated via flash loans
- Flash loans cannot be used to bypass time-weighted checks
- Collateral ratios cannot be manipulated in a single transaction
The Flash Loan Attack Template
Every flash loan attack follows this pattern:
1. Borrow massive amount (AAVE flash loan, Uniswap flash swap)
2. Manipulate some state (pump pool price, inflate vault, stack votes)
3. Exploit the manipulated state (liquidate, withdraw inflated shares, pass proposal)
4. Repay the flash loan
5. Profit
Defense: Virtual Shares (ERC-4626 inflation attack defense):
// SAFE -- OpenZeppelin ERC4626 with virtual offset
function _decimalsOffset() internal pure override returns (uint8) {
return 3; // Adds 1000 virtual shares, making inflation attacks uneconomical
}
Domain 6: Governance
DAOs and governance systems have unique attack surfaces. Token-weighted voting can be gamed.
Checklist
- Voting power snapshots use historical balances (block number), not current balances
- Flash loan cannot be used to acquire voting power (snapshot is at proposal creation block)
- Proposal creation requires minimum token threshold (prevents spam)
- Execution timelock after vote passes (minimum 24-48 hours for users to exit)
- Quorum requirements are reasonable (not so low that a whale can single-handedly pass proposals)
- Quorum is calculated at proposal creation, not execution
- Vote delegation tracks correctly (delegated power cannot be double-counted)
- Double voting is prevented (same address, same proposal)
- Executed proposals cannot be re-executed
- Proposal actions are validated before execution (calldata matches what was voted on)
- Guardian/veto role exists for emergency (can cancel malicious proposals)
- Governance cannot brick itself (cannot vote to remove all admins or set impossible quorum)
- Token transfers during voting period do not allow double-voting
- Cross-chain governance messages validated (bridge message authenticity)
- Timelock delay cannot be set to zero
- Queued transactions can be cancelled
- Expired transactions cannot be executed
- Failed proposal execution does not brick governance
- Proposal targets validated (no calls to
selfdestructor proxy upgrade without checks)
Common Vulnerabilities
No timelock on execution:
// VULNERABLE -- proposal executes immediately after vote passes
function execute(uint256 proposalId) external {
require(state(proposalId) == ProposalState.Succeeded);
_execute(proposalId);
}
// SAFE -- timelock delay
function execute(uint256 proposalId) external {
require(state(proposalId) == ProposalState.Queued);
require(block.timestamp >= proposals[proposalId].eta);
_execute(proposalId);
}
Domain 7: Proxy and Upgradeability
Upgradeable contracts add an entire class of bugs. Storage collisions, initialization bugs, and implementation takeovers.
Checklist
- Proxy pattern identified (Transparent, UUPS, Diamond, Beacon)
- Implementation contract is initialized (cannot be taken over by calling
initializeon the implementation directly) - Storage layout is consistent across upgrades (no slot collisions)
- No
selfdestructin implementation (bricks all proxies on UUPS) - No
delegatecallin implementation to untrusted targets - UUPS:
_authorizeUpgradehas proper access control - UUPS: new implementation must also have
upgradeTo(or proxy is bricked) - Transparent: proxy admin is a separate contract, not the proxy itself
- Transparent: admin cannot call implementation functions
- Transparent: users cannot call admin functions
- Diamond: facet selectors do not collide
- Storage gaps in base contracts for future upgrades (
uint256[50] private __gap) -
constructorin implementation uses_disableInitializers() - Upgrade function cannot be called by unauthorized parties
- New implementation preserves all existing storage variable positions
- New storage variables appended, never inserted before existing ones
- No immutable variables that should be mutable (immutables are in bytecode, not storage -- they reset on upgrade)
- ERC-1967 storage slots used for admin/implementation/beacon addresses
- Initialization of new storage variables in upgraded implementations happens in
reinitializer(N) - Upgrade path protected by timelock
- Upgrade path requires multisig approval
Storage Layout Validation
# Compare storage layouts between versions
forge inspect ContractV1 storage-layout > v1_layout.txt
forge inspect ContractV2 storage-layout > v2_layout.txt
diff v1_layout.txt v2_layout.txt
# No existing slots should change position or type
Common Vulnerabilities
Uninitialized implementation:
// VULNERABLE -- attacker calls initialize on the implementation
contract VaultV1 is Initializable, UUPSUpgradeable {
function initialize(address owner) public initializer {
__Ownable_init(owner);
}
}
// SAFE -- disable initializers in constructor
contract VaultV1 is Initializable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address owner) public initializer {
__Ownable_init(owner);
}
}
Storage collision on upgrade:
// V1 storage layout:
// slot 0: owner
// slot 1: totalSupply
// slot 2: fee
// V2 -- VULNERABLE (inserted new variable, shifts fee to slot 3)
// slot 0: owner
// slot 1: totalSupply
// slot 2: newVariable <-- fee's old slot, reads wrong value
// slot 3: fee <-- new slot, reads zero
// V2 -- SAFE (append new variables after existing ones)
// slot 0: owner
// slot 1: totalSupply
// slot 2: fee
// slot 3: newVariable <-- appended, no collision
Domain 8: Token Integration
Integrating with external tokens is a minefield. Tokens do not all behave the same.
Checklist
- Fee-on-transfer tokens handled (actual received amount may be less than
transferFromamount) - Rebasing tokens handled (balance changes without transfers -- aTokens, stETH)
- Tokens with blocklists handled (USDC, USDT can freeze addresses)
- Tokens with no return value handled (USDT
transferreturns void, not bool) -- useSafeERC20 - Tokens with callbacks handled (ERC-777
tokensReceived-- reentrancy vector) - Tokens that can be paused
- Tokens with maximum transfer limits
- Tokens with per-transaction fees that change
- Tokens with multiple entry points (proxied tokens)
- Token decimals not hardcoded -- read from
decimals()function - USDC/USDT are 6 decimals, WBTC is 8, DAI is 18
- Maximum approval (
type(uint256).max) risk assessed - Token allowance race condition handled (use
increaseAllowanceorforceApprove) - Approve to zero before non-zero for tokens that require it (USDT)
-
permitsignatures cannot be replayed (nonce management) -
permitdeadline is enforced - EIP-2612 permit support is correct
- Empty contract address checked before
transfer/transferFrom(low-level call to non-contract succeeds silently) - Contract receives ETH? Has
receive()orfallback()function -
address(0)transfer checks in place - Correct balance tracking: internal accounting vs
balanceOf(address(this)) - Donation attack: direct token transfer to contract cannot manipulate share calculations
- Share-based accounting handles rounding correctly (always round against the user)
- First depositor attack mitigated (virtual shares/assets or minimum deposit)
- Zero-amount deposits and withdrawals handled
- Withdrawal of exactly the full balance works (no dust remaining)
The SafeERC20 Requirement
// VULNERABLE -- USDT transfer returns void, this reverts
bool success = IERC20(usdt).transfer(to, amount);
// SAFE -- SafeERC20 handles non-standard return values
using SafeERC20 for IERC20;
IERC20(usdt).safeTransfer(to, amount);
Fee-on-Transfer Pattern
// VULNERABLE -- assumes full amount received
function deposit(uint256 amount) external {
token.safeTransferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount; // may be more than actually received
}
// SAFE -- measure actual amount received
function deposit(uint256 amount) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - balanceBefore;
balances[msg.sender] += received;
}
Domain 9: Bridge Security
Cross-chain bridges are the highest-value targets in crypto. Billions have been lost.
Checklist
- Message authenticity verified (only the bridge contract/relayer can deliver messages)
- Replay protection -- messages cannot be executed twice (nonce or hash tracking)
- Source chain validation -- message actually came from the expected chain and contract
- Message ordering -- out-of-order messages handled correctly
- Finality consideration -- source chain tx could reorg (wait for sufficient confirmations)
- Token mapping is correct (wrapped token on destination maps to real token on source)
- Locked tokens on source match minted tokens on destination (accounting invariant)
- Burn-and-unlock: burned tokens match unlocked tokens 1:1
- Bridge pause mechanism for emergencies
- Bridge pause does not lock user funds permanently
- Validator/relayer set cannot be compromised by a single entity
- Gas estimation for destination chain execution is adequate
- Failed destination execution handles locked source tokens (refund mechanism)
- Bridge operator key management (multisig, HSM, not hot wallet)
- Message timeout/expiry is enforced
- Optimistic bridges: challenge period is sufficient
- ZK bridges: proof verification is correct
- Wrapped token decimals match original token
Domain 10: MEV (Maximal Extractable Value)
Miners/validators and searchers can reorder, insert, or censor transactions. Any profitable reordering opportunity will be exploited.
Checklist
- Swap transactions use slippage protection (minimum output amount)
- Slippage parameter cannot be set to zero by the user accidentally
- AMM interactions use deadline parameter (prevents stale transactions from executing at bad prices)
- Liquidation functions cannot be sandwich attacked
- Liquidation incentives are reasonable (not attracting excessive MEV)
- Large token approvals do not enable front-running of transfers
- Commit-reveal scheme used for auction/bidding (prevents bid sniping)
- No profitable front-running opportunity in any public function
- Token launch/IDO has anti-snipe protection
- Oracle updates cannot be sandwiched (update price, exploit, price reverts)
- Private mempool or Flashbots Protect recommended for sensitive transactions
- Batch auction mechanisms considered where applicable
- Large deposits/withdrawals to pools have slippage protection
Common Vulnerabilities
No slippage protection:
// VULNERABLE -- no minimum output, sandwich attacker extracts value
router.swapExactTokensForTokens(
amountIn,
0, // amountOutMin = 0, accepts ANY output
path,
msg.sender,
deadline
);
// SAFE -- minimum output enforced
router.swapExactTokensForTokens(
amountIn,
amountOutMin, // calculated off-chain with acceptable slippage
path,
msg.sender,
deadline
);
No deadline:
// VULNERABLE -- tx can sit in mempool for hours and execute at bad price
router.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
msg.sender,
type(uint256).max // no deadline, never expires
);
// SAFE
router.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
msg.sender,
block.timestamp + 300 // 5 minute deadline
);
Domain 11: Gas Optimization
Gas inefficiency is not just a cost issue -- it can cause transactions to fail if they hit block gas limits.
Checklist
- No unbounded loops that iterate over arrays that can grow indefinitely
- Mappings preferred over arrays for lookups
-
calldataused instead ofmemoryfor read-only function parameters - Storage variables packed correctly (variables smaller than 32 bytes packed into the same slot)
- Constants and immutables used where possible (cheaper than storage reads)
-
++iused instead ofi++in loops - Events emitted instead of storing data that is only needed offchain
- Short-circuit evaluation in
requirestatements (cheap checks first) - Batch operations available for multi-item actions (batch transfer, batch claim)
- No redundant storage reads (cache
storagevalues inmemoryvariables) -
externalvisibility used wherepublicis not needed - Dead code removed
- ERC-20 approve pattern does not require two transactions when it could use permit
- No unnecessary
SLOADin loops (read once, store in memory) -
SSTOREfrom non-zero to non-zero is avoided where possible - Batch operations have gas limits or pagination
- Fallback/receive functions have minimal gas usage
- Contract size is under 24KB limit
Storage Packing
// UNOPTIMIZED -- 3 storage slots (96 bytes)
uint256 amount; // slot 0 (32 bytes)
address owner; // slot 1 (20 bytes, but takes full slot)
bool active; // slot 2 (1 byte, but takes full slot)
// OPTIMIZED -- 2 storage slots (64 bytes)
uint256 amount; // slot 0 (32 bytes)
address owner; // slot 1 (20 bytes)
bool active; // slot 1 (1 byte, packed with owner)
Domain 12: Denial of Service
An attacker makes the contract unusable for everyone else.
Checklist
- No unbounded loops that an attacker can make expensive (add items to array, make loop hit gas limit)
- Pull over push for payments (users withdraw, not contract sends to everyone)
- No single address that can block the protocol by reverting (griefing via revert in callback)
-
transferandsendnot used (2300 gas limit can cause legitimate calls to fail) - Block gas limit cannot be hit by normal operation
- Mapping iteration bounded (cannot iterate over entire mapping)
- Array deletion is swap-and-pop, not shift (O(1) not O(n))
- No dependency on external contract availability for core functions
- Emergency withdrawal function that works even if other functions are bricked
- Rate limiting on expensive operations
- Contract cannot be bricked by self-destruct of a dependency (post-Dencun, selfdestruct only sends ETH)
Common Vulnerabilities
Push-based payment DoS:
// VULNERABLE -- if one recipient reverts, nobody gets paid
function distributeRewards() external {
for (uint i = 0; i < recipients.length; i++) {
(bool success,) = recipients[i].call{value: rewards[i]}("");
require(success); // one revert blocks everyone
}
}
// SAFE -- pull pattern
mapping(address => uint256) public pendingRewards;
function claimReward() external {
uint256 amount = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0;
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
Domain 13: Signature Verification
EIP-712 typed data signing is the standard. Everything else is dangerous.
Checklist
- Signatures use EIP-712 typed data (domain separator, type hash)
- Domain separator includes chain ID
- Domain separator includes contract address
- Domain separator includes name and version
- Type hash is correctly computed
- Struct encoding matches the EIP-712 specification
- Replay protection: nonce incremented on each use OR deadline enforced
- Cross-chain replay protection: chain ID in domain separator
- Cross-contract replay protection: contract address in domain separator
-
ecrecoverreturn value checked foraddress(0)(invalid signatures return zero address) - Signature malleability handled (use OpenZeppelin ECDSA which rejects malleable signatures)
-
svalue restricted to lower half of secp256k1 order (prevents malleability) - Signatures cannot be reused across different functions
- EIP-1271 smart contract signature verification supported (for smart wallet users)
- Permit (EIP-2612) implementation is correct -- nonce, deadline, domain separator
- No reliance on
tx.originfor authentication (phishing vector) - Batch signature operations validate each signature independently
- Cancelled signatures cannot be replayed
- Nonce cannot be skipped or reused
- Multi-signature validation is correct (not just checking first signer)
Common Vulnerabilities
Missing zero address check:
// VULNERABLE -- invalid signature returns address(0), which might match
address signer = ecrecover(hash, v, r, s);
require(signer == authorizedSigner); // if authorizedSigner is address(0), this passes
// SAFE
address signer = ECDSA.recover(hash, v, r, s); // reverts on invalid signature
require(signer == authorizedSigner);
require(signer != address(0));
Missing replay protection:
// VULNERABLE -- same signature can be used twice
function executeWithSig(bytes calldata data, bytes calldata sig) external {
address signer = verify(data, sig);
require(signer == owner);
_execute(data);
}
// SAFE -- nonce prevents replay
mapping(address => uint256) public nonces;
function executeWithSig(bytes calldata data, uint256 nonce, bytes calldata sig) external {
require(nonce == nonces[msg.sender]++);
address signer = verify(data, nonce, sig);
require(signer == owner);
_execute(data);
}
Domain 14: Randomness
Onchain randomness is deterministic. Miners can see and manipulate it.
Checklist
- No use of
block.timestamp,block.number,blockhashas randomness source - No use of
block.prevrandaoas sole randomness source (validators can bias it) - Chainlink VRF used for production randomness (or commit-reveal for simple cases)
- VRF request and fulfillment are separate transactions (request cannot predict result)
- VRF callback validates the request ID (cannot be spoofed)
- Commit-reveal scheme (if used) has proper timeout and bond
- Randomness result cannot be front-run before it is used
- Game outcomes do not depend on user-controllable inputs combined with pseudo-randomness
Common Vulnerabilities
// VULNERABLE -- miner can manipulate block.timestamp, previewer can see blockhash
function roll() external returns (uint256) {
return uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % 6;
}
// SAFE -- use Chainlink VRF
function requestRoll() external {
uint256 requestId = vrfCoordinator.requestRandomWords(...);
rolls[requestId] = msg.sender;
}
function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal override {
uint256 result = randomWords[0] % 6;
emit RollResult(rolls[requestId], result);
}
Domain 15: Timestamp Dependence
block.timestamp is manipulated by validators within a ~12 second window. Any logic depending on exact timestamps is vulnerable.
Checklist
- No critical logic depends on exact
block.timestampvalue - Timelock durations are long enough that 12-second manipulation is irrelevant (hours/days, not seconds)
- Vesting schedules use block-level granularity, not second-level
- Auction end times have sufficient buffer (not exploitable by validator timing)
-
block.timestampcomparisons use>=not==(exact match may never hit) - Time-weighted calculations handle edge cases (same block timestamp for multiple operations)
- Interest accrual calculations handle zero-time-elapsed edge case
Domain 16: External Calls
Every external call is a trust boundary. The called contract can do anything.
Checklist
- Return values from external calls are checked (low-level
callreturns bool) - Low-level
callused instead oftransfer/sendfor ETH transfers -
delegatecallonly used to trusted, immutable implementations - No
delegatecallto user-supplied addresses -
staticcallused for view/pure calls to untrusted contracts (prevents state changes) - Gas limits set on external calls where appropriate (prevents gas griefing)
- External contract existence verified before call (call to non-existent contract returns success)
- External call failure does not brick the contract
- Untrusted external calls made last (after all state changes)
- Callbacks from external contracts handled safely (reentrancy, unexpected state)
- External contracts are verified and trusted (or treated as untrusted)
- Low-level
callreturn data is validated
Common Vulnerabilities
Unchecked low-level call:
// VULNERABLE -- ignores return value, transfer may fail silently
(bool success,) = recipient.call{value: amount}("");
// missing: require(success);
// SAFE
(bool success,) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
Call to non-existent contract:
// VULNERABLE -- if tokenAddress has no code, call succeeds with empty return
IERC20(tokenAddress).transfer(to, amount);
// SAFE -- verify contract exists
require(tokenAddress.code.length > 0, "Not a contract");
IERC20(tokenAddress).transfer(to, amount);
Domain 17: Storage
EVM storage is the most expensive and most subtle part of the execution environment.
Checklist
- Storage layout documented (especially for upgradeable contracts)
- No uninitialized storage pointers (pre-0.5.0 bug, but still check)
- Storage vs memory distinction correct in all struct/array operations
- Mapping keys validated (mapping[address(0)] is accessible)
- Storage deletion of mappings inside structs does not delete the mapping (Solidity limitation)
-
deleteon dynamic arrays clears elements (gas cost proportional to length) - Private variables are NOT actually private (readable via
eth_getStorageAt) - Storage packing across inheritance hierarchy verified (gaps for upgrades)
- Transient storage (
TSTORE/TLOADfrom EIP-1153) used correctly -- cleared after transaction - No reliance on storage variable default values that might differ from intent
- Inherited contract storage order is consistent
Common Vulnerabilities
Private is not private:
// This password is readable by anyone
contract Vault {
bytes32 private secretPassword; // slot 0
}
// Read it:
// cast storage <address> 0
Domain 18: Initialization
Contracts that are not properly initialized after deployment are ticking time bombs.
Checklist
- All state variables have correct initial values
- Proxy implementations use
initializermodifier (not constructors) -
initializecannot be called twice - All inherited contracts are initialized in the
initializefunction -
_disableInitializers()called in implementation constructor - Initial parameter values are validated (zero address checks, range checks)
- Deployment script verifies initialization was successful
- Factory-deployed contracts are initialized in the same transaction as deployment
- Clone/minimal proxy instances are initialized immediately after creation
- No race condition between deployment and initialization (front-running)
- Constructor arguments are correct for the target chain
- External contract addresses point to correct, verified contracts
Common Vulnerabilities
Initialization front-running:
// VULNERABLE -- two separate transactions, attacker can initialize between them
// tx1: deploy proxy
// tx2: call initialize(attacker_address) -- attacker front-runs this
// SAFE -- initialize in same transaction
address proxy = address(new ERC1967Proxy(
implementation,
abi.encodeCall(MyContract.initialize, (owner))
));
Domain 19: Economic and Game Theory
The hardest domain. The code can be bug-free but the economic design exploitable.
Checklist
- Incentive alignment: does every participant benefit from honest behavior?
- Griefing: can an attacker cause disproportionate loss to others at low cost to themselves?
- Free-riding: can participants benefit without contributing?
- Extraction: can a minority extract value from the majority?
- Bank run: if everyone withdraws simultaneously, does the math still work? (pro-rata withdrawal)
- Circular dependency: does protocol A depend on protocol B which depends on protocol A?
- Ponzinomics: does the yield come from new depositors, not actual revenue?
- Liquidity crisis: can the protocol survive a 90% TVL withdrawal?
- Token velocity: is there a reason to hold the token, or just flip it?
- Governance capture: can a whale or cartel control the protocol?
- Fee structure: are fees high enough to sustain the protocol but low enough for users?
- Slashing: are penalties proportional to offense and cannot be gamed?
- Maximum extractable value: can validators/sequencers extract unfair value from users?
- Death spiral: can a depeg or liquidation cascade into total collapse? (LUNA/UST pattern)
- Bootstrapping: how does the protocol get from zero to sustainable?
- Liquidation cascade risk: liquidation of A causes B to become liquidatable
- The protocol cannot be put into a state where total debt > total collateral
- Arbitrage opportunities are bounded and do not harm users
- Yield calculations cannot be manipulated to extract unearned yield
- Fee collection does not create extractable value
Severity Classification
| Severity | Definition | Examples |
|---|---|---|
| Critical | Direct loss of funds, protocol takeover. Exploitable now. | Unprotected admin, reentrancy draining vault, oracle manipulation for liquidation |
| High | Significant loss of funds or protocol disruption. Exploitable with conditions. | Flash loan attack on governance, storage collision on upgrade, broken access control on non-admin function |
| Medium | Limited impact or requires unusual conditions. | Rounding errors that accumulate, missing event emissions for critical actions, no slippage protection on small swaps |
| Low | Minor issues, best practice violations. | Gas optimization opportunities, redundant code, missing NatSpec |
| Informational | Suggestions and observations. No direct security impact. | Code style, documentation gaps, test coverage |
Rule: File GitHub issues for Medium severity and above. Low and Informational go in the report but do not require immediate action.
Tools Reference
| Tool | Purpose | Command |
|---|---|---|
| Slither | Static analysis, vulnerability detection | slither . |
| Slither (printer) | Contract summaries, inheritance, call graphs | slither . --print human-summary |
| Aderyn | Rust-based static analysis, catches different patterns | aderyn . |
| Mythril | Symbolic execution, formal verification light | myth analyze src/Contract.sol |
| Foundry fuzz | Property-based testing | forge test --fuzz-runs 10000 |
| Foundry invariant | Invariant testing | forge test --match-contract InvariantTest |
| Foundry coverage | Line/branch coverage | forge coverage |
| Foundry storage | Storage layout inspection | forge inspect Contract storage-layout |
| Foundry gas | Gas snapshot comparison | forge snapshot --diff |
| Echidna | Advanced fuzzing | echidna . --contract TestContract |
| Certora | Formal verification | certoraRun spec.spec |
Proof of Concept Template
Every Medium+ finding should have a PoC. Use this Foundry template:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnerableContract.sol";
contract ExploitTest is Test {
VulnerableContract target;
address attacker = makeAddr("attacker");
address victim = makeAddr("victim");
function setUp() public {
target = new VulnerableContract();
vm.deal(attacker, 100 ether);
vm.deal(victim, 100 ether);
vm.prank(victim);
target.deposit{value: 50 ether}();
}
function test_exploit() public {
uint256 attackerBalanceBefore = attacker.balance;
vm.startPrank(attacker);
// ... attack steps ...
vm.stopPrank();
uint256 attackerBalanceAfter = attacker.balance;
assertGt(attackerBalanceAfter, attackerBalanceBefore, "Attacker should have profited");
assertEq(address(target).balance, 0, "Vault should be drained");
}
}
Run with:
forge test --match-test test_exploit -vvvv
Audit Report Format
# Security Audit Report
## Project: [Name]
## Auditor: [Agent/Name]
## Date: [Date]
## Commit: [Hash]
## Scope: [Files audited]
---
## Executive Summary
[2-3 paragraphs: what was audited, overall assessment, critical findings count]
## Findings Summary
| ID | Title | Severity | Status |
|----|-------|----------|--------|
| C-01 | [Title] | Critical | Open |
| H-01 | [Title] | High | Open |
| M-01 | [Title] | Medium | Open |
---
## Detailed Findings
### [C-01] Title
**Severity:** Critical
**File:** `src/Vault.sol`
**Lines:** 45-52
**Description:**
[What the vulnerability is]
**Impact:**
[What an attacker can do, estimated loss]
**Proof of Concept:**
[Foundry test demonstrating the exploit]
**Recommendation:**
[The fix with code]
---
## Appendix
### A. Scope
[Full list of files and line counts]
### B. Methodology
[Tools used, domains checked]
### C. Disclaimer
[Standard audit disclaimer -- no guarantee of zero bugs]
Quick Audit Checklist (Top 30 Items)
For a fast-pass audit when time is limited, check these 30 items first. They catch 80% of critical vulnerabilities.
| # | Check | Domain |
|---|---|---|
| 1 | All admin functions have access control | Access Control |
| 2 | initialize() can only be called once |
Initialization |
| 3 | Implementation has _disableInitializers() |
Proxy |
| 4 | No unprotected selfdestruct or delegatecall |
Access Control |
| 5 | CEI pattern followed in all functions | Reentrancy |
| 6 | nonReentrant on functions with external calls |
Reentrancy |
| 7 | SafeERC20 used for all token operations |
Token |
| 8 | Fee-on-transfer tokens handled | Token |
| 9 | Token decimals not hardcoded | Arithmetic |
| 10 | No division before multiplication | Arithmetic |
| 11 | Chainlink feeds fully validated | Oracle |
| 12 | No spot prices from AMM pools | Oracle |
| 13 | No block.timestamp for randomness |
Randomness |
| 14 | Slippage protection on all swaps | MEV |
| 15 | Deadline parameter on all DEX interactions | MEV |
| 16 | Flash loan resistance (no single-block manipulation) | Flash Loan |
| 17 | Governance snapshots at prior block | Governance |
| 18 | Execution timelock on governance | Governance |
| 19 | Storage layout preserved on upgrades | Proxy |
| 20 | No unbounded loops | DoS |
| 21 | Pull over push for payments | DoS |
| 22 | Return values from external calls checked | External Calls |
| 23 | EIP-712 signatures with replay protection | Signature |
| 24 | ecrecover checks for address(0) |
Signature |
| 25 | Two-step ownership transfer | Access Control |
| 26 | Emergency pause mechanism | Access Control |
| 27 | No private data that should be secret | Storage |
| 28 | Tests exist with >80% coverage | Testing |
| 29 | Slither runs clean (no high findings) | Automated |
| 30 | Economic model survives bank run scenario | Economic |
Known Exploit Patterns Reference
| Pattern | Where to Look | Severity |
|---|---|---|
| Reentrancy via ERC-777 | Any contract accepting arbitrary tokens | Critical |
| First depositor inflation | ERC-4626 vaults without virtual shares | High |
| Oracle staleness | Chainlink feeds without updatedAt check |
Medium-High |
| Storage collision on upgrade | Proxy contracts adding/reordering variables | Critical |
| Flash loan governance | Token-weighted voting without checkpoints | High |
| Read-only reentrancy | Balancer pool getRate() during operation |
High |
| Signature replay cross-chain | EIP-712 without chain ID in domain | High |
| Fee-on-transfer accounting | balanceOf diff vs transfer amount |
Medium |
| Sandwich on deposit | Vault deposit without slippage protection | Medium |
| Uninitialized proxy | Missing _disableInitializers() |
Critical |
| Missing zero-address check | transferOwnership(address(0)) |
Medium |
| Unbounded loop DoS | Iterating over user-controlled array | Medium-High |
Every item in this skill has been a real vulnerability in a real protocol. Use the checklist. Trust the process. Do not skip domains because they "probably do not apply." They do.