The OWASP Smart Contract Top 10 is a standard awareness document that aims to provide Web3 developers and security teams with insights into the top 10 vulnerabilities found in smart contracts. It is a sub‑project of the broader OWASP Smart Contract Security (OWASP SCS) initiative. Unlike a conventional vulnerability database, it functions as a risk-prioritization signal: a ranked list of failure classes derived from real incidents and practitioner input, calibrated annually to reflect where material losses are actually occurring.
The current edition is forward-looking: its ordering and category definitions are derived from security incidents and survey data, and then used to forecast which risks are expected to be most significant in the upcoming year. In other words, breach and vulnerability data provides the empirical foundation, while the list reflects how those observations are projected into the near future.
Each year’s ranking is anchored to deduplicated incident data from the prior calendar year, weighted by financial impact and exploit frequency. The 2026 edition is anchored to 122 deduplicated incidents representing roughly $905M in contract-only losses.
The 2026 ranking reflects a maturing threat landscape where attackers are no longer relying on simple code bugs alone, but are increasingly chaining vulnerabilities together, combining flash loans with oracle manipulation, or exploiting weak upgrade governance, to maximize financial damage.
What Changed from the Previous Version
Two structural changes carry the weight of the 2026 revision. Business logic vulnerabilities moved from #3 (titled “Logic Errors”) to #2, with scope explicitly expanded to cover reward and fee logic flaws, eligibility and limit bypasses, path-dependent state machines, and cross-module assumptions. The rename signals recognition that invariant collapse — where every individual code-level check passes but the protocol still fails because the rules being enforced do not match the rules the protocol needed to enforce — is now the largest single-protocol loss category.
Proxy & Upgradeability Vulnerabilities (SC10) is an entirely new addition for 2026, signaling that insecure upgrade patterns and weak governance over contract upgrades have become a prominent emerging risk. Meanwhile, previously ranked categories such as Insecure Randomness and Denial-of-Service attacks have been displaced, reflecting the industry’s evolving attack priorities, as captured in breach data.
The biggest story from a ranking perspective: Reentrancy dropped from #2 to #8. Not because it is solved, but because other attack vectors — business logic, flash loans — have grown more impactful. The rise of business logic bugs to #2 reflects DeFi’s increasing complexity.
The ten categories, in ranked order, are:
| ID | Category |
|---|---|
| SC01 | Access Control Vulnerabilities |
| SC02 | Business Logic Vulnerabilities |
| SC03 | Price Oracle Manipulation |
| SC04 | Flash Loan–Facilitated Attacks |
| SC05 | Lack of Input Validation |
| SC06 | Unchecked External Calls |
| SC07 | Arithmetic Errors |
| SC08 | Reentrancy Attacks |
| SC09 | Integer Overflow and Underflow |
| SC10 | Proxy & Upgradeability Vulnerabilities |
SC01 — Access Control Vulnerabilities
Access control flaws allow unauthorized users or roles to invoke privileged functions or modify critical state, often leading to full protocol compromise when admin, governance, or upgrade paths are exposed.
Access control bugs are the easiest to exploit and the hardest to recover from. Once an attacker gains admin access, game over. Access Control incidents accounted for approximately $220M in tracked losses across 30 separate incidents in the analyzed dataset.
Several protocol compromises stemmed not from cryptographic flaws, but from exposed admin roles, upgrade key mismanagement, or insufficient privilege separation. The failure mode is remarkably consistent: a function that should only be callable by a trusted role is either missing its modifier entirely, uses an incorrect modifier, or relies on an access check that can be satisfied by an attacker.
Vulnerable Pattern
// VULNERABLE: No access control on a mint function
contract InsecureToken {
mapping(address => uint256) public balances;
uint256 public totalSupply;
// Anyone can call this — catastrophic
function mint(address to, uint256 amount) external {
balances[to] += amount;
totalSupply += amount;
}
}
Secure Pattern
// SECURE: Role-based access control
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureToken is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
mapping(address => uint256) public balances;
uint256 public totalSupply;
constructor(address admin) {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
}
// Only addresses holding MINTER_ROLE may mint
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
balances[to] += amount;
totalSupply += amount;
}
}
Key mitigations:
- Apply
onlyOwner,onlyRole, or equivalent modifiers to every state-changing privileged function - Protect upgrade paths with timelocks and multisig governance
- Map all roles and their permitted operations in a threat model before writing code
- Emit events on all privileged operations to support monitoring
SC01 is also the entry point through which ERC-4337 account abstraction introduces new attack surfaces. In account abstraction systems, the validateUserOp function acts as the de-facto access controller for a smart account. If validation logic is too permissive — for example, accepting any non-zero signature — an attacker can authorize arbitrary calls on behalf of a victim’s account. Audit every custom validator implementation as a first-class access control surface.
SC02 — Business Logic Vulnerabilities
Design-level flaws in lending, AMM, reward, or governance logic that break intended economic or functional rules, enabling attackers to extract value even when low-level checks appear correct.
Business logic vulnerabilities involve design-level flaws in lending, AMM, reward, or governance logic that break intended economic rules. These often survive technical audits because they require deep understanding of protocol economics, not just code patterns.
Business Logic incidents account for approximately 47.5% of all analyzed incidents by frequency — the highest of any category. The category’s move from #3 to #2 reflects the shift in DeFi architecture: protocols are no longer isolated contracts but interlocking systems where each component assumes specific invariants about the others. When those assumptions fail under adversarial pressure, the exploit does not require any single function to behave unexpectedly.
One-Block Reward Drain (Business Logic Flaw)
// VULNERABLE: Reward calculation doesn't account for flash-loan deposit duration
contract VulnerableVault {
mapping(address => uint256) public deposited;
mapping(address => uint256) public depositBlock;
uint256 public rewardPerBlock = 1e18;
function deposit(uint256 amount) external {
deposited[msg.sender] += amount;
depositBlock[msg.sender] = block.number;
// Transfer tokens in...
}
function claimRewards() external {
uint256 blocks = block.number - depositBlock[msg.sender];
// BUG: a flash loan depositing for exactly 1 block still earns rewards
uint256 reward = deposited[msg.sender] * blocks * rewardPerBlock / 1e18;
// Transfer reward out...
}
function withdraw(uint256 amount) external {
deposited[msg.sender] -= amount;
// Transfer tokens out...
}
}
Secure Pattern: Minimum Lockup Guard
contract SecureVault {
mapping(address => uint256) public deposited;
mapping(address => uint256) public depositBlock;
uint256 public constant MIN_LOCKUP_BLOCKS = 10;
uint256 public rewardPerBlock = 1e18;
function deposit(uint256 amount) external {
deposited[msg.sender] += amount;
depositBlock[msg.sender] = block.number;
}
function claimRewards() external {
uint256 blocks = block.number - depositBlock[msg.sender];
// Enforce minimum participation before any reward accrues
require(blocks >= MIN_LOCKUP_BLOCKS, "Too early");
uint256 reward = deposited[msg.sender] * blocks * rewardPerBlock / 1e18;
depositBlock[msg.sender] = block.number;
// Transfer reward out...
}
}
Key mitigations:
- Formally specify protocol invariants in plain language before writing code
- Use invariant fuzz testing (Foundry, Echidna, Medusa) to explore adversarial economic states
- Model every path by which value enters and exits the system
- Treat reward logic and fee logic as attack surfaces equivalent to fund transfer functions
SC03 — Price Oracle Manipulation
Weak oracles and unsafe price integrations let attackers skew reference prices, enabling under-collateralized borrowing, unfair liquidations, and mispriced swaps as part of larger exploit chains.
Price oracle manipulation and cross-chain timing discrepancies enabled multi-million dollar extraction events, demonstrating that integration risk often exceeds contract-level bugs. Single-source oracles — particularly those derived from on-chain AMM spot prices — remain the most dangerous configuration. A pool with thin liquidity can have its price moved substantially within a single transaction using a flash loan, and any protocol that reads that pool’s price in the same transaction inherits the manipulated value.
Vulnerable: Spot Price Oracle
// VULNERABLE: reads spot price from an AMM pool in the same transaction
interface IUniswapV2Pair {
function getReserves() external view returns (
uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast
);
}
contract VulnerableLending {
IUniswapV2Pair public immutable pair;
function getPrice() public view returns (uint256) {
(uint112 r0, uint112 r1,) = pair.getReserves();
// Spot price: manipulable within a flash loan
return (uint256(r1) * 1e18) / uint256(r0);
}
function borrow(uint256 collateral) external {
uint256 price = getPrice();
uint256 borrowable = collateral * price / 1e18;
// Attacker inflated price via flash loan; borrows more than collateral is worth
}
}
Secure: TWAP + Chainlink Dual-Source
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract SecureLending {
AggregatorV3Interface public immutable chainlinkFeed;
uint256 public constant MAX_STALENESS = 3600; // 1 hour
uint256 public constant MAX_DEVIATION_BPS = 200; // 2%
function getVerifiedPrice() public view returns (uint256) {
(
,
int256 answer,
,
uint256 updatedAt,
) = chainlinkFeed.latestRoundData();
require(block.timestamp - updatedAt <= MAX_STALENESS, "Stale feed");
require(answer > 0, "Invalid price");
return uint256(answer);
}
}
Key mitigations:
- Use decentralized oracle networks (Chainlink, Pyth) with multiple independent data sources
- Implement TWAP with sufficiently long windows for any on-chain price reference
- Add deviation circuit-breakers that halt operations when a price moves anomalously between updates
- Layer oracle sources and require agreement between independent feeds for high-value operations
In ZK-based systems and L2 sequencer environments, oracle timing introduces additional risk. An L2 sequencer can control transaction ordering within a batch, creating the equivalent of a controlled flash loan environment. Price feeds should include sequencer-uptime checks and should treat any period of sequencer downtime as a potential price-stale condition.
SC04 — Flash Loan–Facilitated Attacks
Attacks that use large, uncollateralized flash loans magnify small bugs in logic, pricing, or arithmetic into large drains, by executing complex multi-step sequences in a single transaction.
Flash loans are the “force multiplier” of DeFi exploits. Any bug that exists at sufficient scale becomes exploitable when capital constraints are removed. Flash loans do not introduce new bug classes — they remove the capital barrier that would otherwise make many bugs uneconomical to exploit. SC04 therefore acts as an amplifier: a protocol vulnerable to SC02, SC03, or SC07 becomes a flash loan attack when the same logic flaw can be triggered with tens of millions in unconstrained capital.
Governance Flash Loan Attack
// VULNERABLE: governance token used as voting power in the same block it's borrowed
contract VulnerableGovernance {
IERC20 public governanceToken;
uint256 public quorum = 1_000_000e18; // 1M tokens
mapping(uint256 => mapping(address => bool)) public hasVoted;
mapping(uint256 => uint256) public votesFor;
function vote(uint256 proposalId) external {
require(!hasVoted[proposalId][msg.sender], "Already voted");
uint256 power = governanceToken.balanceOf(msg.sender);
require(power > 0, "No voting power");
hasVoted[proposalId][msg.sender] = true;
votesFor[proposalId] += power;
// Attacker flash-borrows 1M tokens, votes, repays — proposal passes
}
}
Secure: Snapshot-Based Voting Power
import "@openzeppelin/contracts/governance/utils/IVotes.sol";
contract SecureGovernance {
IVotes public immutable token;
uint256 public votingDelay = 1; // blocks after proposal before voting opens
mapping(uint256 => uint256) public proposalSnapshot;
function propose() external returns (uint256 proposalId) {
proposalId = _generateId();
// Snapshot is taken at creation time; flash loan in voting block has no effect
proposalSnapshot[proposalId] = block.number;
}
function vote(uint256 proposalId) external {
uint256 snapshotBlock = proposalSnapshot[proposalId];
// Voting power is measured at the snapshot block, not the current block
uint256 power = token.getPastVotes(msg.sender, snapshotBlock);
require(power > 0, "No historical voting power");
// Record vote...
}
}
Key mitigations:
- Use snapshot-based (past-block) voting power for governance
- Add minimum holding periods for collateral before it can be used as a borrowing basis
- Use multi-block TWAP oracles so single-transaction price manipulation has no effect
- Model every capital-intensive path under the assumption that the attacker has unlimited liquidity in a single block
SC05 — Lack of Input Validation
Missing or weak validation of user, admin, or cross-chain inputs allows unsafe parameters to reach core logic, corrupting state, breaking assumptions, or enabling direct fund loss.
Input validation failures account for a stubbornly persistent share of direct contract exploits. This category spans a wide range of failure modes: zero-address recipients that burn funds permanently, array length mismatches that corrupt state, deadline parameters set to zero that disable time protections, and slippage parameters set to zero that allow arbitrarily bad execution rates.
Cross-chain messages are a particularly dangerous input surface. A message relayed from an L2 to an L1 — or across chains via a bridge — carries data that the receiving contract must validate independently. Trusting that the sending chain enforced the correct constraints before relaying is a design-time assumption that attackers specifically target.
Vulnerable: Missing Zero-Address Check
// VULNERABLE: zero-address recipient permanently burns tokens
contract VulnerableTransfer {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// Missing: require(to != address(0), "Zero address");
require(balances[msg.sender] >= amount, "Insufficient");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Secure: Comprehensive Input Guards
contract SecureTransfer {
mapping(address => uint256) public balances;
uint256 public constant MAX_TRANSFER = 1_000_000e18;
error ZeroAddress();
error ZeroAmount();
error ExceedsMax(uint256 requested, uint256 maximum);
error InsufficientBalance(uint256 available, uint256 requested);
function transfer(address to, uint256 amount) external {
if (to == address(0)) revert ZeroAddress();
if (amount == 0) revert ZeroAmount();
if (amount > MAX_TRANSFER) revert ExceedsMax(amount, MAX_TRANSFER);
if (balances[msg.sender] < amount) {
revert InsufficientBalance(balances[msg.sender], amount);
}
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Key mitigations:
- Validate all external inputs at the trust boundary — do not rely on caller-enforced invariants
- Check zero addresses, zero amounts, array length consistency, and deadline sanity on every function
- For cross-chain message handlers, re-validate all fields as if the message came from an untrusted source
- Use custom errors for gas-efficient, informative revert reasons
SC06 — Unchecked External Calls
Unsafe interactions with external contracts or addresses where failures, reverts, or callbacks are not safely handled often enable reentrancy or inconsistent state.
Solidity’s low-level .call() returns a boolean success flag that many contracts ignore. When that flag is false — meaning the external call failed silently — the calling contract continues execution as if the operation succeeded, potentially releasing tokens it was meant to hold, crediting balances that were never funded, or skipping cleanup logic.
This category also covers ERC-20 non-standard behavior: tokens like USDT do not return a boolean from transfer(), which causes contracts compiled against the standard interface to revert or behave unexpectedly. Libraries like OpenZeppelin’s SafeERC20 exist precisely to handle this class of integration failure.
Vulnerable: Unchecked Call Return Value
// VULNERABLE: ignores whether the external call succeeded
contract VulnerableEscrow {
mapping(address => uint256) public deposits;
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount, "Insufficient");
deposits[msg.sender] -= amount;
// Low-level call — return value ignored
// If this fails, funds are lost from the user's perspective
(bool success,) = msg.sender.call{value: amount}("");
// success is never checked
}
}
Secure: CEI + Checked Call
contract SecureEscrow {
mapping(address => uint256) public deposits;
error TransferFailed();
error InsufficientDeposit();
// Checks-Effects-Interactions pattern enforced
function withdraw(uint256 amount) external {
// Checks
if (deposits[msg.sender] < amount) revert InsufficientDeposit();
// Effects — state updated before any external interaction
deposits[msg.sender] -= amount;
// Interactions — external call last, return value checked
(bool success,) = msg.sender.call{value: amount}("");
if (!success) revert TransferFailed();
}
}
Key mitigations:
- Always check the return value of
.call(),.delegatecall(), and.staticcall() - Use
SafeERC20for all ERC-20 token transfers - Follow Checks-Effects-Interactions strictly: never make external calls before finalizing state changes
- Apply reentrancy guards (
ReentrancyGuard) on any function that performs external calls followed by state logic
SC07 — Arithmetic Errors
Subtle bugs in integer math, scaling, and rounding — especially in share, interest, and AMM calculations — can be repeatedly exploited to cause precision loss, or siphon value, particularly when paired with flash loans.
Integer overflow incidents averaged approximately $86.8M per incident in the analyzed dataset, making them the highest-impact category on a per-event basis despite relatively low frequency. The most dangerous arithmetic errors in modern Solidity are not overflows (caught by default in 0.8+) but rounding direction errors and precision loss in fixed-point math. Share-based vaults, interest accrual, and AMM fee calculations all involve division that must round in the protocol’s favor — rounding toward the user creates opportunities for repeated tiny extractions that compound into material losses.
Vulnerable: Incorrect Rounding in Share Math
// VULNERABLE: rounds up in favor of the depositor (allows donation attack / inflation exploit)
contract VulnerableVault {
uint256 public totalShares;
uint256 public totalAssets;
function deposit(uint256 assets) external returns (uint256 shares) {
if (totalShares == 0) {
shares = assets;
} else {
// Rounds DOWN — normally OK, but see below
shares = (assets * totalShares) / totalAssets;
}
totalShares += shares;
totalAssets += assets;
}
function redeem(uint256 shares) external returns (uint256 assets) {
// Rounds UP in favor of redeemer — attacker can extract dust repeatedly
assets = (shares * totalAssets + totalShares - 1) / totalShares;
totalShares -= shares;
totalAssets -= assets;
}
}
Secure: Consistent Rounding Direction + Virtual Shares
// SECURE: ERC-4626-compliant virtual shares defend against donation attacks
// Rounds always in favor of the vault (protocol), never the user
contract SecureVault {
uint256 public totalShares;
uint256 public totalAssets;
// Virtual offset prevents the first-depositor inflation attack
uint256 private constant VIRTUAL_SHARES = 1e3;
uint256 private constant VIRTUAL_ASSETS = 1e3;
function convertToShares(uint256 assets) public view returns (uint256) {
// Round DOWN — fewer shares issued means deposit favors the vault
return (assets * (totalShares + VIRTUAL_SHARES))
/ (totalAssets + VIRTUAL_ASSETS);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
// Round DOWN — fewer assets returned means redeem favors the vault
return (shares * (totalAssets + VIRTUAL_ASSETS))
/ (totalShares + VIRTUAL_SHARES);
}
}
Key mitigations:
- Round in favor of the protocol (vault/pool), not the user, in all share and asset calculations
- Use virtual shares/assets offsets to neutralize first-depositor inflation attacks
- Apply formal verification to any fixed-point math used in high-value accumulation paths
- Fuzz test arithmetic with extreme values — zero supply, maximum supply, single wei amounts
SC08 — Reentrancy Attacks
Reentrancy occurs where external calls can re-enter vulnerable functions before state is fully updated, allowing repeated withdrawals or state changes from outdated views of contract state.
Despite being perhaps the longest-documented vulnerability in smart contract history — present since the 2016 DAO hack — reentrancy remains on the list. Reentrancy has been known since the 2016 DAO hack yet still accounts for significant losses. The Checks-Effects-Interactions pattern, where state is updated before external calls, prevents the vast majority of reentrancy vulnerabilities.
Modern reentrancy is not limited to the classic single-contract recursive withdrawal. Cross-function reentrancy (where an attacker re-enters a different function in the same contract) and read-only reentrancy (where a view function reads stale state mid-execution, influencing a downstream dependent protocol) represent the two most common modern variants.
Classic Reentrancy
// VULNERABLE: state updated after external call
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// External call BEFORE state update — attacker re-enters here
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// State updated AFTER call — balances[msg.sender] still nonzero during reentry
balances[msg.sender] = 0;
}
}
Secure: CEI + ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// nonReentrant modifier prevents recursive calls
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
// Effects before Interactions
balances[msg.sender] = 0;
// Interaction last
(bool success,) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Key mitigations:
- Enforce CEI unconditionally — no exceptions, even in “simple” functions
- Apply
ReentrancyGuardor a transient-storage-based equivalent on functions that make external calls - Audit cross-function and cross-contract reentrancy paths, not just recursive single-function patterns
- For read-only reentrancy: ensure that view functions called by external integrators cannot return stale state during a callback
SC09 — Integer Overflow and Underflow
Dangerous arithmetic on platforms or code paths without robust overflow checks leads to wrapped values, broken invariants, and potential drains of liquidity or mis-accounting.
Solidity 0.8 introduced built-in overflow and underflow protection that reverts by default. This has substantially reduced the attack surface for contracts written in modern Solidity. However, three persistent exposure points remain:
uncheckedblocks — Developers useunchecked {}for gas optimization in loops and math that is logically bounded. If the bounding logic is incorrect, overflow can silently occur.- Assembly (
yul) — Inline assembly bypasses all Solidity safety checks. Every arithmetic operation in assembly must be manually validated. - Pre-0.8 code — Contracts deployed before 0.8’s arithmetic protection, or compiled against legacy Solidity, remain vulnerable.
Vulnerable: Unsafe unchecked Block
// VULNERABLE: unchecked arithmetic on externally supplied value
function calculateFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
// Developer assumes feeBps is bounded by caller — dangerous trust assumption
unchecked {
// If feeBps > 10000, this wraps to a huge number, then division gives wrong result
return (amount * feeBps) / 10_000;
}
}
Secure: Validate Before Unchecked
uint256 constant MAX_FEE_BPS = 1000; // 10% maximum
function calculateFee(uint256 amount, uint256 feeBps) external pure returns (uint256) {
require(feeBps <= MAX_FEE_BPS, "Fee too high");
require(amount <= type(uint128).max, "Amount too large");
// Now safe to use unchecked: bounds verified above
unchecked {
return (amount * feeBps) / 10_000;
}
}
Key mitigations:
- Avoid
uncheckedblocks unless you have a formal proof or bounded invariant that makes overflow impossible - Use static analysis tools (Slither, Aderyn) configured to flag unchecked arithmetic
- Apply formal verification (Halmos, Certora) to all critical mathematical paths
- For legacy pre-0.8 contracts holding value, treat migration or comprehensive re-audit as a priority
SC10 — Proxy & Upgradeability Vulnerabilities (New in 2026)
Misconfigured or weakly governed proxy, initialization, and upgrade mechanisms can let attackers seize control of implementations or reinitialize critical state.
Proxy & upgradeability vulnerabilities is a new category at #10, added because uninitialized ERC1967 proxies became an automated attack campaign. The failure modes cluster around three distinct patterns:
- Uninitialized proxies: An implementation contract deployed without calling
initialize()can be seized by anyone who calls it first, taking ownership of the logic contract. If the proxy’sdelegatecalldesign allows the implementation owner toselfdestructthe implementation, all proxies pointing to it are bricked. - Storage layout collisions: When a proxy and its implementation are both using storage slots, a naming mismatch causes one contract’s variable to silently overwrite another’s. This is particularly dangerous when adding new state variables to upgraded implementations.
- Unrestricted upgrade paths: A
upgradeTo()function callable by a single EOA — rather than a timelock-governed multisig — gives a compromised key the ability to point the proxy at a malicious implementation without any delay or community intervention.
Vulnerable: Unprotected Initializer
// VULNERABLE: initialize() can be called by anyone after deployment
contract VulnerableImplementation {
address public owner;
bool private _initialized;
function initialize(address _owner) external {
// Missing: require(!_initialized, "Already initialized");
owner = _owner;
_initialized = true;
// Attacker calls this before the protocol does — they become owner
}
function upgrade(address newImpl) external {
require(msg.sender == owner, "Not owner");
// Attacker-owned owner can point proxy to malicious contract
}
}
Secure: OpenZeppelin Initializable + UUPS
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@
openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract VaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address owner) external initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Key protections: _disableInitializers() prevents direct initialization of the implementation contract; the proxy deployment includes the initialization calldata atomically; _authorizeUpgrade is protected by onlyOwner.
How to Use This List
The OWASP Smart Contract Top 10 is a starting point, not a complete methodology. Each category names a class of vulnerabilities that auditors reliably find and that protocols reliably ship. The appropriate response to the list is:
- Verify each category applies to your codebase — not every protocol has flash loan exposure or upgradeable proxies
- Use the list as a communication tool — when triaging findings, anchoring to a known taxonomy helps stakeholders understand severity
- Do not use it as a checklist substitute — a protocol that addresses all ten categories can still have critical vulnerabilities in its business logic or economic design
- Update your threat model — the list reflects what has been exploited. Novel attack vectors will not appear here until they have claimed victims
The categories that have grown in importance since earlier versions of this list reflect the maturation of the ecosystem: flash loans normalized, MEV professionalized, governance attacks became a standard playbook, and L2 deployments introduced new environmental assumptions. The next revision will likely add ZK circuit vulnerabilities, account abstraction attack surfaces, and AI-assisted attack tooling as categories in their own right.
OWASP Smart Contract Top 10 — Audit Checklist
SC01 — Reentrancy
- No external call precedes a state update in any function
-
nonReentrantapplied to all functions making external calls and sharing state with other callable functions - Read-only reentrancy surface checked for third-party integrations
SC02 — Integer Overflow/Underflow
- All
uncheckedblocks are justified with a documented proof of safety -
SafeCastused for all narrowing type casts - Division-before-multiplication patterns eliminated
SC03 — Access Control
- Every state-changing function has explicit access control or documented justification for open access
-
DEFAULT_ADMIN_ROLEis not held by a deployer EOA post-deployment - Two-step ownership transfer enforced (
Ownable2Step)
SC04 — Unvalidated Inputs
- All external function parameters are validated before use
- Address parameters checked for zero address where appropriate
- Amount parameters checked for zero and maximum bounds
SC05 — Logic Errors
- Protocol invariants are formally stated and tested
- Edge cases (empty pool, single user, zero liquidity) are explicitly handled
- Rounding direction is correct for every division in financial calculations
SC06 — Oracle Manipulation
- No spot price from AMM used as oracle without TWAP
- Chainlink feeds validated: staleness, zero price, decimals, L2 sequencer
- Multi-oracle deviation check implemented
SC07 — Front-Running
- Swap functions include
minAmountOutanddeadlineparameters - Liquidation bonus is bounded
- Sensitive operations use commit-reveal where front-running would be material
SC08 — Denial of Service
- No unbounded loops over user-controlled data structures
- No external call in a loop that can fail and block the entire operation
- Pull payment pattern used instead of push where applicable
SC09 — Data Authenticity
- Off-chain signatures use EIP-712 with full domain separator (chainId, verifyingContract)
- Nonces tracked and incremented atomically
- Expiry enforced on all signatures
- OpenZeppelin ECDSA used, not raw
ecrecover
SC10 — Upgradeable Contract Vulnerabilities
-
_disableInitializers()in every implementation constructor - Deployment and initialization are atomic
- Storage layout is append-only between versions
-
_authorizeUpgradehas appropriate access control - No selector collision between proxy and implementation