Reproduced Exploit
BEC (BeautyChain) Exploit — `batchTransfer()` Integer-Overflow Infinite Mint (`batchOverflow`)
BecToken.batchTransfer() computes the total amount to debit as a plain multiplication:
Loss
Token economically destroyed — 2 × 2^255 ≈ 1.16 × 10^59 BEC minted from thin air (each attacker received 5.79…
Chain
Ethereum
Category
Arithmetic / Overflow
Date
Apr 2018
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: 2018-04-BEC_exp in the
evm-hack-registrymirror. Upstream DeFiHackLabs PoC:src/test/…/BEC_exp.sol.
Vulnerability classes: vuln/arithmetic/overflow · vuln/logic/missing-check
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: BecToken.sol.
Key info#
| Loss | Token economically destroyed — 2 × 2^255 ≈ 1.16 × 10^59 BEC minted from thin air (each attacker received 5.79 × 10^58 BEC, 8.27 × 10^48× the entire 7,000,000,000-token legit supply). Exchanges halted BEC trading; market cap ( |
| Vulnerable contract | BecToken (BeautyChain, BEC) — 0xC5d105E63711398aF9bbff092d4B6769C82F793D |
| Victim | Every BEC holder / liquidity provider on every exchange listing BEC |
| Attacker EOA / tx sender | 0xb4D30Cac5124b46C2Df0CF3e3e1Be05f42119033 (the original on-chain attacker; the two receivers below were credited the minted tokens) |
| Minted-token recipients | 0xb4D30Cac5124b46C2Df0CF3e3e1Be05f42119033 and 0x0e823fFE018727585EaF5Bc769Fa80472F76C3d7 |
| Attack tx | 0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f |
| Chain / block / date | Ethereum mainnet / fork block 5,483,642 / April 22, 2018 |
| Compiler | Source pragma ^0.4.16; verified deploy v0.4.19+commit.c4cbbb05, optimizer off (PoC re-compiles the harness under Solc 0.8.34, but exercises the deployed bytecode via fork) |
| Bug class | Unchecked integer multiplication overflow (cnt * _value) — pre-0.8 arithmetic, missing SafeMath.mul, the canonical batchOverflow (CVE-2018-10299) |
TL;DR#
BecToken.batchTransfer() computes the total amount to debit as a plain multiplication:
uint256 amount = uint256(cnt) * _value; // ← NO SafeMath, can overflow
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount);
The author wrapped every other arithmetic operation in SafeMath (.sub, .add) but wrote
cnt * _value as a bare * (BecToken.sol:257).
On Solidity 0.4.x there are no automatic overflow checks, so an attacker chooses inputs that make the
product wrap around 2^256 to zero:
cnt = 2(two receivers)_value = type(uint256).max / 2 + 1 = 2^255amount = 2 × 2^255 = 2^256 ≡ 0 (mod 2^256)
Now the balance check balances[msg.sender] >= amount becomes balances[msg.sender] >= 0 — always
true even with a zero balance — and the debit balances[msg.sender].sub(0) removes nothing. The loop
then credits each of the two receivers a full _value = 2^255 ≈ 5.79 × 10^58 BEC, conjured out of
nothing. The PoC confirms both attacker accounts go from 0 to
57,896,044,618,658,097,711,785,492,504,343,953,926,634,992,332,820,282,019,728.792… BEC.
That is roughly 8.27 × 10^48 times the entire legitimate 7-billion-token supply — to each receiver.
BEC became worthless within hours; major exchanges suspended deposits/withdrawals and the token never
recovered.
Background — what BEC was#
BeautyChain (BEC) was an ERC-20 token launched in early 2018, briefly one of the larger-cap tokens on OKEx. The token contract (BecToken.sol) is a textbook OpenZeppelin-style stack of that era:
SafeMathlibrary (:7-31) withmul/div/sub/add.BasicToken/StandardToken(:49-147) — normal ERC-20 transfer/approve/transferFrom, each usingSafeMath.Pausable(:195-233) — owner-controlled emergency stop.PausableToken(:241-268) — the standard methods plus a convenience extensionbatchTransfer()to airdrop the same_valueto up to 20 receivers in one call. This non-standard helper is where the bug lives.BecToken(:275-299) — names the token ("BeautyChain",BEC, 18 decimals) and mints7,000,000,000 × 10^18BEC to the deployer.
Relevant on-chain facts at the fork block:
| Parameter | Value |
|---|---|
name / symbol / decimals | BeautyChain / BEC / 18 |
totalSupply | 7,000,000,000 × 10^18 BEC |
paused | false (so whenNotPaused is satisfied) |
| Attacker BEC balance before | 0 (no balance required — that's the whole point) |
totalSupply is a public uint256 set once in the constructor and never updated by batchTransfer
— so even the protocol's own accounting becomes incoherent after the attack (balances vastly exceed
totalSupply).
The vulnerable code#
batchTransfer (BecToken.sol:255-267):
function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool) {
uint cnt = _receivers.length;
uint256 amount = uint256(cnt) * _value; // ⚠️ BARE * — no SafeMath.mul, can overflow
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount); // ⚠️ if amount wraps to 0, this passes for free
balances[msg.sender] = balances[msg.sender].sub(amount); // .sub(0) → no debit
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value); // each gets full _value
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
Note the asymmetry: .sub and .add are SafeMath calls (line 261, 263), but the product on
line 257 is a plain *. SafeMath.mul (:8-12) exists
in the same file and would have thrown (assert(a == 0 || c / a == b)) — it simply was not used here.
The require(_value > 0 …) (:259) only checks _value,
not amount, so a huge _value whose product wraps to zero sails through.
Root cause — why it was possible#
Pre-0.8 Solidity performs modular (wrapping) arithmetic with no implicit overflow trap. The defense
of that era was to route every operation through SafeMath. BEC did so consistently — except for the
single multiplication that computes the batch total.
The exploit chain is purely arithmetic:
- Attacker calls
batchTransfer([r1, r2], 2^255).cnt = 2. amount = 2 * 2^255. Inuint256,2 * 2^255 = 2^256, which reduces mod2^256to0.require(cnt > 0 && cnt <= 20)→2passes.require(_value > 0 && balances[msg.sender] >= amount)→2^255 > 0✓ and0 >= 0✓ — passes regardless of the sender's balance.balances[msg.sender].sub(0)→ sender loses nothing.- The loop credits each receiver
balances[r_i].add(2^255)— minting2^255BEC to each, twice, for a total of2^256newly-minted BEC, whiletotalSupplyis untouched.
There is no access control needed (anyone can call batchTransfer), no capital needed (the
sender starts with 0), and the only state gate, whenNotPaused, was satisfied. The bug is a single
missing SafeMath.mul and a balance check that validates the wrong (already-overflowed) quantity.
Preconditions#
paused == false(thewhenNotPausedmodifier onbatchTransfer). True at the time of the attack.2 ≤ cnt ≤ 20— the attacker usescnt = 2, the minimum count whose product with2^255wraps to 0.- A
_valuesuch thatcnt * _value ≡ 0 (mod 2^256). Forcnt = 2,_value = 2^255works exactly (type(uint256).max / 2 + 1). The PoC uses precisely this value (BEC_exp.sol:35). - No balance, no allowance, no privileged role required. The sender begins with
0BEC.
Step-by-step attack walkthrough (with on-chain numbers from the trace)#
All figures below are taken directly from the Transfer events and balanceOf returns in
output.txt.
The single attack call is batchTransfer([attacker1, attacker2], 2^255) where
2^255 = 57896044618658097711785492504343953926634992332820282019728792003956564819968.
| # | Step | Computation | Result |
|---|---|---|---|
| 0 | Initial — read attacker balances | balanceOf(attacker1) = balanceOf(attacker2) = 0 | Both attackers hold 0 BEC |
| 1 | cnt = _receivers.length | 2 | passes cnt > 0 && cnt <= 20 |
| 2 | amount = cnt * _value | 2 × 2^255 = 2^256 ≡ 0 (mod 2^256) | amount = 0 (overflow) |
| 3 | require(_value > 0 && balances[msg.sender] >= amount) | 2^255 > 0 ✓, 0 >= 0 ✓ | check passes for free |
| 4 | balances[msg.sender] = balances[msg.sender].sub(amount) | .sub(0) | sender debited nothing |
| 5 | loop i=0: balances[attacker1].add(_value) + Transfer(sender→attacker1, 2^255) | 0 + 2^255 | attacker1 credited 2^255 BEC |
| 6 | loop i=1: balances[attacker2].add(_value) + Transfer(sender→attacker2, 2^255) | 0 + 2^255 | attacker2 credited 2^255 BEC |
| 7 | Final — read attacker balances | balanceOf(attacker1) = balanceOf(attacker2) = 2^255 | Each holds 5.79 × 10^58 BEC |
The trace shows exactly this: two Transfer events each carrying value
57896044618658097711785492504343953926634992332820282019728792003956564819968
(5.789e76 wei = 5.789e58 whole BEC), zero storage change for the sender's balance slot, and both
recipient slots flipped from 0 to 0x8000…0000 (= 2^255).
The two storage writes confirm the mint (and confirm cnt = 2):
storage changes:
@ 0x3be1…9486: 0 → 0x8000000000000000000000000000000000000000000000000000000000000000 (attacker1 balance = 2^255)
@ 0x619a…183f: 0 → 0x8000000000000000000000000000000000000000000000000000000000000000 (attacker2 balance = 2^255)
0x8000…0000 is 2^255, matching type(uint256).max / 2 + 1.
Profit / loss accounting#
| Quantity | Value |
|---|---|
| BEC held by each attacker before | 0 |
| BEC minted to attacker1 | 2^255 ≈ 5.79 × 10^58 |
| BEC minted to attacker2 | 2^255 ≈ 5.79 × 10^58 |
| Total newly minted | 2^256 ≈ 1.16 × 10^59 |
Legitimate total supply (7e9 × 10^18) | 7 × 10^27 |
| Minted / legitimate supply ratio | ~1.65 × 10^31× total (each attacker alone holds ~8.27 × 10^48× the legit supply) |
| Sender's balance debited | 0 (the wrap defeated the debit) |
The "profit" is not denominated in another asset as in an AMM drain — the attacker materialized more BEC than could ever be backed, then dumped it on exchanges before trading was halted. The economic loss is the destruction of the entire token's value: any holder's BEC, and any quote-asset liquidity paired against BEC on exchanges, became unredeemable.
Diagrams#
Sequence of the attack#
Where the overflow defeats the guard#
Token-supply state: legitimate vs. post-attack#
Why these magic numbers#
cnt = 2— the smallest receiver count that, multiplied by an attacker-chosen_value, can wrap to zero. With two receivers the required_valueis exactly2^256 / 2 = 2^255, a clean half of the word, and it stays withincnt <= 20._value = type(uint256).max / 2 + 1 = 2^255— chosen so the productcnt * _value = 2 * 2^255equals2^256, the exact overflow point that reduces to0. Any pair(cnt, _value)withcnt * _value ≡ 0 (mod 2^256)works;(2, 2^255)is the canonical minimal choice.- No starting balance — the overflow turns the
balances[msg.sender] >= amountcheck into>= 0, so the attack needs no BEC and no capital at all.
Remediation#
- Use checked multiplication. Replace
uint256 amount = uint256(cnt) * _value;withuint256 amount = SafeMath.mul(cnt, _value);(the library is already in the file). On Solidity ≥ 0.8 the bare*already reverts on overflow, which is why this exact bug class largely disappeared after the 0.8 migration. - Validate the actual debit, not a pre-overflowed quantity. Even with the multiplication fixed, the
invariant to assert is
balances[msg.sender] >= cnt * _valuecomputed safely; never let an arithmetic result that can wrap feed arequirethat gates fund movement. - Maintain supply coherence.
batchTransferdebits the sender and credits receivers buttotalSupplyis never touched; a fixed version is fine because the sum is conserved, but contracts should assertsum(balances) == totalSupplyin invariant tests so a minting bug like this is caught immediately. - Audit every non-standard ERC-20 extension. The standard
transfer/transferFromwere correctlySafeMath-guarded; the bug lived only in the bolted-onbatchTransferhelper. Custom convenience functions deserve the same scrutiny — and ideally fuzzing/overflow checks — as the core methods. - Prefer audited libraries / modern compilers. Compiling under Solidity ≥ 0.8 (with explicit
uncheckedblocks only where intentional) makes this entire class of overflow mint impossible by default.
How to reproduce#
The PoC was extracted into a standalone Foundry project (the umbrella DeFiHackLabs repo has many
unrelated PoCs that fail to compile under a whole-project forge build):
_shared/run_poc.sh 2018-04-BEC_exp -vvvvv
- RPC: forks Ethereum mainnet at block 5,483,642 (
foundry.tomlmainnetendpoint). The deployed BEC bytecode at0xC5d105…F793Dis exercised directly, so any mainnet endpoint serving state at that block works. - Result:
[PASS] testExploit()— both attacker balances jump from0to2^255BEC.
Expected tail:
Ran 1 test for test/BEC_exp.sol:ContractTest
[PASS] testExploit() (gas: 84028)
Logs:
Before Exploit, Attacker1 BEC Balance: 0.000000000000000000
Before Exploit, Attacker2 BEC Balance: 0.000000000000000000
After Exploit, Attacker1 BEC Balance: 57896044618658097711785492504343953926634992332820282019728.792003956564819968
After Exploit, Attacker2 BEC Balance: 57896044618658097711785492504343953926634992332820282019728.792003956564819968
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.73s (1.13s CPU time)
Reference: the original "batchOverflow" (CVE-2018-10299), April 22, 2018. Attack tx
0xad89ff16fd1ebe3a0a7cf4ed282302c06626c1af33221ebe0d3a470aba4a660f. PeckShield disclosure; SlowMist
Hacked archive — https://hacked.slowmist.io/ (BEC / BeautyChain, Ethereum).
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 2018-04-BEC_exp (evm-hack-registry mirror).
- Upstream DeFiHackLabs PoC:
BEC_exp.sol. - Attack transaction: view on explorer.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "BEC (BeautyChain) 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.