Инцидент с 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()