Reproduced Exploit
SKYDAO sell path — `controlburn` + `sync()` before the net credit desyncs the pair reserve
SKYDAO is a fee-on-transfer token (35% tax) paired with USDT on PancakeSwap V2. A sell (to == pair) does not just take a tax. It calls the unverified pool controller's sellToken(gross, _swapLock) while the gross amount is still intact, and only afterwards credits amount - tax to the pair (SKYDAO.so…
Loss
183,482.883991401873856911 USDT leaves the SKYDAO/USDT pair output.txt:256. One wei of USDT remains output.tx…
Chain
BNB Chain
Category
logic
Date
Sep 2026
Source
DeFiHackLabs
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: 2026-09-SKYDAO_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/SKYDAO_exp.sol.
Vulnerability classes: vuln/logic/incorrect-order-of-operations · vuln/logic/state-update · vuln/defi/price-manipulation Reproduction: the PoC compiles and runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Online pass. There is no
anvil_state.jsonin this folder, so_shared/run_poc.shcannot replay it offline. The committed trace forks a live BSC RPC (createSelectFork("<rpc url>", 124921241)at output.txt:13) and passes.
Key info#
| Loss | 183,482.883991401873856911 USDT leaves the SKYDAO/USDT pair output.txt:256. One wei of USDT remains output.txt:254. Attacker net 59,914.122896306033123040 USDT output.txt:255 |
| Vulnerable contract | SKYDAO 0x7eBa33c7a0e555D115277BA4Af04DFbB4F4Fa70c — verified, sources/SKYDAO_7eBa33/SKYDAO.sol |
| Unverified accomplice | Pool controller 0xEe5fDff6364dDe0A3C66dD38A4303cDd3D10730c. getsourcecode returned UNVERIFIED. sellToken behaviour below is from the trace |
| Pair | PancakeSwap V2 SKYDAO/USDT 0x096e08ddA1E18625fFdfBae4BB65a414Aa7eC2c8 (token0 = USDT) |
| Attacker EOA (historical) | 0x5C9214d91eA1d2D6A46F80457c25a6e7e4D56EBc |
| Attack tx | 0x8e3016674ea8e5d2ad3af422ae5328f5a1f448e6b5a93d5d773e358bd2e440eb (block 124,921,242, contract-creation; constructor ran the attack) |
| This PoC's attacker | SKYDAOAttack deployed in setUp at 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f output.txt:15. Not the historical EOA |
| Chain / block / date | BNB Chain / fork parent 124,921,241 output.txt:13 / fork block.timestamp 1,790,777,005 = 2026-09-30 14:03:25 UTC (router deadline output.txt:61) |
| Compiler | v0.8.20+commit.a1b79de6, optimizer off (sources/SKYDAO_7eBa33/_meta.json) |
| Bug class | On a sell, _transfer hands the pool controller the gross SKY amount and lets it controlburn the pair and sync() before the seller's net 65% is credited. Stored reserve1 latches at 2,854 wei. A direct pair.swap then pays out essentially all USDT. |
TL;DR#
SKYDAO is a fee-on-transfer token (35% tax) paired with USDT on PancakeSwap V2. A sell (to == pair) does not just take a tax. It calls the unverified pool controller's sellToken(gross, _swapLock) while the gross amount is still intact, and only afterwards credits amount - tax to the pair (SKYDAO.sol lines 258–271).
In the trace that callback sells the 35% tax for USDT, calls SKYDAO.controlburn(gross), which moves the pair's whole SKY balance to 0x…dEaD, then pair.sync(). sync reads a SKY balance of 2,854 wei and stores that as reserve1 output.txt:175. The outer _transfer then credits the net 2,535,948,812.272412882885892348 SKY output.txt:210. Real SKY balance is now ~2.536e9; stored reserve is 2,854 wei output.txt:217.
pair.swap(reserve0 - 1, 0, attacker, "") passes the constant-product check against that stale reserve and sends 495,281.556845443667402613 USDT to the attacker output.txt:218. The pair's USDT side ends at 1 wei. Net of the USDT spent to buy the SKY, profit is 59,914.122896306033123040 USDT output.txt:7. [PASS] testExploit at output.txt:4.
Background#
The token stores balances in _tOwned and moves them in _basicTransfer with no Uniswap callback of its own. The pair address is cached in _uniswapV2Pair. Config (pair, controller, tax recipients) is an external getAllConfig() array; results[8] is the pool controller 0xEe5f…730c, results[12] is USDT.
A transfer whose sender is the pair is a buy. A transfer whose recipient is the pair is a sell (sort = 2). getTax returns 35 for every address that is not the controller or results[6], and 0 for those two. The buy path reverts "Transaction Closed" unless the recipient is one of those addresses or _swapLock is already true. _swapLock flips to true, inside _transfer, once totalHolder >= 200000 (lines 228–230). Nothing on the sell path checks an owner, a signer, or _swapLock.
controlburn is externally gated to results[6] or results[8]. The gate does not protect the pair: the public sell path is what causes the controller to call it, and the amount it burns is the seller's gross, not the tax.
The PoC is test/SKYDAO_exp.sol. It borrows USDT from Moolah (0x8F73b65B…5D8C), which in this trace held exactly the borrowed amount and charged no fee. Pancake router 0x10ED43C7…024E. USDT is BSC 0x55d39832…7955 (18 decimals).
The vulnerable code#
Verified source, sources/SKYDAO_7eBa33/SKYDAO.sol:
function _transfer(address from, address to, uint256 amount) private {
// ...
if (_swapLock == false && totalHolder >= 200000) {
_swapLock = true;
}
// ...
if (from == _uniswapV2Pair) {
require(to == results[8] || to == results[6] || _swapLock, "Transaction Closed");
tax = getTax(to, results);
} else if (to == _uniswapV2Pair) { // sell
tax = getTax(from, results); // 35
sort = 2;
}
if (tax > 0) {
uint256 taxAmount = (amount * tax) / 100;
if (sort == 1) {
_basicTransfer(from, results[25], taxAmount);
} else {
_basicTransfer(from, results[8], taxAmount); // 35% to the controller
IPancakeFactory(results[8]).sellToken(amount, _swapLock); // GROSS, before the net credit
}
amount -= taxAmount;
}
_basicTransfer(from, to, amount); // net 65% credited only now
}
function controlburn(uint256 amount) external {
address[] memory results = REFER(config).getAllConfig();
require(msg.sender == results[6] || msg.sender == results[8], "Mast Mark Control");
_basicTransfer(_uniswapV2Pair, 0x000000000000000000000000000000000000dEaD, amount);
}
What the sell trace does with that order:
- Tax transfer of 1,365,510,898.915914629246249725 SKY from the attacker to the controller output.txt:109. That is 35% of the 3,901,459,711.188327512132142073 SKY just bought.
PoolController.sellToken(3901459711188327512132142073, true)— the gross wei amount, not the tax output.txt:110.- Inside
sellToken: the controller sells that tax SKY into the pair and receives 216,245.331916417721284273 USDT output.txt:137, then pushes 92,676.570821321880550402 USDT back into the pair output.txt:156. SKYDAO.controlburn(gross)moves 3,901,459,711.188327512132142073 SKY from the pair to0x…dEaDoutput.txt:162, output.txt:165. Pair SKY balance is then 2,854 wei.pair.sync()storesreserve0 = 495,281.556845443667402614USDT andreserve1 = 2854output.txt:175.- Only then does
_basicTransfercredit the net 2,535,948,812.272412882885892348 SKY to the pair output.txt:210.getReservesstill returns(495281.556845443667402614, 2854, …)output.txt:217. Real SKY balance is 2,535,948,812.272412882885895202 output.txt:228 = the net credit plus the 2,854 wei dust.
The controller's sellToken body is not verified. The calls above are what the fork executed. controlburn and sync are not in the SKYDAO source as a pair; sync is the controller. SKYDAO's bug is calling out with the gross amount before the balance update that the pair's reserve is supposed to reflect.
Root cause#
- External call in the middle of a balance update.
sellToken(amount, …)runs beforeamount -= taxAmountand before_basicTransfer(from, pair, amount). The controller is handed a number that still includes tokens the pair has not been credited. controlburnburns the pair, not the tax. The argument is the gross sell. Combined withsync(), the stored SKY reserve is the post-burn dust (2,854 wei), not the post-sell balance.syncis the wrong moment. A Uniswap V2 pair sets reserves frombalanceOfat the end ofswap, or whensyncis called. Callingsyncwhile a transfer into the pair is half-finished freezes a reserve the nextswapwill trust.- The K check then uses the lie. After the net credit, SKY balance ≫
reserve1.swap(reserve0 - 1, 0, …)only has to satisfybalance0 * balance1 >= reserve0 * reserve1withreserve1 = 2854. The USDT output can be the entire reserve minus 1 wei. - The
controlburnmodifier is not an authorization of the economic action. Only the controller may call it, and the public sell path calls the controller. No attacker-held role is required. - The buy gate opened inside the same transfer.
totalHolderwent 199,999 → 200,000 and slot 14 (_swapLock) went 0 → 1 during the buy output.txt:84, before the"Transaction Closed"check. The attacker's buy both crossed the threshold and was allowed by it.
Preconditions#
- Permissionless. The PoC contract has no role. It calls Moolah
flashLoan, the Pancake router,SKY.transfer, andpair.swap. - Fee-free flash of a fixed size. Moolah lent 2,656,932.603895226812594665 USDT, its entire USDT balance in this trace output.txt:46, output.txt:49. Repay is
transferFromof that same amount output.txt:240. Moolah uses transient storage, so the test needs Cancun. _swapLockmust be true for an ordinary buy. Here the buy itself flipped it, becausetotalHolderhit 200,000. A fork where the lock is already true works the same; a fork where the lock cannot flip would revert the buy.- The pair must hold the USDT. Starting pair USDT is 183,482.883991401873856912 output.txt:44. The attack pulls the post-buy, post-tax-sell balance, which is larger than the starting reserve because the buy added USDT.
- No SKY and no USDT principal. Attacker USDT starts at 0 output.txt:6.
Attack walkthrough (with on-chain numbers from the trace)#
| # | Action | Amount | Trace |
|---|---|---|---|
| 0 | Attacker USDT before | 0 | output.txt:6 |
| 1 | Pair USDT before | 183,482.883991401873856912 | output.txt:44 |
| 2 | Moolah.flashLoan(USDT, 2,656,932.603895226812594665) | +2,656,932.603895226812594665 USDT | output.txt:46, output.txt:50 |
| 3 | Router buy, exact USDT in. Pair sends 6,002,245,709.520503864818680111 SKY gross; 35% (2,100,785,998.332176352686538038) goes to 0xA210…796d, 65% to the attacker. This transfer also sets _swapLock | 435,367.433949137634279573 USDT in; attacker receives 3,901,459,711.188327512132142073 SKY | output.txt:61, output.txt:80, output.txt:81, output.txt:84 |
| 4 | SKY.transfer(pair, full balance). Tax to controller, then sellToken(gross) | tax 1,365,510,898.915914629246249725 SKY | output.txt:106, output.txt:109, output.txt:110 |
| 5 | Controller sells the tax SKY for USDT, then returns part of that USDT to the pair | USDT out of pair 216,245.331916417721284273; USDT back 92,676.570821321880550402 | output.txt:137, output.txt:156 |
| 6 | controlburn(gross) pair → dEaD, then sync. Stored SKY reserve = 2,854 wei. USDT reserve = 495,281.556845443667402614 | burn 3,901,459,711.188327512132142073 SKY | output.txt:162, output.txt:165, output.txt:175 |
| 7 | Outer transfer credits the net 65% after sync | +2,535,948,812.272412882885892348 SKY to the pair | output.txt:210 |
| 8 | getReserves still (495,281.556845443667402614, 2854). swap(reserve0 - 1, 0, attacker) | 495,281.556845443667402613 USDT to the attacker. Pair USDT left = 1 wei | output.txt:217, output.txt:218, output.txt:226 |
| 9 | Repay Moolah the exact loan | −2,656,932.603895226812594665 USDT | output.txt:240 |
| 10 | Logged profit and pair loss | 59,914.122896306033123040 profit; 183,482.883991401873856911 pair loss | output.txt:251, output.txt:255, output.txt:256 |
Profit identity (USDT, 18 dp)
| Component | USDT |
|---|---|
Drain swap | +495,281.556845443667402613 |
| Buy | −435,367.433949137634279573 |
| Flash principal in and out | 0 |
| Net | 59,914.122896306033123040 |
495,281.556845443667402613 − 435,367.433949137634279573 = 59,914.122896306033123040. The logged pair loss is starting balance minus 1 wei.
Pair USDT in during the attack: 435,367.433949137634279573 (buy) + 92,676.570821321880550402 (controller refill) = 528,044.004770459514829975. Pair USDT out: 216,245.331916417721284273 (tax sell) + 495,281.556845443667402613 (drain) = 711,526.888761861388686886. Out − in = 183,482.883991401873856911, the logged loss output.txt:256.
assertApproxEqRel uses 1% tolerance against the test's expected 59,914.122886306033123041 and 183,482.883991401873856912 output.txt:257, output.txt:259. The trace's own figures are the ones in the table. Suite: 1 passed output.txt:270.
Diagrams#
Remediation#
- Finish the pair credit before any external call. Move
_basicTransfer(from, pair, net)and the tax transfer abovesellToken. Do not passamount(the gross) into a contract that can touch the pair. - Do not
syncfrom inside a transfer that is still crediting the pair. If a burn of pair tokens is required, do it in a separate transaction after reserves match balances, or have the pair's ownswapbe the only reserve update. controlburnmust not be reachable from a permissionless sell, and its amount must be the tax actually received, never the seller's gross and neverbalanceOf(pair).- Measure the K input from balance delta the pair observed, which is what Uniswap V2 already does when nobody calls
syncmid-transfer. A hook that rewrites reserves in the middle defeats that. - Cap
swapoutput with a reserve check that uses the post-transfer balance, or revert in_transferifbalanceOf(pair)andgetReservesdiverge at the end of the call.
How to reproduce#
No anvil_state.json. _shared/run_poc.sh 2026-09-SKYDAO_exp -vvvvv exits before Forge: the harness requires that file. The passing artifact is the online trace output.txt.
# from the registry root — this folder has no anvil state, so the offline harness will not run it
# _shared/run_poc.sh 2026-09-SKYDAO_exp -vvvvv
# online, matching the committed trace (Cancun: Moolah transient storage)
cd 2026-09-SKYDAO_exp
forge test --match-contract SKYDAO_exp --evm-version cancun -vvvvv
- Chain / fork: BNB Chain, block 124,921,241, RPC in the test:
https://bsc-mainnet.public.blastapi.io. - Expected result (this is what output.txt already records,
finished in 568.78ms):
[PASS] testExploit()
Attacker Before exploit USDT Balance: 0.000000000000000000
attacker net USDT profit: 59914.122896306033123040
pair USDT loss : 183482.883991401873856911
Attacker After exploit USDT Balance: 59914.122896306033123040
Suite result: ok. 1 passed; 0 failed; 0 skipped
Reference: attack tx and fork RPC in the header of test/SKYDAO_exp.sol — 0x8e3016674ea8e5d2ad3af422ae5328f5a1f448e6b5a93d5d773e358bd2e440eb, RPC https://bsc-mainnet.public.blastapi.io.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2026-09-SKYDAO_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
SKYDAO_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "SKYDAO sell path".
- 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.