All incidents

BCT Referral Treasury Drain

Share
Dec 09, 2023 13:27 UTCAttackLoss: 1,130,499.36 BCT, 10.15 WBNBPending manual check2 exploit txWindow: 9m 51s
Estimated Impact
1,130,499.36 BCT, 10.15 WBNB
Label
Attack
Exploit Tx
2
Addresses
4
Attack Window
9m 51s
Dec 09, 2023 13:27 UTC → Dec 09, 2023 13:37 UTC

Exploit Transactions

TX 1BSC
0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250
Dec 09, 2023 13:27 UTCExplorer
TX 2BSC
0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103
Dec 09, 2023 13:37 UTCExplorer

Victim Addresses

0x70ca72bb4a1386439a2a51476f2335a31005ebe8BSC
0x8ebeb2bf3c6ca7d5c6b345515e368e94eda0ab26BSC
0x5a25b8576b14699bbb15947111f5811e58b39a82BSC
0x88b3eb62e363d9f153beab49c5c2ef2e785a375aBSC

Loss Breakdown

1,130,499.36BCT
10.15WBNB

Similar Incidents

Root Cause Analysis

BCT Referral Treasury Drain

1. Incident Overview TL;DR

On BNB Smart Chain, an unprivileged adversary cluster used two public transactions to turn BCT's referral system into a treasury-drain loop. In setup transaction 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250, the attacker created a five-level inviter chain with dust-sized BCT transfers. In exploit transaction 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103, the attacker flash-borrowed 20 WBNB, repeatedly sold BCT into BCT's hardcoded BCT/USDT pair, reclaimed the post-fee principal with skim(), harvested treasury-funded referral rewards from payTokenAddress, converted the drained inventory through public pairs, repaid the flash swap, and exited with 10.154043154622408863 WBNB.

The root cause is a protocol bug in BCT. The token recognizes only one AMM pair, the constructor-created BCT/USDT pair, while all other BCT pairs are treated as ordinary transfers. That design sends 50% of non-designated-pair transfers to payTokenAddress, then later pays up to 40% of each sell into the hardcoded pair out of that same treasury via promoteReward, without requiring any irreversible economic loss by the seller. Because direct pair transfers leave excess token balances that can be reclaimed with skim(), the same capital can repeatedly harvest treasury-funded rewards.

2. Key Background

BCT is the contract at 0x70ca72bb4a1386439a2a51476f2335a31005ebe8. Its constructor creates exactly one tracked PancakeSwap pair against USDT, stores that address in uniswapV2Pair, and sets the referral treasury payTokenAddress to 0x8ebeb2bf3c6ca7d5c6b345515e368e94eda0ab26.

The inviter graph is not access-controlled. bindInviter records relationship state using two exact transfer sizes:

  • 1e15 BCT marks beinvited[to] = from
  • 5e14 BCT finalizes inviter[to] = from

promoteReward then walks up to five inviter levels and transfers treasury BCT from payTokenAddress if each inviter holds at least 30 * 10**18 BCT.

The critical market structure is:

  • Hardcoded pair: BCT/USDT at 0x5a25b8576b14699bbb15947111f5811e58b39a82
  • External live pair: BCT/WBNB at 0x88b3eb62e363d9f153beab49c5c2ef2e785a375a
  • Flash-swap pair: WBNB/BUSD at 0x1b96b92314c44b159149f7e0303511fb2fc4774f

Because BCT only recognizes the USDT pair, trades that involve the WBNB pair are processed by the generic transfer branch and continuously refill payTokenAddress.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability class is an application-level economic attack caused by inconsistent pair classification and treasury-funded referral payouts. The constructor binds fee logic to a single USDT pair, but BCT is actively traded in a separate WBNB pair. As a result, BCT/WBNB transfers hit the generic else branch in takeAllFee, which diverts 50% of every such transfer to payTokenAddress. Once that treasury is large, any user can build a five-address inviter chain and qualify each inviter with at least 30 BCT. After qualification, every sell into the hardcoded USDT pair transfers 1% to payTokenAddress, 14% to the token contract, and up to 40% of the nominal sell amount from payTokenAddress to attacker-controlled inviters. The seller does not need to give up the full nominal amount permanently, because direct transfers into the pair leave reclaimable excess balances that can be pulled back with skim(). The exploit therefore converts a reusable trading balance into repeated treasury withdrawals until the attacker can dump the harvested BCT back into public liquidity and exit in WBNB.

The vulnerable components are concentrated in one contract:

  • constructor pair selection and treasury initialization in Contract.sol lines 1379-1400
  • inviter binding and reward payout in Contract.sol lines 1473-1505
  • transfer classification and fee routing in Contract.sol lines 1507-1599

The security principles violated are also concrete:

  • referral rewards are not conserved against realized trade value
  • pair-specific fee logic is unsafe when only one live AMM pair is recognized
  • treasury-funded rewards are exposed to repeated harvesting through reversible pair interactions
  • AMM reserve assumptions break when taxed transfers are pushed into a pair before sync()

Victim token code excerpt:

address public uniswapV2Pair;
address private _baseToken = 0x55d398326f99059fF775485246999027B3197955;

constructor(TokenWarp _warp) ERC20(_name, _symbol) {
    address _uniswapV2Pair =
        IUniswapV2Factory(_uniswapV2Router.factory()).createPair(address(this), address(_baseToken));
    uniswapV2Pair = _uniswapV2Pair;
    payTokenAddress = 0x8eBeb2bf3C6CA7d5C6b345515e368E94Eda0aB26;
}

function bindInviter(address from, address to, uint256 amount) internal {
    if (inviter[to] == address(0) && amount == 10**15) beinvited[to] = from;
    if (inviter[from] == address(0) && beinvited[from] == to && amount == 5 * 10**14) {
        inviter[to] = from;
    }
}

function takeAllFee(address from, address to, uint256 amount) private returns (uint256 amountAfter) {
    if (to == uniswapV2Pair) {
        transferToken(from, payTokenAddress, amount * 1 / 100);
        transferToken(from, address(this), amount * 14 / 100);
        promoteReward(from, amount);
    } else {
        transferToken(from, payTokenAddress, amount * 50 / 100);
    }
}

That excerpt is sufficient to explain the invariant break: a sell should only pay referral rewards from value actually consumed by the sell, and all live BCT pairs should be handled consistently. BCT violates both conditions.

4. Detailed Root Cause Analysis

4.1 Pair misclassification creates exploitable treasury inventory

The verified BCT source fixes uniswapV2Pair to the constructor-created USDT pair. The external BCT/WBNB pair is therefore invisible to the token's pair-specific logic. Whenever BCT moves through BCT/WBNB, _transfer falls through to the generic branch in takeAllFee and moves 50% of the amount into payTokenAddress. The exploit balance diff for tx 0xdae0... shows that this treasury was already heavily funded before the attack and lost 1130499359007577072673681 raw BCT units during the exploit.

The ACT exploit conditions were all public and satisfied on-chain before the exploit:

  • the attacker could deploy arbitrary helper contracts
  • the inviter chain could be created with the public 1e15 / 5e14 transfer pattern
  • each inviter could be topped above 30 BCT using public BCT/WBNB liquidity
  • payTokenAddress already held enough BCT inventory to fund repeated rewards
  • the BCT/USDT pair still held USDT and the BCT/WBNB pair still held WBNB for exit liquidity

4.2 Referral binding is public and deterministic

The attacker first sent a tiny setup transaction to the primary attacker contract 0xe9616ff20ad519bce0e3d61353a37232f0c27a50, passing five helper addresses. The setup trace shows the exact 1e15 / 5e14 pattern required by bindInviter:

emit Transfer(from: 0xe9616f..., to: 0xcc1378..., value: 500000000000000)
Token::transfer(0xe9616f..., 500000000000000)
emit Transfer(from: 0xcc1378..., to: 0xe9616f..., value: 250000000000000)
...
emit Transfer(from: 0x64b200..., to: 0x3b132c..., value: 500000000000000)
Token::transfer(0x64b200..., 500000000000000)
emit Transfer(from: 0x3b132c..., to: 0x64b200..., value: 250000000000000)

Those transfers correspond to the attacker-controlled chain:

  • attacker contract 0xe9616f...
  • helper1 0xcc1378...
  • helper2 0xf5eb1f...
  • helper3 0x23d167...
  • helper4 0x64b200...
  • helper5 0x3b132c...

The setup balance diff confirms that each helper ended the transaction with a small positive BCT balance, and the exploit transaction later tops each helper up above the 30 BCT threshold.

4.3 Flash liquidity qualifies inviters and starts the drain loop

In tx 0xdae0..., the attacker contract flash-borrowed 20 WBNB from the WBNB/BUSD pair. The exploit trace shows pancakeCall, then five buys from the BCT/WBNB pair that each deliver 60 BCT gross to a helper. That satisfies the balanceOf(inviter) >= 30 * 10**18 gate in promoteReward.

Immediately after qualification, the attacker starts the core loop:

  1. Move WBNB through BCT/WBNB so BCT arrives at the hardcoded BCT/USDT pair.
  2. Call skim() on BCT/USDT to reclaim the excess BCT balance that now sits above reserves.
  3. Transfer that recovered BCT back into the same pair, which triggers promoteReward(from, amount) because to == uniswapV2Pair.
  4. Receive referral payouts from payTokenAddress across the five inviter levels.
  5. Dump helper excess balances into the same pair and continue.

The first drain cycle is explicit in the trace:

PancakePair::skim(0xe9616f...)
Token::transfer(0xe9616f..., 194045828222693844822)
emit Transfer(from: 0x8eBeb2..., to: 0xcc1378..., value: 29106874233404076723)
emit Transfer(from: 0x8eBeb2..., to: 0xf5Eb1F..., value: 19404582822269384482)
emit Transfer(from: 0x8eBeb2..., to: 0x23D167..., value: 9702291411134692241)
emit Transfer(from: 0x8eBeb2..., to: 0x64B200..., value: 9702291411134692241)
emit Transfer(from: 0x8eBeb2..., to: 0x3B132C..., value: 9702291411134692241)
emit Transfer(from: PancakePair: [0x5A25...], to: 0xe9616f..., value: 164938953989289768099)

The exploit trace contains 278 skim() calls, which matches the root-cause claim that the attacker repeatedly recycled reclaimable principal into new treasury-funded rewards.

4.4 Conversion and exit

After materially draining payTokenAddress, the attacker used the remaining USDT and BCT flows to re-enter the BCT/WBNB pair, converted the recovered inventory back into WBNB, repaid the flash swap with fee, and sent the surplus to the profit EOA 0x9c66b0c68c144ffe33e7084fe8ce36ebc44ad21e.

End-of-trace excerpt:

WBNB::transfer(0x1B96B92314C44b159149f7E0303511fB2Fc4774f, 20050000000000000001)
WBNB::balanceOf(0xe9616f...) -> 10154043154622408863
WBNB::transfer(0x9c66B0c68c144Ffe33E7084FE8cE36EBC44aD21e, 10154043154622408863)

That final transfer amount matches the profit figure reported in root_cause.json.

5. Adversary Flow Analysis

The attack is a clean two-transaction ACT sequence.

  1. 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 The EOA 0x9c66b0c68c144ffe33e7084fe8ce36ebc44ad21e sends 0.00065 BNB to the attacker contract 0xe9616f..., passing five helper addresses. The contract buys dust BCT, executes the 1e15 / 5e14 transfer handshake five times, and builds the inviter ladder.
  2. 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 The same EOA calls the same attacker contract. The contract flash-borrows 20 WBNB, buys enough BCT for each helper to cross the 30 BCT gate, repeatedly harvests payTokenAddress by selling into the hardcoded BCT/USDT pair and reclaiming principal with skim(), dumps helper balances through the same pair, converts the resulting inventory via BCT/WBNB, repays 20.05 WBNB to the flash pair, and transfers the remaining WBNB to the EOA.

The decision points are fully public and permissionless:

  • The attacker can deploy new helper contracts.
  • The inviter logic depends only on public transfer sizes.
  • The flash swap uses public PancakeSwap liquidity.
  • The profit path uses public AMM reserves and public pair mechanics.

No privileged key, whitelist, or private infrastructure is required.

6. Impact & Losses

The measurable impacts are:

  • payTokenAddress lost 1130499359007577072673681 raw BCT units.
  • The attacker EOA received 10154043154622408863 raw WBNB units after flash-loan repayment.
  • The BCT/USDT and BCT/WBNB pools were used as exit liquidity, so external liquidity providers absorbed part of the economic loss while the protocol treasury was directly depleted.

The setup transaction cost the attacker 4291523000000000 wei in gas-equivalent native value. The exploit transaction cost 89147673000000000 wei in native value. Those costs do not offset the positive WBNB result shown in the exploit trace and balance diff.

7. References

  • Tx 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 metadata: metadata.json
  • Tx 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 trace: trace.cast.log
  • Tx 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 balance diff: balance_diff.json
  • Tx 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 metadata: metadata.json
  • Tx 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 trace: trace.cast.log
  • Tx 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 balance diff: balance_diff.json
  • Verified BCT source: Contract.sol