Expert level
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.
| Class | Incident | When | Loss |
|---|---|---|---|
| Reentrancy | The DAO | June 2016 | 3.6 million ETH, about a third of the fund, reversed by a hard fork |
| Access control and initialization | Parity multisig wallets | July and November 2017 | 153,037 ETH stolen, then 513,774 ETH frozen |
| Oracle manipulation | Mango Markets (Solana) | October 2022 | About $115 million of bad debt |
| Accounting logic | Euler Finance | March 2023 | About $197 million, later returned |
| Rounding direction | Balancer v2 Composable Stable Pools | November 2025 | An 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.
// 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
transferwhilewithdrawis 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.
// 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).
// 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.
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.
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:
| Setup (10,000 token donation, 10,000 token deposit) | Attacker loses | Victim loses |
|---|---|---|
| Naive vault | Nothing, gains 10,000 | 10,000 |
| Virtual offset, δ = 0 | 3,333.33 | 3,333.33 |
| δ = 3 | 4,998.75 | 2.50 |
| δ = 6 | 5,000.00 | 0.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.chainidchanges 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.
ecrecoverreturns address(0) for garbage input. If the expected signer can be unset, garbage "matches".
// 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.
// 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.
// 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.
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.
| Class | First line of defense | What tends to catch it |
|---|---|---|
| Reentrancy | Checks effects interactions, shared locks | Static analysis, invariant fuzzing with a malicious callback actor |
| Access control | Default deny, locked implementations, atomic deploy scripts | Manual review, static analysis, deployment tests |
| Oracle manipulation | Resistant feeds, caps, bounds | Economic review, fork tests that simulate large trades |
| Rounding | Round against the caller, virtual offsets, dead shares | Invariant fuzzing, symbolic checks on share math |
| Signature replay | EIP-712 domains, nonces, deadlines | Unit tests that replay signatures across chains and contracts |
| Unchecked calls | SafeERC20, checked flags, pull payments | Static analysis, integration tests with nonstandard tokens |
| Accounting | One solvency check every path passes through | Invariant 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.
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.