All incidents

Hegic WBTC Pool Repeated Tranche Withdrawal Exploit

Share
Jan 24, 2025 01:39 UTCAttackLoss: 0.65 WBTCManually checked4 exploit txWindow: 30d 22h
Estimated Impact
0.65 WBTC
Label
Attack
Exploit Tx
4
Addresses
1
Attack Window
30d 22h
Jan 24, 2025 01:39 UTC → Feb 23, 2025 23:51 UTC

Exploit Transactions

TX 1Ethereum
0x9c27d45c1daa943ce0b92a70ba5efa6ab34409b14b568146d2853c1ddaf14f82
Jan 24, 2025 01:39 UTCExplorer
TX 2Ethereum
0x49af686dec543879cdbc0f288a558c712fa9c7964ceb697779480c99ca954862
Feb 23, 2025 23:46 UTCExplorer
TX 3Ethereum
0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea73
Feb 23, 2025 23:48 UTCExplorer
TX 4Ethereum
0x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54c
Feb 23, 2025 23:51 UTCExplorer

Victim Addresses

0x7094e706e75e13d1e0ea237f71a7c4511e9d270bEthereum

Loss Breakdown

0.6525WBTC

Similar Incidents

Root Cause Analysis

Hegic WBTC Pool Repeated Tranche Withdrawal Exploit

1. Incident Overview TL;DR

An unprivileged adversary used a custom router contract to repeatedly withdraw from the Hegic V8888 WBTC pool (HegicPUT at 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b) for the same liquidity tranche NFT (trancheID 2). After a single 250000-unit WBTC deposit, the router invoked the pool’s withdraw logic hundreds of times for trancheID 2, causing the pool to pay out far more WBTC than was originally deposited.

The root cause is a state-machine bug in HegicPool._withdraw: the function does not enforce that a tranche is still open and does not clear the tranche’s share after withdrawal. Because the commented-out state check is never executed and t.share is left unchanged, the same tranche NFT can be used as a recurring withdrawal handle. The attacker exploited this by looping withdraw calls via the router, realizing a net profit of 0.6525 WBTC while using only permissionless on-chain interactions.

2. Key Background

  • Hegic V8888 pools represent liquidity provider positions as ERC721 tranche tokens; each tranche holds a share of the pool and entitles the owner to withdraw their underlying deposit plus P&L after a lockup period.
  • The exploited WBTC pool is deployed as HegicPUT at 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b, which inherits the abstract HegicPool contract and manages tranches, options, and WBTC collateral (WBTC at 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599).
  • Liquidity providers deposit WBTC and receive an ERC721 tranche token. After the lockup period, they are supposed to call withdraw or withdrawWithoutHedge once, which internally calls _withdraw to compute their share of totalBalance and transfer tokens.
  • The adversary deployed a router contract at 0xf51e888616a123875eaf7afd4417fbc4111750f7. This router orchestrates ETH funding from the EOA, swaps into WBTC, deposits into HegicPUT to mint trancheID 2, and later triggers repeated withdrawals for trancheID 2. It also includes owner-only sweep functionality to consolidate ERC20 balances back to the EOA.
  • All interactions are standard Ethereum transactions from EOA 0x4b53608ff0ce42cdf9cf01d7d024c2c9ea1aa2e8 through the router, with normal gas pricing and no privileged roles or whitelisting. The exploit is therefore an anyone-can-take opportunity as long as the vulnerable contracts remain deployed.

3. Vulnerability Analysis & Root Cause Summary

At a high level, the vulnerability is a broken withdrawal state machine in the Hegic WBTC pool. The pool fails to enforce that each liquidity tranche can only be withdrawn once and fails to clear the tranche’s share after withdrawal. This violates the basic per-tranche conservation invariant and allows a single tranche NFT to trigger multiple payouts.

Concretely, the internal HegicPool._withdraw function:

  • Only checks that the caller is approved or owner of the tranche NFT and that the lockup period has passed.
  • Does not enforce that the tranche state is still Open (the relevant require is commented out).
  • Does not zero out t.share after a successful withdrawal.

As a result, every call to withdraw or withdrawWithoutHedge for the same trancheID computes amount = (t.share * totalBalance) / totalShare using the unchanged t.share and transfers WBTC to the owner. The adversary router uses this to execute hundreds of withdraws against trancheID 2, draining pool funds beyond the original 250000-unit deposit.

4. Detailed Root Cause Analysis

4.1 Vulnerable withdrawal implementation

The vulnerable logic resides in HegicPool._withdraw, which underlies both withdraw and withdrawWithoutHedge in the WBTC pool implementation:

function withdraw(uint256 trancheID)
    external
    override
    nonReentrant
    returns (uint256 amount)
{
    address owner = ownerOf(trancheID);
    Tranche memory t = tranches[trancheID];
    amount = _withdraw(owner, trancheID);
    emit Withdrawn(owner, trancheID, amount);
}

function withdrawWithoutHedge(uint256 trancheID)
    external
    override
    nonReentrant
    returns (uint256 amount)
{
    address owner = ownerOf(trancheID);
    amount = _withdraw(owner, trancheID);
    emit Withdrawn(owner, trancheID, amount);
}

function _withdraw(address owner, uint256 trancheID)
    internal
    returns (uint256 amount)
{
    Tranche storage t = tranches[trancheID];
    // require(t.state == TrancheState.Open);
    require(_isApprovedOrOwner(_msgSender(), trancheID));
    require(
        block.timestamp > t.creationTimestamp + lockupPeriod,
        "Pool Error: The withdrawal is locked up"
    );

    t.state = TrancheState.Closed;
    amount = (t.share * totalBalance) / totalShare;
    totalShare -= t.share;
    totalBalance -= amount;

    token.safeTransfer(owner, amount);
}

Key properties of this implementation:

  • The state check require(t.state == TrancheState.Open); is commented out, so _withdraw does not guard against repeated calls on a closed tranche.
  • t.share is never set to zero, so the tranche’s share of the pool remains intact in storage even after a withdrawal.
  • The only gatekeeping is _isApprovedOrOwner(_msgSender(), trancheID) and the lockup timestamp check.

The intended invariant is that each trancheID should be withdrawable once after lockup, at which point it should no longer yield additional funds. The actual implementation violates this by allowing _withdraw to execute again even when t.state has already been set to Closed, and by reusing the unchanged t.share value.

4.2 Invariant and breakpoint

  • Invariant: For each trancheID, the pool must allow at most one full withdrawal of that tranche’s share of totalBalance. After a successful withdrawal, the tranche cannot be used to withdraw additional funds and cannot be transferred.
  • Breakpoint: In HegicPool._withdraw, the commented-out require(t.state == TrancheState.Open); and the failure to clear t.share mean that repeated calls for the same trancheID still compute a positive amount and transfer WBTC to the owner. This is the specific operation that violates the per-tranche single-withdraw invariant.

4.3 Evidence from trancheID 2 logs

Decoded logs for trancheID 2 show that this invariant is violated repeatedly for the same account and trancheID. The following entries are representative samples from the decoded log set for tokenId 2:

{
  "decoded": {
    "event": "Transfer",
    "address": "0x7094e706e75e13d1e0ea237f71a7c4511e9d270b",
    "transactionHash": "0x9c27d45c1daa943ce0b92a70ba5efa6ab34409b14b568146d2853c1ddaf14f82",
    "args": {
      "from": "0x0000000000000000000000000000000000000000",
      "to": "0xF51E888616a123875EAf7AFd4417fbc4111750f7",
      "tokenId": 2
    }
  }
}
{
  "decoded": {
    "event": "Withdrawn",
    "address": "0x7094e706e75e13d1e0ea237f71a7c4511e9d270b",
    "transactionHash": "0x49af686dec543879cdbc0f288a558c712fa9c7964ceb697779480c99ca954862",
    "args": {
      "account": "0xF51E888616a123875EAf7AFd4417fbc4111750f7",
      "trancheID": 2,
      "amount": 250000
    }
  }
}

From the full decoded log set for tokenId 2:

  • The first Transfer event shows HegicPUT minting trancheID 2 to the router at 0xf51e888616a123875eaf7afd4417fbc4111750f7 in tx 0x9c27d4….
  • Subsequent Withdrawn events show the same router account withdrawing amount = 250000 for trancheID = 2 across multiple transactions.
  • Aggregating the decoded logs for tokenId 2, there are 442 Withdrawn events with (account=router, trancheID=2, amount=250000) over txs 0x49af68…, 0x260d5e…, and 0x444854… while only a single 250000-unit deposit for trancheID 2 was made.

This pattern is exactly what the invariant forbids and is fully explained by the _withdraw implementation: every call recomputes a positive amount and transfers it to the router because the state check is disabled and t.share remains non-zero.

4.4 Router behavior and repeated withdraws

The adversary’s router at 0xf51e888616a123875eaf7afd4417fbc4111750f7 is decompiled and shows:

  • An owner variable tied to tx.origin in owner-only functions.
  • A function with selector 0x1941472a (labeled Unresolved_1941472a) that takes an address and a uint256, and is used for withdraw orchestration.
  • A sweep-style function (selector 0x88a772f4) that transfers ERC20 balances from the router to the owner.

The cast traces for the exploit transactions (e.g., tx 0x49af68…) show the router being called by the EOA with this withdraw selector and then making repeated calls into HegicPUT’s withdraw logic for trancheID 2 within the same transaction. There is no use of reentrancy or privileged roles; the router simply loops through withdrawal calls that the pool erroneously honours.

5. Adversary Flow Analysis

5.1 Funding and tranche creation (tx 0x9c27d4…)

  • Transaction: 0x9c27d45c1daa943ce0b92a70ba5efa6ab34409b14b568146d2853c1ddaf14f82 (block 21691132, Ethereum mainnet).
  • Actors:
    • EOA 0x4b53608ff0ce42cdf9cf01d7d024c2c9ea1aa2e8 (adversary EOA).
    • Router 0xf51e888616a123875eaf7afd4417fbc4111750f7.
    • HegicPUT WBTC pool 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b.
  • Flow:
    • The EOA sends ETH to the router, calling selector 0xdf791e50 with parameters including the HegicPUT pool and WBTC token.
    • The router swaps ETH into WBTC and deposits 250000 WBTC units into HegicPUT, minting liquidity tranche tokenId 2 to itself.
    • The decoded HegicPUT Transfer event confirms minting of tokenId 2 to the router, and WBTC logs confirm the 250000-unit deposit.

5.2 Post-lockup repeated withdrawals on trancheID 2

After the lockup period elapses, the EOA uses the router’s withdraw entry (selector 0x1941472a) to trigger repeated withdraw calls on trancheID 2:

  • First withdraw transaction: 0x49af686dec543879cdbc0f288a558c712fa9c7964ceb697779480c99ca954862 (block 21912400).

    • The EOA calls the router with zero ETH value and selector 0x1941472a.
    • The router calls HegicPUT’s withdraw for trancheID 2.
    • HegicPUT emits a Withdrawn(account=router, trancheID=2, amount=250000) event and WBTC transfers 250000 units from HegicPUT to the router.
  • Looped withdraw transaction: 0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea73 (block 21912409).

    • The router performs multiple withdraw calls for trancheID 2 in a single transaction.
    • The decoded logs show multiple Withdrawn events for (account=router, trancheID=2, amount=250000) within this tx.
  • Further repeated withdraw transaction: 0x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54c (block 21912424).

    • The router again calls into HegicPUT for trancheID 2 and receives additional WBTC withdrawals.

Across these three transactions, the decoded trancheID 2 logs show 442 Withdrawn events with the same account and trancheID and amount = 250000. Each event corresponds to a WBTC Transfer from HegicPUT to the router, so the pool pays out 442 * 250000 WBTC units against a single 250000-unit deposit.

5.3 Profit consolidation and accounting

The adversary consolidates and accounts for profits via additional router calls:

  • Sweep and further activity:

    • Transactions 0x722f67f6f9536fa6bbf4af447250e84b8b9270b66195059c9904a0e249543e80 (block 21912415) and 0x37b5a799bdc7efbcb077b883e82b52fd643776a0b628011e3d5e0ace09f94faf (block 21912464) involve the router’s owner-only sweep logic and additional swaps, moving accumulated ERC20 balances to the EOA as needed.
  • P&L evidence: The aggregated P&L summary for the EOA-router cluster shows the net WBTC gain on the router:

{
  "addresses": {
    "EOA": "0x4b53608ff0ce42cdf9cf01d7d024c2c9ea1aa2e8",
    "router": "0xf51e888616a123875eaf7afd4417fbc4111750f7"
  },
  "token_pnl": {
    "router": {
      "raw_balance_changes": {
        "WBTC": "65250000"
      },
      "human_readable": {
        "WBTC": 0.6525
      }
    }
  }
}

Interpreting this with WBTC’s 8 decimals, the router’s net WBTC change over the analysis window is +65,250,000 raw units = +0.6525 WBTC, while the EOA funds the strategy with ETH and pays gas. This confirms that the adversary cluster exits with positive WBTC and ETH balances after exploiting repeated withdrawals on trancheID 2.

6. Impact & Losses

  • Victim protocol: Hegic V8888 WBTC pool (HegicPUT at 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b).
  • Asset impacted: WBTC (0x2260fac5e5542a773aa44fbcfedf7c193bc2c599, 8 decimals).
  • Direct measurable loss:
    • The pool pays out WBTC corresponding to 442 Withdrawn events of 250000 units each for trancheID 2 against only one 250000-unit deposit.
    • Over the analyzed window, the adversary router’s net WBTC gain is 0.6525 WBTC (65,250,000 raw units), as shown by the P&L computation that tracks WBTC token transfers to and from the router address.

Beyond the measured 0.6525 WBTC gain, the more severe impact is that any holder of a tranche NFT can repeatedly drain additional WBTC from the pool after lockup by calling withdraw multiple times. This breaks the per-tranche accounting assumptions of the protocol and creates an open-ended, permissionless drain vector until the contracts are upgraded, paused, or otherwise mitigated.

7. References

  • Core contracts and code:

    • HegicPUT WBTC pool contract: 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b (Hegic V8888 WBTC pool implementation, including HegicPool and _withdraw).
    • WBTC token contract: 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 (collateral token used by the pool).
    • Router contract: 0xf51e888616a123875eaf7afd4417fbc4111750f7 (adversary router with withdraw orchestration and owner-only sweep).
  • Key transactions:

    • Tranche creation and initial deposit: 0x9c27d45c1daa943ce0b92a70ba5efa6ab34409b14b568146d2853c1ddaf14f82 (block 21691132).
    • First post-lockup withdraw on trancheID 2: 0x49af686dec543879cdbc0f288a558c712fa9c7964ceb697779480c99ca954862 (block 21912400).
    • Loop of repeated withdraws on trancheID 2: 0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea73 (block 21912409).
    • Additional repeated withdraws on trancheID 2: 0x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54c (block 21912424).
    • Profit sweep and consolidation: 0x722f67f6f9536fa6bbf4af447250e84b8b9270b66195059c9904a0e249543e80 and 0x37b5a799bdc7efbcb077b883e82b52fd643776a0b628011e3d5e0ace09f94faf.
  • Trace and log artifacts:

    • Seed transaction metadata and traces for 0x9c27… and later exploit txs, used to reconstruct the exact call flow through the router into HegicPUT.
    • Decoded HegicPUT logs for tokenId 2, showing 442 Withdrawn events for (account=router, trancheID=2, amount=250000) across the exploit transactions.
    • WBTC transfer logs from HegicPUT to the router, confirming that each Withdrawn event corresponds to a WBTC transfer.
  • P&L and account-level evidence:

    • EOA/router P&L summary aggregating WBTC and ETH flows for EOA 0x4b5360…ae2e8 and router 0xf51e88…750f7, showing a net router gain of 0.6525 WBTC.

These references, taken together, allow a third party to independently verify the exploit mechanics, root cause, and measured impact directly from on-chain data and published contract code.