Smart Contract Vulnerabilities Explained
Key Takeaways
- •Reentrancy is the oldest smart contract vulnerability class, the most expensive, and somehow still appearing in production deployments in 2024.
- •Before Solidity 0.8.0, integer arithmetic silently wrapped around on overflow or underflow.
- •Access control bugs are simultaneously the simplest vulnerability class and the most consistently catastrophic.
- •Flash loans are uncollateralized loans that must be borrowed and repaid within a single transaction.
- •Smart contracts cannot natively access off-chain data.
- •The most effective method for finding the vulnerability classes above before deployment is invariant testing — writing tests that verify properties of your contract that must always hold true, regardless of what sequence of operations is performed.
The DAO hack in June 2016 drained $60 million from what was then the largest crowdfunded project in history through a reentrancy bug that was 30 lines of vulnerable code. The same category of bug — with slight variations — appeared in Cream Finance in August 2021 ($18.8 million), in Fei Protocol in April 2022 ($80 million), and in Euler Finance in March 2023 ($197 million). Different protocols, different years, same fundamental mistake.
Smart contracts are immutable code managing billions of dollars with no human override. When they have bugs, there is no patch, no rollback, no support ticket. The money is simply gone — often within one transaction block, 12 seconds after the exploit begins.
Over $4.2 billion was lost to smart contract exploits in 2022. The number dropped to approximately $1.8 billion in 2023 as the bear market reduced TVL, but the vulnerability classes driving those numbers did not change. The same five categories of bugs accounted for over 80% of losses in both years. Developers keep shipping them. Auditors keep missing them. This post explains exactly how they work, why they keep appearing, and what the prevention looks like in code.
Reentrancy Attacks
Reentrancy is the oldest smart contract vulnerability class, the most expensive, and somehow still appearing in production deployments in 2024. It has been understood since July 2016, documented in every security guide and audit checklist for eight years, and it has cost the ecosystem over $1 billion in that time.
The vulnerability requires two conditions:
- A contract makes an external call to an untrusted address before updating its own state
- The called address contains a fallback or receive function that calls back into the vulnerable contract
When both conditions exist, the attacker creates a recursive call loop that drains the contract before the state update that would terminate the loop ever executes.
The DAO: $60 Million, June 2016
The DAO was a decentralized venture fund that raised 11.5 million ETH — roughly 15% of all ETH in existence at the time, worth approximately $150 million — in what was then the largest crowdfunded project in history. In June 2016, an attacker drained 3.6 million ETH ($60 million) through a reentrancy bug in the withdrawal function.
The vulnerability was in the splitDAO function. The relevant pattern:
// VULNERABLE — The DAO's withdrawal pattern (simplified)
// This is the bug that drained $60 million
contract VulnerableDAO {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// External call BEFORE state update — this is the fatal mistake
// msg.sender could be an attacker contract with a malicious fallback
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// State update happens AFTER the external call
// The attacker's fallback re-enters withdraw() before this executes
// So balances[msg.sender] still shows the original balance
// The loop continues until the contract is empty
balances[msg.sender] -= amount;
}
}
// The attacker's contract
contract Attacker {
VulnerableDAO public target;
uint256 public attackAmount;
constructor(address _target) {
target = VulnerableDAO(_target);
}
// This executes when VulnerableDAO sends ETH to this contract
// Before the balance update happens
receive() external payable {
// Re-enter withdraw() as long as the target has funds
if (address(target).balance >= attackAmount) {
target.withdraw(attackAmount);
}
}
function attack() external payable {
attackAmount = msg.value;
target.withdraw(attackAmount);
}
}Trace the execution:
- Attacker deposits 1 ETH into the DAO
- Attacker calls
withdraw(1 ether) - DAO checks:
balances[attacker] >= 1 ETH— TRUE - DAO calls
attacker.call{value: 1 ether}("")— this triggersAttacker.receive() - Attacker's
receive()callstarget.withdraw(1 ether)again - DAO checks:
balances[attacker] >= 1 ETH— still TRUE (balance not yet updated) - DAO calls
attacker.call{value: 1 ether}("")again —Attacker.receive()triggers again - This continues until the DAO's ETH balance is drained
- Only then does the state update execute — but there is nothing left
The severity of the DAO hack was enough to fracture Ethereum itself. The community voted to hard-fork the chain at block 1,920,000 to return the stolen funds — creating the ETH/ETC split that persists today. The minority that opposed the fork (arguing "code is law") continued on the original chain as Ethereum Classic. A $60 million smart contract bug created a permanent blockchain schism.
The Correct Pattern: Checks-Effects-Interactions
The fix is simple in principle and requires discipline in execution. The Checks-Effects-Interactions (CEI) pattern mandates that state updates happen before any external calls.
// SAFE — Checks-Effects-Interactions pattern
contract SafeBank {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
// 1. CHECKS — validate preconditions, revert if invalid
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. EFFECTS — update all state BEFORE any external interaction
// The balance is zeroed BEFORE the ETH is sent
// A reentrant call would now see balance = 0 and fail the require
balances[msg.sender] -= amount;
// 3. INTERACTIONS — external calls come last
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}Alternatively, use a reentrancy guard mutex from OpenZeppelin:
// SAFE — using OpenZeppelin's ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
// The nonReentrant modifier sets a boolean lock
// If the function is called recursively, the lock check fails and reverts
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}The nonReentrant modifier implementation:
// How nonReentrant actually works (simplified from OpenZeppelin)
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED; // Set lock
_; // Execute function body
_status = _NOT_ENTERED; // Release lock
}Cross-Function Reentrancy: The Sophisticated Variant
The simple reentrancy case above is now widely understood. Auditors check for it. The dangerous modern variant is cross-function reentrancy, where the attacker does not re-enter the same function — they re-enter a different function that shares the same state.
// VULNERABLE to cross-function reentrancy
// Two functions share the `balances` state
// The bug is in their interaction, not in either function alone
contract VulnerableLendingPool {
mapping(address => uint256) public deposits;
mapping(address => uint256) public borrows;
function deposit() external payable {
deposits[msg.sender] += msg.value;
}
function borrow(uint256 amount) external {
require(deposits[msg.sender] >= amount * 2, "Insufficient collateral");
borrows[msg.sender] += amount;
payable(msg.sender).transfer(amount);
}
function withdraw() external {
uint256 amount = deposits[msg.sender];
// External call before clearing deposit
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
deposits[msg.sender] = 0;
// ATTACK: During this external call, attacker calls borrow()
// deposits[msg.sender] is still the original value
// attacker can borrow against collateral they are simultaneously withdrawing
}
}The nonReentrant modifier on withdraw() does not help if borrow() is not also protected — the attacker re-enters borrow(), not withdraw(). The guard only blocks re-entrant calls to the same function.
The correct fix: apply nonReentrant to all state-modifying functions that share state, not just the ones that make external calls.
The Cream Finance October 2021 attack ($130 million) used cross-function reentrancy between the cToken price calculation and the borrow function. The Fei Protocol April 2022 attack ($80 million) similarly exploited a reentrancy path that auditors had missed because it required entering through a different function than the one making the external call.
Read-Only Reentrancy
An even subtler variant exploits the fact that view functions — which do not modify state and are not protected by nonReentrant — can return inconsistent values during an ongoing external call.
Consider a protocol that reads a token's price from an external source that uses balanceOf() under the hood. If that external call occurs during a reentrancy window, the balanceOf() call returns a value reflecting a state that has not been fully committed:
// READ-ONLY REENTRANCY
// The view function returns a value from a partially-updated state
// Protocol A reads Protocol B's state during Protocol B's external call execution
// Protocol B's state is inconsistent at this point
// Curve Finance is a documented example of this attack surface
// In 2022, multiple protocols using Curve's LP token price were vulnerable
// because they read Curve's virtual_price() during Curve's own operationsThe Mango Markets exploit in October 2022 ($116 million) used a variation of this. Read-only reentrancy attacks require the attacker to have deep knowledge of how multiple protocols interact — but the payoffs are among the largest in DeFi history.
Apply nonReentrant guards to all public and external state-modifying functions as a default, not just withdrawal functions. Cross-function and read-only reentrancy have been responsible for $500+ million in losses. The gas overhead of the guard (approximately 2,300 gas per call) is negligible relative to the protection it provides.
Integer Overflow and Underflow
Before Solidity 0.8.0, integer arithmetic silently wrapped around on overflow or underflow. A uint256 at its maximum value (2^256 - 1) plus 1 became 0. A uint256 at 0 minus 1 became 2^256 - 1 — an astronomically large number. No exception was thrown. No error was returned. The math just silently produced a wrong answer.
This was responsible for some of the most straightforward exploits in the early ERC-20 era.
The Beauty Chain (BEC) Token: April 2018
The BEC token launched with great fanfare in early 2018. On April 22, 2018, an attacker called the batchTransfer function with carefully chosen parameters that triggered an integer overflow. The attack was two lines of math.
// VULNERABLE — The BEC batchTransfer function
// This is the code that destroyed hundreds of millions in market cap
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
// THE BUG: This multiplication can overflow
// If cnt = 2 and _value = 2^255:
// 2 * 2^255 = 2^256 = 0 (overflow wraps to 0)
uint256 amount = uint256(cnt) * _value;
// Balance check passes because amount = 0
// sender's balance appears sufficient (any balance >= 0)
require(_value > 0 && balances[msg.sender] >= amount);
// Sender's balance decreases by 0 (amount overflowed to 0)
balances[msg.sender] = balances[msg.sender].sub(amount);
// But each receiver gets 2^255 tokens — an astronomical amount
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}The attacker passed:
_receivers = [address1, address2](2 receivers)_value = 57896044618658097711785492504343953926634992332820282019728792003956564819968(which is 2^255)
The multiplication 2 * 2^255 wrapped to 0. The require check passed (any balance is >= 0). Each receiver was credited with 2^255 BEC tokens — more tokens than existed in total supply by orders of magnitude.
Within hours, the recipients attempted to dump these tokens. The BEC token price collapsed to essentially zero. Hundreds of millions of dollars in market cap evaporated in one transaction.
The same vulnerability pattern was simultaneously found in multiple other ERC-20 tokens deployed from similar code templates, including SMT (SmartMesh Token), which lost similar value in the same week.
Prevention in Modern Solidity
Solidity 0.8.0 (released December 2020) introduced built-in overflow and underflow checking. All arithmetic operations now revert by default if they overflow or underflow, with no additional code required.
// Solidity 0.8.x — overflow reverts automatically
pragma solidity ^0.8.0;
contract SafeArithmetic {
function safeAdd(uint256 a, uint256 b) public pure returns (uint256) {
// This reverts with "Arithmetic operation overflowed" if a + b > 2^256 - 1
return a + b;
}
function safeSub(uint256 a, uint256 b) public pure returns (uint256) {
// This reverts if b > a (would underflow)
return a - b;
}
}
// If you explicitly need wrapping behavior for gas optimization
// (only use when you've mathematically proven overflow cannot cause harm):
contract GasOptimized {
function wrappingAdd(uint256 a, uint256 b) public pure returns (uint256) {
unchecked {
return a + b; // No overflow check — reverts disabled
}
}
}For contracts still on Solidity 0.7.x or lower (which should be migrated urgently), use OpenZeppelin's SafeMath library:
// Legacy SafeMath usage (Solidity < 0.8.0)
import "@openzeppelin/contracts/math/SafeMath.sol";
contract LegacySafe {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// sub() reverts on underflow, add() reverts on overflow
balances[msg.sender] = balances[msg.sender].sub(amount, "Insufficient balance");
balances[to] = balances[to].add(amount);
}
}The unchecked block in Solidity 0.8.x disables overflow and underflow protection for gas savings. It is appropriate only for loop counters, index arithmetic, and other contexts where you have mathematically proven that the values cannot overflow in any execution path. Never use unchecked for arithmetic involving user-supplied values, token amounts, or any value influenced by external inputs.
Access Control Vulnerabilities
Access control bugs are simultaneously the simplest vulnerability class and the most consistently catastrophic. The pattern: a function that should be restricted to authorized callers is reachable by anyone, or the authorization mechanism itself is flawed.
The Parity Multisig Freeze: $150 Million Permanently Locked, November 2017
The Parity Multisig library incident is one of the most technically interesting incidents in Ethereum history — and one of the most financially devastating. Unlike most smart contract hacks, no funds were stolen. Instead, an accident permanently locked approximately $150 million worth of ETH that is inaccessible to this day.
Parity's multisig wallet architecture used a library contract (WalletLibrary) deployed once, with individual wallet contracts delegating execution to it via delegatecall. This meant all multisig wallets shared a single copy of the core logic code.
The library contract had an initialization function that should have been called once during deployment. It was not protected:
// VULNERABLE — The Parity WalletLibrary
// This is the function that killed $150 million
contract WalletLibrary {
address public owner;
// CRITICAL BUG: This function has no access control
// The library itself is a deployed contract — it can be initialized
// Anyone can call this on the library directly (not through a wallet proxy)
function initWallet(address[] _owners, uint _required, uint _daylimit) {
initDaylimit(_daylimit);
initMultiowned(_owners, _required);
}
// initMultiowned sets msg.sender as the first owner if no owners exist
function initMultiowned(address[] _owners, uint _required) internal {
m_numOwners = _owners.length + 1;
m_owners[1] = uint(msg.sender); // msg.sender becomes owner
// ... more owner setup
}
// Owner can destroy the library contract
function kill(address _to) onlyowner {
suicide(_to); // selfdestruct
}
}On November 6, 2017, a user named "devops199" (who may have stumbled onto this accidentally) called initWallet() on the library contract itself. Since the library had never been initialized, msg.sender became the owner. Devops199 then called kill(). The library contract self-destructed.
Every Parity multisig wallet that delegated to this library via delegatecall now pointed to a non-existent contract. Every call to those wallets reverted. Approximately 587 wallets, holding a combined $150 million in ETH (at the time), became permanently inaccessible. No backdoor. No exploit. Just inaccessible, forever.
Proposed EIP-999 to recover the funds was controversial and never passed. The ETH remains locked to this day.
The fix is straightforward: initializer functions must be protected against being called more than once, and against being called directly on library contracts.
// SAFE — Using OpenZeppelin's Initializable pattern
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract WalletLibrary is Initializable {
address public owner;
// initializer modifier ensures this can only be called once
// and only via the proxy, not directly on the implementation
function initialize(address[] calldata _owners, uint256 _required)
external initializer
{
require(_owners.length > 0, "At least one owner required");
// Setup logic here
}
}
// Additionally: disable initialization on the implementation contract itself
constructor() {
_disableInitializers();
}Ronin Bridge: $625 Million, March 2022
The Ronin Network bridge, supporting Sky Mavis's Axie Infinity game, was exploited for $625 million on March 23, 2022 — the largest single DeFi hack in history.
The attack was not a Solidity code vulnerability. It was an access control failure at the protocol governance level, exploited by a nation-state threat actor (later attributed to North Korea's Lazarus Group by the FBI, Chainalysis, and US Treasury).
The Ronin bridge used a multi-signature scheme: transactions required 5 of 9 validator signatures to be approved. Sky Mavis held 4 validator keys. The Axie DAO held 1.
In November 2021, during a period of extremely high Axie Infinity activity, Sky Mavis asked the Axie DAO to temporarily whitelist Sky Mavis's nodes to help them process transactions. This whitelisting effectively gave Sky Mavis access to the Axie DAO validator signature — meaning Sky Mavis could authorize transactions using 5 of 9 keys unilaterally.
The emergency whitelisting was supposed to be temporary. It was never revoked.
In January-February 2022, Lazarus Group operatives spear-phished Sky Mavis employees (reportedly through a fake LinkedIn job offer PDF that contained malware). The malware established access to Sky Mavis's internal infrastructure. From there, attackers accessed four Sky Mavis validator private keys. Combined with the previously granted Axie DAO key access that was still active, they had 5 of 9 signatures — enough for consensus.
On March 23, 2022, the attackers submitted fraudulent withdrawal transactions: 173,600 ETH and 25.5 million USDC, totaling $625 million. The Axie DAO key had given consent through the administrative process months earlier. The signature scheme considered this legitimate.
Sky Mavis did not discover the breach for six days — until a user reported they could not withdraw funds.
The policy failures:
- Temporary elevated permissions were never revoked
- Multi-signature threshold (5/9) was too low for funds of this value
- No automated anomaly detection on validator signature behavior
- Validator key management allowed phishing to compromise multiple keys
- No emergency circuit breaker to pause withdrawals on unusual activity
Ronin was relaunched with upgraded security in June 2022. Sky Mavis raised $150 million from investors to reimburse victims.
Common Access Control Patterns and Their Failures
// MISTAKE 1: No modifier on privileged function
// Any caller can change the fee recipient
function setFeeRecipient(address _recipient) external {
feeRecipient = _recipient;
// Missing: require(msg.sender == owner, "Not authorized");
// Or: missing the onlyOwner modifier
}
// MISTAKE 2: Using tx.origin instead of msg.sender for authentication
// tx.origin is the original EOA that initiated the transaction chain
// msg.sender is the immediate caller
// If owner calls AttackerContract which calls this function:
// tx.origin == owner (passes the check)
// msg.sender == AttackerContract (would fail, but we're not checking it)
function adminAction() external {
require(tx.origin == owner, "Not owner"); // VULNERABLE
// Attacker can deploy a contract, trick the owner into calling it
// (via phishing), and that contract calls adminAction() — which passes
}
// CORRECT:
function adminAction() external {
require(msg.sender == owner, "Not owner"); // Check the immediate caller
}
// MISTAKE 3: Pre-0.5.0 constructor naming convention
// Before Solidity 0.5.0, constructors were named after the contract
// A typo in the contract name makes the "constructor" a regular public function
// that anyone can call to take ownership
contract Rubixi { // Original contract name was "DynamicPyramid"
address private owner;
// This was supposed to be the constructor
// But after renaming the contract, it became a regular callable function
function DynamicPyramid() public { // Anyone can call this!
owner = msg.sender;
}
}
// MISTAKE 4: Default public visibility in older Solidity
// Pre-0.5.0 Solidity, functions defaulted to public
// An internal helper function with no visibility modifier was callable by anyone
function _internalTransfer(address from, address to, uint256 amount) {
// Supposed to be internal, but was actually public
_balances[from] -= amount;
_balances[to] += amount;
}OpenZeppelin's AccessControl library provides a battle-tested role-based access control system with events, role administration, and granular permission management. For any contract with multiple roles (owner, operator, pauser, upgrader), use OpenZeppelin's AccessControl or Ownable rather than rolling your own. The number of access control exploits that have hit custom-built systems that "looked correct" is extensive.
Flash Loan Attacks
Flash loans are uncollateralized loans that must be borrowed and repaid within a single transaction. If the loan is not repaid, the entire transaction reverts as if it never happened. They were introduced by Aave in January 2020 as a legitimate DeFi primitive.
Flash loans do not create vulnerabilities. They amplify vulnerabilities that already exist. An attacker with zero capital can borrow $500 million, use it to manipulate prices or exploit protocol logic, extract profit, and repay the loan — all in one atomic transaction. The capital requirement for an attack that would require hundreds of millions of dollars without flash loans drops to the gas cost of the transaction.
How Flash Loan Attacks Work
// A generalized flash loan attack template
contract FlashLoanAttacker {
IFlashLender public lender; // e.g., Aave lending pool
IVulnerableProtocol public target;
function executeAttack() external {
uint256 loanAmount = 50_000_000 * 1e6; // 50 million USDC
// Step 1: Request flash loan from Aave
// Aave will call executeOperation() on this contract
lender.flashLoan(
address(this),
address(USDC),
loanAmount,
"" // Arbitrary data passed to executeOperation
);
// By the time we're back here, everything below has completed
// including the loan repayment (or the whole tx reverted)
}
// Aave calls this function with the borrowed funds
function executeOperation(
address asset,
uint256 amount,
uint256 premium, // Aave's fee (0.09%)
address initiator,
bytes calldata params
) external returns (bool) {
// Step 2: Use the 50M USDC to manipulate prices or exploit logic
manipulateOracleAndExtractProfit();
// Step 3: Approve Aave to take back the loan + fee
uint256 repayAmount = amount + premium;
IERC20(asset).approve(address(lender), repayAmount);
return true;
}
}Euler Finance: $197 Million, March 2023
The Euler Finance hack is one of the most instructive exploits because it attacked a protocol that had been audited multiple times (six independent audits), had a $1 million bug bounty program, and was generally considered a carefully built protocol.
The vulnerability was introduced in a feature called donateToReserves. The function allowed users to donate their eTokens (collateral deposit receipts) to Euler's reserve pool — a legitimate use case to improve protocol stability. The bug: the function allowed users to reduce their eToken balance without a corresponding check on whether the resulting position remained solvent.
The attack flow (simplified to illustrate the logic):
// Simplified version of the vulnerable Euler pattern
// The real attack involved multiple recursive operations and two addresses
contract EulerVulnerable {
mapping(address => uint256) eTokenBalance; // Collateral deposited
mapping(address => uint256) dTokenBalance; // Debt owed
function deposit(uint256 amount) external {
eTokenBalance[msg.sender] += amount * exchangeRate;
// Transfer underlying asset from user
}
function borrow(uint256 amount) external {
require(isHealthy(msg.sender), "Undercollateralized");
dTokenBalance[msg.sender] += amount;
// Transfer asset to user
}
// THE BUG: Allows reducing collateral balance without health check
// A position with $100 eToken and $50 dToken is "healthy" (2x collateral)
// After donating $60 eToken, position has $40 eToken vs $50 dToken
// Position is now underwater — but the function lets this happen
function donateToReserves(uint256 eTokenAmount) external {
eTokenBalance[msg.sender] -= eTokenAmount;
reserves += eTokenAmount;
// MISSING: health check after modification
}
// A health check here would have caught the attack
function donateToReservesFixed(uint256 eTokenAmount) external {
eTokenBalance[msg.sender] -= eTokenAmount;
reserves += eTokenAmount;
// ADDED: Verify position remains solvent after modification
require(isHealthy(msg.sender), "Position undercollateralized after donation");
}
}The actual Euler attack was more complex, involving a soft liquidation mechanism and a two-address setup that created a profitable liquidation. The attacker used a flash loan from Aave for $30 million DAI. The extracted profit was approximately $197 million.
Unusually, the attacker returned the funds after Euler engaged them through on-chain messages and off-chain communication, offering a 10% whitehack reward and threatening law enforcement engagement. Euler recovered approximately $176 million over the following weeks.
The core lesson: any function that modifies a user's financial position must include a post-modification health check. Invariants that the protocol depends on (collateralization ratios, position solvency) must be verified after every operation that could affect them — including operations that are not obviously "dangerous" like donation functions.
Wormhole Bridge: $320 Million, February 2022
The Wormhole cross-chain bridge hack is notable because it exploited a vulnerability in the Solana program's signature verification logic rather than the typical Solidity-based attack.
Wormhole's bridge architecture required guardian signatures to authorize cross-chain messages. To bridge ETH from Ethereum to Solana, guardians must sign an attestation. The Solana program responsible for verifying these signatures called the load_instruction_at helper to verify that a valid guardian set had signed the message.
The vulnerability: load_instruction_at was deprecated in favor of load_instruction_at_checked. The deprecated version trusted the instruction data passed to it — it did not verify that the sysvar account being read was actually the Instructions sysvar. An attacker could pass a fake Instructions sysvar account containing fabricated guardian signatures.
// VULNERABLE (Solana program, Rust)
// This code trusted account data without verifying the account's identity
let instruction_sysvar = accounts.instruction_sysvar.data.borrow();
// load_instruction_at doesn't verify this is actually the Instructions sysvar
let signature_ix = load_instruction_at(0, &instruction_sysvar)?;
// If signature_ix was fabricated, this "verification" passes on fakes
verify_signatures(&signature_ix)?;
// SAFE:
// load_instruction_at_checked DOES verify the account identity
let signature_ix = load_instruction_at_checked(0, &accounts.instruction_sysvar)?;
verify_signatures(&signature_ix)?;The attacker used this to fabricate a guardian signature set, then minted 120,000 wETH (wrapped ETH on Solana) without depositing any ETH on the Ethereum side. The wETH was immediately swapped for USDC and SOL.
Jump Crypto, Wormhole's backer, injected $320 million from their own funds to cover the shortfall and maintain the bridge's 1:1 peg — one of the largest emergency bailouts in DeFi history.
Cross-chain bridges are the highest-risk DeFi infrastructure class. Between February 2021 and December 2022, bridge hacks accounted for approximately $2.5 billion in losses, including Ronin ($625M), Wormhole ($320M), Nomad ($190M), Harmony Horizon ($100M), and Multichain ($126M). Bridge architecture requires heterogeneous trust models across different chains with different security properties. Minimize the value you hold in bridge contracts at any time.
Oracle Manipulation
Smart contracts cannot natively access off-chain data. Any price feed, event outcome, or real-world state must be provided by an oracle — an external system that writes data onto the blockchain. Oracle manipulation attacks exploit the gap between the price an oracle reports and the true market price.
On-Chain Spot Price Manipulation
The most exploitable oracle pattern is using the spot price from a DEX liquidity pool as a price feed. DEX spot prices can be manipulated within a single transaction using a flash loan.
// VULNERABLE — using Uniswap V2 spot price as an oracle
contract VulnerableLending {
IUniswapV2Pair public immutable pair; // WETH/USDC pool
uint256 public constant COLLATERAL_RATIO = 150; // 150% collateralization
// Gets the current ETH price from the Uniswap pool
// This is the spot price — it can be manipulated in the same transaction
function getEthPrice() internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
// reserve0 = WETH, reserve1 = USDC
return uint256(reserve1) * 1e18 / uint256(reserve0);
}
// Liquidate an undercollateralized position
function liquidate(address user) external {
uint256 ethPrice = getEthPrice();
uint256 collateralValue = deposits[user] * ethPrice / 1e18;
uint256 debtValue = borrows[user];
// If price is manipulated DOWN, legitimate positions appear undercollateralized
// Attacker can liquidate healthy positions at a discount
require(collateralValue * 100 < debtValue * COLLATERAL_RATIO,
"Position is healthy");
executeLiquidation(user, ethPrice);
}
}
// ATTACK: Using flash loan to manipulate the oracle
contract Attacker {
VulnerableLending target;
IUniswapV2Pair pair;
function attack(address victim) external {
// 1. Borrow massive WETH from Aave flash loan
// 2. Dump the WETH into the Uniswap pool, crashing the WETH/USDC price
// (more WETH in pool = lower WETH price per USDC)
// 3. Call target.liquidate(victim) — at the manipulated low price,
// victim's ETH collateral looks worth less than their debt
// 4. Acquire victim's collateral at a deep discount
// 5. Restore the price by buying back WETH
// 6. Repay flash loan
// Net profit: discount on liquidation minus flash loan fee and slippage
}
}The fix is time-weighted average prices (TWAP). Instead of reading the current reserve ratio, the contract reads the cumulative price over a time window (e.g., 30 minutes) and computes the average. Manipulating a TWAP requires maintaining the price at the manipulated level for the entire window — which costs real capital and is economically infeasible for large windows.
// SAFER — using Uniswap V3 TWAP oracle
// A 30-minute TWAP cannot be manipulated in a single transaction
function getTWAP(uint32 twapInterval) internal view returns (uint256 price) {
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapInterval; // 30 * 60 = 1800 seconds ago
secondsAgos[1] = 0; // now
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
int24 timeWeightedAverageTick = int24(
tickCumulativesDelta / int56(int32(twapInterval))
);
// Convert tick to price (Uniswap V3 specific math)
uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(timeWeightedAverageTick);
price = FullMath.mulDiv(
uint256(sqrtPriceX96) * uint256(sqrtPriceX96),
1e18,
2**192
);
}For protocols where a 30-minute TWAP window is too slow (e.g., liquidations need to respond to price drops quickly), the recommended architecture is layered oracles: a Chainlink price feed as the primary source, with a circuit breaker that pauses operations if the TWAP deviates significantly from Chainlink, indicating potential manipulation.
Chainlink Oracle Risks
Chainlink aggregates prices from multiple off-chain data providers and posts them on-chain. It is significantly harder to manipulate than on-chain DEX prices. But it is not manipulation-proof.
Heartbeat delays. Chainlink price feeds update when the price deviates by a threshold (e.g., 0.5% for ETH/USD) or after a maximum heartbeat (e.g., 1 hour for ETH/USD). During the heartbeat window, the on-chain price may be stale. The Venus Protocol liquidation cascade in May 2021 was partially caused by stale Chainlink prices.
L2 sequencer downtime. On Ethereum L2s (Arbitrum, Optimism), the sequencer can go offline. Chainlink feeds have no way to report updates if the sequencer is down. Protocols must check sequencer status before consuming prices:
// IMPORTANT for L2 protocols: Check Chainlink L2 sequencer uptime
AggregatorV2V3Interface internal sequencerUptimeFeed;
uint256 private constant GRACE_PERIOD_TIME = 3600; // 1 hour
function isSequencerAlive() internal view returns (bool) {
(, int256 answer, uint256 startedAt, , ) =
sequencerUptimeFeed.latestRoundData();
// answer == 0 means sequencer is up
// answer == 1 means sequencer is down
bool isUp = answer == 0;
if (!isUp) return false;
// Check that the sequencer has been up for at least GRACE_PERIOD_TIME
// Prices immediately after sequencer restart may not reflect true market
bool isGracePeriodOver = block.timestamp - startedAt > GRACE_PERIOD_TIME;
return isGracePeriodOver;
}Circuit breakers and price bounds. For critical protocols, implement bounds on acceptable prices. If the reported ETH price is $0 or $1,000,000, something is wrong — reject the price and pause operations:
function getSafePrice() internal view returns (uint256) {
(, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
// Check price is not stale (older than 1 hour for ETH/USD)
require(block.timestamp - updatedAt <= 3600, "Price feed stale");
// Check price is positive and within plausible bounds
require(price > 0, "Negative price");
require(uint256(price) > MIN_ACCEPTABLE_PRICE, "Price too low");
require(uint256(price) < MAX_ACCEPTABLE_PRICE, "Price too high");
return uint256(price);
}The Invariant Testing Approach
The most effective method for finding the vulnerability classes above before deployment is invariant testing — writing tests that verify properties of your contract that must always hold true, regardless of what sequence of operations is performed.
Foundry's fuzzer is the current state of the art for Solidity invariant testing:
// Foundry invariant test
// The fuzzer generates random sequences of function calls
// and verifies the invariant holds after each sequence
contract BankInvariantTest is Test {
Bank bank;
function setUp() public {
bank = new Bank();
}
// INVARIANT: The contract's ETH balance must always equal
// the sum of all user account balances
// If reentrancy drains funds, this invariant would break
function invariant_bankSolvency() public {
uint256 totalDeposits = 0;
for (uint i = 0; i < users.length; i++) {
totalDeposits += bank.balances(users[i]);
}
assertEq(address(bank).balance, totalDeposits,
"Bank is insolvent — ETH balance doesn't match deposits");
}
// INVARIANT: No single user's balance can exceed total deposits
function invariant_noUserBalanceExceedsTotal() public {
uint256 totalETH = address(bank).balance;
for (uint i = 0; i < users.length; i++) {
assertLe(bank.balances(users[i]), totalETH,
"User balance exceeds total contract balance");
}
}
}Run the fuzzer:
# Foundry invariant testing
# Runs 1000 random operation sequences, checking invariants after each
forge test --match-contract InvariantTest --runs 1000 -vv
# For deeper fuzzing with more operations per run:
forge test --match-contract InvariantTest --runs 5000 --depth 500Echidna is the other major Solidity fuzzer, particularly good at finding arithmetic edge cases:
// Echidna property test
// Echidna generates random inputs and checks that this function never returns false
function echidna_balance_never_negative() public returns (bool) {
return balances[msg.sender] >= 0; // Always true for uint, but useful for int types
}
function echidna_total_supply_constant() public returns (bool) {
return totalSupply == INITIAL_SUPPLY; // Supply should never change without a mint/burn event
}Pre-Deployment Audit Checklist
Before deploying or interacting with any significant smart contract, these questions determine whether you are taking acceptable risk:
Reentrancy
- [ ] Every function making external calls follows Checks-Effects-Interactions order
- [ ]
nonReentrantapplied to all public/external state-modifying functions - [ ] Cross-function reentrancy paths analyzed (functions sharing state have guards on all of them)
- [ ] Read-only reentrancy paths analyzed for protocols that read from external contracts
Arithmetic (Solidity < 0.8.0)
- [ ] SafeMath used for all arithmetic on untrusted values
- [ ] All multiplication operations reviewed for overflow at maximum expected values
- [ ] Division operations reviewed (no divide-by-zero, no precision loss on integer division)
Access Control
- [ ] Every privileged function has an appropriate access modifier
- [ ]
tx.originnot used for authentication anywhere - [ ] Initializer functions are protected against re-initialization and against direct calls on implementation contracts
- [ ] Admin key management process documented — how are keys stored, how are they rotated?
- [ ] Temporary elevated permissions have a documented expiry process
Oracle Security
- [ ] No spot price from a DEX used as an oracle for any security-critical calculation
- [ ] TWAP window length is at least 30 minutes for liquidation-related price reads
- [ ] Chainlink feeds checked for freshness and valid range before use
- [ ] L2 sequencer uptime checked before consuming price data on Arbitrum/Optimism
- [ ] Multi-oracle redundancy for protocols where price accuracy is critical
Flash Loan Resistance
- [ ] Any combination of allowed external calls within a single transaction has been analyzed for attack paths
- [ ] Protocol invariants (collateral ratios, solvency) are tested post-operation, not just pre-operation
- [ ] Flash loan attack scenarios included in fuzzing harness with realistic capital amounts
General
- [ ] Contract is on Solidity 0.8.0 or later
- [ ] OpenZeppelin libraries used for standard patterns (ReentrancyGuard, AccessControl, SafeERC20)
- [ ] Invariant tests written and passing with 10,000+ fuzzing runs
- [ ] Audit completed by a firm with documented DeFi security experience
- [ ] Bug bounty program established before mainnet deployment
The Euler exploit happened to an audited protocol with a $1 million bug bounty. The Wormhole hack happened to a professionally developed, well-funded bridge. Audits reduce risk; they do not eliminate it. The most reliable defense is simplicity — contracts that do less have fewer ways to fail. Every feature added to a smart contract is an invariant that must be maintained under adversarial conditions at any capital level.
Development Security Resources
| Resource | What It Provides | |---|---| | SWC Registry | Canonical vulnerability classification with code examples | | Damn Vulnerable DeFi | Hands-on CTF covering all major vulnerability classes | | OpenZeppelin Contracts | Audited implementations of security-critical patterns | | Immunefi Bug Reports | Real disclosed vulnerabilities with technical breakdowns | | Rekt.news | Post-mortems of major exploits, updated continuously | | Trail of Bits Blog | Deep technical security research including Slither and Echidna | | Foundry Book | Complete reference for Foundry fuzzing and invariant testing |