Reproduced Exploit
Clober DEX Exploit — `Rebalancer._burn` Reentrancy via Attacker-Controlled `burnHook`
Clober's Rebalancer is an LP-vault that wraps two Clober order-books. When you burn() your LP shares it computes your payout from the pool reserves, burns your shares, calls pool.strategy.burnHook(...), and only afterwards writes the decremented reserves back to storage (src_Rebalancer.sol:259-291).
Loss
~$501K — 133.71 WETH drained from the Clober Rebalancer vault
Chain
Base
Category
Reentrancy
Date
Dec 2024
Source & credit. Exploit reproduction, trace data, and analysis adapted from DeFiHackLabs by SunWeb3Sec — an open registry of reproduced on-chain exploits. Standalone Foundry PoC and full write-up: 2024-12-CloberDEX_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/CloberDEX_exp.sol.
Vulnerability classes: vuln/reentrancy/single-function · vuln/logic/incorrect-order-of-operations
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder (the umbrella DeFiHackLabs repo contains many unrelated PoCs that do not whole-compile, so this one was extracted). Full verbose trace: output.txt. Verified vulnerable source: src_Rebalancer.sol.
Key info#
| Loss | ~$501K — 133.71 WETH drained from the Clober Rebalancer vault |
| Vulnerable contract | Rebalancer — 0x6A0b87D6b74F7D5C92722F6a11714DBeDa9F3895 |
| Victim pool | The single WETH/quote pool whose entire reserveB (133.71 WETH) sat in the Rebalancer |
| Attacker EOA | 0x012Fc6377F1c5CCF6e29967Bce52e3629AaA6025 |
| Attacker contract | 0x32Fb1BedD95BF78ca2c6943aE5AEaEAAFc0d97C1 |
| Flash-loan source | Morpho Blue — 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb |
| Attack tx | 0x8fcdfcded45100437ff94801090355f2f689941dca75de9a702e01670f361c04 |
| Chain / block / date | Base / 23,514,451 / Dec 10, 2024 |
| Compiler | Solidity v0.8.25, optimizer 1000 runs |
| Bug class | Reentrancy — stale cached reserves; external hook to a fully attacker-controlled strategy fires before pool state is written back |
TL;DR#
Clober's Rebalancer is an LP-vault that wraps two Clober order-books. When you burn() your LP
shares it computes your payout from the pool reserves, burns your shares, calls
pool.strategy.burnHook(...), and only afterwards writes the decremented reserves back to storage
(src_Rebalancer.sol:259-291).
The strategy is an arbitrary address the caller supplies when opening a pool via open(...)
(:104-116). There is no allow-list, no
nonReentrant, and the hook is invoked before the reserve write-back. So the attacker opens a pool
with strategy = own contract, and re-enters burn() from inside burnHook while
pool.reserveA/reserveB still hold the pre-burn (full) values.
Both the outer burn and the re-entrant inner burn therefore divide their withdrawal against the same un-decremented reserve, so the vault pays out far more than the shares are worth.
The attacker:
- Flash-loans
2 × rebalancerWETH = 267.42 WETHfrom Morpho Blue, plus deploys a worthlessFakeToken(free supply). - Opens a fresh WETH ⇄ FakeToken pool with
strategy = attacker contract. - Mints LP with
267.42 WETH + 267.42 FAKE, receiving267.42LP shares. The vault now holds its original133.71 WETHplus the deposited267.42 WETH=401.12 WETH. - Burns
133.71LP. The reentrancy makes the vault pay267.42 WETHto the inner burn and133.71 WETHto the outer burn = 401.12 WETH total — emptying it. - Repays the
267.42 WETHflash loan and unwraps the leftover133.71 WETHto ETH.
Net result: the attacker walks off with the Rebalancer's entire 133.71 WETH of honest liquidity,
having returned exactly the flash-loaned principal.
Background — what the Rebalancer does#
Rebalancer (source) is an automated market-making
vault on top of Clober's BookManager (a central-limit-order-book engine,
0x382CCccbD3b142D7DA063bF68cd0c89634767F76).
It is an ERC6909 multi-token where each pool's LP position is one token id (uint256(key)).
A pool is a pair of opposite order-books (bookKeyA = base→quote, bookKeyB = quote→base). The
vault tracks two reserves per pool, reserveA (the A-book quote currency) and reserveB (the
A-book base currency), inside its Pool struct
(IRebalancer:13-18):
struct Pool {
BookId bookIdA;
BookId bookIdB;
IStrategy strategy; // ← caller-supplied at open()
uint256 reserveA;
uint256 reserveB;
...
}
Every state-mutating action delegates to a strategy contract through hooks (mintHook,
burnHook, rebalanceHook, computeOrders) so external "strategy" logic can react to pool changes
(IStrategy:30-39).
The on-chain facts at the fork block (read from the trace):
| Fact | Value |
|---|---|
| Rebalancer WETH balance (the prize) | 133.707875556674808577 WETH |
Flash-loan size (rebalancerWETH × 2) | 267.415751113349617154 WETH |
| LP shares minted by attacker | 267.415751113349617154 |
| FakeToken supply minted for free | 1000 FAKE (only ~267 used) |
The entire game is that strategy is not trusted yet is called mid-state-update.
The vulnerable code#
1. open() lets the caller pick the strategy — no validation beyond non-zero#
function open(
IBookManager.BookKey calldata bookKeyA,
IBookManager.BookKey calldata bookKeyB,
bytes32 salt,
address strategy // ← attacker passes its own contract
) external returns (bytes32 key) { ... }
_open only checks strategy != address(0)
(:240) and that the pool isn't already open.
There is no registry, no owner-gating: anyone can register an arbitrary contract as the strategy
for a pool they create.
2. _burn() — payout math runs, then the untrusted hook fires, then reserves are written#
function _burn(bytes32 key, address user, uint256 burnAmount)
public
selfOnly
returns (uint256 withdrawalA, uint256 withdrawalB)
{
Pool storage pool = _pools[key];
uint256 supply = totalSupply[uint256(key)];
(uint256 canceledAmountA, uint256 canceledAmountB, uint256 claimedAmountA, uint256 claimedAmountB) =
_clearPool(key, pool, burnAmount, supply);
uint256 reserveA = pool.reserveA; // ← (1) cache CURRENT reserves
uint256 reserveB = pool.reserveB;
withdrawalA = (reserveA + claimedAmountA) * burnAmount / supply + canceledAmountA; // ← (2) payout from cache
withdrawalB = (reserveB + claimedAmountB) * burnAmount / supply + canceledAmountB;
_burn(user, uint256(key), burnAmount); // ← (3) shares destroyed (supply decreases)
pool.strategy.burnHook(msg.sender, key, burnAmount, supply); // ⚠️ (4) EXTERNAL CALL to attacker BEFORE reserve write-back
emit Burn(user, key, withdrawalA, withdrawalB, burnAmount);
IBookManager.BookKey memory bookKeyA = bookManager.getBookKey(pool.bookIdA);
pool.reserveA = _settleCurrency(bookKeyA.quote, reserveA) - withdrawalA; // ← (5) reserves finally updated
pool.reserveB = _settleCurrency(bookKeyA.base, reserveB) - withdrawalB;
if (withdrawalA > 0) { bookKeyA.quote.transfer(user, withdrawalA); } // ← (6) tokens sent
if (withdrawalB > 0) { bookKeyA.base.transfer(user, withdrawalB); }
}
The fatal ordering is (4) before (5): pool.reserveB in storage is still the full pre-burn
value when the attacker re-enters burn() from inside burnHook. burn() itself has no
nonReentrant guard (:200-209) — it only
goes through bookManager.lock, which is re-entered cleanly here.
Root cause — why it was possible#
The reserves are read into local variables at the top of _burn, used to compute the payout, and
written back to storage only after an external call to a contract the attacker fully controls.
Three design decisions compose into a critical, fully self-funding theft:
- Untrusted external hook mid-update.
pool.strategy.burnHook(...)is an external call to a caller-chosen address, placed between the share-burn and the reserve write-back. This is the textbook reentrancy "interaction before effect" — the storagereserveA/reserveBare still stale (full) when control returns to the attacker. - No reentrancy guard.
burn()/_burn()carry nononReentrantmodifier; the only serialization isbookManager.lock, which happily re-acquires for a nestedburn. - Permissionless
strategy.open()accepts any non-zerostrategy, so the attacker is the strategy. There is no expectation thatburnHookis benign.
The arithmetic of the theft:
- Outer
burn(133.71 LP):supply = 267.42, cachedreserveB = 267.42(133.71 original + 267.42 deposited... actually the vault balance is 401.12, butreserveBaccounting tracks the attacker's deposit of 267.42 plus the pre-existing reserve; see numbers below).withdrawalB = reserveB × 133.71 / 267.42 = 133.71 WETH. Shares burned →supply = 133.71. - Re-entrant
burn(133.71 LP)fromburnHook:supply = 133.71, butpool.reserveBstorage is still the stale full value (the outer call's write-back at line 282-283 hasn't run).withdrawalB = reserveB × 133.71 / 133.71 = 267.42 WETH— the entire cached reserve for half the shares. Shares burned →supply = 0. The inner call writes the reserve back first. - Total paid out:
267.42 + 133.71 = 401.12 WETH, exactly the vault's whole balance. The attacker deposited only267.42 WETH, so the surplus133.71 WETHis pure profit = the vault's original honest liquidity.
In short: the second (inner) burn is valued as if no shares had been burned and no reserve removed, because the outer burn's effect on storage hadn't landed yet.
Preconditions#
- A Rebalancer pool whose reserve the attacker wants to drain holds real value (here
133.71 WETH). open()is callable by anyone with an arbitrarystrategy→ attacker controlsburnHook. (:104-116)- Working capital to mint LP at a ratio that dominates the pool — supplied by a Morpho Blue flash
loan (
2× rebalancerWETH), fully repaid intra-transaction, so the attack costs only gas. - The quote token can be a worthless attacker-minted token (the
FakeTokenhere), since the attacker controls both sides of the freshly-opened pool; only the WETH side carries value.
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from output.txt (events + storage diffs). reserveB is
the WETH side of the pool.
| # | Step | Trace ref | WETH movement | Effect |
|---|---|---|---|---|
| 0 | Initial — Rebalancer holds the honest liquidity | L1596 | 133.71 in vault | The prize. |
| 1 | Flash-loan 267.42 WETH from Morpho Blue → attacker | L1599-1607 | +267.42 to attacker | Working capital. |
| 2 | open() WETH⇄FAKE pool, strategy = attacker | L1608-1648 | — | Attacker now owns burnHook. |
| 3 | mint(267.42 WETH, 267.42 FAKE) → 267.42 LP | L1659-1696 | −267.42 attacker / vault now 401.12 | supply = 267.42, reserveB = 267.42. |
| 4 | burn(133.71 LP) (outer) — caches reserveB=267.42, burns shares → supply=133.71, fires burnHook | L1697-1712 | (pending) | Reserve write-back deferred past the hook. |
| 5 | Re-entrant burn(133.71 LP) from burnHook — reads stale reserveB=267.42, supply=133.71 ⇒ withdrawalB = 267.42 WETH; burns shares → supply=0 | L1713-1764 | −267.42 → attacker | The over-payment. |
| 6 | Outer burn resumes: withdrawalB = 133.71 WETH transferred | L1786-1799 | −133.71 → attacker | Vault reserveB → 0. |
| 7 | Repay Morpho 267.42 WETH; unwrap & send 133.71 WETH→ETH to attacker | L1803-1834 | net +133.71 kept | Vault WETH balance = 0. |
Final reads: Exploit-contract WETH after = 133.71 (L1817-1819); Rebalancer WETH
after = 0 (L1820-1822).
Profit accounting (WETH)#
| Direction | Amount |
|---|---|
| Flash-loan borrowed | 267.415751 |
Deposited into vault via mint | −267.415751 |
| Received — re-entrant (inner) burn | +267.415751 |
| Received — outer burn | +133.707876 |
| Flash-loan repaid | −267.415751 |
| Net profit (the vault's original liquidity) | +133.707876 |
The profit equals the Rebalancer's starting 133.707875556674808577 WETH to the wei — the attacker
simply walked off with all the honest liquidity, recovering 100% of the flash-loaned capital.
Diagrams#
Sequence of the attack#
Pool state evolution#
The flaw inside _burn#
Why each magic number#
- Flash-loan =
rebalancerWETH × 2= 267.42 WETH: sized so that the attacker'smintdominates the pool and the cachedreserveB(267.42) is exactly2×the burn amount (133.71). Withsupply = 267.42and two burns of133.71each, the inner burn (on a halvedsupplybut fullreserveB) pays out267.42, and the outer pays133.71— together draining the401.12vault to zero while leaving the attacker the original133.71after repayment. FakeToken: the quote side of the freshly-opened pool has no real value, so the attacker mints it for free; only the WETH side is ever the target.transfer()on the fake token is a no-op stub.reEntryboolean guard in the PoC'sburnHook: ensures exactly one level of reentrancy. A second reentrant burn would findsupply = 0and revert on the division, so the attack reenters precisely once.makerPolicy / takerPolicyfee values (8888608 / 8888708): arbitrary validFeePolicyencodings to open the books; they do not affect the drain because the attacker controls both books and never trades through them.
Remediation#
- Apply checks-effects-interactions. Write the decremented
pool.reserveA/reserveBto storage before callingpool.strategy.burnHook(...)(andmintHook). The external hook must never observe stale reserves. Likewise move token transfers after all state is settled. - Add a reentrancy guard. Mark
mint,burn, andrebalance(and thelockAcquired-dispatched internals)nonReentrant. ThebookManager.lockre-acquisition is not a substitute for a guard on the vault's own accounting. - Do not call out to an untrusted, caller-chosen
strategyduring a state mutation. Either restrictstrategyto an allow-list / owner-approved set, or treat strategy hooks as fully adversarial and never invoke them while invariants (reserve ↔ share accounting) are temporarily broken. - Snapshot-and-validate. After the hook returns, re-read reserves/supply and assert they match the values the payout was computed against; revert on any mismatch caused by reentrancy.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a whole-project forge build):
_shared/run_poc.sh 2024-12-CloberDEX_exp --match-test testExploit -vvvvv
- RPC: a Base archive endpoint is required (fork block 23,514,450).
foundry.tomluses an Infura Base archive endpoint;evm_version = 'cancun'(the PoC notes this requirement). - Result:
[PASS] testExploit()with the attacker ending up holding133.71 WETHand the Rebalancer drained to0.
Expected tail:
Rebalancer WETH Balance Before exploit:: 133.707875556674808577
--- Flash Loan and Exploit ---
Exploit Contract WETH Balance After exploit:: 133
Rebalancer WETH Balance After exploit:: 0.000000000000000000
--- Withdrawn WETH to ETH ---
Attacker ETH Balance After exploit:: 134
Suite result: ok. 1 passed; 0 failed; 0 skipped
References: CertiK incident analysis — https://www.certik.com/resources/blog/clober-dex-incident-analysis · PeckShield — https://x.com/peckshield/status/1866443215186088048 · SolidityScan — https://blog.solidityscan.com/cloberdex-liquidity-vault-hack-analysis-f22eb960aa6f (Clober DEX, Base, ~$501K).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2024-12-CloberDEX_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
CloberDEX_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Clober DEX Exploit".
- Web3Sec X hacked database: search.
- Rekt leaderboard: search.
- Solodit incident search: search.
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.