WhaleBit CES / IGT Staking Spot-Oracle Manipulation
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.
Root Cause
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.
The Vulnerable Code
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.
Vulnerable Contracts
| Role | Proxy | Implementation |
|---|---|---|
| Staking (entry point) | 0x40465755eb5846d655bbcc8c186a477469f9ce36 | 0x9153e149b0d90dea634ed9f7df6ff71c2109b654 (unverified) |
| Price module (root cause) | 0xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868 | 0x0729ea061132bcd76f420f9139af8957b41b90cb (Dedaub decompile) |
| Oracle pool (manipulated) | — | 0xd3a9331a654444f9fe7ddbaec6678c2dc9113197 (CES/USDT0 Algebra) |
Attack Execution
High-Level Flow
- The attacker EOA calls helper
0xb5a8d7a37d60aa662f4dc9b3ef4c32a3fe21fadf(top-level selector0x2512b5d8). - The helper flash-borrows
51,024.905390945780848543 CESfrom0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9viaflash(address,uint256,uint256,bytes)(0x490e6cbc). - Inside
uniswapV3FlashCallback(uint256,uint256,bytes)(0xe9cbafb0), the helper runs three batches. Each batch consists of:- five helper deposit loops into the staking proxy,
- one large CES dump into the CES/USDT0 oracle pool,
- five helper redemption loops,
- one unwind swap that buys CES back with USDT0.
- After the third batch, the helper repays the flash loan principal plus fee and keeps the residual CES.
Detailed Call Trace
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.
Batch-Level Token Flow
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:
- total IGT minted and later burned:
97,366.64127003649 IGT - total CES routed into the staking price path:
90,890.144365474106696073 CES - total CES redeemed from the staking path:
104,555.844041180056907725 CES
That net uplift on the staking leg is what ultimately funds the final profit after the flash-loan fee.
Financial Impact
Flash-Loan Leg
- Flash principal borrowed:
51,024.905390945780848543 CES - Flash repayment:
51,177.980107118618191089 CES - Flash fee:
153.074716172837342546 CES
Oracle-Manipulation Leg
Aggregate oracle-pool swaps across the three batches:
- CES dumped into oracle pool:
495,307.327751325000017712 CES - USDT0 received from oracle pool:
500,081.597331 USDT0 - USDT0 spent to unwind:
500,081.597331 USDT0 - CES recovered on unwind:
492,300.828078601091756628 CES
Final Profit
The exploit executor’s net balance change in funds_flow.json is:
+10,506.125286809215449705 CES0 USDT0
At 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.
Evidence
Transaction Metadata
- Transaction:
0x5d54fa839821e370b020d13a9b11b6f4f8cadc4eaed0a404ea17ad1bd725dbde - Chain: Polygon
- Block:
84938872 - Timestamp:
2026-03-31T22:56:21Z - Status: success (
0x1) - Gas used:
9,560,441 - Log count:
234
Proxy Resolution
Confirmed via EIP-1967 implementation slot checks at block 84938872:
- staking proxy
0x40465755eb5846d655bbcc8c186a477469f9ce36->0x9153e149b0d90dea634ed9f7df6ff71c2109b654 - price-module proxy
0xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868->0x0729ea061132bcd76f420f9139af8957b41b90cb - accounting proxy
0x1caefc860308b58d0b5bb643d75c807c6a9d3a63->0x35952dd1d135215cb22c07ae956ee02d4793023b
Trace-Backed Oracle Evidence
getPrice()[0x98d5fdca] reads on price module proxy0xb5ea...:30(avg price, deviation guard only)getActualPrice()[0x6cc09081] reads on price module proxy0xb5ea...:30(spot price, used in calculations)deposit[0xcaddba3d] calls on price module proxy0xb5ea...:15withdraw[0xa527aed6] calls on price module proxy0xb5ea...:15globalState()reads on oracle pool0xd3a933...:207(forwarded from DEX adapter viagetActualPrice/getAveragePrice)- oracle-pool
swap(...)calls:6total (3dumps +3unwinds)
Key Transfer Evidence
- Flash loan in:
0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9 -> 0xb5a8...51,024.905390945780848543 CES - Final repayment:
0xb5a8... -> 0x296b95dd0e8b726c4e358b0683ff0b6d675c35e951,177.980107118618191089 CES - Total IGT minted:
97,366.64127003649 IGT - Total IGT burned:
97,366.64127003649 IGT - Final executor net change:
+10,506.125286809215449705 CES
Confidence / Limitations
High confidence on:
- transaction metadata
- flash-loan amounts
- helper counts and sequencing
- proxy resolution
- oracle-pool reads and swap order
- final profit figure
Medium confidence on:
- exact internal WhaleBit staking and accounting contract layout (unverified, trace-reconstructed only)
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.
Related URLs
- PolygonScan TX: https://polygonscan.com/tx/0x5d54fa839821e370b020d13a9b11b6f4f8cadc4eaed0a404ea17ad1bd725dbde
- Attacker EOA: https://polygonscan.com/address/0xe66b37de57b65691b9f4ac48de2c2b7be53c5c6f
- Exploit executor: https://polygonscan.com/address/0xb5a8d7a37d60aa662f4dc9b3ef4c32a3fe21fadf
- Staking proxy: https://polygonscan.com/address/0x40465755eb5846d655bbcc8c186a477469f9ce36
- Price-module proxy: https://polygonscan.com/address/0xb5ea1d17f3d8da34a6d6a1d2acc2a148e1411868
- Oracle pool: https://polygonscan.com/address/0xd3a9331a654444f9fe7ddbaec6678c2dc9113197
- Flash pool: https://polygonscan.com/address/0x296b95dd0e8b726c4e358b0683ff0b6d675c35e9
- CES token: https://polygonscan.com/address/0x1bdf71ede1a4777db1eebe7232bcda20d6fc1610
- IGT token: https://polygonscan.com/address/0xc9e596b8b8aaf454a80a5bde0311e4d38a3690ac