一年前在 BSC 链看到一个很有意思的合约漏洞,涉及资金还不少,不过现在早已没有利用条件了,可以记录分享出来

源码只截取关键部分,其他可以去 Bscscan 查看合约代码

  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}

逻辑很简单,合约创建的时候创建了对应的 pancake v2 流动性池,用户可以花费 BNB 来 MintToken,当合约收集到一定数量的 BNB 后就自动添加流动性到流动性池

问题很简单,添加流动性时没有检查是否已经添加流动性,没有价格检查,虽然在 _transfer 里限制了在添加流动性之前只有白名单可以转账,但恰好合约又设计了邀请机制,并且没有对 inviter 做任何限制

那么漏洞利用路径就很清楚了,通过 fallback 来让 pancake 流动性池成为 inviter,再 MintToken 让合约自己给流动性池转账,调用者再主动给池子转一点 WBNB,那么就能在合约收集满资金之前提前初始化流动性池了

注意到 MintToken 方法还有一个 !Address.isContract(msg.sender) 来限制合约调用,但这个其实只要在 constructor 里调用就能简单绕过了,因为合约在构造的时候还没有把字节码写入到地址的存储里

注意到 MintToken 里要求 accumulatedEth 大于等于 64 BNB 才会触发添加流动性的逻辑,并且每个地址 MintToken 有次数限制,需要部署多个子合约来绕过

于是整个流程都通了,在合约创建的时候,先给 pancake 池子打一点点 WBNB,再调用目标合约设置 inviter,在同一个 fallback 进入 MintToken 流程,流动性池收到邀请奖励 Token,调用流动性池的 sync 方法更新储备,这时就初始化好了,再一直部署子合约进行 MintToken 操作直到目标合约的余额满足条件自动添加流动性,最后再卖出持有的所有代币,就能掏空目标合约刚添加的流动性获利

具体的 PoC 可见于我的 Github Repo

img0