Инцидент с SurgeDEFI: Reentrancy

Для ознакомления: Что такое reentrancy

Код контракта: https://bscscan.com/address/0xe1e1aa58983f6b8ee8e4ecd206cea6578f036c21#code

Это BEP-20 с таким функционалом:

  • пользователь может купить токены SURGE, для этого он отправляет BNB контракту, контракт вычисляет соответствующее количество токенов SURGE и эмитирует их на счет пользователя
  • пользователь может продать свои токены SURGE, вызвав функцию sell() и получив соответствующее количество BNB, при этом токены сжигаются

Цена SURGE/BNB вычисляется, исходя из соотношения всех SURGE и BNB на контракте

    receive() external payable {
        uint256 val = msg.value;
        address buyer = msg.sender;
        purchase(buyer, val);
    }
    /** Purchases SURGE Tokens and Deposits Them in Sender's Address*/
    function purchase(address buyer, uint256 bnbAmount) internal returns (bool) {
        // make sure we don't buy more than the bnb in this contract
        require(bnbAmount <= address(this).balance, 'purchase not included in balance');
        // previous amount of BNB before we received any        
        uint256 prevBNBAmount = (address(this).balance).sub(bnbAmount);
        // if this is the first purchase, use current balance
        prevBNBAmount = prevBNBAmount == 0 ? address(this).balance : prevBNBAmount;
        // find the number of tokens we should mint to keep up with the current price
        uint256 nShouldPurchase = hyperInflatePrice ? _totalSupply.mul(bnbAmount).div(address(this).balance) : _totalSupply.mul(bnbAmount).div(prevBNBAmount);
        // apply our spread to tokens to inflate price relative to total supply
        uint256 tokensToSend = nShouldPurchase.mul(spreadDivisor).div(10**2);
        // revert if under 1
        if (tokensToSend < 1) {
            revert('Must Buy More Than One Surge');
        }
        
        // mint the tokens we need to the buyer
        mint(buyer, tokensToSend);
        emit Transfer(address(this), buyer, tokensToSend);
        return true;
    }

    /** Sells SURGE Tokens And Deposits the BNB into Seller's Address */
    function sell(uint256 tokenAmount) public nonReentrant returns (bool) {
        
        address seller = msg.sender;
        
        // make sure seller has this balance
        require(_balances[seller] >= tokenAmount, 'cannot sell above token amount');
        
        // calculate the sell fee from this transaction
        uint256 tokensToSwap = tokenAmount.mul(sellFee).div(10**2);
        
        // how much BNB are these tokens worth?
        uint256 amountBNB = tokensToSwap.mul(calculatePrice());
        
        // send BNB to Seller
        (bool successful,) = payable(seller).call{value: amountBNB, gas: 40000}(""); 
        if (successful) {
            // subtract full amount from sender
            _balances[seller] = _balances[seller].sub(tokenAmount, 'sender does not have this amount to sell');
            // if successful, remove tokens from supply
            _totalSupply = _totalSupply.sub(tokenAmount);
        } else {
            revert();
        }
        emit Transfer(seller, address(this), tokenAmount);
        return true;
    }

    /** Returns the Current Price of the Token */
    function calculatePrice() public view returns (uint256) {
        return ((address(this).balance).div(_totalSupply));
    }
    
    /** Mints Tokens to the Receivers Address */
    function mint(address receiver, uint amount) internal {
        _balances[receiver] = _balances[receiver].add(amount);
        _totalSupply = _totalSupply.add(amount);
    }

Как видно, в функции sell() вызов call() (строка 595) происходит перед сжиганием токенов с баланса пользователя и уменьшением значения переменной _totalSupply (строка 600), в которой хранится общее количество выпущенных токенов.

Здесь можно было бы сделать reentrancy в sell() и вывести все BNB, но мешает модификатор nonReentrant.

Зато находясь внутри sell(), можно зайти в purchase(), несмотря на то что она internal, ведь она вызывается функцией receive() при приходе BNB. При вычислении цены в purchase() используется переменная _totalSupply, а так как до ее уменьшения выполнение не дошло, то высчитываемое из соотношения:

_totalSupply * bnbAmount / prevBNBAmount (строка 566)

количество токенов SURGE при покупке будет больше, чем должно быть по задумке. Таким образом, атакующему контракту нужно вызвать sell(), чтобы продать токены, и сразу отправить полученные BNB на покупку большего количества токенов. Повторить несколько раз до высасывания всех BNB с баланса.

Атакующая транзакция: https://bscscan.com/tx/0x7e2a6ec08464e8e0118368cb933dc64ed9ce36445ecf9c49cacb970ea78531d2

Первым делом атакующий берет мгновенный займ (flashloan) на 10000 BNB на PancakeSwap и отправляет их на уязвимый контракт, получая большое количество SURGE. После этого вызывает sell() с повторным входом в контракт в функцию purchase(), в результате чего получает значительно больше SURGE. Повторяет несколько раз до истощения контракта и возвращает flashloan.

Исправить уязвимость можно было бы, распространив модификатор nonReentrant также на purchase()