audit

star 0

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.

andginja By andginja schedule Updated 3/6/2026

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

  1. 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.

  2. 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.

  3. 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.

  4. You reported severity wrong. A missing event emission is not "High." An unprotected selfdestruct is not "Low." Severity must reflect actual impact and exploitability, not how scary the code looks.

  5. 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 for initializer modifier or manual guard)
  • No unprotected selfdestruct or delegatecall
  • Role-based access uses OpenZeppelin AccessControl or equivalent with proper role hierarchy
  • Ownership transfer uses two-step pattern (Ownable2Step) -- not single-step transferOwnership
  • 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 external are not public (reduces attack surface)
  • No functions missing access control that modify sensitive state
  • msg.sender checks cannot be bypassed via delegatecall context
  • 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.origin for 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 unchecked blocks 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).max used 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 safeTransferFrom triggers onERC721Received/onERC1155Received callbacks -- reentrancy vector
  • ERC-777 tokensReceived hook -- reentrancy vector
  • ETH transfers via call with callback -- reentrancy vector
  • Flash loan callbacks -- reentrancy vector
  • Governance execute functions -- can proposals trigger reentrancy?
  • No nested nonReentrant calls (will revert unexpectedly)
  • View functions that return state used by other protocols -- is the state consistent during external calls?
  • safeTransfer variants trigger receiver hooks -- treated as external calls
  • receive() and fallback() 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 selfdestruct or 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 initialize on the implementation directly)
  • Storage layout is consistent across upgrades (no slot collisions)
  • No selfdestruct in implementation (bricks all proxies on UUPS)
  • No delegatecall in implementation to untrusted targets
  • UUPS: _authorizeUpgrade has 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)
  • constructor in 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 transferFrom amount)
  • 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 transfer returns void, not bool) -- use SafeERC20
  • 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 increaseAllowance or forceApprove)
  • Approve to zero before non-zero for tokens that require it (USDT)
  • permit signatures cannot be replayed (nonce management)
  • permit deadline 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() or fallback() 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
  • calldata used instead of memory for 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)
  • ++i used instead of i++ in loops
  • Events emitted instead of storing data that is only needed offchain
  • Short-circuit evaluation in require statements (cheap checks first)
  • Batch operations available for multi-item actions (batch transfer, batch claim)
  • No redundant storage reads (cache storage values in memory variables)
  • external visibility used where public is not needed
  • Dead code removed
  • ERC-20 approve pattern does not require two transactions when it could use permit
  • No unnecessary SLOAD in loops (read once, store in memory)
  • SSTORE from 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)
  • transfer and send not 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
  • ecrecover return value checked for address(0) (invalid signatures return zero address)
  • Signature malleability handled (use OpenZeppelin ECDSA which rejects malleable signatures)
  • s value 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.origin for 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, blockhash as randomness source
  • No use of block.prevrandao as 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.timestamp value
  • 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.timestamp comparisons 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 call returns bool)
  • Low-level call used instead of transfer/send for ETH transfers
  • delegatecall only used to trusted, immutable implementations
  • No delegatecall to user-supplied addresses
  • staticcall used 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 call return 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)
  • delete on 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/TLOAD from 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 initializer modifier (not constructors)
  • initialize cannot be called twice
  • All inherited contracts are initialized in the initialize function
  • _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.

Install via CLI
npx skills add https://github.com/andginja/ethskills --skill audit
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator