Orca Crypto
Menu
Start Here
Learn
Chains
Exchanges
Markets
Tools
Safety
Glossary
More
Our tokens Book a session
Expert · Security and mechanism design

Smart contract vulnerability classes

Exploits repeat by class, not by line of code. Learn the classes and you can spot the next one before it ships.

Loading live prices
By the Orca Crypto teamUpdated 2026-09-28How we check this17 min readExpert
The short answer

Most smart contract losses come from a short list of recurring bug classes: reentrancy, broken access control and initialization, price oracle manipulation, rounding and precision errors, signature replay, unchecked external calls and accounting logic errors. Each has a known mechanism and a known defense. The skill is recognizing the shape of a class in code that looks new, and writing invariants that fail loudly when it appears.

Exploits repeat by class

A decade of incident history compresses into a short list. The contracts change, the chains change and the amounts grow, but the mechanisms repeat: a contract hands control to someone else while its books are half updated, trusts a caller it never checked, reads a price an attacker can move, or does arithmetic that rounds in the attacker's favor. Each class below gets its mechanism, a minimal Solidity sketch where code helps, a real incident and the defense. The sketches show the shape of a bug for teaching, never a working exploit.

ClassIncidentWhenLoss
ReentrancyThe DAOJune 20163.6 million ETH, about a third of the fund, reversed by a hard fork
Access control and initializationParity multisig walletsJuly and November 2017153,037 ETH stolen, then 513,774 ETH frozen
Oracle manipulationMango Markets (Solana)October 2022About $115 million of bad debt
Accounting logicEuler FinanceMarch 2023About $197 million, later returned
Rounding directionBalancer v2 Composable Stable PoolsNovember 2025An estimated $94.8 million per the post-mortem; early reports put it near $128 million

Governance attacks have their own guide: onchain governance design.

Reentrancy: The DAO and everything since

Every external call hands control to code you do not control. If your state is inconsistent at that moment, the callee can call back into you and act on the inconsistency. In June 2016 an attacker drained 3.6 million ETH, about a third of the 11.5 million ETH committed to The DAO, by recursively calling its split function, which sent ether before it cleared the caller's balance. The funds landed in a child DAO with a 28 day holding period, which bought time for the hard fork at block 1,920,000 on July 20, 2016.

solidity
// Vulnerable: pays out before it updates the books. The DAO's split
// function had the same ordering in June 2016.
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}("");  // attacker code runs here...
    require(ok, "send failed");
    balances[msg.sender] = 0;                           // ...before this line
}

// Fixed: checks, then effects, then interactions, plus a lock that
// covers every function touching the same balances.
function withdraw() external nonReentrant {
    uint256 amount = balances[msg.sender];              // check
    require(amount > 0, "nothing to withdraw");
    balances[msg.sender] = 0;                           // effect
    (bool ok, ) = msg.sender.call{value: amount}("");  // interaction
    require(ok, "send failed");
}

The vulnerable version still works on Solidity 0.8: checked arithmetic only reverts on overflow, and the late write is an assignment. Had it been balances[msg.sender] -= amount, the nested calls would underflow while unwinding and revert everything, an accidental defense nobody should rely on.

The variants that survive a guard

  • Cross function. The attacker reenters a different function that reads the same stale state, for example transfer while withdraw is mid flight. A lock on one function does nothing; the lock must cover every function that shares the state.
  • Cross contract. A vault and its share token keep related books; reentering one sees the other half updated.
  • Hooks. ERC-721 and ERC-1155 receiver callbacks and ERC-777 hooks turn a token transfer into an external call.
  • Read only reentrancy. The target is safe and a third party is the victim, described next.

Read only reentrancy

View functions carry no lock, because they cannot change state. But other protocols read them. ChainSecurity described in 2022 how Curve pools that pay out native ETH could be reentered from inside remove_liquidity: LP tokens were already burned but the second coin had not yet left, so get_virtual_price, which divides the pool invariant by LP supply, reported an inflated value during the ETH callback. The pool lost nothing; a lender valuing Curve LP collateral with that function in that window would have. The fix sits in the reader: before trusting a view, call a cheap state changing function on the target that is protected by its lock, which reverts if the target is mid execution.

Access control and initialization: Parity, twice

The simplest class is a privileged function anyone can call. Proxies and shared libraries make it subtle, because logic and storage live in different contracts and each has its own idea of whether setup has run.

Parity's multisig wallets forwarded any call they did not recognize to a shared library via DELEGATECALL. On July 19, 2017, an attacker called initWallet through three wallets. It had no check that it had already run, so it made the attacker the owner, and 153,037 ETH left. A fixed library was deployed the next day. On November 6, 2017, a user called initWallet directly on that new library, which had itself never been initialized, became its owner, and called kill. The library self destructed, and 587 wallets that delegated to it were left pointing at empty code with 513,774.16 ETH inside. A protocol change to restore the code, EIP-999, was proposed in 2018 and is marked withdrawn.

solidity
// Parity, 2017, reduced to its shape. Each wallet forwarded unknown calls
// to one shared library with DELEGATECALL, so library code ran against
// the wallet's own storage.
contract WalletLibrary {
    address public owner;

    // July: called a second time through a wallet.
    // November: called on the library itself, which nobody had initialized.
    function initWallet(address _owner) public {   // nothing stops a second call
        owner = _owner;
    }

    function kill(address to) public {
        require(msg.sender == owner);
        selfdestruct(payable(to));  // before Dencun this deleted the code every wallet used
    }
}

// The modern shape: initialize exactly once, deploy and initialize in the
// same transaction, and lock the implementation the moment it exists.
contract VaultV1 is Initializable, OwnableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() { _disableInitializers(); }

    function initialize(address initialOwner) external initializer {
        __Ownable_init(initialOwner);
    }
}

Two things have changed. EIP-6780, live since Dencun in March 2024, means SELFDESTRUCT only deletes code when called in the same transaction that created the contract, so a Parity style kill today would move ether and leave the code in place. And the pattern still recurs: a February 2022 report to Wormhole through Immunefi, rewarded with a $10 million bounty, showed its bridge implementation behind a proxy was uninitialized and could have been taken over and destroyed. Same bug, five years later. The rules are simple: default deny on every external function, an initializer that runs once, _disableInitializers() in every implementation constructor, and deployment scripts that deploy and initialize in one transaction so nobody can front run the setup. Upgrade and admin paths are covered in upgradeability, admin keys and timelocks.

Price oracle manipulation and flash loans

A protocol that reads a price from a source an attacker can move, then lends or liquidates at that price, has handed the attacker a lever. Spot AMM reserves are the classic source: the price is a reserve ratio that moves with trade size (see AMM math).

solidity
// Fragile: values collateral at whatever the pool says in this block.
function collateralValue(address user) public view returns (uint256) {
    (uint112 r0, uint112 r1, ) = pair.getReserves();
    uint256 spot = uint256(r1) * 1e18 / r0;   // one large swap moves this
    return deposits[user] * spot / 1e18;
}
// Sturdier: a time weighted average over many blocks or a feed that
// aggregates venues, checked for staleness and bounds.

A flash loan removes the capital constraint: any amount the lending markets hold, for one transaction, for a small fee. So "too expensive to manipulate" has to be measured in fees and slippage, not in capital. And a round trip inside one transaction is cheaper than intuition suggests, because the attacker sells back into the same curve it just moved and recovers most of the input.

attack pays when (manipulated value − true value) × borrowable fraction > swap fees + loan fees + slippageEverything on the right is small for a thin market. The left is bounded only by what the protocol will lend.

Mango Markets on Solana in October 2022 needed no flash loan at all. Per rekt.news, the attacker took a large MNGO perpetual position against a second account they controlled, then drove MNGO's thinly traded price, which the protocol's oracle tracked, from about $0.03 to $0.91. The unrealized profit counted as collateral and was borrowed against until the lending pools were empty, leaving about $115 million of bad debt. The oracle was not wrong. It faithfully reported a market that had been moved on purpose. Defenses beyond better feeds: caps sized to real market depth and haircuts on unrealized profit (more in how oracles fail).

Rounding, precision and share inflation

Integer arithmetic always rounds, so the only question is in whose favor. The rule is to round against the caller on every path: shares minted round down, assets owed by the caller round up. The ERC-4626 inflation attack shows what happens when an empty vault gets this technically right and still loses.

shares = assets × totalSupply ÷ totalAssets  (rounded down)A naive vault. The first depositor gets shares 1:1.

Take a 6 decimal token. The attacker deposits one base unit and receives one share. They then transfer 10,000 tokens straight to the vault, a donation that raises totalAssets without minting shares. A victim deposits 10,000 tokens and receives 10,000·106 × 1 ÷ (10,000·106 + 1), which rounds to zero shares. The attacker's single share now redeems 20,000 tokens. The attacker needs to land between vault creation and the first real deposit, which is a mempool ordering problem (see MEV).

OpenZeppelin's mitigation adds virtual shares and a virtual asset to the exchange rate, and lets the vault use more decimals for shares than for assets:

shares = assets × (totalSupply + 10δ) ÷ (totalAssets + 1)δ is _decimalsOffset(). The virtual shares absorb part of any donation.
Setup (10,000 token donation, 10,000 token deposit)Attacker losesVictim loses
Naive vaultNothing, gains 10,00010,000
Virtual offset, δ = 03,333.333,333.33
δ = 34,998.752.50
δ = 65,000.000.0025

Computed with the OpenZeppelin formulas and rounded to the cent. Even at δ = 0 the attack stops paying, and each extra digit of offset shifts the loss onto the attacker by a factor of ten. Other defenses: dead shares (Uniswap v2 locks the first 1,000 LP units forever), a seed deposit by the deployer, internal accounting that ignores donations, and a minimum shares out check on deposit.

Rounding also bites far from vault creation. Balancer's post-mortem of November 3, 2025 traces an estimated $94.8 million theft (early reports, counting differently, put it near $128 million) from v2 Composable Stable Pools to rounding in the exact out swap path: the requested output was rounded down while being scaled, and the stable math then rounded the required input down too, so the swapper underpaid. Tiny per swap, the error was magnified by rate providers and a pool pushed into a low liquidity state, then repeated across many carefully sized swaps. The lesson: rounding direction must be traced through the whole computation, not checked one operation at a time.

Signature replay, nonces and domains

A signature authorizes a message, not a context. Whatever the message does not bind, an attacker can vary.

  • Again, here. Without a nonce, a signed approval or order can be submitted twice. Sequential nonces suit permits; bitmap nonces suit orders that can fill in any order.
  • On another chain. EIP-155 put the chain ID into transaction signatures in November 2016, after the DAO fork left two chains on which the same transaction was valid. Applications must do the same for their own messages, which is what the EIP-712 domain separator is for: name, version, chain ID and verifying contract. Cache it, but rebuild it if block.chainid changes after a fork.
  • In another contract. The verifying contract in the domain stops a signature for one deployment working on a copy.
  • Later. A deadline bounds how long a leaked signature stays dangerous.
  • Malleability. For any valid ECDSA signature (r, s), the pair (r, n − s) is also valid. Code that uses signature bytes as its replay key can be replayed. Key replay protection on nonces, and use a library that rejects the upper half of s.
  • The zero address. ecrecover returns address(0) for garbage input. If the expected signer can be unset, garbage "matches".
solidity
// ERC-2612 permit: the parts that stop a signature being used twice.
function permit(address owner, address spender, uint256 value,
                uint256 deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(block.timestamp <= deadline, "expired");           // expiry
    bytes32 structHash = keccak256(abi.encode(
        PERMIT_TYPEHASH, owner, spender, value,
        nonces[owner]++,                                         // one use only
        deadline));
    bytes32 digest = keccak256(abi.encodePacked(
        "\x19\x01", _domainSeparatorV4(), structHash));         // this chain, this contract
    address signer = ECDSA.recover(digest, v, r, s);            // rejects high s and address(0)
    require(signer == owner, "bad signature");
    _approve(owner, spender, value);
}

Contract signers verify through ERC-1271 instead of ecrecover, which matters as more users sign from smart accounts. The user side of this is in reading what you sign.

Unchecked external calls and hostile tokens

A low level call does not revert when the callee fails. It returns false, and code that ignores the flag treats a failed payment as made. Tokens add their own surprises, because "ERC-20" describes an interface, not a behavior.

solidity
// 1. A low level call does not revert on failure. It returns a flag.
(bool ok, bytes memory ret) = target.call(data);
if (!ok) revert CallFailed(ret);

// 2. ERC-20s disagree about return values. Some return false instead of
// reverting; some (USDT on Ethereum) return nothing at all.
token.transfer(to, amount);                 // result ignored: a false looks like success
SafeERC20.safeTransfer(token, to, amount);  // handles both shapes, reverts on failure

// 3. For tokens you do not control, trust the balance change, not the amount.
uint256 before = token.balanceOf(address(this));
SafeERC20.safeTransferFrom(token, msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - before;  // fee on transfer safe
  • Fee on transfer and rebasing tokens. The amount received is not the amount sent, and balances change without transfers. Account in measured balance changes or refuse such tokens explicitly.
  • Blocklists and pauses. A transfer can revert for one address. In a loop that pays many recipients, one reverting recipient blocks everyone. Pull over push: record what is owed and let each party withdraw.
  • Returndata bombs. A callee can return enormous data, and copying it into memory costs quadratic gas (see the EVM under the hood). Cap what you copy when calling untrusted code.

Accounting logic errors: Euler Finance

This is the hardest class, because nothing pattern matches. The code does exactly what it says, and what it says breaks an invariant the rest of the system depends on. On March 13, 2023, Euler Finance lost about $197 million this way. Per Euler's own account, donateToReserves had been added to fix a much smaller first depositor bug and had been sent to audit. It let an account give its collateral to the protocol's reserves, but unlike every other function that reduced collateral, it never checked the account's health afterward.

solidity
// Euler, March 2023, reduced to its shape. Every other path that reduced
// an account's collateral ended in a health check. This one did not.
function donateToReserves(uint256 amount) external {
    _burnCollateral(msg.sender, amount);
    reserves += amount;
    // missing: _requireHealthy(msg.sender);
}

// The structural fix: one solvency check that every state change passes
// through, so a function added later cannot forget it.
modifier checksSolvency() {
    _;
    _requireHealthy(msg.sender);
}

Cyfrin's analysis walks through the sequence. A 30 million DAI flash loan from Aave; a 20 million DAI deposit; Euler's own mint function to build leverage to about 93% loan to value, a partial repayment and a second mint; then a donation of 100 million of collateral tokens to reserves. Debt was untouched, so the health score fell to about 0.75. A second contract liquidated the position, and because Euler's liquidation discount grew with how unhealthy an account was, capped at 20%, the liquidator received collateral worth more than the debt it assumed. Repeated across markets, that came to about $197 million. The attacker returned the funds over the following three weeks, finishing on April 3, 2023.

Write the invariant, not just the function
The invariant Euler needed fits in one sentence: every account is healthy after any operation that account initiates. Written as an invariant test with a leveraged actor, a single call to the new function breaks it. Accounting bugs are found by stating what must always be true and letting a machine search for the sequence that breaks it, which is the subject of the audits and verification guide.

Defensive patterns that hold up

Checks, effects, interactions

Validate, update your own state, then call out, so nobody runs code against half finished books.

Locks where state is shared

One reentrancy lock across every function that touches the same books. Transient storage (EIP-1153) makes it cheap.

Pull over push

Record what is owed and let recipients withdraw, so one broken recipient only hurts itself.

Invariants as code

Solvency, conservation and access rules as properties, fuzzed in CI and monitored after launch.

ClassFirst line of defenseWhat tends to catch it
ReentrancyChecks effects interactions, shared locksStatic analysis, invariant fuzzing with a malicious callback actor
Access controlDefault deny, locked implementations, atomic deploy scriptsManual review, static analysis, deployment tests
Oracle manipulationResistant feeds, caps, boundsEconomic review, fork tests that simulate large trades
RoundingRound against the caller, virtual offsets, dead sharesInvariant fuzzing, symbolic checks on share math
Signature replayEIP-712 domains, nonces, deadlinesUnit tests that replay signatures across chains and contracts
Unchecked callsSafeERC20, checked flags, pull paymentsStatic analysis, integration tests with nonstandard tokens
AccountingOne solvency check every path passes throughInvariant testing, formal verification of solvency

Every incident at the top of this guide was a known class when it happened. The gap is rarely knowledge. It is a new function, integration or token that nobody reexamined against the list, so keep the list in your review checklist and test suite.

Common questions

Does Solidity 0.8 prevent reentrancy?

No. Solidity 0.8 added checked arithmetic, which reverts on overflow and underflow. Reentrancy is about the order of state updates and external calls, and a vulnerable withdraw that assigns zero after paying out works exactly as before. Use checks, effects, interactions and a reentrancy lock that covers every function sharing the state.

What is read only reentrancy?

An attacker reenters a contract during an external call and, instead of changing its state, gets a third protocol to read one of its view functions while its state is inconsistent. The target loses nothing; the reader acts on a wrong value, such as an inflated LP token price. Readers defend by checking the target is not mid execution before trusting the view.

How does an ERC-4626 inflation attack work?

In an empty vault, an attacker mints a tiny number of shares and then donates assets directly, so each share is worth a lot. The next depositor's share count rounds down, possibly to zero, and the attacker captures the difference. Virtual shares with a decimals offset, dead shares or a seed deposit make the attack cost the attacker far more than it can take.

Are flash loans a vulnerability?

No. A flash loan is a source of capital, repaid in the same transaction. It turns "this attack needs $100 million" into "this attack needs the fee", which exposes bugs that were already there, usually in how a protocol reads prices or counts votes. Design as if every attacker has unlimited capital for one transaction.

Can a signature be replayed on another chain?

Yes, if the signed message does not commit to a chain. Transactions have included the chain ID since EIP-155 in 2016, but application messages only do if the contract uses an EIP-712 domain separator with the chain ID and its own address, plus a nonce and a deadline.

Kept on this device only. Nothing is sent anywhere.

Where to go next

Next: How audits, fuzzing and formal verification work

Guide 14 of 16 in the Expert level. Your place is kept on this device, so you can pick up exactly here next time.

Share this page

Was this page useful?