Reproduced Exploit
Split (Kub) Exploit — Self-Doubling Balance via Manipulable On-Chain "Token Price" Oracle
Split is a "reflection"-style deflationary token. On every token transfer its _beforeTokenTransfer hook calls setSplit() (Split.sol:1231-1237). When an internal price reading getTokenPrice() exceeds 100 ether, setSplit() bumps a counter
Loss
~$22.2K — attacker netted 22,049.48 BUSDT + 126.38 KUB (≈ $22.2K) after repaying all flash loans
Chain
BNB Chain
Category
Oracle Manipulation
Date
Sep 2023
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: 2023-09-Kub_Split_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/Kub_Split_exp.sol.
Vulnerability classes: vuln/oracle/price-manipulation · vuln/oracle/spot-price · vuln/logic/state-update
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 sources: Split.sol, StakingRewards.sol.
Key info#
| Loss | ~$22.2K — attacker netted 22,049.48 BUSDT + 126.38 KUB (≈ $22.2K) after repaying all flash loans |
| Vulnerable contract | Split token — 0xc98E183D2e975F0567115CB13AF893F0E3c0d0bD |
| Co-conspiring contract | StakingRewards — 0x3A006dD44a4a0e43C942f57d452a6a7Ada25AdC3 and 0x26Eea9ff… |
| Victim pools | BUSDT/Split 0xe4D038…155fF, KUB/Split 0x16bF07…C570, BUSDT/KUB 0x39aDFE…7600 & 0x1E338D…11AF |
| Attacker EOA | 0x7Ccf451D3c48C8bb747f42F29A0CdE4209FF863e |
| Attack contract | 0xa7fe9c5d4b87b0d03e9bb99f4b4e76785de26b5d |
| Attack tx | 0x2b0877b5495065e90d956e44ffde6aaee5e0fcf99dd3c86f5ff53e33774ea52d |
| Chain / block / date | BSC / fork at 32,021,099 (32_021_100 - 1) / ~Sep 22 2023 |
| Compiler | Solidity v0.8.4+commit.c7e474f2, optimizer enabled (1 / 200 runs) |
| Bug class | Balance/supply inflation driven by an instantaneous, attacker-manipulable on-chain price "oracle" |
TL;DR#
Split is a "reflection"-style deflationary token. On every token transfer its
_beforeTokenTransfer hook calls setSplit()
(Split.sol:1231-1237). When an internal
price reading getTokenPrice() exceeds 100 ether, setSplit() bumps a counter
split and invokes two internal "rebase" routines that literally double numbers in
storage:
_total(split)doubles_totalSupply(Split.sol:731-741), and_balance(to, split)doubles each touched account's_balances[to](Split.sol:742-752).
getTokenPrice() is computed live from the spot reserves of a pool the attacker
controls (KUB.balanceOf(fistLP) and Split.balanceOf(fistLP),
Split.sol:1238-1246). So the attacker
first force-feeds that pool until the reading clears 100 ether, then triggers the hook
repeatedly with zero-value, self-directed transfers. Each call doubles their Split
balance held inside the AMM pairs. Pair balances are then harvested with skim(), the
inflated Split is routed through a fresh attacker-owned fakeUSDC/Split pair and through
the StakingRewards sell() path back into BUSDT and KUB, and finally five DODO/DPP
flash loans (~1.87M BUSDT borrowed) are repaid. The attacker keeps the difference:
22,049.48 BUSDT + 126.38 KUB.
Background — what the contracts do#
Split (Split.sol:1125-1255) inherits a
modified OpenZeppelin ERC20. Two non-standard pieces are bolted into the base
ERC20:
- A hidden "rebase" pair
_total()/_balance()(Split.sol:731-752) that doubles supply and per-account balancessp - <last>times. - A
balanceOfoverride (Split.sol:1212-1216) that returns a floor of_initialBalance(= 1) for any account whose real balance is zero. This "phantom dust" is what gives the doubling something to multiply.
The StakingRewards contract
(StakingRewards.sol:414-862)
is a yield/referral farm with a permissionless sell()
(StakingRewards.sol:767-782)
that buys back token (Split/KUB) for token1 (KUB/BUSDT) at a spot price and, if it is
short on inventory, pulls it out of the LP via removeLiquidity(). This is the path the
attacker uses to convert inflated Split into real value.
The on-chain state at the fork block (read from the trace):
| Quantity | Value |
|---|---|
| Attacker initial Split balance | 1 wei (phantom dust, output.txt:127) |
| Attacker initial BUSDT / KUB | 0 / 0 (output.txt:117-122) |
| BUSDT/Split pair reserves | 4,176.99 BUSDT / 20.66 Split (output.txt:256) |
KUB/Split pair reserves (the price source fistLP) | 720.42 KUB / 901.16 Split (output.txt:243-245) |
getTokenPrice() reading | > 100 ether (gate satisfied — see below) |
| Total flash-loan capital borrowed (5× DODO/DPP) | ~1,874,170 BUSDT (output.txt:141-185) |
The vulnerable code#
1. Every transfer can trigger a balance "rebase"#
// Split._beforeTokenTransfer (Split.sol:1164-1173)
function _beforeTokenTransfer(address from,address to,uint amount) internal override trading{
if(LP != address(0) && from !=LP){
setSplit(); // ⚠️ called on (almost) every transfer
}
_balance(from,split); // ⚠️ doubles from's balance `split-spr[from]` times
_balance(to,split); // ⚠️ doubles to's balance `split-spr[to]` times
...
}
2. setSplit() keys off a spot-reserve "price" and increments the rebase counter#
// Split.sol:1231-1246
function setSplit() public {
if(getTokenPrice() > 100 ether){ // ⚠️ attacker-controllable threshold
split++; // bump rebase counter
_total(split); // double _totalSupply
IFactory(LP).sync();
}
}
function getTokenPrice() view private returns(uint){
// price = (KUB held by fistLP × KUB/USDT price) / (Split held by fistLP)
uint kubPrice = IRouter(KUBDEX).getAmountsOut(1 ether, [KUB, USDT])[1];
uint kubUSDT = IERC20(KUB).balanceOf(fistLP) * kubPrice / 1 ether;
uint fistlpToken = kubUSDT * 1 ether / IERC20(address(this)).balanceOf(fistLP);
return fistlpToken; // ⚠️ pure spot read of a pool the attacker fills
}
3. The doubling primitives#
// Split.sol:731-752 (added to the modified ERC20 base)
function _total(uint sp) internal {
if(sp > spss){
uint a = sp - spss;
for(uint i=0;i<a;i++){ _totalSupply = _totalSupply*2; } // ⚠️ supply ×2 per step
spss = sp;
}
}
function _balance(address to,uint sp) internal {
if(sp > spr[to]){
uint a = sp - spr[to];
for(uint i=0;i<a;i++){ _balances[to] = _balances[to]*2; } // ⚠️ balance ×2 per step
spr[to] = sp;
}
}
4. The phantom-dust balanceOf override that makes the doubling productive#
// Split.sol:1212-1216
function balanceOf(address account) public view virtual override returns (uint) {
uint balance = super.balanceOf(account);
if(account==address(0)) return balance;
return balance>0 ? balance : _initialBalance; // ⚠️ zero-balance accounts read as 1 wei
}
The interaction is the whole bug: an account that has never held Split still reports
1 wei. The first time the rebase counter catches up with it, _balance doubles its
real stored balance — and the per-account spr[to] is many steps behind split, so a
single touch can multiply that seed by 2^(split - spr[to]), an astronomically large
factor.
Root cause — why it was possible#
Split derives a security-relevant decision (whether to inflate everyone's balances)
from getTokenPrice(), which is nothing more than an instantaneous read of AMM pool
reserves the attacker can fill at will. There is no TWAP, no admin gate, no
rate-limit, and the inflation is unbounded — _total/_balance multiply by 2 once per
unit the lagging counter is behind.
Concretely, four design faults compose into a critical exploit:
- Spot-price-driven control flow.
setSplit()flips inflation on whenevergetTokenPrice() > 100 ether. The price is(KUB reserve × KUB-price) / Split reserveof thefistLPpool. By cornering KUB into that pool and starving it of Split, the attacker drives the reading above the threshold deterministically. - Per-account doubling with a lagging counter.
_balance(to, split)doubles_balances[to]split - spr[to]times. Becausespr[to]starts at 0 whilesplithas been advanced many steps, the first touch of an account multiplies its seed by a huge power of two. - Phantom-dust floor.
balanceOfreturns_initialBalance = 1for zero-balance accounts, so even fresh addresses (and the pair contracts) carry a non-zero seed for the doubling to act on. - Permissionless harvest paths. Uniswap-V2
skim()lets anyone sweep a pair's surplus token balance to themselves;StakingRewards.sell()(StakingRewards.sol:767-782) permissionlessly buys back the inflated Split for KUB/BUSDT and even withdraws pool liquidity to fund the payout. Together they convert "doubled storage numbers" into real, withdrawable assets.
Preconditions#
- The attacker can move the
fistLP(KUB/Split) reserves so thatgetTokenPrice() > 100 ether. In the live attack this is done by swapping BUSDT→KUB and dumping the KUB into the KUB/Split pair, thensync()ing (output.txt:235-251). - The
Splitrebase countersplitis allowed to advance freely via repeated transfers — there is no cooldown, so the attacker can fire dozens oftransfer(self, 0)calls in one transaction (Kub_Split_exp.sol:122-126). - Working capital in BUSDT to seed the swaps. The attack borrows it via five chained DODO/DPP flash loans (~1.87M BUSDT, fully repaid intra-tx), so the only real cost is gas — i.e. it is effectively capital-free.
Attack walkthrough (with on-chain numbers from the trace)#
All figures are taken directly from the output.txt trace.
| # | Step | Trace ref | Effect |
|---|---|---|---|
| 0 | Seed attacker with 10,000 fakeUSDC, zero BUSDT/KUB; record 1-wei phantom Split balance | 127 | Starting position. |
| 1 | Borrow BUSDT via 5 nested flash loans: DPPOracle1 178,235.6 + DPPOracle2 725,624.9 + DPPAdvanced 140,302.4 + DPPOracle3 756,552.0 + DPP 73,455.4 ≈ 1,874,170 BUSDT | 141-185 | Capital to position the pools. |
| 2 | Swap 4,102.28 BUSDT → 10.05 KUB, then transfer that KUB into the KUB/Split pair and sync() → reserves become 720.42 KUB / 901.16 Split | 203-251 | Inflates KUB side of fistLP so getTokenPrice() > 100 ether. |
| 3 | Swap 8,353.98 BUSDT → 13.76 Split out of the BUSDT/Split pair (acquires real Split + warms the price hook) | 254-316 | Gives attacker live Split; first setSplit() rebases fire. |
| 4 | Call StakingRewards1.stake(KUB, BUSDT, BUSDT, up, 1000e18) | 317-443 | Drives staking-side bookkeeping / extra transfers that keep advancing the rebase counter. |
| 5 | Loop Split.transfer(self, 0) ×30 | 123-126, 444-1449 | Each zero-value transfer trips setSplit(), advancing split and doubling balances. Attacker's stored Split slot grows geometrically (e.g. 1463: 0x2e4d… → 0x5c9b…). |
| 6 | Split.transfer(BUSDT_Split,0) then BUSDT_Split.skim(self) | 1450-1668 | The BUSDT/Split pair's Split balance is doubled to 5.916e28 (1478); skim() sweeps the surplus to the attacker. |
| 7 | Split.transfer(KUB_Split,0), KUB.transfer(KUB_Split,1), KUB_Split.skim(self), more BUSDT_Split.skim(self) | 1519-1705 | Harvest both pairs; BUSDT/Split Split balance doubles again to 1.775e29 (1675). |
| 8 | Create a fresh fakeUSDC/Split pair, seed it 1e6 fakeUSDC / 55.10 Split, Split.setPair(fakeUSDC), then fakeUSDC → Split → BUSDT swap of all 10,000 fakeUSDC | 1708-1857 | Routes value out: attacker BUSDT jumps to 1,868,975.7 (1856) (≈ borrowed + profit). |
| 9 | Loop StakingRewards2.sell(Split, KUB, …) ×100 | 159-172, 1882-… | Dumps the gigantic Split balance (3.165e31, 1881) for KUB via the permissionless sell/removeLiquidity path. |
| 10 | Loop StakingRewards1.sell(KUB, BUSDT, …) ×10 | 174-183 | Converts harvested KUB back into BUSDT. |
| 11 | Repay all five flash loans in full (73,455.4 + 756,552.0 + 140,302.4 + 725,624.9 + 178,235.6 BUSDT) | 16634-16704 | Loans closed; DODOFlashLoan events fire clean. |
| 12 | Final balances: 22,049.48 BUSDT + 126.38 KUB + 3.165e31 leftover Split | 22049…/126… (tail) | Net profit. |
Why "doubling a 1-wei seed" produces 3.165e31 Split#
The per-account routine _balances[to] = _balances[to]*2 runs split - spr[to] times.
Because the pair/attacker accounts start with spr=0 (or far behind) while split has
been advanced ~30+ times in step 5, the first touch multiplies the seed by 2^k for a
large k. The trace shows the BUSDT/Split pair's reported Split balance leaping from
~27.55 (real LP) to 5.916e28 (1478) and then 1.775e29
(1675) across two rebase touches — exactly the geometric blow-up the doubling
loops predict. skim() then realizes that phantom surplus as a transferable balance.
Profit / loss accounting (net of flash loans)#
| Asset | Before | After | Δ |
|---|---|---|---|
| BUSDT | 0 | 22,049.48 | +22,049.48 |
| KUB | 0 | 126.38 | +126.38 |
| Split | 1 wei | 3.165e31 | +3.165e31 (worthless once the token is drained) |
All five flash loans (~1,874,170 BUSDT) are repaid in full
(output.txt:16634-16704); the DODO DODOFlashLoan events at the tail confirm
no shortfall. The realized profit — 22,049.48 BUSDT + 126.38 KUB (≈ $22.2K at the
BUSDT≈$1, KUB price implied by the trace) — is value siphoned out of the KUB/Split,
BUSDT/Split, and (via StakingRewards) BUSDT/KUB liquidity pools.
Diagrams#
Sequence of the attack#
The flaw inside setSplit() / _balance()#
Balance blow-up (BUSDT/Split pair's reported Split balance)#
Remediation#
- Never gate state mutations on a spot AMM price.
getTokenPrice()reads live pool reserves the attacker can fill. If a price signal is genuinely needed, use a manipulation-resistant TWAP/oracle, or an admin-set value — not an instantaneousgetAmountsOut/balanceOf(pair)reading. - Remove the doubling rebase entirely.
_total()and_balance()(Split.sol:731-752) multiply supply and balances by 2 with no cap and no invariant tying balances to a fixed total supply. Any legitimate rebase must preserveΣ balances == totalSupplyand be bounded / permissioned. - Delete the phantom-dust
balanceOffloor. Returning_initialBalancefor zero-balance accounts (Split.sol:1212-1216) manufactures value out of nothing and is what gives the doubling something to multiply. - Don't run unbounded logic in the transfer hook.
_beforeTokenTransfercalling a public, externally-influencedsetSplit()on every transfer turns a no-optransfer(self, 0)into a state-mutating weapon. Hooks should be side-effect-free with respect to global supply. - Harden the StakingRewards
sell()path. It permissionlessly buys back tokens at a spot price and withdraws pool liquidity to fund the payout (StakingRewards.sol:767-788). It should validate token solvency against a manipulation-resistant price and never pull LP reserves to satisfy a spot-priced redemption.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo
does not whole-compile under a single forge build):
_shared/run_poc.sh 2023-09-Kub_Split_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 32,021,099).
foundry.tomluseshttps://bsc-mainnet.public.blastapi.io, which serves historical state at that block; most public BSC RPCs prune it and fail withheader not found/missing trie node. - Result:
[PASS] testExploit().
Expected tail (from output.txt):
Attacker BUSDT balance after attack: 22049.483174547645804689
Attacker KUB balance after attack: 126.379336484940877783
Attacker Split balance after attack: 31651945174745.793911796322504040
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 49.12s
References: CertiK Alert — https://twitter.com/CertiKAlert/status/1705966214319612092 (Split / Kub, BSC, ~$22K). SlowMist Hacked archive — https://hacked.slowmist.io/.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-09-Kub_Split_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
Kub_Split_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "Split (Kub) 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.