In short

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 getReserves or a balanceOf is 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 chainId and verifyingContract, a per-signer nonce and a deadline, and low-s ECDSA, or it can be replayed.

Verify it

  1. For every price read, confirm it comes from an oracle an attacker cannot move within one transaction; flag any use of getReserves, balanceOf or a single-pool spot price as a price.
  2. On each Chainlink read, confirm the code checks updatedAt against a maximum age, requires answer > 0, and on L2 checks the sequencer uptime feed.
  3. 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.
  4. For an upgradeable contract, run the OpenZeppelin upgrades plugin storage-layout check between versions, and confirm the implementation constructor calls _disableInitializers().
  5. Attempt to initialize the implementation contract directly on a testnet fork and confirm it reverts.
  6. 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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 27 minutes: 7 to read and 20 for the exercise

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 practice

A flash loan moves a spot price inside one transaction

Your protocolFlash-loanproviderAttacker contractAMM poolLending protocolBorrow a large amount1Funds, repayable inthis transaction1Large swap shifts thereserves2Deposit collateral and borrow3Read spot price fromreserves3Manipulated price3Loan worth more than the collateral3Swap back4Repay loan and fee4Trust boundaryUntrusted input1Step, described below
  1. BorrowThe attacker’s contract takes a flash loan that must be repaid before the transaction ends.
  2. Move the priceIt swaps the borrowed funds into the AMM pool, shifting the reserve ratio the lending contract reads as a price.
  3. Borrow against the inflated priceThe lending contract values the attacker’s collateral from getReserves and lends far more than the collateral is worth.
  4. 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.
Every arrow is part of one transaction, so the price read in step 3 reflects the attacker’s own trade. A Chainlink feed or a long TWAP does not move in step 2.

Map the attack classes to the Smart Contract Top 10

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.

Map the attack classes to the Smart Contract Top 10
ClassOWASP 2026 IDRoot cause
Price oracle manipulationSC03:2026Pricing from a value an attacker can move in one transaction
Flash-loan-facilitated attacksSC04:2026Unlimited single-block capital amplifying another weakness
Proxy and upgradeabilitySC10:2026Storage collisions and uninitialized implementations
Signature replayNone (closest: SC01:2026)EIP-712 without nonce, deadline or chain id; ECDSA malleability

See why a spot price is manipulable

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.

VulnerableVulnerableLending.solSolidity
// 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
    }
}

Price from a manipulation-resistant oracle

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.

FixedSafeLending.solSolidity
// 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);
    }
}

Understand what a flash loan amplifies

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.

TestOracleManipulation.t.solSolidity
// 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).
    }
}

Lay out upgradeable storage so it cannot collide

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.

FixedVaultStorage.solSolidity
// 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 }
    }
}

Disable the implementation's initializer

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.

FixedVault.solSolidity
// 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 {}
}

Spot a replayable signature

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.

VulnerableVulnerableClaim.solSolidity
// 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
    }
}

Bind a signature with EIP-712, a nonce and a deadline

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/.

FixedSafeClaim.solSolidity
// 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);
    }
}

Test replay, cross-chain and expiry in Foundry

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.

TestSafeClaim.t.solSolidity
// 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).
    }
}

Learn from verified DeFi incidents

Learn from verified DeFi incidents
IncidentClassWhat 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 bypassA 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
Plain text
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. 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 updatedAt age and answer > 0, plus the L2 sequencer feed) or a sufficiently long Uniswap v3 TWAP, and bound deviation.

  2. 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 / ERC20Votes getPastVotes) and add a timelock between a passing vote and execution.

  3. 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 _authorizeUpgrade with onlyOwner.

  4. 4. Fix the storage layout

    Explain what E4 breaks and the correct approach.

    Expected result and reasoning

    Storage collision (SC10): inserting treasury before totalShares shifts every later slot, so the proxy's existing totalShares value is now read as treasury and 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. 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 ecrecover is malleable. Use EIP-712 with a domain separator (chainId + verifyingContract), a consumed per-signer nonce, a deadline, and ECDSA.recover; use a distinct signer per chain or rely on the chainId in the domain.

Your deliverable: An audit note listing E1-E6 with the attack class, the exploit (including where a flash loan is the enabler), and the fixed Solidity or configuration, plus the Foundry tests that would prove each fix.

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.

  1. OWASP Smart Contract Top 10 (2026)
  2. OWASP SC03:2026 Price Oracle Manipulation
  3. OWASP SC04:2026 Flash Loan-Facilitated Attacks
  4. Chainlink Data Feeds API reference (latestRoundData and its timestamps)
  5. Chainlink: L2 Sequencer Uptime Feeds
  6. ERC-7201: Namespaced Storage Layout. Final (created 20 June 2023).
  7. ERC-1967: Proxy Storage Slots. Final (created 24 April 2019).
  8. OpenZeppelin Contracts 5.x: proxy and upgradeable utilities
  9. EIP-712: Typed structured data hashing and signing. Final (created 12 September 2017).
  10. Parity Technologies: a postmortem on the Parity multi-sig library self-destruct (archived copy). 15 November 2017.
  11. US CFTC: charges against Avraham Eisenberg over the Mango Markets manipulation. Release 8647-23 (9 January 2023).
  12. Halborn: explained, the Beanstalk hack (April 2022)
  13. CertiK: Wormhole bridge exploit incident analysis
  14. Foundry: cheatcodes reference (vm.sign, vm.warp, vm.chainId)

Reference information

Download this guide as Markdown