Hegic WBTC Pool Repeated Tranche Withdrawal Exploit
Exploit Transactions
0x9c27d45c1daa943ce0b92a70ba5efa6ab34409b14b568146d2853c1ddaf14f820x49af686dec543879cdbc0f288a558c712fa9c7964ceb697779480c99ca9548620x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea730x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54cVictim Addresses
0x7094e706e75e13d1e0ea237f71a7c4511e9d270bEthereumLoss Breakdown
Similar Incidents
WBTC Drain via Insecure Router transferFrom Path
36%SilicaPools decimal-manipulation bug drains WBTC flashloan collateral
35%SorraV2 staking withdraw bug enables repeated SOR reward drain
35%WIFStaking claimEarned bug enables repeated WIF reward extraction
32%NOON Pool Drain via Public transfer
31%TRU reserve mispricing attack drains WBNB from pool
31%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
HegicPoolcontract 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
withdraworwithdrawWithoutHedgeonce, which internally calls_withdrawto compute their share oftotalBalanceand 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 relevantrequireis commented out). - Does not zero out
t.shareafter 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_withdrawdoes not guard against repeated calls on a closed tranche. t.shareis 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 oftotalBalance. After a successful withdrawal, the tranche cannot be used to withdraw additional funds and cannot be transferred. - Breakpoint: In
HegicPool._withdraw, the commented-outrequire(t.state == TrancheState.Open);and the failure to cleart.sharemean that repeated calls for the sametrancheIDstill compute a positiveamountand 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
Transferevent shows HegicPUT minting trancheID 2 to the router at 0xf51e888616a123875eaf7afd4417fbc4111750f7 in tx 0x9c27d4…. - Subsequent
Withdrawnevents show the same router account withdrawingamount = 250000fortrancheID = 2across multiple transactions. - Aggregating the decoded logs for tokenId 2, there are 442
Withdrawnevents 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
ownervariable tied totx.originin owner-only functions. - A function with selector
0x1941472a(labeledUnresolved_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
0xdf791e50with 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
Transferevent confirms minting of tokenId 2 to the router, and WBTC logs confirm the 250000-unit deposit.
- The EOA sends ETH to the router, calling selector
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
withdrawfor trancheID 2. - HegicPUT emits a
Withdrawn(account=router, trancheID=2, amount=250000)event and WBTC transfers 250000 units from HegicPUT to the router.
- The EOA calls the router with zero ETH value and selector
-
Looped withdraw transaction: 0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea73 (block 21912409).
- The router performs multiple
withdrawcalls for trancheID 2 in a single transaction. - The decoded logs show multiple
Withdrawnevents for(account=router, trancheID=2, amount=250000)within this tx.
- The router performs multiple
-
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
Withdrawnevents 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.
- The pool pays out WBTC corresponding to 442
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
HegicPooland_withdraw). - WBTC token contract: 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599 (collateral token used by the pool).
- Router contract: 0xf51e888616a123875eaf7afd4417fbc4111750f7 (adversary router with withdraw orchestration and owner-only sweep).
- HegicPUT WBTC pool contract: 0x7094e706e75e13d1e0ea237f71a7c4511e9d270b (Hegic V8888 WBTC pool implementation, including
-
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
Withdrawnevents for(account=router, trancheID=2, amount=250000)across the exploit transactions. - WBTC transfer logs from HegicPUT to the router, confirming that each
Withdrawnevent 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.