Beyond reentrancy, four attack classes drain DeFi protocols. Price-oracle manipulation (SC03:2026) reads a price an attacker can move in one block, so use a manipulation-resistant feed with staleness and deviation checks. Flash loans (SC04:2026) let an attacker borrow enormous capital for one transaction and amplify any same-block price or governance weakness. Upgradeable proxies (SC10:2026) fail through storage collisions and uninitialized implementation contracts, so use namespaced storage and disable the implementation's initializer. Signature replay happens when an EIP-712 signature lacks a nonce, deadline or chain id, or when ECDSA malleability is ignored. Each has a concrete fix, verified in a Foundry test.
- Never price an asset from a value one transaction can move: an AMM spot price from
getReservesor abalanceOfis manipulable, especially with a flash loan. - Use a manipulation-resistant oracle (a Chainlink feed with a staleness and answer-sign check, or a sufficiently long Uniswap v3 TWAP) and bound deviation.
- A flash loan is not itself the bug; it is the capital that makes a same-block price or snapshot-less governance vote exploitable.
- Upgradeable storage must not collide: use ERC-7201 namespaced storage (OpenZeppelin 5.x), never reorder inherited state, and keep the proxy's and implementation's layouts compatible.
- An implementation contract left uninitialized is takeable: call
_disableInitializers()in its constructor and guard upgrades with_authorizeUpgrade. - An EIP-712 signature needs a domain separator with
chainIdandverifyingContract, a per-signer nonce and a deadline, and low-s ECDSA, or it can be replayed.
| Setting | Value | Note | Source |
|---|---|---|---|
| Price source | A manipulation-resistant oracle, not a spot AMM price | Chainlink data feed, or a Uniswap v3 TWAP over a window long enough to make manipulation uneconomic. | OWASP SC03:2026 Price Oracle Manipulation |
| Chainlink feed freshness | Check updatedAt against a max-age and require answer > 0 | latestRoundData can return stale or zero data; on L2 also check the sequencer uptime feed. | Chainlink Data Feeds API reference |
| Same-block price trust | Never trust a price that can change within one transaction | Flash loans provide unlimited single-block capital; this is why spot prices fall. | OWASP SC04:2026 Flash Loan-Facilitated Attacks |
| Upgradeable storage | ERC-7201 namespaced storage; never reorder inherited variables | OpenZeppelin Contracts 5.x uses namespaced storage for its upgradeable modules. | ERC-7201; OpenZeppelin upgradeable docs |
| Implementation contract | _disableInitializers() in the constructor; initializer/reinitializer on init | An uninitialized implementation can be initialized and, via selfdestruct or an upgrade, taken over. | OpenZeppelin Initializable |
| UUPS upgrade authorization | Implement _authorizeUpgrade with access control | The upgrade path lives in the implementation; an unguarded one lets anyone upgrade. | OpenZeppelin UUPSUpgradeable |
| Signatures | EIP-712 with chainId + verifyingContract, a nonce and a deadline | Use OpenZeppelin ECDSA.recover (low-s, rejects malleable signatures) and EIP-712 for the domain. | EIP-712; OpenZeppelin ECDSA |
- For every price read, confirm it comes from an oracle an attacker cannot move within one transaction; flag any use of
getReserves,balanceOfor a single-pool spot price as a price. - On each Chainlink read, confirm the code checks
updatedAtagainst a maximum age, requiresanswer > 0, and on L2 checks the sequencer uptime feed. - Write a Foundry test that manipulates the price source within one transaction (a mock or a fork plus a flash loan) and assert the protected function reverts or is unaffected.
- For an upgradeable contract, run the OpenZeppelin upgrades plugin storage-layout check between versions, and confirm the implementation constructor calls
_disableInitializers(). - Attempt to initialize the implementation contract directly on a testnet fork and confirm it reverts.
- For each signed message, write a Foundry test that replays the same signature twice, on a different chain id, and after the deadline, and assert each is rejected.
- Applies to
- Solidity 0.8.37 (latest 0.8.x)
- OpenZeppelin Contracts / Contracts-Upgradeable 5.x (5.7.0 is the latest release; npm’s latest tag is still 5.6.1)
- Foundry current stable (forge)
- OWASP Smart Contract Top 10 2026 edition
Solidity smart contracts on EVM chains, especially DeFi protocols. Versions below are current as of October 2026; pin your compiler and library versions.
- Part of
- Workflow: Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Price assets from manipulation-resistant oracles with staleness and deviation checks.
- Recognize when a flash loan turns a weak oracle or governance design into a drain.
- Lay out upgradeable storage safely and disable uninitialized implementations.
- Build EIP-712 signatures that cannot be replayed across nonces, deadlines or chains.
Before you start
Solidity developers and auditors working on DeFi protocols or upgradeable contracts.
- Write and test Solidity contracts with Foundry.
- Understand reentrancy and the checks-effects-interactions pattern.
Refresh first: Test smart contracts through state invariants
Go to the guided practiceA flash loan moves a spot price inside one transaction
- BorrowThe attacker’s contract takes a flash loan that must be repaid before the transaction ends.
- Move the priceIt swaps the borrowed funds into the AMM pool, shifting the reserve ratio the lending contract reads as a price.
- Borrow against the inflated priceThe lending contract values the attacker’s collateral from
getReservesand lends far more than the collateral is worth. - Unwind and repayThe attacker swaps back, repays the flash loan and fee, and keeps the excess loan. If any step fails, the whole transaction reverts.
The reentrancy guide at /domains/appsec/smart-contracts/security-invariants/ covers one class. The OWASP Smart Contract Top 10 (2026 edition), built from 2025 incidents and survey data, names several more. Three have entries of their own: price oracle manipulation, flash-loan-facilitated attacks, and proxy and upgradeability vulnerabilities. Signature replay has no entry; in practice it is an access-control failure (SC01:2026). The SWC registry that older material references is no longer maintained; use the Smart Contract Top 10 and current library docs instead.
| Class | OWASP 2026 ID | Root cause |
|---|---|---|
| Price oracle manipulation | SC03:2026 | Pricing from a value an attacker can move in one transaction |
| Flash-loan-facilitated attacks | SC04:2026 | Unlimited single-block capital amplifying another weakness |
| Proxy and upgradeability | SC10:2026 | Storage collisions and uninitialized implementations |
| Signature replay | None (closest: SC01:2026) | EIP-712 without nonce, deadline or chain id; ECDSA malleability |
A contract that reads the price from an AMM pool's reserves, or from a token balance, reads a number any trader can move. An attacker swaps into the pool to move the price, interacts with the victim at the manipulated price, and swaps back, all in one transaction. This lending contract values collateral from Uniswap v2 reserves, so a large swap inflates the collateral's apparent value and lets the attacker borrow far more than it is worth.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
interface IPair { function getReserves() external view returns (uint112, uint112, uint32); }
contract VulnerableLending {
IPair public pair;
// Vulnerable: price = reserve ratio, which a single swap (or a flash loan) moves.
function collateralPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pair.getReserves();
return (uint256(r1) * 1e18) / uint256(r0); // spot price, manipulable in one tx
}
}Read from an oracle an attacker cannot move within a transaction. A Chainlink data feed aggregates many sources; call latestRoundData and reject stale or non-positive answers, and on an L2 also check the sequencer uptime feed so you don't trade on a frozen price after an outage. Where a Chainlink feed is unavailable, a Uniswap v3 time-weighted average price (TWAP) over a long enough window raises the cost of manipulation, because the attacker must hold the price moved across many blocks. Bound how far a price may deviate between reads as a circuit breaker.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
interface AggregatorV3Interface {
function latestRoundData()
external view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}
contract SafeLending {
AggregatorV3Interface public feed;
uint256 public constant MAX_AGE = 3600; // reject prices older than one hour
function collateralPrice() public view returns (uint256) {
(, int256 answer, , uint256 updatedAt, ) = feed.latestRoundData();
require(answer > 0, "bad price"); // not zero or negative
require(block.timestamp - updatedAt <= MAX_AGE, "stale price");
return uint256(answer);
}
}A flash loan lends an uncollateralized amount that must be repaid within the same transaction (Aave v3's flashLoanSimple, or the ERC-3156 interface). It is not an exploit by itself; it removes the capital constraint that used to make manipulation uneconomic. With millions borrowed for one block, an attacker can move a spot oracle, win a governance vote that counts tokens held at that instant, or push a thinly-traded pool, then unwind and repay. The defenses are the ones in this guide: price from manipulation-resistant oracles, count governance votes from a past snapshot and add a timelock, and never trust a value that can change within the transaction.
Beanstalk lost about $181 million in April 2022: the attacker proposed malicious changes, waited out the one-day delay on emergency commits, then used a flash loan to take about 79% of the voting power within one transaction and called emergencyCommit to enact them. Voting power came from in-the-moment deposits, not a past snapshot. A Foundry fork test that borrows and manipulates within one transaction is the way to prove your contract resists this.
// A Foundry test sketch: prove the protected read resists single-tx manipulation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {Test} from "forge-std/Test.sol";
contract OracleManipulationTest is Test {
function test_spotPriceCanBeMovedInOneTx() public {
// 1. snapshot price, 2. swap a large amount into the pool, 3. read price again:
// the vulnerable spot read changes within the same transaction.
// Assert the SafeLending read is unchanged (it uses the aggregator, not the pool).
}
}An upgradeable contract splits code (the implementation) from state (the proxy), and the proxy runs the implementation's code against the proxy's storage via delegatecall. If the implementation's storage layout disagrees with what the proxy already holds, a write lands on the wrong slot: a storage collision. This happens when a new version inserts or reorders a state variable, changes an inheritance order, or shares a slot with the proxy's own admin variables. OpenZeppelin Contracts 5.x avoids it with ERC-7201 namespaced storage, which puts a module's state at a hashed, collision-resistant slot rather than sequential slots 0, 1, 2.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
// ERC-7201 namespaced storage: state lives at a hashed slot, not slot 0,
// so adding or reordering variables in a later version cannot collide.
contract VaultStorage {
/// @custom:storage-location erc7201:example.vault
struct VaultData {
uint256 totalAssets;
mapping(address => uint256) shares;
}
// keccak256(abi.encode(uint256(keccak256("example.vault")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant SLOT = 0x... ; // set to the computed namespace slot
function _vault() internal pure returns (VaultData storage v) {
bytes32 slot = SLOT;
assembly { v.slot := slot }
}
}The implementation contract is never meant to be used directly, only through the proxy, so its own storage should stay empty. If its initializer can still be called, an attacker can initialize the implementation, become its owner, and, in a UUPS setup, call an upgrade or (historically) a selfdestruct that bricks every proxy pointing at it. Call _disableInitializers() in the implementation's constructor so it can never be initialized on its own, use the initializer and reinitializer modifiers for actual setup, and guard the upgrade entry point with _authorizeUpgrade.
Parity's second multisig incident in November 2017 is the canonical case: a shared library contract was left uninitialized, a user took ownership of it and called its selfdestruct, and 513,774.16 ETH across 587 wallets became permanently frozen. The library had been built by copying wallet code that kept a callable self-destruct, and only wallets deployed after 20 July 2017 were affected.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract Vault is Initializable, OwnableUpgradeable, UUPSUpgradeable {
constructor() {
_disableInitializers(); // the implementation itself can never be initialized
}
function initialize(address owner_) public initializer {
__Ownable_init(owner_);
__UUPSUpgradeable_init();
}
// Only the owner may upgrade; an unguarded _authorizeUpgrade lets anyone replace the logic.
function _authorizeUpgrade(address) internal override onlyOwner {}
}A contract that acts on a signed message must make each signature usable exactly once, for this contract, on this chain. This claim function hashes only the recipient and amount and recovers the signer with the low-level ecrecover. It has three replay problems: no nonce, so the same signature can be submitted again; no domain separator, so a signature meant for another contract or chain can be reused; and ecrecover accepts malleable (high-s) signatures, so a second valid signature exists for the same message.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
contract VulnerableClaim {
function claim(address to, uint256 amount, bytes32 r, bytes32 s, uint8 v) external {
// Vulnerable: no nonce, no domain separator, no deadline, raw ecrecover (malleable).
bytes32 hash = keccak256(abi.encodePacked(to, amount));
address signer = ecrecover(hash, v, r, s);
require(signer == admin, "bad sig");
_payout(to, amount); // replayable: submit the same signature again
}
}Use EIP-712 typed data: the domain separator includes the chain id and the verifying contract's address, so a signature is valid only for this contract on this chain, and it is recomputed if the chain forks. Add a per-signer nonce that you consume on use so a signature cannot be replayed, and a deadline so an old signature expires. Recover with OpenZeppelin's ECDSA.recover, which rejects high-s (malleable) signatures and the zero address. This pattern is the basis of EIP-2612 permit; for contract signers, verify with ERC-1271 instead of ecrecover. Signed webhooks need the same once-only and expiry checks: /domains/appsec/secure-features/verify-webhook-deliveries/.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract SafeClaim is EIP712 {
address public admin;
mapping(address => uint256) public nonces; // one-time use per signer
bytes32 private constant CLAIM_TYPEHASH =
keccak256("Claim(address to,uint256 amount,uint256 nonce,uint256 deadline)");
constructor() EIP712("SafeClaim", "1") {}
function claim(address to, uint256 amount, uint256 deadline, bytes calldata sig) external {
require(block.timestamp <= deadline, "expired");
uint256 nonce = nonces[to]++; // consume the nonce
bytes32 structHash = keccak256(abi.encode(CLAIM_TYPEHASH, to, amount, nonce, deadline));
bytes32 digest = _hashTypedDataV4(structHash); // binds chainId + this contract
require(ECDSA.recover(digest, sig) == admin, "bad sig"); // low-s, non-zero signer
_payout(to, amount);
}
}Prove the binding holds. Sign a claim with vm.sign, submit it once and expect success, then submit the identical signature again and expect a revert because the nonce is spent. Change the chain id with vm.chainId and confirm the same signature no longer verifies, and warp past the deadline with vm.warp and confirm it is rejected.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.37;
import {Test} from "forge-std/Test.sol";
contract SafeClaimTest is Test {
function test_signatureCannotBeReplayed() public {
// 1. (signer, key) = makeAddrAndKey("admin"); build the EIP-712 digest.
// 2. (v, r, s) = vm.sign(key, digest); first claim succeeds.
// 3. Replaying the same signature reverts (nonce spent).
// 4. vm.chainId(other) then verify reverts (domain separator changed).
// 5. vm.warp(deadline + 1) then verify reverts (expired).
}
}| Incident | Class | What failed |
|---|---|---|
| Mango Markets, October 2022 (over $110M) | Oracle manipulation (SC03) | A trader moved the thinly-traded MNGO perpetual's oracle price up, then borrowed against inflated collateral; the US CFTC charged the manipulation |
| Beanstalk, April 2022 (~$181M) | Flash loan + governance (SC04) | A flash loan bought about 79% of the votes within one transaction and enacted a malicious proposal through emergencyCommit; voting power came from in-the-moment deposits, not a snapshot |
| Parity multisig, November 2017 (513,774 ETH frozen) | Uninitialized implementation (SC10) | A shared library was left uninitialized; a user took ownership and called its selfdestruct, bricking every wallet that delegated to it |
| Wormhole bridge, February 2022 (~$320M) | Signature verification bypass | A deprecated Solana call did not check that the supplied sysvar account was genuine, so a fake one bypassed the guardian signature check and 120,000 wETH was minted without a valid signature set |
Guided practice
You are auditing a lending-and-rewards protocol before mainnet. The code excerpts and notes below are from the review. For each, name the attack class, explain the exploit, and write the fix.
Supplied scenario material
E1 Collateral is valued as token.balanceOf(pool) * price, where price = reserve0 / reserve1 of a Uniswap v2 pool.
E2 A governance proposal passes if votes this block exceed quorum; voting power = governanceToken.balanceOf(voter).
E3 The implementation contract has a public initialize(owner) with the initializer modifier, and no constructor.
E4 Version 2 of the proxy's logic adds `address public treasury;` as the first state variable, before the existing `uint256 public totalShares;`.
E5 Reward claims are authorized by ecrecover(keccak256(abi.encodePacked(to, amount)), v, r, s) == signer, with no other checks.
E6 The team plans to deploy the same contracts to three chains with the same signer key.1. Fix the oracle
Name the class in E1 and give the fix.
Expected result and reasoning
Price oracle manipulation (SC03), exploitable with a flash loan: a large swap moves the reserve ratio within one transaction, inflating collateral value. Replace the spot reserve ratio with a Chainlink feed (checking
updatedAtage andanswer > 0, plus the L2 sequencer feed) or a sufficiently long Uniswap v3 TWAP, and bound deviation.2. Fix the governance vote
Explain E2 and the fix.
Expected result and reasoning
Flash-loan governance attack (SC04): voting power from current balance lets a one-block flash loan buy a majority, as in Beanstalk. Count votes from a past snapshot (checkpointed balances /
ERC20VotesgetPastVotes) and add a timelock between a passing vote and execution.3. Secure the implementation
Explain the risk in E3 and the fix.
Expected result and reasoning
Uninitialized implementation (SC10): with no constructor calling
_disableInitializers(), the implementation can be initialized directly by anyone, who becomes owner and can upgrade or destroy it (Parity 2017). Add a constructor that calls_disableInitializers(), and guard_authorizeUpgradewithonlyOwner.4. Fix the storage layout
Explain what E4 breaks and the correct approach.
Expected result and reasoning
Storage collision (SC10): inserting
treasurybeforetotalSharesshifts every later slot, so the proxy's existingtotalSharesvalue is now read astreasuryand vice versa. Append new variables at the end, or use ERC-7201 namespaced storage, and run the OpenZeppelin upgrades storage-layout check between versions.5. Fix the signature scheme
Explain the replay problems in E5 and E6 and the fix.
Expected result and reasoning
Signature replay: no nonce (replay the same claim), no domain separator (E6 lets a signature from one chain be replayed on the other two), no deadline, and raw
ecrecoveris malleable. Use EIP-712 with a domain separator (chainId+verifyingContract), a consumed per-signer nonce, a deadline, andECDSA.recover; use a distinct signer per chain or rely on thechainIdin the domain.
The exercise reasons about excerpts and does not compile or run the contracts; the Solidity here was written against current library docs but not compiled. A real audit compiles with the pinned toolchain, runs Foundry fork and invariant tests, and runs the OpenZeppelin upgrades plugin.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Smart Contract Top 10 (2026)
- OWASP SC03:2026 Price Oracle Manipulation
- OWASP SC04:2026 Flash Loan-Facilitated Attacks
- Chainlink Data Feeds API reference (latestRoundData and its timestamps)
- Chainlink: L2 Sequencer Uptime Feeds
- ERC-7201: Namespaced Storage Layout. Final (created 20 June 2023).
- ERC-1967: Proxy Storage Slots. Final (created 24 April 2019).
- OpenZeppelin Contracts 5.x: proxy and upgradeable utilities
- EIP-712: Typed structured data hashing and signing. Final (created 12 September 2017).
- Parity Technologies: a postmortem on the Parity multi-sig library self-destruct (archived copy). 15 November 2017.
- US CFTC: charges against Avraham Eisenberg over the Mango Markets manipulation. Release 8647-23 (9 January 2023).
- Halborn: explained, the Beanstalk hack (April 2022)
- CertiK: Wormhole bridge exploit incident analysis
- Foundry: cheatcodes reference (vm.sign, vm.warp, vm.chainId)
Reference information
- SDLC phase
- DesignDevelopmentTesting
- Role
- Developers