All incidents

Revest TokenVault withdrawFNFT accounting flaw drains RENA vault reserves

Share
Mar 27, 2022 01:10 UTCAttackLoss: 360,000 RENAManually checked3 exploit txWindow: 44m 9s
Estimated Impact
360,000 RENA
Label
Attack
Exploit Tx
3
Addresses
2
Attack Window
44m 9s
Mar 27, 2022 01:10 UTC → Mar 27, 2022 01:54 UTC

Exploit Transactions

TX 1Ethereum
0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae
Mar 27, 2022 01:10 UTCExplorer
TX 2Ethereum
0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428
Mar 27, 2022 01:41 UTCExplorer
TX 3Ethereum
0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863
Mar 27, 2022 01:54 UTCExplorer

Victim Addresses

0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11fEthereum
0x2320a28f52334d62622cc2eafa15de55f9987ed9Ethereum

Loss Breakdown

360,000RENA

Similar Incidents

Root Cause Analysis

Revest TokenVault withdrawFNFT accounting flaw drains RENA vault reserves

1. Incident Overview TL;DR

An attacker-controlled helper contract for the Revest protocol used the depositAdditionalToFNFT and TokenVault.handleMultipleDeposits path to inflate the per-FNFT depositAmount of a fresh FNFT series, then withdrew nearly the entire RENA balance of the Revest TokenVault into attacker-controlled addresses before swapping the resulting position into ETH.

The root cause is that TokenVault’s multi-deposit accounting allows a new FNFT series to reuse pre-existing tokenTrackers[asset] balance without corresponding deposits. By minting and reconfiguring FNFT ids 0x403/0x404, the attacker was able to withdraw RENA that previously backed older FNFT positions.

2. Key Background

Revest represents tokenized positions as ERC-1155 FNFTs managed by FNFTHandler, while TokenVault holds the underlying ERC-20 collateral for all FNFTs. For each underlying asset, TokenVault maintains a tokenTrackers[asset] structure with fields such as lastBalance and lastMul. For each FNFT id, TokenVault stores an FNFTConfig containing the asset, depositAmount, depositMul, and other parameters. The value of an FNFT series is derived from both its FNFTConfig and the shared token tracker for that asset.

The RENA token (0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76) is an ERC-20 with a transfer fee, implemented as:

// Seed source: Rena.sol for 0x56de8b...
contract Rena is ERC20("Rena", "RENA"), Ownable, ReentrancyGuard {
    using SafeMath for uint256;
    ...
    function _transfer(address from, address to, uint256 amount) internal override {
        if(feeDivisor > 0 && feeless[from] == false && feeless[to] == false) {
            uint256 feeAmount = amount.div(feeDivisor);
            super._transfer(from, address(feeDistributor), feeAmount);
            super._transfer(from, to, amount.sub(feeAmount));
        } else {
            super._transfer(from, to, amount);
        }
    }
}

Prior to the attack, TokenVault 0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f held a large RENA balance backing multiple historical FNFT positions. Pre-seed state at block 14465356 shows that the specific ids used by the attacker (0x403 and 0x404) had no associated asset or value:

// TokenVault pre-seed FNFT state (block 14465356) for ids 0x403/0x404
{
  "fnft_id_hex": "0x403",
  "getFNFT": {
    "asset": "0x0000000000000000000000000000000000000000",
    "depositAmount": 0,
    "depositMul": 0,
    "isMulti": false
  },
  "getFNFTCurrentValue": 0
}

The attacker EOA 0xef967ece5322c0d7d26dab41778acb55ce5bd58b deployed a helper contract 0xb480ac726528d1c195cd3bb32f19c92e8d928519 that hard-codes the Revest controller (0x2320a28f52334d62622cc2eafa15de55f9987ed9), TokenVault (0xa81bd1...), FNFTHandler (0xe952bd...), RENA, and relevant liquidity contracts. This helper orchestrates minting, additional deposits, FNFT withdrawal, and subsequent swaps.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability arises from Revest’s handling of “multi-deposit” FNFT series. When a holder calls depositAdditionalToFNFT, the protocol updates a global token tracker for the underlying asset and then uses handleMultipleDeposits to increase the depositAmount associated with an entire FNFT series. That update reuses a shared tokenTrackers[asset].lastBalance that already includes collateral deposited for historical FNFT series.

Because the token tracker is global per asset and not partitioned per FNFT series, a freshly created FNFT series can be configured so that its depositAmount reflects both its own deposits and pre-existing collateral. As a result, withdrawing the new series with withdrawFNFT can pull out RENA that originally backed older positions. In this incident, FNFT ids 0x403/0x404 were created with no pre-state backing and then reconfigured via depositAdditionalToFNFT and handleMultipleDeposits to inherit a large claim on the existing RENA pool.

The core root cause is a violation of accounting isolation: TokenVault’s accounting of depositAmount per FNFT series is not constrained to match the share of collateral that series introduced into the vault, and the multi-deposit path reuses global tokenTrackers[asset] state without proportionally adjusting other series.

4. Detailed Root Cause Analysis

4.1 TokenVault accounting model

TokenVault maintains a per-asset tracker and per-FNFT configuration:

// TokenVault.sol (victim contract 0xa81bd1...)
mapping(uint => IRevest.FNFTConfig) private fnfts;
mapping(address => IRevest.TokenTracker) public tokenTrackers;

function updateBalance(uint fnftId, uint incomingDeposit) internal {
    IRevest.FNFTConfig storage fnft = fnfts[fnftId];
    address asset = fnft.asset;
    IRevest.TokenTracker storage tracker = tokenTrackers[asset];

    uint currentAmount;
    uint lastBal = tracker.lastBalance;

    if (asset != address(0)) {
        currentAmount = IERC20(asset).balanceOf(address(this));
    } else {
        currentAmount = lastBal;
    }
    tracker.lastMul = lastBal == 0 ? multiplierPrecision : multiplierPrecision * currentAmount / lastBal;
    tracker.lastBalance = currentAmount + incomingDeposit;
}

function depositToken(uint fnftId, uint transferAmount, uint quantity) public override onlyRevestController {
    updateBalance(fnftId, quantity * transferAmount);
    IRevest.FNFTConfig storage fnft = fnfts[fnftId];
    fnft.depositMul = tokenTrackers[fnft.asset].lastMul;
}

function handleMultipleDeposits(uint fnftId, uint newFNFTId, uint amount)
    external override onlyRevestController
{
    require(amount >= fnfts[fnftId].depositAmount, 'E003');
    IRevest.FNFTConfig storage config = fnfts[fnftId];
    config.depositAmount = amount;
    mapFNFTToToken(fnftId, config);
    if (newFNFTId != 0) {
        mapFNFTToToken(newFNFTId, config);
    }
}

updateBalance computes a per-asset multiplier based on the previous tracker.lastBalance and the current ERC-20 balance in the vault, then updates tracker.lastBalance to currentAmount + incomingDeposit. Crucially, tokenTrackers[asset] is shared across all FNFT series for that asset.

withdrawToken later uses the FNFT’s depositAmount and the shared tracker state to determine how much collateral each unit can withdraw:

function withdrawToken(uint fnftId, uint quantity, address user)
    external override onlyRevestController
{
    IRevest.FNFTConfig storage fnft = fnfts[fnftId];
    IRevest.TokenTracker storage tracker = tokenTrackers[fnft.asset];
    address asset = fnft.asset;

    updateBalance(fnftId, 0);

    uint withdrawAmount = fnft.depositAmount * quantity * tracker.lastMul / fnft.depositMul;
    tracker.lastBalance -= withdrawAmount;
    ...
    if (asset != address(0)) {
        IERC20(asset).safeTransfer(user, withdrawAmount);
    }
}

The intended invariant is that, for each FNFT series, depositAmount * supply * tracker.lastMul / depositMul should equal collateral that was actually deposited on behalf of that series. That invariant relies on depositAmount being calibrated to the series’ share of the global token tracker.

4.2 Multi-deposit reconfiguration via Revest controller

The Revest controller (0x2320a2...) exposes depositAdditionalToFNFT, which allows an FNFT holder to add more collateral to an existing series:

// Revest.sol (controller 0x2320a2...)
function depositAdditionalToFNFT(
    uint fnftId,
    uint amount,
    uint quantity,
    bool createNewSeries
) external payable override returns (uint) {
    ...
    uint newFNFTId;
    if (createNewSeries) {
        newFNFTId = IFNFTHandler(handler).getNextId();
        ILockManager(lockHandler).pointFNFTToLock(newFNFTId, lockId);
        burn(_msgSender(), fnftId, quantity);
        IFNFTHandler(handler).mint(_msgSender(), newFNFTId, quantity, "");
    } else {
        newFNFTId = 0;
    }

    ITokenVault(vault).depositToken(fnftId, amount, quantity);
    if (fnft.asset != address(0)) {
        IERC20(fnft.asset).safeTransferFrom(_msgSender(), vault, quantity * amount);
    }

    ITokenVault(vault).handleMultipleDeposits(fnftId, newFNFTId, fnft.depositAmount + amount);

    emit FNFTAddionalDeposited(_msgSender(), newFNFTId, quantity, amount);
    return newFNFTId;
}

In this flow:

  • depositToken calls updateBalance, which recalculates the global tracker based on the vault’s entire RENA balance (including legacy deposits).
  • handleMultipleDeposits then overwrites fnfts[fnftId].depositAmount with fnft.depositAmount + amount and, if newFNFTId != 0, clones that same config to the new series.

handleMultipleDeposits does not compute amount from a per-series share of tokenTrackers[asset].lastBalance and does not adjust other FNFT configs. That omission allows a new series to be assigned a depositAmount that effectively claims a large portion of the pre-existing RENA pool.

4.3 Pre-state and FNFT creation

Pre-seed FNFT state for TokenVault at block 14465356 shows that:

  • fnftId 0x403 and 0x404 have asset = 0x0, depositAmount = 0, depositMul = 0, and getFNFTCurrentValue = 0.
  • They have no associated RENA or other assets at this point.

In the seed vault-drain transaction (0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428, block 14465357), the trace for helper contract 0xb480ac... shows calls into Revest:

  • Revest.mintAddressLock is invoked with an FNFTConfig whose asset is set to RENA and depositAmount is nonzero, targeting a fresh fnftId that becomes 0x403.
  • TokenVault.createFNFT is called from Revest, which maps fnftId to the new config and calls depositToken to update tracker state.
  • FNFTHandler mints FNFT units of id 0x403 to the helper contract.

This sequence initializes a new RENA-backed FNFT series while the vault already holds more than 360,000,000,129,143,348,989,911 RENA from historical positions.

4.4 Multi-deposit misconfiguration of ids 0x403/0x404

Later in the same seed transaction, the helper calls Revest.depositAdditionalToFNFT on fnftId 0x403. The call trace shows:

  • Revest.depositAdditionalToFNFT invoking TokenVault.depositToken(fnftId=0x403, amount, quantity), which:
    • Calls updateBalance(0x403, quantity * amount); currentAmount includes the entire RENA balance in TokenVault (old plus new deposits).
    • Sets fnft.depositMul for 0x403 to tokenTrackers[RENA].lastMul, which is derived from this global balance.
  • Revest then transfers quantity * amount RENA from the helper into TokenVault.
  • Revest calls TokenVault.handleMultipleDeposits(fnftId=0x403, newFNFTId=0x404, amount = fnft.depositAmount + amount), which:
    • Requires that the new amount is at least the previous depositAmount.
    • Writes this increased depositAmount back to fnfts[0x403].
    • Clones the updated config to fnftId 0x404 via mapFNFTToToken.

At this point:

  • Both 0x403 and 0x404 share the same inflated depositAmount and depositMul.
  • The tokenTrackers[RENA].lastBalance used for their valuation still includes the large RENA pool contributed by historical FNFTs; there is no logic that restricts their claim to the incremental deposit from depositAdditionalToFNFT.

The invariant “each FNFT series can only withdraw collateral it introduced” is thus broken in a specific, code-local way: handleMultipleDeposits allows a freshly created series to inherit a depositAmount calibrated against the global tracker, not the series’ own deposits.

4.5 Vault drain via withdrawFNFT and evidence from balance diffs

After inflating the series, the helper calls Revest.withdrawFNFT for fnftId 0x404 with the full supply. The trace shows:

  • Revest.withdrawFNFT calling TokenVault.withdrawToken(fnftId=0x404, quantity=full_supply, user=helper).
  • TokenVault.withdrawToken calling updateBalance(0x404, 0), recomputing tracker.lastMul from the current RENA balance and tokenTrackers[RENA].lastBalance.
  • withdrawToken computing:
    • withdrawAmount = fnft.depositAmount * quantity * tracker.lastMul / fnft.depositMul.
  • TokenVault transferring RENA to the helper and the RENA fee recipient according to the token’s transfer logic.

The prestateTracer balance diff for the seed transaction confirms the resulting RENA flows:

// Seed tx balance_diff for 0xe0b0c2...
{
  "erc20_balance_deltas": [
    {
      "token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
      "holder": "0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f",
      "before": "364710000000000000000000",
      "after": "4709999870856651010089",
      "delta": "-360000000129143348989911",
      "contract_name": "Rena"
    },
    {
      "token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
      "holder": "0xef967ece5322c0d7d26dab41778acb55ce5bd58b",
      "before": "0",
      "after": "352835762625573396345012",
      "delta": "352835762625573396345012",
      "contract_name": "Rena"
    },
    {
      "token": "0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76",
      "holder": "0xd94c8cbdbbb602005c3c4c22dedb856a4d5e9e13",
      "delta": "7164059503569952644899",
      "contract_name": "Rena"
    }
  ]
}

TokenVault loses exactly 360,000,000,129,143,348,989,911 RENA in this single transaction. The attacker EOA receives 352,835,762,625,573,396,345,012 RENA, and the RENA tax recipient receives the remainder due to the token’s transfer fee mechanism.

The native ETH balance diff for the same transaction shows the attacker paying 42,505,000,000,000,000 wei in gas while maintaining a positive net position within this step.

4.6 Profit realization and ACT success predicate

In the subsequent Uniswap swap transaction (0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863, block 14465416), the attacker converts the drained position into ETH using the Uniswap V2 router 0xd9e1ce17f2641f24ae83637ab66a2cca9c378b9f. The balance diff shows:

// Swap tx balance_diff for 0xa0ff4e...
{
  "native_balance_deltas": [
    {
      "address": "0xef967ece5322c0d7d26dab41778acb55ce5bd58b",
      "before_wei": "771512329924161079",
      "after_wei": "540493006867079299645",
      "delta_wei": "539721494537155138566"
    }
  ]
}

Combining the prestateTracer artifacts for the vault-drain and swap transactions:

  • The attacker EOA’s ETH balance immediately before the vault-drain tx is:
    • value_before_in_reference_asset = 860,553,334,815,435,805 wei (~0.860553334815435833 ETH).
  • Immediately after the swap tx, the balance is:
    • value_after_in_reference_asset = 540,493,006,867,079,299,645 wei (~540.49300686707931618 ETH).
  • The net change over the attack window is:
    • value_delta_in_reference_asset = 539,632,453,532,263,863,840 wei (~539.632453532263866691 ETH).

Root_cause.json records a deterministic fees_paid_in_reference_asset of 93,028,483,363,884,246 wei, representing the sum of gas costs for the attacker-crafted transactions in the window. Regardless of the fee breakdown per tx, the measured value_delta_in_reference_asset remains strongly positive, satisfying the profit predicate for an ACT opportunity.

5. Adversary Flow Analysis

5.1 Adversary-related accounts

The adversary-related cluster consists of:

  • EOA 0xef967ece5322c0d7d26dab41778acb55ce5bd58b (unprivileged external account).
  • Helper/orchestrator contract 0xb480ac726528d1c195cd3bb32f19c92e8d928519 (deployed by the EOA).

Victim-side contracts are:

  • Revest controller 0x2320a28f52334d62622cc2eafa15de55f9987ed9.
  • Revest TokenVault 0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f.
  • Revest FNFTHandler 0xe952bda8c06481506e4731c4f54ced2d4ab81659.

5.2 Lifecycle stages

  1. Helper contract deployment

    • Transaction: 0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae (block 14465209).
    • Mechanism: CREATE initiated by EOA 0xef967e....
    • Evidence: tx_trace_1_0xef6c9c....json shows the constructor writing hard-coded addresses for:
      • RENA, BLOCKS, Revest controller, FNFTHandler, TokenVault, and multiple liquidity contracts (including the Uniswap pair and routers).
    • Outcome: 0xb480ac... is a dedicated orchestrator for this strategy, under full attacker control.
  2. Multi-deposit FNFT misconfiguration and vault drain

    • Transaction: 0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428 (block 14465357).
    • Mechanism: helper calls into Revest controller to:
      • Mint address-locked FNFT series 0x403/0x404 backed by RENA.
      • Call depositAdditionalToFNFT on 0x403, inducing TokenVault.depositToken and handleMultipleDeposits.
      • Call withdrawFNFT on 0x404 to pull RENA from TokenVault.
    • Evidence:
      • tx_trace_1_0xe0b0c2....json shows the call stack: helper → Revest.mintAddressLock → TokenVault.createFNFT → TokenVault.depositToken → Revest.depositAdditionalToFNFT → TokenVault.handleMultipleDeposits → Revest.withdrawFNFT → TokenVault.withdrawToken.
      • Pre-seed token_vault_fnft_state_preseed.json confirms 0x403/0x404 were uninitialized immediately before the seed tx.
      • Seed balance_diff.json confirms the exact RENA flows and TokenVault’s loss.
    • Outcome: TokenVault loses 360,000,000,129,143,348,989,911 RENA; most of it is routed to the attacker EOA, with a portion sent to the RENA tax recipient.
  3. Profit realization via Uniswap swap

    • Transaction: 0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863 (block 14465416).
    • Mechanism: EOA 0xef967e... calls the Uniswap V2 router 0xd9e1ce... to swap the attack-derived position into ETH.
    • Evidence:
      • tx_trace_1_0xa0ff4e....json shows the swap path.
      • balance_diff_1_0xa0ff4e....json shows the attacker’s ETH balance increasing by 539,721,494,537,155,138,566 wei during this tx.
    • Outcome: Attacker’s net ETH position increases from ~0.86 ETH before the vault-drain to ~540.49 ETH after the swap, net of gas fees.

5.3 ACT properties

All attacker-crafted steps were executed by an unprivileged EOA and its own contract using public Revest and Uniswap interfaces:

  • The helper deployment uses a standard CREATE transaction with no special permissions.
  • Revest functions (mintAddressLock, depositAdditionalToFNFT, withdrawFNFT) are public and callable by any address that satisfies the lock conditions and holds the relevant FNFTs.
  • The Uniswap V2 swap is a standard swapExactTokensForETH-style call.

Any unprivileged adversary that can deploy an equivalent helper contract and construct the same call sequence against the same Revest contracts and pre-state can reproduce the same vault drain and profit. This satisfies the “anyone-can-take” definition for ACT opportunities.

6. Impact & Losses

6.1 Direct token loss

Seed balance diffs record the TokenVault’s RENA loss:

  • TokenVault 0xa81bd1... loses:
    • 360,000,000,129,143,348,989,911 RENA (delta = -360000000129143348989911).
  • Attacker EOA 0xef967e... receives:
    • 352,835,762,625,573,396,345,012 RENA.
  • RENA tax recipient 0xd94c8c... receives:
    • 7,164,059,503,569,952,644,899 RENA.

The total RENA outflow from TokenVault is thus concentrated in a single attacker-crafted transaction.

6.2 Economic impact in ETH terms

Using ETH as the reference asset and the prestateTracer balance diffs:

  • Attacker EOA ETH before the sequence:
    • 860,553,334,815,435,805 wei (~0.860553334815435833 ETH).
  • Attacker EOA ETH after the swap:
    • 540,493,006,867,079,299,645 wei (~540.49300686707931618 ETH).
  • Net ETH gain:
    • 539,632,453,532,263,863,840 wei (~539.632453532263866691 ETH).
  • Recorded total gas fees paid during the attack window:
    • 93,028,483,363,884,246 wei (~0.093028483363884246 ETH).

Even after accounting for all gas fees, the attacker’s net portfolio value in ETH increases by more than 539 ETH, confirming a clear profit-based success predicate.

6.3 Affected parties

  • Primary victim contract: Revest TokenVault at 0xa81bd16aa6f6b25e66965a2f842e9c806c0aa11f, which lost the RENA collateral backing multiple historical FNFT positions.
  • Indirectly affected: holders of older FNFTs whose RENA backing was diverted to the attacker’s FNFT series 0x403/0x404, reducing or eliminating the value of their positions.

7. References

  • Seed vault-drain transaction and pre-state

    • Seed tx: 0xe0b0c2672b760bef4e2851e91c69c8c0ad135c6987bbf1f43f5846d89e691428 (Ethereum mainnet, block 14465357).
    • Seed metadata and balance diffs: artifacts/root_cause/seed/1/0xe0b0c2.../metadata.json, balance_diff.json.
    • Pre-seed FNFT state at block 14465356: artifacts/root_cause/data_collector/iter_2/token_vault_fnft_state_preseed.json.
  • Helper contract deployment

    • Deployment tx: 0xef6c9cb605bf14a9a9c3c0d6fcf75f34112f604257b8d4bfe0904f7f15d270ae (block 14465209).
    • Trace: artifacts/root_cause/data_collector/iter_2/tx_trace_1_0xef6c9c....json showing constructor code and hard-coded addresses.
  • Profit-taking swap

    • Swap tx: 0xa0ff4e057f1f0652aa5a0f4fdeb0746fef24caad4a4829552e79a88c28c97863 (block 14465416).
    • Trace and balance diff: artifacts/root_cause/data_collector/iter_1/tx_trace_1_0xa0ff4e....json, balance_diff_1_0xa0ff4e....json.
  • Revest and RENA contract code

    • Revest controller (0x2320a2...), TokenVault (0xa81bd1...), FNFTHandler (0xe952bd...): verified source bundles under artifacts/root_cause/data_collector/iter_1/contracts/.
    • RENA token (0x56de8b...): cloned repository under artifacts/root_cause/seed/1/0x56de8bc61346321d4f2211e3ac3c0a7f00db9b76/.

These references are sufficient for an external reviewer to replay the traces, verify the accounting bug in TokenVault’s multi-deposit logic, and confirm the adversary’s profit and the ACT nature of the opportunity.