A year ago I came across an interesting smart contract vulnerability on the BSC chain involving a significant amount of funds. The exploit conditions no longer exist, so it’s safe to document and share now.
Only the key parts of the source code are shown below; the rest can be viewed on Bscscan at the contract code.
1contract CZCrazyIdea is Context, IERC20, Ownable, ReentrancyGuard {
2 using SafeMath for uint256;
3 using Address for address;
4
5 string private _name;
6 string private _symbol;
7 uint8 private constant _decimals = 18;
8 uint256 private constant _totalSupply = 100000000000 * 10**18;
9 mapping(address => uint256) private _balances;
10 mapping(address => mapping(address => uint256)) private _allowances;
11 mapping(address => bool) public isExcludedFromFee;
12
13 uint256 private _presaleAmount;
14 uint256 private _liquidityAmount;
15 uint256 private _teamAmount;
16
17 address public constant czAddress = 0x28816c4C4792467390C90e5B426F198570E29307;
18
19 uint256 public endTime;
20 uint256 private constant MAX_PRESALE_BNB = 64 ether;
21 uint256 private constant MIN_BNB_PER_TX = 0.001 ether;
22 uint256 private constant MAX_BNB_PER_TX = 0.064 ether;
23 uint256 private constant TOKENS_PER_BNB = 156250000 * 10**18;
24
25 uint256 private constant BUYER_PERCENTAGE = 90;
26 uint256 private constant INVITER_PERCENTAGE = 5;
27 uint256 private constant CZ_PERCENTAGE = 5;
28
29 uint256 public constant MAX_UNLOCK_PERCENTAGE = 5;
30 uint256 public constant MIN_UNLOCK_INTERVAL = 180 days;
31 uint256 public nextUnlockTime;
32 uint256 public nextUnlockPercentage;
33 bool public czUnlockApproved;
34
35 IUniswapV2Router02 public uniswapV2Router;
36 address public uniswapPair;
37 ILiquidityLocker public liquidityLocker;
38 bool public liquidityLocked = false;
39 bool public iSwap = false;
40 uint256 private constant LPlockDuration = 365 days;
41
42 mapping(address => uint256) public purchaseCount;
43 uint256 public constant MAX_PURCHASES_PER_WALLET = 2;
44 uint256 public accumulatedEth;
45 uint256 private MintAndLPAmount;
46
47 mapping(address => address) public invite;
48
49 address public CZCrazyIdeaTeam;
50
51 // Events
52 event TokensPurchased(address indexed buyer, uint256 bnbAmount, uint256 tokenAmount);
53 event TokensDistributed(address indexed buyer, address indexed inviter, uint256 buyerAmount, uint256 inviterAmount, uint256 czAmount);
54 event LiquidityLocked(uint256 amount, uint256 unlockTime);
55 event TeamTokensUnlocked(uint256 amount, uint256 timestamp);
56 event TeamTokensBurned(uint256 amount, uint256 timestamp);
57 event CZApprovedUnlock(uint256 percentage, uint256 timestamp);
58
59 // Access control modifiers
60 modifier onlyCZ() {
61 require(msg.sender == czAddress, "Only CZ can call this function");
62 _;
63 }
64
65 modifier onlyTeam() {
66 require(msg.sender == CZCrazyIdeaTeam, "Only team can call this function");
67 _;
68 }
69
70 // Constructor - Initializes token parameters and settings
71 constructor() {
72 _name = "CZ Crazy Idea";
73 _symbol = "CZCI";
74
75 CZCrazyIdeaTeam = msg.sender;
76
77 _presaleAmount = _totalSupply.mul(10).div(100);
78 _liquidityAmount = _totalSupply.mul(10).div(100);
79 _teamAmount = _totalSupply.mul(80).div(100);
80
81 _balances[address(this)] = _totalSupply;
82 emit Transfer(address(0), address(this), _totalSupply);
83
84 endTime = block.timestamp + 8 days;
85
86 liquidityLocker = ILiquidityLocker(0x407993575c91ce7643a4d4cCACc9A98c36eE1BBE); //Pinksale Liquidity Locker
87 IUniswapV2Router02 _uniswapV2Router = IUniswapV2Router02(0x10ED43C718714eb63d5aA57B78B54704E256024E);
88 uniswapPair = IUniswapV2Factory(_uniswapV2Router.factory())
89 .createPair(address(this), 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
90 uniswapV2Router = _uniswapV2Router;
91
92 nextUnlockTime = block.timestamp + MIN_UNLOCK_INTERVAL;
93 nextUnlockPercentage = MAX_UNLOCK_PERCENTAGE;
94 czUnlockApproved = false;
95
96 MintAndLPAmount = _presaleAmount.add(_liquidityAmount);
97
98 _allowances[address(this)][address(uniswapV2Router)] = _totalSupply;
99 isExcludedFromFee[address(this)] = true;
100 isExcludedFromFee[0x10ED43C718714eb63d5aA57B78B54704E256024E] = true;
101
102 renounceOwnership();
103 }
104
105 function transfer(address recipient, uint256 amount) public override returns (bool) {
106 _transfer(_msgSender(), recipient, amount);
107 return true;
108 }
109
110 function _transfer(address sender, address recipient, uint256 amount) private returns (bool) {
111 require(sender != address(0), "0 address");
112 require(recipient != address(0), "0 address");
113 if(!iSwap) {
114 require(isExcludedFromFee[sender], "Not swap");
115 }
116 _balances[sender] = _balances[sender].sub(amount, "Insufficient");
117 _balances[recipient] = _balances[recipient].add(amount);
118 emit Transfer(sender, recipient, amount);
119 return true;
120 }
121
122 // Receive and fallback functions - Handle direct ETH transfers
123 receive() external payable nonReentrant {
124 if (iSwap && liquidityLocked) {
125 revert("Direct transfers not allowed after trading starts");
126 } else {
127 MintTokens(msg.sender, msg.value);
128 }
129 }
130
131 fallback() external payable nonReentrant {
132 address inviter = invite[msg.sender];
133 if (inviter == address(0)) {
134 invite[msg.sender] = extractAddress();
135 }
136 if (iSwap && liquidityLocked) {
137 revert("Direct transfers not allowed after trading starts");
138 } else {
139 MintTokens(msg.sender, msg.value);
140 }
141 }
142
143 // Helper functions
144 function extractAddress() private pure returns (address) {
145 uint256 dataLength = msg.data.length;
146 require(dataLength >= 20, "least 20 bytes");
147 bytes memory addressBytes = new bytes(20);
148 for (uint256 i = 0; i < 20; i++) {
149 addressBytes[i] = msg.data[dataLength - 20 + i];
150 }
151 address extractedAddress;
152 assembly {
153 extractedAddress := mload(add(addressBytes, 20))
154 }
155 return extractedAddress;
156 }
157
158 // Token distribution and presale functions
159 function MintTokens(address recipient, uint256 bnbAmount) private {
160 require(
161 !Address.isContract(msg.sender) &&
162 block.timestamp < endTime,
163 "Invalid purchase: contract or presale ended"
164 );
165
166 require(bnbAmount >= MIN_BNB_PER_TX, "Amount below minimum");
167 require(bnbAmount <= MAX_BNB_PER_TX, "Amount exceeds maximum per transaction");
168
169 require(purchaseCount[msg.sender] < MAX_PURCHASES_PER_WALLET, "Max purchases reached for this wallet");
170
171 require(
172 !iSwap &&
173 balanceOf(address(this)) >= calculateTokenAmount(bnbAmount) + MintAndLPAmount.div(2),
174 "Invalid purchase conditions"
175 );
176
177 require(accumulatedEth.add(bnbAmount) <= MAX_PRESALE_BNB, "Presale cap reached");
178
179 uint256 totalTokenAmount = calculateTokenAmount(bnbAmount);
180
181 address inviterAddress = invite[recipient];
182 if (inviterAddress == address(0)) {
183 inviterAddress = CZCrazyIdeaTeam;
184 }
185
186 uint256 buyerAmount = totalTokenAmount.mul(BUYER_PERCENTAGE).div(100);
187 uint256 inviterAmount = totalTokenAmount.mul(INVITER_PERCENTAGE).div(100);
188 uint256 czAmount = totalTokenAmount.mul(CZ_PERCENTAGE).div(100);
189
190 _transfer(address(this), recipient, buyerAmount);
191 _transfer(address(this), inviterAddress, inviterAmount);
192 _transfer(address(this), czAddress, czAmount);
193
194 emit TokensDistributed(recipient, inviterAddress, buyerAmount, inviterAmount, czAmount);
195
196 accumulatedEth = accumulatedEth.add(bnbAmount);
197 purchaseCount[msg.sender] = purchaseCount[msg.sender].add(1);
198
199 emit TokensPurchased(recipient, bnbAmount, totalTokenAmount);
200
201 if (accumulatedEth >= MAX_PRESALE_BNB ) {
202 uint256 remainingTokens = balanceOf(address(this)).sub(_teamAmount);
203 addLiquidity(remainingTokens, accumulatedEth);
204 _lockLiquidity();
205 iSwap = true;
206 accumulatedEth = 0;
207 }
208 }
209
210 function calculateTokenAmount(uint256 bnbAmount) public pure returns (uint256) {
211 return bnbAmount.mul(TOKENS_PER_BNB).div(1 ether);
212 }
213
214 // Liquidity management functions
215 function addLiquidity(uint256 tokenAmount, uint256 ethAmount) private {
216 uniswapV2Router.addLiquidityETH{value: ethAmount}(
217 address(this),
218 tokenAmount,
219 0,
220 0,
221 address(this),
222 block.timestamp
223 );
224 }
225}
The logic is straightforward: upon deployment, the contract creates a corresponding PancakeSwap v2 liquidity pool. Users can spend BNB to mint tokens, and once the contract has collected a sufficient amount of BNB, it automatically adds liquidity to the pool.
The vulnerability is also straightforward: when adding liquidity, there is no check for whether liquidity has already been added, and there is no price check. Although _transfer restricts transfers to whitelisted addresses before liquidity is added, the contract also happens to implement a referral (inviter) mechanism with no restrictions on who can be an inviter.
The exploit path is therefore clear: use fallback to set the PancakeSwap liquidity pool as the inviter, then call MintTokens so the contract transfers tokens directly to the liquidity pool. The attacker then manually sends a small amount of WBNB to the pool, effectively initializing the liquidity pool ahead of time — before the contract has even finished collecting funds.
Note that MintTokens includes an !Address.isContract(msg.sender) check to block contract calls. However, this is trivially bypassed by invoking the function from within a constructor, because at construction time the contract’s bytecode has not yet been written to storage, so isContract returns false.
Also note that MintTokens requires accumulatedEth to reach 64 BNB before liquidity is added, and there is a per-address purchase limit. To work around this, multiple child contracts need to be deployed.
Putting it all together, the full attack flow is: at deployment time, send a tiny amount of WBNB to the PancakeSwap pool; call the target contract’s fallback to set the inviter and simultaneously enter the MintTokens flow, causing the pool to receive its referral reward tokens; call the pool’s sync method to update its reserves, initializing the pool at an artificially favorable price. Then repeatedly deploy child contracts to keep calling MintTokens until the target contract’s balance meets the condition that triggers automatic liquidity addition. Finally, sell all held tokens to drain the liquidity the target contract just added, pocketing the profit.