All incidents

RewardsHypervisor reentrant deposit mints unbacked vVISR and drains VISR

Share
Dec 21, 2021 14:18 UTCAttackLoss: 8,812,958.14 VISRManually checked2 exploit txWindow: 10m 56s
Estimated Impact
8,812,958.14 VISR
Label
Attack
Exploit Tx
2
Addresses
1
Attack Window
10m 56s
Dec 21, 2021 14:18 UTC → Dec 21, 2021 14:29 UTC

Exploit Transactions

TX 1Ethereum
0x69272d8c84d67d1da2f6425b339192fa472898dce936f24818fda415c1c1ff3f
Dec 21, 2021 14:18 UTCExplorer
TX 2Ethereum
0x6eabef1bf310a1361041d97897c192581cd9870f6a39040cd24d7de2335b4546
Dec 21, 2021 14:29 UTCExplorer

Victim Addresses

0xc9f27a50f82571c1c8423a42970613b8dbda14efEthereum

Loss Breakdown

8,812,958.14VISR

Similar Incidents

Root Cause Analysis

RewardsHypervisor reentrant deposit mints unbacked vVISR and drains VISR

1. Incident Overview TL;DR

An unprivileged Ethereum mainnet EOA 0x8efab89b497b887cdaa2fb08ff71e4b3827774b2 deployed and configured a self-owned IVisor-compatible orchestrator contract 0x10c509aa9ab291c76c45414e7cdbd375e1d5ace8 that targets the VISR RewardsHypervisor staking pool 0xc9f27a50f82571c1c8423a42970613b8dbda14ef. In transaction 0x69272d8c84d67d1da2f6425b339192fa472898dce936f24818fda415c1c1ff3f (block 13849007) the EOA calls the orchestrator, which triggers two nested calls to RewardsHypervisor.deposit(1e26, 0x10c5..ace8, 0x8efa..74b2).

Because RewardsHypervisor.deposit trusts the external IVisor.delegatedTransferERC20 callback, computes vVISR shares from an attacker-chosen visrDeposit and existing pool balances, and neither enforces that visr.balanceOf(this) increases nor guards against re-entrancy, the orchestrator re-enters deposit() instead of transferring VISR. Across the two calls, the hypervisor mints 195249950963631432273419474 vVISR to the EOA while its VISR balance remains unchanged. In transaction 0x6eabef1bf310a1361041d97897c192581cd9870f6a39040cd24d7de2335b4546 (block 13849051) the EOA calls withdraw() with exactly these shares, deterministically receiving 8812958138426966035592617 VISR from the pool, and then swaps the withdrawn VISR for ETH on DEXes.

2. Key Background

The VISR RewardsHypervisor 0xc9f2..14ef is a single-sided staking contract that pools VISR token 0xf938424f7210f31df2aee3011291b658f872e91e and issues vVISR ERC20 share tokens 0x3a84ad5d16adbe566baa6b3dafe39db3d5e261e5. Users are expected to call deposit(visrDeposit, from, to) to move VISR into the hypervisor in exchange for vVISR shares, and later call withdraw(shares, to, from) to redeem VISR based on the pool’s VISR balance and total vVISR supply.

The verified RewardsHypervisor implementation contains the following core logic:

function deposit(
    uint256 visrDeposit,
    address payable from,
    address to
) external returns (uint256 shares) {
    require(visrDeposit > 0, "deposits must be nonzero");
    require(to != address(0) && to != address(this), "to");
    require(from != address(0) && from != address(this), "from");

    shares = visrDeposit;
    if (vvisr.totalSupply() != 0) {
      uint256 visrBalance = visr.balanceOf(address(this));
      shares = shares.mul(vvisr.totalSupply()).div(visrBalance);
    }

    if(isContract(from)) {
      require(IVisor(from).owner() == msg.sender); 
      IVisor(from).delegatedTransferERC20(address(visr), address(this), visrDeposit);
    }
    else {
      visr.safeTransferFrom(from, address(this), visrDeposit);
    }

    vvisr.mint(to, shares);
}

function withdraw(
    uint256 shares,
    address to,
    address payable from
) external returns (uint256 rewards) {
    require(shares > 0, "shares");
    require(to != address(0), "to");
    require(from != address(0), "from");

    rewards = visr.balanceOf(address(this)).mul(shares).div(vvisr.totalSupply());
    visr.safeTransfer(to, rewards);

    require(from == msg.sender || IVisor(from).owner() == msg.sender, "Sender must own the tokens");
    vvisr.burn(from, shares);
}

The vVISR token 0x3a84..61e5 is an ERC20Permit + ERC20Snapshot share token whose mint and burn functions are restricted to an owner (initially the deployer, later transferred to the RewardsHypervisor). Users never call vVISR directly; they interact exclusively via deposit() and withdraw(), so any bug in the hypervisor’s share-minting or redemption logic directly translates into unbacked vVISR supply or mispriced withdrawals.

To support integrations, deposit() accepts a from address that may be a contract. When from is a contract, it is treated as an IVisor helper and the hypervisor performs an external callback:

if (isContract(from)) {
    require(IVisor(from).owner() == msg.sender);
    IVisor(from).delegatedTransferERC20(address(visr), address(this), visrDeposit);
} else {
    visr.safeTransferFrom(from, address(this), visrDeposit);
}

The assumption is that delegatedTransferERC20 will move visrDeposit VISR from the integration contract to the hypervisor before vvisr.mint(to, shares) executes. There is no re-entrancy guard and no post-condition tying minted shares to an observed increase in visr.balanceOf(address(this)).

The adversary’s orchestrator contract 0x10c5..ace8 is an IVisor-compatible helper configured with pool = 0xc9f2..14ef and self-owned via an internal ownership variable. The decompiled delegatedTransferERC20 and the public 0x4a0b0c38 entrypoint both call back into the pool rather than transferring VISR:

/// @custom:selector    0x2e88fb97
/// delegatedTransferERC20(address arg0, address arg1, uint256 arg2)
function delegatedTransferERC20(address arg0, address arg1, uint256 arg2) public payable {
    count = 0x01 + count;
    require(!count < 0x02);
    uint256 amount = 0x52b7d2dcc80cd2e4000000; // 1e26
    require(address(pool).code.length != 0);
    // calls pool with a function selector that resolves to RewardsHypervisor.deposit
    (bool success, bytes memory ret0) = address(pool).call(abi.encodeWithSelector(0x2e2d2984, amount));
    require(ret0.length >= 0x20);
}

/// @custom:selector    0x4a0b0c38
function exploitEntry() public payable {
    uint256 amount = 0x52b7d2dcc80cd2e4000000; // 1e26
    require(address(pool).code.length != 0);
    (bool success, bytes memory ret0) = address(pool).call(abi.encodeWithSelector(0x2e2d2984, amount));
    require(ret0.length >= 0x20);
}

This design makes the hypervisor vulnerable to a re-entrant deposit when from is the orchestrator and msg.sender is its owner.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is a re-entrancy and invariant-enforcement bug in the VISR RewardsHypervisor’s deposit() function. When from is a contract, deposit() calls an untrusted external callback IVisor(from).delegatedTransferERC20 before minting vVISR shares, but it computes the share amount as

shares = visrDeposit * vVISR.totalSupply() / visr.balanceOf(address(this))

using only the caller-supplied visrDeposit and the current pool balances, without enforcing that the VISR balance actually increases by visrDeposit. There is no re-entrancy guard around this external call and no post-condition check tying shares to any observed asset inflow.

A malicious IVisor contract can exploit this by having delegatedTransferERC20 re-enter RewardsHypervisor.deposit() instead of transferring VISR. In the incident, the orchestrator calls deposit(1e26, orchestrator, EOA) twice within a single transaction, each time computing shares from essentially the same pre-attack VISR balance and vVISR totalSupply, and then minting vVISR to the attacker’s EOA without any VISR transfers. This breaks the collateralization invariant that vVISR totalSupply must be fully backed by VISR held by the hypervisor and sets up a deterministic VISR theft via withdraw().

4. Detailed Root Cause Analysis

4.1 Pre-attack state and invariant

Let V denote visr.balanceOf(RewardsHypervisor) and S denote vVISR.totalSupply() immediately before the exploit transaction. From the VISR/vVISR balance window for the hypervisor address, the pool’s VISR balance across blocks 1384900213849012 (including the seed block 13849007) is constant:

{
  "hypervisor": "0xc9f27a50f82571c1c8423a42970613b8dbda14ef",
  "tokens": {
    "0xf938424f7210f31df2aee3011291b658f872e91e": {
      "symbol": "VISR",
      "balances": [
        { "block": 13849006, "balance": "9219200268612237484049971" },
        { "block": 13849007, "balance": "9219200268612237484049971" }
      ]
    }
  },
  "seed_block": 13849007
}

Thus the pool holds

V = 9219200268612237484049971 VISR

before and after the seed transaction. From the seed trace and vVISR source, the pre-attack vVISR total supply is

S = 9000242001852185487035933 vVISR

The intended invariant for a healthy state is:

  • Every outstanding vVISR share is fully backed by VISR held in the hypervisor.
  • deposit() either increases V by visrDeposit and mints proportional shares, or reverts.
  • vVISR.mint is only invoked when corresponding VISR has been transferred into the pool.

Formally, for any account the total redeemable VISR for all its vVISR shares should not exceed V, and the aggregate vVISR supply should correspond to VISR actually held by the pool.

4.2 Exploit transaction: re-entrant deposit and unbacked vVISR minting

In the seed transaction 0x6927..1ff3f the adversary EOA calls the orchestrator’s 0x4a0b0c38 exploit entrypoint. The trace shows the orchestrator calling RewardsHypervisor.deposit and then re-entering it via delegatedTransferERC20:

├─ RewardsHypervisor::deposit(100000000000000000000000000 [1e26], 0x10C5..ACE8, 0x8EFA..74B2)
│   ├─ ERC20::balanceOf(RewardsHypervisor)
│   ├─ 0x10C5..ACE8::delegatedTransferERC20(VISR, RewardsHypervisor, 1e26)
│   │   ├─ RewardsHypervisor::deposit(100000000000000000000000000 [1e26], 0x10C5..ACE8, 0x8EFA..74B2)
│   │   │   ├─ ERC20::balanceOf(RewardsHypervisor)
│   │   │   ├─ 0x10C5..ACE8::delegatedTransferERC20(VISR, RewardsHypervisor, 1e26)
│   │   │   │   ├─ emit Transfer(0x0000000000000000000000000000000000000000, 0x8EFA..74B2, 97624975481815716136709737)
│   │   ├─ emit Transfer(0x0000000000000000000000000000000000000000, 0x8EFA..74B2, 97624975481815716136709737)

Key observations from this trace and the accompanying balance data:

  1. Both deposit(1e26, 0x10c5..ace8, 0x8efa..74b2) calls execute with the same pre-attack V and S, because visr.balanceOf(this) is only read (staticcall) and the orchestrator never transfers VISR.

  2. Each deposit() call computes

    shares_each = 1e26 * S / V ≈ 97624975481815716136709737 vVISR
    

    and then calls vvisr.mint(to, shares_each).

  3. The nested structure causes vvisr.mint to execute twice with to = 0x8efa..74b2, producing a total of

    shares_total = 2 * shares_each
                 = 195249950963631432273419474 vVISR
    

    as confirmed by the two Transfer events from the vVISR token contract to the EOA in the trace.

  4. Throughout the seed trace, there are no Transfer events for VISR involving the hypervisor, and the VISR balance window confirms that V remains at 9219200268612237484049971 across the window containing block 13849007.

As a result, the adversary’s EOA ends the seed transaction holding 195249950963631432273419474 newly minted vVISR shares that are completely unbacked by additional VISR in the pool, violating the collateralization invariant.

4.3 Breakpoint and withdraw-side realization of the invariant break

After the seed transaction, the adversary retains control of the unbacked vVISR. In transaction 0x6eab..b4546 (block 13849051), the EOA calls withdraw() directly on the hypervisor with

shares = 0xa181c90cf59ed4f8c6dcd2
       = 195249950963631432273419474 vVISR

matching the total unbacked vVISR minted in the exploit transaction. At this point:

  • The hypervisor’s VISR balance is still V = 9219200268612237484049971 (no intermediate VISR movements to or from the pool between the exploit and withdraw in the hypervisor’s tx history).

  • The vVISR total supply has increased to

    S' = S + shares_total
       = 9000242001852185487035933
         + 195249950963631432273419474
       = 204250192965483617760455406.914376… (before rounding)
    

The withdraw() function computes

rewards = V * shares_total / S'
        ≈ 8812958138426966035592617 VISR

which matches the VISR amount recorded in the root cause analysis as the deterministic payout from the pool. This calculation uses only on-chain quantities (V, S, and shares_total) and the exact ERC20 amounts observed in the trace and source; no off-chain prices or assumptions are required.

The exploit’s concrete breakpoint can therefore be summarized as:

  1. First breakpoint (deposit-side): deposit() mints vVISR based solely on visrDeposit, V, and S, while the orchestrator re-enters deposit() through delegatedTransferERC20 without performing a VISR transfer, causing V to remain constant while S and the attacker’s vVISR balance increase.
  2. Second breakpoint (withdraw-side): withdraw() uses the inflated vVISR supply and the attacker’s unbacked shares to compute rewards = V * shares / S', transferring 8812958138426966035592617 VISR from the pool to the attacker.

These two operations together permanently violate the pool’s collateralization invariant and drain most of the VISR backing honest vVISR holders.

5. Adversary Flow Analysis

The adversary lifecycle is fully captured by a small cluster of transactions executed by EOA 0x8efa..74b2 on Ethereum mainnet:

  1. Adversary contract deployment and configuration

    • Tx 0xbe65cb0dd9f4619939cfeb56b3ef3a996e2b028b93fd66443abfa06d6df8e58d (block 13848982): The EOA deploys the orchestrator contract 0x10c5..ace8 with constructor arguments pointing pool to RewardsHypervisor 0xc9f2..14ef.
    • Tx 0x27f2210536553392cf180c0b37055b3dc92094a5d585d7d2a51f790c9145e47c (block 13848983): The EOA calls transferOwnership(0x10c5..ace8) on the orchestrator, making the contract self-owned. This guarantees that the check IVisor(from).owner() == msg.sender in RewardsHypervisor.deposit passes when msg.sender is the EOA and from is the orchestrator.
  2. Re-entrant deposit and unbacked vVISR minting

    • Tx 0x69272d8c84d67d1da2f6425b339192fa472898dce936f24818fda415c1c1ff3f (block 13849007): The EOA calls orchestrator function 0x4a0b0c38. The orchestrator calls RewardsHypervisor.deposit(1e26, 0x10c5..ace8, 0x8efa..74b2) and, via delegatedTransferERC20, re-enters deposit() with the same arguments. In both calls, shares is computed from the same V and S, and vvisr.mint(to, shares) executes twice, minting a total of 195249950963631432273419474 vVISR to the EOA. The trace and VISR balance window confirm no VISR transfers into the hypervisor during this transaction.
  3. VISR withdrawal and profit realization

    • Tx 0x6eabef1bf310a1361041d97897c192581cd9870f6a39040cd24d7de2335b4546 (block 13849051): The EOA calls RewardsHypervisor.withdraw directly with shares = 195249950963631432273419474 and to = from = 0x8efa..74b2. Using the on-chain V and S', withdraw() transfers 8812958138426966035592617 VISR from the pool to the EOA.
    • Tx 0x2dfd114bb28f6625f08663c8782c0a72bef89d6c0d4457542928a3f2416dd964 (block 13849059): The EOA approves VISR spending for a DEX router.
    • Tx 0x86d2689eeb9b1dd233e6a9ab62ffa16ecdedff55ea5f6f10571432cf9830d907 (block 13849099) and Tx 0xbd6e12468cb498d35fc9f0f9679c2e74a70de09d1c79b42ed54fefbd7ff7131a (block 13849105): The EOA executes swapExactTokensForETH calls on Uniswap-style routers, converting the withdrawn VISR into ETH under standard ERC20 and DEX semantics.

Throughout this lifecycle, the adversary operates entirely through unprivileged accounts and contracts they control. The orchestrator’s exploit entrypoint 0x4a0b0c38 is a publicly callable function that simply requires the caller to be its owner (which any unprivileged EOA can arrange for its own copy). The exploitation strategy is therefore an anyone-can-take (ACT) opportunity: any unprivileged searcher could reproduce the same sequence by deploying and configuring an equivalent orchestrator against a hypervisor in the same vulnerable state.

6. Impact & Losses

From the perspective of the VISR RewardsHypervisor pool, the exploit has a single dominant on-chain impact: it drains 8812958138426966035592617 VISR from the pool to the adversary cluster without any legitimate VISR deposit.

  • Immediately before the exploit, the balance window shows the hypervisor holding 9219200268612237484049971 VISR.
  • After the re-entrant deposit and the subsequent withdraw, withdraw() transfers 8812958138426966035592617 VISR to the attacker’s EOA, corresponding to approximately 95.6% of the pre-attack pool balance.
  • Honest vVISR holders are left with vVISR shares backed by only the residual VISR remaining in the pool, so their claims are severely undercollateralized. The invariant that vVISR total supply must be fully backed by VISR held by the hypervisor is permanently broken.

In metadata terms, the quantifiable loss is:

Token: VISR (0xf938424f7210f31df2aee3011291b658f872e91e)
Amount: 8812958138426966035592617 (18 decimals)
Chain: Ethereum mainnet (chainid 1)

Subsequent swaps by the attacker convert this VISR into ETH, but the economic harm to the protocol is fully captured by the deterministic VISR transfer from the hypervisor to the adversary cluster.

7. References

  • [1] RewardsHypervisor.sol verified source
    artifacts/root_cause/data_collector/iter_1/contract/1/0xc9f27a50f82571c1c8423a42970613b8dbda14ef/source/src/RewardsHypervisor.sol

  • [2] vVISR.sol verified source
    artifacts/root_cause/data_collector/iter_1/contract/1/0xc9f27a50f82571c1c8423a42970613b8dbda14ef/source/src/vVISR.sol

  • [3] VISR ERC20 verified source
    artifacts/root_cause/data_collector/iter_1/contract/1/0xf938424f7210f31df2aee3011291b658f872e91e/source/src/Contract.sol

  • [4] Orchestrator 0x10c5..ace8 decompiled source
    artifacts/root_cause/data_collector/iter_2/contract/1/0x10c509aa9ab291c76c45414e7cdbd375e1d5ace8/decompile/0x10c509aa9ab291c76c45414e7cdbd375e1d5ace8-decompiled.sol

  • [5] Seed transaction trace (re-entrant deposit)
    artifacts/root_cause/data_collector/iter_1/tx/1/0x69272d8c84d67d1da2f6425b339192fa472898dce936f24818fda415c1c1ff3f/trace.cast.log

  • [6] RewardsHypervisor VISR/vVISR balance window
    artifacts/root_cause/data_collector/iter_2/address/1/0xc9f27a50f82571c1c8423a42970613b8dbda14ef/visr_vvisr_balance_window.json

  • [7] Adversary EOA 0x8efa..74b2 full tx history
    artifacts/root_cause/data_collector/iter_1/address/1/0x8efab89b497b887cdaa2fb08ff71e4b3827774b2/normal_txlist.json