Skip to content

Contract-level attacks

Most contract exploits come from a short list of patterns: the contract hands control to someone else too early, trusts a number that one trade can move, rounds in the attacker’s favour, or runs a call or a signature it should have checked.

Three properties of the platform turn ordinary bugs into theft. See Basics.

  • Anyone can call anything. Every public function is exposed to the whole internet, and contracts call each other like microservices with no auth between them.
  • The code holds the money. Every bug comes with a payout.
  • Transactions are atomic. An attacker can borrow, manipulate, profit and repay in one transaction. If any step fails, everything reverts and the attempt cost only gas.

The list below was learned one incident at a time, starting with The DAO in 2016 (reentrancy, about $60M) and the Parity multisig in 2017 (a public initialiser, 513k ETH frozen).

Sending ETH, or calling a token with hooks, runs the receiver’s code. If the contract has not updated its own books yet, the receiver can call back in and act on stale state.

// Reentrant: pays first, updates the balance after
function withdraw() external {
uint amt = bal[msg.sender];
(bool ok,) = msg.sender.call{value: amt}("");
require(ok);
bal[msg.sender] = 0; // too late
}
// Checks, effects, interactions (CEI), plus a guard
function withdraw() external nonReentrant {
uint amt = bal[msg.sender]; // checks
bal[msg.sender] = 0; // effects
(bool ok,) = msg.sender.call{value: amt}("");
require(ok); // interactions
}
Attacker contract Victim contract 1. withdraw() balance = 10 2. sends 10 ETH receive() runs 3. calls withdraw() again balance still 10 4. sends 10 ETH again … loops until the victim is empty 5. balance = 0, too late
What the first code block does at runtime. Sending ETH runs the receiver's code, so control leaves the victim before it updated its books. Moving step 5 before step 2 (checks, effects, interactions) kills the loop.

A flash loan lends any amount with no collateral, on the condition that it is repaid inside the same transaction. If it is not, everything reverts.

  1. Borrow a very large amount, for example $100M.
  2. Dump it into a thin pool. The pool’s spot price for the token jumps.
  3. Borrow from a lender that reads that pool’s spot price as its oracle. The inflated collateral now supports a far bigger loan than it is worth.
  4. Repay the flash loan plus a small fee.
  5. Keep the rest. The lender is left with bad debt.
ONE TRANSACTION · IF ANY STEP FAILS, ALL OF IT REVERTS Borrowflash loan$100M, no collateral Dump into poolthin poolTOKEN price x10 Borrow against itlender readspool spot price Repay loan+ small feesame tx Keep the restlender leftwith bad debt the bug: trusting a price that one trade can move fix: Chainlink or TWAP, staleness and deviation checks
The pattern behind Mango Markets and many smaller cases. The flash loan is not the bug. It only makes "nobody has that much money" false for the length of one transaction. Figures are illustrative.

Rounding and the ERC-4626 inflation attack

Section titled “Rounding and the ERC-4626 inflation attack”

There are no floats, and division rounds down. Two consequences.

  • Rounding drift. If rounding ever favours the user, repeating a tiny operation many times accumulates real value. Balancer V2 composable stable pools lost about $128M this way in November 2025.
  • First-depositor inflation. In an empty vault, the attacker deposits the smallest unit and gets 1 share. Then they send a large amount of the asset straight to the vault, without minting shares. One share is now worth that whole donation. The next depositor’s shares are computed as deposit × totalShares / totalAssets, which rounds down to 0. Their deposit stays in the vault, and the attacker’s single share redeems it.

Defences for vaults: virtual shares and a decimals offset (OpenZeppelin’s ERC-4626 does this), seeding dead shares at deploy, a minimum deposit.

A contract that executes a caller-supplied target and calldata will also execute token.transferFrom(victim, attacker, amount). Every user who ever approved that contract can be drained, because the token sees the trusted contract as the spender. LI.FI lost about $10M in 2024 through a new diamond facet with this flaw, and Socket about $3.3M the same year.

A signature proves that a key approved some bytes. It does not say where or how often.

  • Without a nonce, the same signature can be used twice.
  • Without chainId and the verifying contract in the signed data, it can be used on another chain or another contract. Wintermute 2022: a Safe deployment transaction was replayed on Optimism, 20M OP.
  • Every ECDSA signature has a twin with a high s value that is also valid. If the contract tracks used signatures by their bytes, the twin passes.
  • ecrecover returns address(0) on garbage input. A check against an unset owner then passes.

Defence: an EIP-712 domain, a per-signer nonce, a deadline, and OpenZeppelin ECDSA, which rejects high-s signatures and the zero address. See Token standards.

Loss figures are approximate, at the time of the attack.

Attack How it works Real case Defence
Reentrancy External call before state update; the callee calls back and withdraws again. The DAO 2016, about $60M; Curve 2023 through a Vyper compiler lock bug, about $70M Checks-effects-interactions, nonReentrant (transient version), pull payments.
Read-only reentrancy During a callback, another protocol reads a view price that is mid-update. Lending protocols pricing Curve LP tokens, 2022 to 2023 Check the reentrancy lock before trusting another protocol’s view functions.
Access control A sensitive function is left public or has the wrong modifier. Parity multisig 2017: public initWallet, then the library was killed, 513k ETH frozen Role-based access (OpenZeppelin AccessControl), tests that every state-changing function rejects strangers.
Oracle manipulation Price read from a pool’s spot reserves; a flash loan moves it inside one transaction. Mango Markets 2022, about $114M Chainlink or TWAP, staleness and deviation checks, never spot reserves.
Flash-loan governance Borrow voting power, pass a proposal, execute, repay in one transaction. Beanstalk 2022, about $182M Snapshot voting power at an earlier block, timelock on execution.
Missing invariant check One function skips the solvency check the others have. Euler 2023, donateToReserves, about $197M (returned) Invariant fuzzing: “no account ends insolvent after any call sequence”.
Rounding / precision Repeated tiny operations where rounding favours the user accumulate into real value. Balancer V2 composable stable pools, Nov 2025, about $128M Round against the user everywhere, fuzz at tiny amounts and low liquidity.
ERC-4626 inflation First depositor mints 1 share, donates assets directly; the next depositor’s shares round to 0. Common audit finding on new vaults Virtual shares and decimals offset (OpenZeppelin), seed dead shares at deploy, minimum deposit.
Arbitrary external call The contract executes a user-supplied target and calldata; the attacker calls token.transferFrom(victim, ...) using victims’ approvals. LI.FI 2024, new diamond facet, about $10M; Socket 2024, about $3.3M Allowlist of target plus selector, decode and validate arguments, never hold user approvals in a contract that forwards calls.
Signature replay / malleability The signature lacks nonce, chainId or contract binding; or the high-s twin is accepted; ecrecover returns address(0) on garbage. Wintermute 2022: Safe deployment replayed on Optimism, 20M OP EIP-712 domain, per-signer nonce, deadline, OpenZeppelin ECDSA (rejects high-s and zero address).
Front-running / MEV Searchers see the pending transaction and sandwich it or copy it. Daily on every DEX Slippage limit plus deadline, private orderflow (Flashbots Protect, MEV Blocker), commit-reveal.
Overflow Pre-0.8 maths, unchecked, assembly, or a homemade overflow check. Cetus (Sui) May 2025, faulty overflow check in a maths library, about $223M Checked maths by default; fuzz and formally verify custom maths.
delegatecall to untrusted code A delegatecall to a user-chosen address lets it write your storage. Parity 2017 (library), Bybit 2025 (through the Safe) Never delegatecall a target that is not allowlisted; monitor for it.
DoS / griefing Unbounded loop, or a push payment to a contract that always reverts. King of the Ether 2016 Pull over push, bounded loops, pagination.
Bridge verification A message proof or signer check can be skipped or forged. Wormhole 2022, about $325M; Nomad 2022, zero root trusted, about $190M Multiple independent verifiers, rate limits on release, delay on large amounts. See Bridges.
Variant What re-enters What it abuses Defence
Classic The same function State not yet updated when ETH is sent CEI, nonReentrant
Through a token hook A callback from ERC-721, ERC-1155 or ERC-777 transfers The transfer is an external call that looks like bookkeeping Treat every token transfer as an external call
Read-only A view function of protocol A, called by protocol B during A’s callback A’s reported price is mid-update B checks A’s reentrancy lock before trusting the value
Compiler-level Any of the above The lock itself was broken (Vyper, Curve 2023) Pin and review compiler versions; do not rely on a single layer
Source How it is computed Cost to move it Weakness
Pool spot reserves Ratio of the two token balances right now One large trade, free with a flash loan Never use it for a money decision
TWAP (time-weighted average price) Average of the pool price over a window The price must be held off-market across many blocks Lags real moves; thin pools are still cheap to push
Chainlink feed Off-chain nodes aggregate many markets and post on-chain Compromise or stall of the oracle network Can go stale; check updatedAt, answer > 0, decimals, and the sequencer on L2
Two sources with a deviation check Compare a feed against a second source Both must be moved together More code; needs a rule for what to do on disagreement (usually pause)

A review checklist for any function. Four questions find most of the classes above.

  1. Who can call it? If the answer is “anyone”, what stops a stranger?
  2. What state changes before the external call? Anything updated after it is a reentrancy candidate.
  3. What does it trust? A price, a signature, a message, a caller-supplied address. Each needs its own check.
  4. What happens at the rounding edge? Amounts of 1, empty pools, the first deposit.
Symptom Cause Check
Contract balance falls faster than recorded withdrawals Reentrancy slither . --detect reentrancy-eth; replay with cast run $TX_HASH and look for nested calls to the same function
Users who only gave an approval lose tokens Arbitrary external call in a contract holding approvals slither . --detect arbitrary-send-erc20; grep for .call( with caller-supplied targets
A stranger became owner Unprotected initialiser or missing modifier slither . --detect unprotected-upgrade; test that every state-changing function rejects an unknown caller
A loan far above collateral value was opened in one transaction Spot-price oracle moved by a flash loan Read which contract the price comes from; simulate a large swap on a fork first
New vault, first real depositor receives 0 shares ERC-4626 inflation Compare totalAssets() with totalSupply() right after deploy; check for a decimals offset
The same signed action executed twice or on a second chain Replay Check the signed struct for nonce, deadline, chainId and verifyingContract
A function can never complete Unbounded loop or a reverting recipient in a push payment cast run shows where gas runs out or which transfer reverts

How these are caught before deployment is in Defence stack.

reentrancy, checks-effects-interactions, CEI, nonReentrant, ReentrancyGuard, pull payment, read-only reentrancy, cross-function reentrancy, token hook, access control, AccessControl, initWallet, oracle manipulation, spot price, TWAP, Chainlink, staleness check, deviation check, flash loan, atomic transaction, flash-loan governance, snapshot voting, invariant, solvency check, donateToReserves, rounding, precision loss, ERC-4626 inflation attack, first depositor, donation attack, virtual shares, decimals offset, dead shares, arbitrary external call, transferFrom, approval drain, allowlist, signature replay, signature malleability, high-s, ecrecover, address(0), EIP-712, nonce, deadline, ECDSA, front-running, sandwich, MEV, slippage, commit-reveal, overflow, unchecked, delegatecall, DoS, griefing, pull over push, The DAO, Parity, Curve, Vyper, Mango Markets, Beanstalk, Euler, Balancer, LI.FI, Socket, Wintermute, Cetus, Wormhole, Nomad.