Reproduced Exploit
HCT (CoinToken) Exploit — Reflection-Token `burn()` Deflates the Pool's Reserve to 1 wei
CoinToken (HCT) is a SafeMoon-style reflection token: every account's balance is stored as a reflected amount _rOwned[account] and the visible balance is computed on the fly as _rOwned[account] / rate, where rate = _rTotal / _tTotal (CoinToken.sol:634-638,
Loss
~$8.6K — 31.05 WBNB profit drained from the HCT/WBNB PancakeSwap pair
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-HCT_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/HCT_exp.sol.
Vulnerability classes: vuln/logic/state-update · vuln/defi/slippage · vuln/oracle/price-manipulation
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 into its own project). Full verbose trace: output.txt. Verified vulnerable source: CoinToken.sol.
Key info#
| Loss | ~$8.6K — 31.05 WBNB profit drained from the HCT/WBNB PancakeSwap pair |
| Vulnerable contract | CoinToken (HCT) — 0x0FDfcfc398Ccc90124a0a41d920d6e2d0bD8CcF5 |
| Victim pool | HCT/WBNB PancakePair — 0xdbE783014Cb0662c629439FBBBa47e84f1B6F2eD |
| Flash-loan source | DODO DPPOracle — 0xFeAFe253802b77456B4627F8c2306a9CeBb5d681 (2,200 WBNB) |
| Attacker EOA | 0xc892d5576c65e5b0db194c1a28aa758a43bb42a5 |
| Attacker contract | 0xd7a2fc756e1053b152f90990129f94c573e006fd |
| Attack tx | 0x84bd77f25cc0db493c339a187c920f104a69f89053ab2deabb93c35220e6dfc0 |
| Chain / block / date | BSC / fork at 31,528,197 (31_528_198 - 1) / Sep 2023 |
| Compiler (token) | Solidity v0.8.3, optimizer 200 runs |
| Bug class | Reflection-token accounting flaw — burn() mixes r-space and t-space, deflating other holders' balances; AMM price manipulation |
TL;DR#
CoinToken (HCT) is a SafeMoon-style reflection token: every account's balance is stored as a
reflected amount _rOwned[account] and the visible balance is computed on the fly as
_rOwned[account] / rate, where rate = _rTotal / _tTotal
(CoinToken.sol:634-638,
:848-851).
Its public burn(uint256 _value) (:669-671) calls an
internal _burn that is catastrophically mis-implemented for a reflection token
(:683-688):
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]); // compares a token amount to a REFLECTED balance
_rOwned[_who] = _rOwned[_who].sub(_value); // subtracts a token amount from a REFLECTED balance
_tTotal = _tTotal.sub(_value); // subtracts the SAME number from the token-space total
emit Transfer(_who, address(0), _value);
}
_value is a token-space number (the user-supplied amount), yet it is subtracted from a
reflection-space balance _rOwned[_who] and from the token-space _tTotal, while _rTotal
(the reflection-space total) is never touched. Reducing _tTotal raises
rate = _rTotal / _tTotal, and because every other holder's visible balance is
_rOwned / rate, a larger rate silently deflates everyone else's balance — including the AMM
pair's.
The attacker exploits this in a single flash-loaned transaction:
- Flash-borrows 2,200 WBNB from DODO's
DPPOracleand swaps it into the pool, receiving 3.585×10²⁷ HCT — far more HCT than the pool itself now holds (1.088×10²⁶). - Repeatedly
burn()s its own HCT (174 iterations ofburn(balance*8/10 - 1)). Each burn shrinks_tTotal, raising the rate. Because the pair's_rOwnedis fixed, the pair's visible HCT balance collapses from 1.088×10²⁶ → 1 wei. - Calls
pair.sync()so the pair adopts that 1-wei HCT balance asreserve0. The pool's WBNB reserve (~2,266 WBNB after the borrow) is untouched, so HCT's marginal price explodes. - Sells 64 wei of HCT into the degenerate pool and receives 2,231 WBNB back.
- Repays 2,200 WBNB to DODO and walks away with 31.05 WBNB.
Background — what CoinToken does#
CoinToken (source) is a generic BEP20 reflection token with
tax/burn/charity fees. The mechanics that matter here:
- Reflection accounting. Balances live in reflection space:
_rOwned[account]. The exchange rate between reflection and token space israte = _rTotal / _tTotal(_getRate). A holder's visible balance istokenFromReflection(_rOwned[account]) = _rOwned[account] / rate(balanceOf, tokenFromReflection). In a correctly built reflection token, the only wayrateshould change is when_rTotalis reduced by reflective fees (_reflectFee, :805-812) — never via an isolated_tTotalchange. - Public burn.
burn(_value)(:669-671) is permissionless — anyone can burn their own tokens — and forwards to the buggy_burn. - Fee-on-transfer.
_transfersplits transfers into tax/burn/charity components, which is why the PoC usesswapExactTokensForTokensSupportingFeeOnTransferTokens. The fee plumbing is incidental to the exploit; the reflection-rate flaw is the core bug.
On-chain state observed at the fork block (from the trace):
| Parameter | Value (from output.txt) |
|---|---|
Pair token0 (HCT) reserve, initial | 3,717,772,046,944,618,148,783,712,500 (≈ 3.718×10²⁷ HCT) |
Pair token1 (WBNB) reserve, initial | 65,997,190,154,150,201,517 (≈ 65.997 WBNB) |
| Flash-loan size (WBNB) | 2,200 WBNB (baseAMount = 2.2e21) |
| Attacker HCT after the buy | 3,584,864,718,117,811,096,161,532,165 (≈ 3.585×10²⁷) |
Pair HCT balance after the buy (reserve0) | 108,899,889,060,817,903,347,222,460 (≈ 1.088×10²⁶) |
The decisive fact: after the buy the attacker holds ~33× more HCT than the pool does, so when the
attacker burns its own holdings to crank the rate up, the pool's much-smaller _rOwned divides down to
near-zero long before the attacker runs out of tokens to burn.
The vulnerable code#
1. balanceOf is a function of the rate, not a stored number#
// CoinToken.sol:559-562
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]); // ← _rOwned / rate
}
// CoinToken.sol:634-638
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate); // ← divide by rate
}
// CoinToken.sol:848-851
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply); // ← rate = _rTotal / _tTotal (non-excluded path)
}
The PancakePair is not an excluded account, so its visible HCT balance — and hence the AMM
reserve that sync() will read — is _rOwned[pair] / rate. The attacker never has to touch
_rOwned[pair]; it only has to move rate.
2. The fatal _burn mixes reflection space and token space#
// CoinToken.sol:683-688
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]); // ⚠️ token-space _value vs reflection-space _rOwned
_rOwned[_who] = _rOwned[_who].sub(_value); // ⚠️ subtract token amount from a reflection balance
_tTotal = _tTotal.sub(_value); // ⚠️ reduce _tTotal, but NOT _rTotal
emit Transfer(_who, address(0), _value);
}
Two compounding defects:
_rTotalis not updated. Every other reflection token reduces both_rTotaland_tTotalon a burn so the rate stays sane. Here only_tTotaldrops, sorate = _rTotal / _tTotalstrictly increases with every burn the attacker performs. A rising rate deflates_rOwned[pair] / ratetoward zero._valueis treated as both a token amount and a reflection amount. The same raw number is subtracted from_rOwned[_who](reflection space) and from_tTotal(token space). These spaces differ by the (large)ratefactor, so the burn destroys the caller's reflection balance far faster than it "should," but it also drags_tTotaldown hard — which is exactly the lever that moves the rate.
3. The public entry point is permissionless#
// CoinToken.sol:669-671
function burn(uint256 _value) public {
_burn(msg.sender, _value); // ← anyone, any amount they hold, no access control
}
Anyone holding HCT can call burn arbitrarily many times. The attacker simply buys a huge HCT position
first, then burns it down to manipulate the shared rate.
Root cause — why it was possible#
A Uniswap-V2/PancakeSwap pair derives price purely from its reserves and trusts that a token's balance
only changes through transfers/mints/burns it can reason about; sync() exists to let the pair adopt
its current token balance as the new reserve.
CoinToken violates that trust at the accounting layer:
A holder's visible balance is a global function of
_tTotal. By burning its own tokens, the attacker shrinks_tTotal, inflatesrate, and thereby silently shrinks the pair's balance without ever sending a transfer to the pair. A singlepair.sync()then bakes that artificially deflated balance intoreserve0, leavingreserve1(WBNB) fully intact.
The composing design decisions:
- Reflection rate is mutated by an isolated
_tTotalwrite._burnreduces_tTotalwithout reducing_rTotal, so the rate is attacker-controllable viaburn(). burn()is permissionless and unbounded. The attacker can call it as many times as needed (174 times here) to drive the pair's balance to 1 wei.- The attacker can become the dominant HCT holder cheaply. Flash-borrowing 2,200 WBNB and buying gives the attacker ~33× the pool's HCT, so the rate manipulation annihilates the pool's reserve long before the attacker exhausts its own balance.
- AMM reserves trust
balanceOf+sync(). PancakePair readsIERC20(token).balanceOf(pair)and accepts whatever it returns. A token whosebalanceOfis rate-dependent and attacker-mutable makes the reserve attacker-mutable.
The token's own tax/burn/charity fees do not help: the reflective fee path (_reflectFee) reduces
_rTotal, but the standalone burn() path bypasses it entirely.
Preconditions#
- A PancakeSwap HCT/WBNB pair with real WBNB liquidity (≈ 65.997 WBNB at the fork block).
- HCT's
burn()reachable (it ispublic, no pause needed —_burnis called directly, bypassing thewhenNotPausedmodifier ontransfer). - Working capital in WBNB to corner the pool's HCT — here a 2,200 WBNB DODO flash loan (HCT_exp.sol:59), fully repaid intra-transaction, so the attack is effectively capital-free.
- The pair must not be an "excluded" account (excluded accounts use the fixed
_tOwnedpath and would be immune); the pair is a normal holder, so its balance follows the rate.
Attack walkthrough (with on-chain numbers from the trace)#
The pair's token0 = HCT, token1 = WBNB, so reserve0 = HCT, reserve1 = WBNB. All figures are
taken directly from the getReserves, Sync, and balanceOf records in
output.txt. The whole thing runs inside DODO's DPPFlashLoanCall callback
(HCT_exp.sol:66-72).
| # | Step | Pool HCT (reserve0) | Pool WBNB (reserve1) | Attacker HCT | Effect |
|---|---|---|---|---|---|
| 0 | Initial pool | 3.718×10²⁷ | 65.997 WBNB | 0 | Honest pool. |
| 1 | Flash-borrow 2,200 WBNB, swap WBNB → HCT (swapWBNBtoHCT, :74-81) | 1.088×10²⁶ | 2,266 WBNB | 3.585×10²⁷ | Attacker now holds ~33× the pool's HCT; pool WBNB is the prize. |
| 2 | Burn loop, iteration 1 — burn(2.868×10²⁷) | (shrinking) | 2,266 WBNB | 2.549×10²⁷ | _tTotal drops, rate rises, every non-excluded balance deflates. |
| … | 172 more burns — each burn(balance*8/10 - 1) | (shrinking) | 2,266 WBNB | …→ 89 → 64 wei | Pool's _rOwned/rate driven toward 0 as _tTotal collapses. |
| 3 | Burn loop end (attacker HCT ≤ 70 ⇒ break, :84-89) | 1 wei | 2,266 WBNB | 64 wei | Pair's visible HCT balance is now 1 wei. |
| 4 | pair.sync() (HCT_exp.sol:69) | 1 wei | 2,266 WBNB | 64 wei | Pair adopts 1-wei HCT as reserve0; invariant destroyed, HCT price explodes. |
| 5 | Sell 64 wei HCT → WBNB (swapHCTtoWBNB, :92-98) | 65 wei | 34.95 WBNB | 0 | 64 wei of HCT buys 2,231 WBNB out of the pool. |
| 6 | Repay 2,200 WBNB to DODO (:71) | 65 wei | 34.95 WBNB | 0 | Flash loan closed. |
Why 64 wei of HCT drains ~2,231 WBNB: after sync(), reserveIn (HCT) = 1. PancakeSwap's
getAmountOut is out = (in·9975·reserveOut) / (reserveIn·10000 + in·9975). With reserveIn = 1,
reserveOut ≈ 2,266 WBNB, and in = 64: the denominator 1·10000 + 64·9975 = 648,400 is dominated by
the input term, so the swap returns nearly the entire WBNB reserve — the trace shows
amount1Out = 2,231,049,670,256,646,342,764 wei (≈ 2,231 WBNB), bringing the pool's WBNB reserve from
2,266 down to 34.95 WBNB.
Profit accounting (WBNB)#
| Direction | Amount (WBNB) |
|---|---|
| Borrowed from DODO (in) | 2,200.000 |
| WBNB out of the pool on the final sell | 2,231.050 |
| Repaid to DODO (out) | 2,200.000 |
| Net profit | +31.050 |
The PoC logs confirm it (output.txt tail): Attack Exploit: 31.49670256646342764 BNB
of WBNB balance gained inside the callback, of which 2,231.050 − 2,200 = 31.050 WBNB is net profit
after repayment (the test header records Total Lost : 30.5BNB). The PoC starts with a zero WBNB
balance and ends holding 31.05 WBNB (output.txt: final
WBNB::balanceOf(ContractTest) → 31049670256646342764).
Diagrams#
Sequence of the attack#
Pool / accounting state evolution#
The accounting flaw inside burn / _burn#
Why the burn is theft: correct vs. broken reflection bookkeeping#
Why each magic number#
baseAMount = 2,200 WBNB(HCT_exp.sol:43): the flash-loan size. Large enough that, after buying, the attacker holds vastly more HCT (3.585×10²⁷) than the pool (1.088×10²⁶), guaranteeing that burning the attacker's own balance drives the pool's balance to dust before the attacker runs out of HCT to burn. Fully repaid, so the only true cost is the flash-loan fee (zero in the trace).burn(balance*8/10 - 1)(HCT_exp.sol:88): burns ~80% of the attacker's current visible balance each loop, leaving ~20%. Because the rate is also rising, the post-burn visible balance settles near ~71% per iteration in the trace. Iterating drives the rate up geometrically; 174 iterations are enough to crush the pool's HCT balance from 1.088×10²⁶ to 1 wei.balanceOf(this) <= 70break (HCT_exp.sol:85): stop once the attacker's own balance is dust (it ends at 64 wei). At that point the pool is already at 1 wei, so further burns are pointless.- Final swap of 64 wei HCT (HCT_exp.sol:95-97): with
reserveIn = 1, even a few dozen wei of HCT input buys essentially the whole 2,266-WBNB reserve.
Remediation#
- Fix the reflection bookkeeping in
_burn. A burn must reduce both_rTotaland_tTotalin the correct spaces. Convert the token amount to reflection space via the current rate and subtract consistently:With both totals reduced by the same reflection/token amounts,function _burn(address who, uint256 tValue) internal { uint256 rate = _getRate(); uint256 rValue = tValue * rate; require(rValue <= _rOwned[who], "burn exceeds balance"); _rOwned[who] = _rOwned[who] - rValue; _rTotal = _rTotal - rValue; // ← keep rate invariant _tTotal = _tTotal - tValue; emit Transfer(who, address(0), tValue); }rate = _rTotal/_tTotalis preserved and other holders' balances are unaffected. - Never let a single holder's action move a global rate that other balances depend on. Any operation
that mutates
_tTotalmust mutate_rTotalin lockstep (or the token must not be a reflection token). - Do not pair manipulable rebasing/reflection tokens against AMM reserves without protection. AMM
pairs read
balanceOf+sync(); a token whosebalanceOfis attacker-mutable makes reserves attacker-mutable. If the token must be tradable, exclude the pair from reflection (fixed_tOwnedpath) so its reserve cannot be deflated by rate changes. - Cap or oracle-gate single-operation reserve impact. A swap where one reserve has collapsed to a handful of wei should be treated as a price-manipulation red flag and reverted.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has several
unrelated PoCs that fail to compile under forge test's whole-project build):
_shared/run_poc.sh 2023-09-HCT_exp --mt testExploit -vvvvv
- RPC: a BSC archive endpoint is required (the fork pins block
31_528_198 - 1); most pruned public BSC RPCs fail withheader not found/missing trie nodeat that height. - Result:
[PASS] testExploit(), profit ≈ 31.05 WBNB.
Expected tail (from output.txt):
Ran 1 test for test/HCT_exp.sol:ContractTest
[PASS] testExploit() (gas: 6345325)
Before Start: 0 BNB
Attack Exploit: 31.49670256646342764 BNB
(The Attack Exploit figure is the attacker's WBNB balance gained inside the flash-loan callback before
repayment is netted out; net profit after repaying the 2,200 WBNB loan is 31.05 WBNB.)
Reference: DeFiHackLabs — HCT (CoinToken), BSC, Sep 2023. Attack tx
0x84bd77f25cc0db493c339a187c920f104a69f89053ab2deabb93c35220e6dfc0.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2023-09-HCT_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
HCT_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "HCT (CoinToken) 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.