Skip to main content
Sigvex

Cross-Function Reentrancy Exploit Generator

Sigvex exploit generator that validates cross-function reentrancy vulnerabilities where shared state is inconsistent across multiple functions during an external call.

Cross-Function Reentrancy Exploit Generator

Overview

The cross-function reentrancy exploit generator validates vulnerabilities where an external call in Function A creates a window during which Function B — which shares state with Function A — can be called with a stale view of that state. Unlike simple reentrancy (which re-enters the same function), this variant exploits the inconsistency between two functions that lack a shared reentrancy lock.

This pattern is historically linked to the Cream Finance exploit (October 2021, $130M) and the Lendf.Me hack (2020, $25M), both of which involved ERC-777 token callbacks enabling callers to invoke different functions while the initial call was in progress.

Note: Exploit generation in Sigvex is for vulnerability validation purposes only.

Attack Scenario

  1. Setup: The attacker deposits collateral into a lending contract. The contract records the balance in storage slot 0 and a derived borrow limit in slot 1. No global reentrancy guard is set (slot 2 = 0).
  2. Trigger (Function A): The attacker calls withdrawCollateral(amount). The function validates the balance, sends ETH (external call), and schedules the state update for after the call returns.
  3. Exploitation (Function B): During the external call’s callback (the attacker’s receive()), the attacker calls borrow(limit). Because Function A has not yet updated slot 0 or slot 1, borrow() reads the pre-withdrawal collateral and borrow limit. The attacker borrows against collateral they are simultaneously withdrawing.
  4. Impact: The attacker receives both the withdrawn collateral and the borrowed funds. The protocol has disbursed more value than the attacker’s collateral actually backs.

The generator runs four execution scenarios:

Scenario Description Storage slot 2 (guard) Slot 3 (state-before-call)
1 Normal withdraw (victim caller) 0 (no guard) 1 (state updated)
2 Cross-reentrant (attacker) 0 (no guard) 0 (NOT updated)
3 Cross-function call via transfer() 0 (no guard) 0 (NOT updated)
4 Protected (global guard) 1 (guard set) 1 (updated)

If Scenario 1 succeeds and either Scenario 2 or Scenario 3 also succeeds, the generator reports a critical cross-function reentrancy finding with confidence 0.90.

Exploit Mechanics

Sigvex constructs the test by executing the contract bytecode under four world-state configurations. Key implementation details:

  1. Selector extraction: The 4-byte selector is read from finding.locations[0].bytecode_offset. The fallback selector is 0x3ccfd60b (withdraw()).
  2. ERC-20 transfer selector: Scenario 3 uses the ERC-20 transfer() selector (0xa9059cbb) to simulate calling a different state-modifying function during the callback.
  3. Storage layout: Slot 0 = user balance, Slot 1 = total supply, Slot 2 = reentrancy guard flag, Slot 3 = whether state was pre-updated.
  4. Verdict logic: The finding is critical if Scenario 1 does not revert and at least one of Scenarios 2–3 does not revert.

The generated PoC demonstrates both the vulnerable pattern and the correct nonReentrant fix:

// VULNERABLE: Functions A and B share state but not a reentrancy guard
contract VulnerableLendingProtocol {
    mapping(address => uint256) public deposits;
    mapping(address => uint256) public borrowLimit;

    // Function A: sends ETH before updating state
    function withdrawCollateral(uint256 amount) external {
        require(deposits[msg.sender] >= amount);
        (bool success,) = msg.sender.call{value: amount}(""); // callback window
        require(success);
        deposits[msg.sender] -= amount;
        borrowLimit[msg.sender] = deposits[msg.sender] * 75 / 100;
    }

    // Function B: reads stale state during Function A's callback
    function borrow(uint256 amount) external {
        require(amount <= borrowLimit[msg.sender]); // stale borrow limit!
        borrowLimit[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
    }
}

Remediation

Apply nonReentrant from OpenZeppelin’s ReentrancyGuard to all state-changing functions that share state. A per-function guard is insufficient — the guard must be global across the entire contract:

import "@audited/security/ReentrancyGuard.sol";

contract SecureBank is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant { ... }
    function transfer(address to, uint256 amount) external nonReentrant { ... }
    // Both functions share the same _status flag
}

References

  • Cream Finance Hack Analysis (October 2021, $130M)
  • Lendf.Me Hack Analysis (April 2020, $25M)
  • SWC-107: Reentrancy
  • OpenZeppelin ReentrancyGuard Documentation