Reproduced Exploit
CFC Exploit — Self-Burning `sync()` + `skim()` Reserve Drain
CFC is a "tax + dividend" BEP20 whose _transfer runs an internal sync() helper on every sell (any transfer where to == uniswapV2Pair). That helper directly mutates the pair's CFC token balance — _tOwned[uniswapV2Pair] -= sellAmount — moving 95% of the sold amount to the mineAdd/0xdead addresses, an…
Loss
+6,124.40 BEP20USDT net profit this transaction (PoC). SlowMist reported ~$16K total across the incident — th…
Chain
BNB Chain
Category
Oracle Manipulation
Date
Jun 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-06-CFC_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/CFC_exp.sol.
Vulnerability classes: vuln/oracle/price-manipulation · vuln/logic/state-update · vuln/defi/slippage
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: sources/CFC_dd9B22/CFC.sol. Pair source: sources/PancakePair_595488/PancakePair.sol.
Key info#
| Loss | +6,124.40 BEP20USDT net profit this transaction (PoC). SlowMist reported ~$16K total across the incident — this is the second exploit tx. |
| Vulnerable contract | CFC — 0xdd9B223AEC6ea56567A62f21Ff89585ff125632c |
| Victim pool | CFC/SAFE PancakeSwap pair (CakeLP) — 0x595488F902C4d9Ec7236031a1D96cf63b0405CF0 |
| Tokens | SAFE 0x4d7Fa587…18a1 (token0), CFC 0xdd9B223A…632c (token1), BEP20USDT 0x55d39832…7955 |
| Attacker contract | 0x8213e87bb381919b292ace364d97d3a1ee38caa4 |
| Attack tx | 0xa3c130ed8348919f73cbefce0f22d46fa381c8def93654e391ddc95553240c1e (Phalcon) |
| Chain / block / date | BSC / 29,116,478 / June 2023 |
| Compiler | CFC: Solidity v0.8.18 (optimizer off); Pair: v0.5.16 |
| Bug class | Token tampers with its own AMM reserve (_tOwned[pair] -= … + pair.sync()), creating a balance > reserve gap that skim() lets an attacker repeatedly harvest |
TL;DR#
CFC is a "tax + dividend" BEP20 whose _transfer runs an internal sync() helper on every sell
(any transfer where to == uniswapV2Pair). That helper directly mutates the pair's CFC token balance —
_tOwned[uniswapV2Pair] -= sellAmount — moving 95% of the sold amount to the mineAdd/0xdead addresses,
and then calls the real pancakePair.sync() to force the pair to adopt the reduced balance as its reserve
(CFC.sol:756-769).
In other words, the token destroys CFC sitting inside the pair, on the pair's behalf, on every sell.
A Uniswap-V2 pair trusts that its token.balanceOf(pair) only changes through swaps/mints/burns it
mediates. CFC breaks that trust, so the attacker can engineer a persistent gap between the pair's actual
CFC balance and its recorded reserve, then sweep that gap with the permissionless skim()
(PancakePair.sol:483-488).
The attacker, funded by a chain of five DODO DPPOracle flashloans of BEP20USDT:
- Seeds itself with SAFE (swaps 13,000 USDT → 2,515 SAFE on a different pair).
- Flash-borrows ~41,922 CFC from the CFC/SAFE pair via a low-level
swap()and repays it with the 2,515 SAFE, ending up holding ~39,826 CFC (net of CFC's 3% sell tax). - Runs a
transfer → skimloop 19 times: eachCFC.transfer(pair, …)fires CFC's self-sync(), which shrinks the pair's CFC reserve far below its real balance; thenCakeLP.skim(attacker)ships the surplus CFC out to the attacker. The pair's CFC reserve is driven from 100,246 CFC → 9 wei, while its SAFE reserve barely moves (it stays ~3,766 SAFE). - Buys the entire SAFE side: with the CFC reserve at ~9 wei, a single
swapof ~800 CFC pulls out the whole 3,766 SAFE reserve. - Re-mints LP and exits: re-adds dust liquidity to grab the protocol-fee LP, swaps all harvested SAFE back to BEP20USDT, repays the five flashloans, and walks away with 6,124.40 USDT.
Background — what CFC does#
CFC (source) is a 3.1M-supply BEP20 with a PancakeSwap CFC/SAFE pair
(_token = SAFE = 0x4d7Fa587…18a1) created in its constructor
(CFC.sol:519-562). It layers three "tokenomics" features on top of
a basic ERC20:
- Buy/sell taxes — on any transfer touching the pair, it siphons 1% to the contract, 1% to
mineAdd, 1% tonodeAdd, and burns up to 2% to0xdead; the recipient only receives97% − burn(CFC.sol:711-733). - LP dividend distribution in the
SAFEtoken via aTokenDistributorand a shareholder loop. - The "rebase-on-sell" helper — on every sell it calls a private
sync()that removes CFC from the pair itself and re-syncs the pair. This is the vulnerable mechanism.
On-chain state at the fork block (CFC/SAFE pair, from the trace):
| Parameter | Value |
|---|---|
totalSupply (_tTotal) | 3,100,000 CFC |
minSwap | 155,000 CFC |
| Pair reserve0 = SAFE | 3,466.66 SAFE |
| Pair reserve1 = CFC | 100,246.52 CFC |
| Pair CFC balance == reserve1 | yes (honest, in sync) |
The pair holds ~3,466 SAFE that is the prize, priced against ~100,246 CFC.
The vulnerable code#
1. The token mutates the pair's reserve on every sell#
_transfer — when to == uniswapV2Pair (a sell) and it is not an add-liquidity — sets
sellAmount = amount·95/100 and calls the private sync():
// CFC.sol:706-733 (excerpt)
if (to == uniswapV2Pair && !_isAddLiquidity()) {
sellAmount = amount.mul(95).div(100); // 95% of the sell amount
sync(); // ← mutate the pair, see below
}
// CFC.sol:756-769
function sync() private {
if (_tOwned[uniswapV2Pair] > sellAmount && _tOwned[address(0xdead)] < _tTotal - minSwap) {
if (sellAmount > _tTotal - _tOwned[address(0xdead)] - minSwap) {
sellAmount = _tTotal - _tOwned[address(0xdead)] - minSwap;
}
_tOwned[uniswapV2Pair] -= sellAmount; // ⚠️ delete CFC from the PAIR's balance
_tOwned[mineAdd] += sellAmount.div(2); // give half to mineAdd
_tOwned[address(0xdead)]+= sellAmount.div(2); // burn half
emit Transfer(uniswapV2Pair, mineAdd, sellAmount.div(2));
emit Transfer(uniswapV2Pair, address(0xdead), sellAmount.div(2));
sellAmount = 0;
IUniswapV2Pair(uniswapV2Pair).sync(); // ⚠️ force pair reserve = reduced balance
}
}
This is executed before the actual _basicTransfer of the incoming amount happens, and it operates on
_tOwned[uniswapV2Pair] — the pair's own CFC balance. The pair never authorized this. After the helper
runs, the pair has lost 95% of sellAmount worth of CFC for free, and IUniswapV2Pair.sync() makes the
pair record the now-smaller balance as its reserve.
2. The pair's skim() ships out any balance-above-reserve surplus#
// PancakePair.sol:483-488
function skim(address to) external lock {
_safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
_safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}
skim is permissionless and pays out balanceOf(pair) − reserve for each token. Combined with CFC's
self-sync, the attacker can manufacture exactly the balance > reserve discrepancy skim is designed to
flush — and pocket it.
Root cause — why it was possible#
The two contracts disagree about who owns the CFC inside the pair:
-
CFC unilaterally edits the pair's balance.
_tOwned[uniswapV2Pair] -= sellAmountpluspair.sync()is a donation of the pair's assets tomineAdd/0xdead, performed by the token on every sell, without the pair's consent. A token must never decrement an AMM pair's balance and resync it; doing so corrupts the pair's accounting. -
skim()is the leak valve. Each time the attacker pushes CFC into the pair and triggers the self-sync(which lowers the recorded reserve below the real balance),skim()lets them pull the surplus straight back out. Iterating this collapses the CFC reserve to dust while the attacker keeps recycling the same CFC. -
The SAFE side never re-prices fairly. Because the CFC reserve is being annihilated independently of the SAFE reserve, the pair's price is driven to near-zero CFC reserve. A final
swapthen buys the entire SAFE reserve for a trivial amount of CFC — the classic broken-x·y=koutcome.
Concretely, the design decisions that compose into the bug:
- A token writing to
_tOwned[pair]and callingpair.sync()(self-burn-from-pool pattern). - The 95%-of-sell magnitude, which makes each self-sync remove a large slice of the pair's CFC reserve.
- The presence of
skim()(standard Uniswap-V2) as a permissionless surplus-extraction primitive.
Preconditions#
- The pair's recorded reserve must currently equal its balance (true at the fork block).
- The attacker needs enough CFC to push into the pair so that
_tOwned[uniswapV2Pair] > sellAmount(the guard insync()); the flash-borrowed ~39,826 CFC satisfies this. The CFC itself is sourced via a flashswap()on the pair and repaid with cheaply-acquired SAFE. - Working capital in BEP20USDT to buy the initial SAFE. The PoC obtains this through a stack of five DODO
DPPOracleBEP20USDT flashloans, all repaid in the same transaction — so the attack is effectively zero-capital / flash-loanable.
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = SAFE, token1 = CFC, so in every Sync(reserve0, reserve1) event
reserve0 = SAFE, reserve1 = CFC. All figures are taken from the Sync / Swap / Transfer events
in output.txt.
| # | Step | CFC reserve (reserve1) | SAFE reserve (reserve0) | Effect |
|---|---|---|---|---|
| 0 | Initial (:165-180) | 100,246.52 | 3,466.66 | Honest pool. |
| 1 | Seed: swap 13,000 USDT → 2,515.33 SAFE on the SAFE/USDT pair (:135-158) | — | — | Attacker now holds 2,515 SAFE. |
| 2 | Flash-borrow CFC: CakeLP.swap(1, 41,922.5 CFC out, …data); repay with 2,515 SAFE in pancakeCall (:171-223) | 58,323.98 | 5,981.98 | Attacker holds ~39,826 CFC (post 3% tax). Reserve already shrinking. |
| 3a | Loop iter 1 — CFC.transfer(pair, 37,835) fires self-sync (:285-290) | 20,922.97 | 5,937.90 | Self-sync burned ~37,835 CFC from the pair, lowering reserve1 far below the pair's real CFC balance. |
| 3b | …then CakeLP.skim(attacker) (:310-339) | 20,922.97 | — | Pair ships ~35,943 CFC surplus back to the attacker (net of tax). CFC recycled. |
| 4 | Loop iters 2-19 repeat transfer→skim (Sync chain :379…:1639) | 1,822 → 499 → 60.5 → 12.7 → 0.637 → 0.0318 → … → 9 wei | 5,725 → 4,680 → 4,370 → 3,766.01 | CFC reserve annihilated to 9 wei; SAFE reserve barely touched. |
| 5 | Sell ~800 CFC then CakeLP.swap(3,766.01 SAFE out, 0, …) (:1692-1741) | 799.66 (balance) | 43 wei | One swap empties the entire SAFE reserve for ~800 CFC. Attacker now holds 3,766.01 SAFE. |
| 6 | Re-mint LP (grabs accrued fee-LP) + dust (:1747-1796) | 31,986 | 1,808 | mint mints 5.99e25 LP to attacker; emits Mint(amount0:1765, amount1:31,186). |
| 7 | Swap 3,766.01 SAFE → 19,124.40 USDT on the SAFE/USDT pair (:1799-1824) | — | — | Convert spoils to USDT. |
| 8 | Repay 5 DODO flashloans (:1831-1906) | — | — | All BEP20USDT borrowings returned. |
| 9 | Final balance (:1918-1922) | — | — | Attacker BEP20USDT = 6,124.40 (started at 0). |
Why the skim loop works (mechanism)#
On each loop iteration the attacker calls CFC.transfer(pair, X) where X ≈ pair's current CFC balance.
Because to == uniswapV2Pair, CFC's _transfer first runs sync(), which does
_tOwned[uniswapV2Pair] -= sellAmount (≈0.95·X) and pair.sync() — so the pair's recorded reserve1
drops to roughly 5% of its real balance. Then CakeLP.skim(attacker) pays out
balanceOf(CFC@pair) − reserve1, which is the large surplus the self-sync just created, handing most of the
CFC straight back to the attacker. The same CFC is recycled 19 times, each pass shaving the pair's reserve
by ~95% (100,246 → 58k → 21k → 1.8k → … → 9 wei) while the SAFE reserve only bleeds from CFC's own tax
transfers (3,466 → 3,766 actually rises slightly from the flash-swap, then settles ~3,766).
Profit accounting (BEP20USDT)#
| Direction | Amount (USDT) |
|---|---|
| Spent — buy initial SAFE | 13,000.00 |
| Received — sell drained 3,766.01 SAFE | 19,124.40 |
| Net (after repaying all 5 flashloans) | +6,124.40 |
The attacker started with a deal'd balance of 0 BEP20USDT and ended the transaction holding
6,124.398799521459371489 USDT — verified by the PoC's logged before/after balances and the trace's final
balanceOf of 6,124.40 USDT (:1918).
Diagrams#
Sequence of the attack#
Pool reserve evolution#
The flaw: CFC vs. the pair's accounting#
Remediation#
- A token must never write to a pair's balance. Remove the
_tOwned[uniswapV2Pair] -= sellAmount+IUniswapV2Pair(uniswapV2Pair).sync()pattern entirely (CFC.sol:756-769). Any "deflation/redistribution" must only ever move tokens the contract itself owns, never tokens held by an AMM pair. Self-burning from the pool plussync()corrupts the pair's reserves and is the entire bug. - Do not couple tax logic to
sync()at all. If a rebase effect is required, implement it as the protocol buying & burning from its own treasury so both reserves move together andx·y=kis preserved. - Treat
skim()as adversarial. Becauseskim()is permissionless on every Uniswap-V2 pair, any token that can produce abalance ≠ reservediscrepancy on the pair will be drained through it. Eliminating the self-sync(item 1) removes the discrepancy and closes theskimvector. - Add reserve-impact sanity checks. Reject any single token operation that would move the pair's recorded reserve by more than a small percentage; a 95%-of-sell self-burn against the pool is a red flag.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo does not
whole-compile under forge test):
_shared/run_poc.sh 2023-06-CFC_exp -vvvvv
- RPC: a BSC archive endpoint is required (fork block 29,116,478).
foundry.tomlis configured with a BSC archive RPC; most public BSC RPCs prune state this old and fail withheader not found/missing trie node. - Result:
[PASS] testSkim()with attacker BEP20USDT going from 0 → 6,124.40.
Expected tail:
Ran 1 test for test/CFC_exp.sol:CFCTest
[PASS] testSkim() (gas: 3354509)
Logs:
Attacker BEP20USDT balance before attack: 0.000000000000000000
Attacker BEP20USDT balance after attack: 6124.398799521459371489
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 28.07s
Reference: hexagate_ thread — https://twitter.com/hexagate_/status/1669280632738906113 (second TX). SlowMist Hacked — https://hacked.slowmist.io/ (CFC, BSC).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-06-CFC_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
CFC_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- Original alert / thread: post on X.
- DeFiHackLabs incident explorer: search "CFC 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.