Calculated from recorded token losses using historical USD prices at the incident time.
0x7094e706e75e13d1e0ea237f71a7c4511e9d270bEthereumAn 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.
HegicPool contract and manages tranches, options, and WBTC collateral (WBTC at 0x2260fac5e5542a773aa44fbcfedf7c193bc2c599).withdraw or withdrawWithoutHedge once, which internally calls _withdraw to compute their share of totalBalance and transfer tokens.0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea730x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54cAt 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:
Open (the relevant require is commented 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.
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:
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._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.
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.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.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:
Transfer event shows HegicPUT minting trancheID 2 to the router at 0xf51e888616a123875eaf7afd4417fbc4111750f7 in tx 0x9c27d4….Withdrawn events show the same router account withdrawing amount = 250000 for trancheID = 2 across multiple transactions.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.
The adversary’s router at 0xf51e888616a123875eaf7afd4417fbc4111750f7 is decompiled and shows:
owner variable tied to tx.origin in owner-only functions.0x1941472a (labeled Unresolved_1941472a) that takes an address and a uint256, and is used for withdraw orchestration.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.
0xdf791e50 with parameters including the HegicPUT pool and WBTC token.Transfer event confirms minting of tokenId 2 to the router, and WBTC logs confirm the 250000-unit deposit.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).
0x1941472a.withdraw for trancheID 2.Withdrawn(account=router, trancheID=2, amount=250000) event and WBTC transfers 250000 units from HegicPUT to the router.Looped withdraw transaction: 0x260d5eb9151c565efda80466de2e7eee9c6bd4973d54ff68c8e045a26f62ea73 (block 21912409).
withdraw calls for trancheID 2 in a single transaction.Withdrawn events for (account=router, trancheID=2, amount=250000) within this tx.Further repeated withdraw transaction: 0x444854ee7e7570f146b64aa8a557ede82f326232e793873f0bbd04275fa7e54c (block 21912424).
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.
The adversary consolidates and accounts for profits via additional router calls:
Sweep and further activity:
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.
Withdrawn events of 250000 units each for trancheID 2 against only one 250000-unit deposit.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.
Core contracts and code:
HegicPool and _withdraw).Key transactions:
Trace and log artifacts:
Withdrawn events for (account=router, trancheID=2, amount=250000) across the exploit transactions.Withdrawn event corresponds to a WBTC transfer.P&L and account-level evidence:
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.