On March 31, 2026 at 22:56:21 UTC (Polygon block 84938872), an attacker exploited WhaleBit’s unverified staking system through a same-transaction spot-oracle manipulation funded by a flash loan. The attacker EOA 0xe66b37de57b65691b9f4ac48de2c2b7be53c5c6f used helper contract 0xb5a8d7a37d60aa662f4dc9b3ef4c32a3fe21fadf to borrow 51,024.905390945780848543 CES, run three batches of five helper deposits into the staking path, dump CES into the WhaleBit CES/USDT0 Algebra-style pool to depress the same-block spot price, redeem previously minted IGT for inflated CES payouts, unwind the market leg, and repay the loan. The finding is revalidated: the root cause is not the flash loan itself, but a staking redemption path that trusts a live pool spot price through the WhaleBit pricing module while the attacker can still move that pool inside the same transaction. After repaying 51,177.980107118618191089 CES, the exploit executor retained 10,506.125286809215449705 CES.
The price module (0xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868 → impl 0x0729ea061132bcd76f420f9139af8957b41b90cb, decompiled by Dedaub) exposes a deposit and a withdraw function. Both read getActualPrice() — the live spot price of the CES/USDT0 Algebra pool — to convert between CES and IGT. Because an attacker can move that spot price by swapping against the pool inside the same transaction, they can deposit at a fair price, depress the spot, and withdraw at the manipulated price to extract the difference.
getActualPrice() returns the live, manipulable spot price (price module impl, Dedaub decompile):
// selector 0x6cc09081
function getActualPrice() external returns (uint256) {
return IDexAdapter(_dexAddress).getActualPrice(); // live spot from the Algebra pool
}
getPrice() (selector 0x98d5fdca) also exists and returns an average price — but it is only used as a sanity reference in the deviation guard, never in any calculation.
deposit mints IGT using the spot price (price module impl, selector 0xcaddba3d):
function deposit(address depositor, uint256 cesAmount) external returns (uint256) {
uint256 spotPrice = getActualPrice(); // live spot
_checkDeviation(getPrice(), spotPrice);
uint256 igtAmount = cesAmount * spotPrice / 1e18; // ← spot drives the mint rate
IGT.mint(msg.sender, igtAmount);
return igtAmount;
}
At pre-manipulation spot P: IGT = CES × P / 1e18
withdraw releases CES using the spot price — same read, inversely applied (price module impl, selector 0xa527aed6):
function withdraw(address recipient, uint256 igtAmount) external returns (uint256) {
uint256 spotPrice = getActualPrice(); // live spot — depressed by the attacker at this point
_checkDeviation(getPrice(), spotPrice);
uint256 cesAmount = igtAmount * 1e18 / spotPrice; // ← lower spot → MORE CES out
IGT.burn(recipient, igtAmount);
CES.transfer(recipient, cesAmount);
return cesAmount;
}
At post-manipulation spot P’ (P’ < P): CES_out = IGT × 1e18 / P'
Why this is profitable:
Step 1 — deposit at fair price P:
IGT = CES_in × P / 1e18
Step 2 — attacker dumps CES, spot drops to P' = 0.8645 × P
Step 3 — withdraw at depressed price P':
CES_out = IGT × 1e18 / P'
= CES_in × P / P' (substituting IGT from step 1)
Profit = CES_out − CES_in = CES_in × (P / P' − 1)
= CES_in × (1 / 0.8645 − 1)
≈ CES_in × 15.7%
The deviation guard existed but the threshold was too permissive (price module impl):
function _checkDeviation(uint256 avgPrice, uint256 spotPrice) internal view {
uint256 deviation;
if (avgPrice <= spotPrice) {
deviation = (spotPrice - avgPrice) * 10000 / avgPrice;
} else {
deviation = (avgPrice - spotPrice) * 10000 / spotPrice; // ← fires during withdrawal
}
require(deviation <= _maxPriceDeviation, "Price deviation too high");
}
At the observed 13.55% spot drop, the else-branch yields (1 − 0.8645) / 0.8645 × 10000 ≈ 1567 bps. The check passed because _maxPriceDeviation ≥ 1567 bps. A TWAP-based oracle or a tighter threshold would have reverted the withdrawal.
| Role | Proxy | Implementation |
|---|---|---|
| Staking (entry point) | 0x40465755eb5846d655bbcc8c186a477469f9ce36 | 0x9153e149b0d90dea634ed9f7df6ff71c2109b654 (unverified) |
| Price module (root cause) | 0xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868 | 0x0729ea061132bcd76f420f9139af8957b41b90cb (Dedaub decompile) |
| Oracle pool (manipulated) | — | 0xd3a9331a654444f9fe7ddbaec6678c2dc9113197 (CES/USDT0 Algebra) |
0xb5a8d7a37d60aa662f4dc9b3ef4c32a3fe21fadf (top-level selector 0x2512b5d8).51,024.905390945780848543 CES from 0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9 via flash(address,uint256,uint256,bytes) (0x490e6cbc).uniswapV3FlashCallback(uint256,uint256,bytes) (0xe9cbafb0), the helper runs three batches. Each batch consists of:The trace below is condensed from trace_callTracer.json and focuses on the price-sensitive path.
EOA 0xe66b37de57b65691b9f4ac48de2c2b7be53c5c6f
-> ExploitExecutor 0xb5a8d7a37d60aa662f4dc9b3ef4c32a3fe21fadf [0x2512b5d8]
-> CES/USDT0 flash pool 0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9.flash(...) [0x490e6cbc]
-> CES.transfer(ExploitExecutor, 51024.905390945780848543)
-> ExploitExecutor.uniswapV3FlashCallback(...) [0xe9cbafb0]
[BATCH 1: five helper deposits]
-> helper.deposit-wrapper -> staking proxy.deposit(uint256) [0xb6b55f25] x5
-> staking proxy DELEGATECALL impl 0x9153...
-> accounting proxy.getPriceForLevel(...) [0x2266fc92]
-> accounting proxy.getBalance(...) [0xf8b2cb4f]
-> price module.getPrice() [0x98d5fdca] ← reads average price (deviation guard only)
-> price module.getActualPrice() [0x6cc09081] ← reads SPOT price
-> price module.deposit(addr,amt) [0xcaddba3d] ← mints IGT = CES * spotPrice / 1e18
-> accounting proxy.paymentDepositTo(...) [0xe06cbd2b]
-> IGT minted to staking proxy
-> oracle pool 0xd3a933....swap(...) [0x128acb08]
-> algebraSwapCallback(...) on ExploitExecutor [0x2c8958f6]
-> executor sends 161683.584248230639260623 CES
-> executor receives 163524.673671 USDT0
-> ExploitExecutor -> helper.withdraw() [0x3ccfd60b] x5
-> helper -> staking proxy [0x2e1a7d4d] x5
-> staking proxy DELEGATECALL impl 0x9153...
-> accounting proxy.getBalance(...) [0xf8b2cb4f]
-> price module.igt() [0x1162d512]
-> IGT.balanceOf(staking proxy)
-> price module.getPrice() [0x98d5fdca] ← reads average (guard only)
-> price module.getActualPrice() [0x6cc09081] ← reads SPOT (now depressed ~13.55%)
-> accounting proxy.updateBalance(...) [0xe0b1cccb]
-> CES.transfer(helper, redeemed amount)
-> price module.withdraw(addr,amt) [0xa527aed6] ← burns IGT, releases CES = IGT * 1e18 / spotPrice
-> oracle pool unwind swap
-> executor sends 163524.673671 USDT0
-> executor receives 160650.278584760338216069 CES
[BATCH 2]
-> same five deposit loops
-> dump 165017.905023638276437724 CES for 166650.549328 USDT0
-> same five helper redemption loops
-> unwind 166650.549328 USDT0 for 164056.172947708879699775 CES
[BATCH 3]
-> same five deposit loops
-> dump 168605.838479456084319365 CES for 169906.374332 USDT0
-> same five helper redemption loops
-> unwind 169906.374332 USDT0 for 167594.376546131873840784 CES
-> ExploitExecutor repays flash pool
-> CES.transfer(0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9, 51177.980107118618191089)
The redemption uplift per batch is directly visible from the per-helper CES-in vs CES-out figures:
| Batch | CES into price path per helper | CES redeemed per helper | Uplift |
|---|---|---|---|
| 1 | 6,059.542725546299036334 | 6,931.857727916549998558 | +14.3957% |
| 2 | 6,058.332440140962354227 | 6,970.086813756999230093 | +15.0496% |
| 3 | 6,060.153707407558306175 | 7,009.224266562462678752 | +15.6608% |
The spot price depression responsible for the uplift is confirmed by sequential sqrtPriceX96 snapshots forwarded through the DEX adapter to the Algebra pool’s globalState():
| Snapshot | sqrtPriceX96 | Implied price vs open |
|---|---|---|
| Opening in-tx | 82004854114830364833361 | 1.0000x |
| Lowest in-tx | 76246824926299225497826 | 0.8645x |
| Highest in-tx | 82009523352267903341628 | 1.0001x |
Using price ~ sqrtPriceX96², the trough is 13.55% below the opening spot — consistent with the observed redemption uplifts of 14.4%–15.7% across the three batches.
The batch totals derived from funds_flow.json are:
| Batch | CES into staking price path | IGT minted/burned | CES dump into oracle pool | USDT0 received | CES redeemed from staking | CES from unwind |
|---|---|---|---|---|---|---|
| 1 | 30,297.713627731497 | 32,455.547090012158 | 161,683.584248230639260623 | 163,524.673671 | 34,659.28863958275 | 160,650.278584760338216069 |
| 2 | 30,291.662200704814 | 32,455.547090012158 | 165,017.905023638276437724 | 166,650.549328 | 34,850.434068785 | 164,056.172947708879699775 |
| 3 | 30,300.768537037795 | 32,455.547090012158 | 168,605.838479456084319365 | 169,906.374332 | 35,046.12133281231 | 167,594.376546131873840784 |
Across all three batches:
97,366.64127003649 IGT90,890.144365474106696073 CES104,555.844041180056907725 CESThat net uplift on the staking leg is what ultimately funds the final profit after the flash-loan fee.
51,024.905390945780848543 CES51,177.980107118618191089 CES153.074716172837342546 CESAggregate oracle-pool swaps across the three batches:
495,307.327751325000017712 CES500,081.597331 USDT0500,081.597331 USDT0492,300.828078601091756628 CESThe exploit executor’s net balance change in funds_flow.json is:
+10,506.125286809215449705 CES0 USDT0At the observed swap prices, CES traded close to 1 USDT0, so the realized profit is approximately $10.5k equivalent.
This is also the cleanest protocol-loss statement visible on-chain: the attacker exits with 10,506.125286809215449705 CES extracted from the WhaleBit staking/oracle path.
0x5d54fa839821e370b020d13a9b11b6f4f8cadc4eaed0a404ea17ad1bd725dbde849388722026-03-31T22:56:21Z0x1)9,560,441234Confirmed via EIP-1967 implementation slot checks at block 84938872:
0x40465755eb5846d655bbcc8c186a477469f9ce36 -> 0x9153e149b0d90dea634ed9f7df6ff71c2109b6540xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868 -> 0x0729ea061132bcd76f420f9139af8957b41b90cb0x1caefc860308b58d0b5bb643d75c807c6a9d3a63 -> 0x35952dd1d135215cb22c07ae956ee02d4793023bgetPrice() [0x98d5fdca] reads on price module proxy 0xb5ea...: 30 (avg price, deviation guard only)getActualPrice() [0x6cc09081] reads on price module proxy 0xb5ea...: 30 (spot price, used in calculations)deposit [0xcaddba3d] calls on price module proxy 0xb5ea...: 15withdraw [0xa527aed6] calls on price module proxy 0xb5ea...: 15globalState() reads on oracle pool 0xd3a933...: 207 (forwarded from DEX adapter via getActualPrice / getAveragePrice)swap(...) calls: 6 total (3 dumps + 3 unwinds)0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9 -> 0xb5a8... 51,024.905390945780848543 CES0xb5a8... -> 0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9 51,177.980107118618191089 CES97,366.64127003649 IGT97,366.64127003649 IGT+10,506.125286809215449705 CESHigh confidence on:
Medium confidence on:
The price-module implementation (0x0729ea061132bcd76f420f9139af8957b41b90cb) was fully decompiled by Dedaub; its code-level claims are high-confidence. The staking proxy implementation (0x9153...) and accounting proxy implementation (0x35952...) remain unverified; their code-level statements are trace-backed approximations.