背景
Akutars 一个利用了荷兰拍卖的合约 NFT 项目,在合约上出了一个 BUG,导致 3400W 美元的 ETH 永远被锁到合约不能提现,项目方少赚几千万。
- Akutars - 官方网站
- AkutarMintPass - NFT 基础合约
- AkuAuction - 事故拍卖合约
- Akutar: Deployer - 项目创建者地址
- Twiter - 问题讨论 Twitter
设计
开始以 3.5ETH 价格进行拍卖
- 2022-04-23 04:35:12 ~ 2022-04-23 06:01:01 进行限时 126 分钟的拍卖
- 初始价格每个 NFT 3.2ETH,后面每 6 分钟减少 0.1ETH,最终价格 2.1ETH
constructor(address _project,
uint256 startingTime,
uint256 _startingPrice,
uint256 _discountRate)
{
project = payable(_project);
startingPrice = _startingPrice;
startAt = startingTime;
expiresAt = startAt + DURATION;
discountRate = _discountRate;
require(_startingPrice >= _discountRate * (DURATION/6 minutes), "Starting price less than minimum");
}
function getPrice() public view returns (uint80) {
uint256 currentTime = block.timestamp;
if(currentTime > expiresAt) currentTime = expiresAt;
uint256 timeElapsed = (currentTime - startAt) / 6 minutes;
uint256 discount = discountRate * timeElapsed;
return uint80(startingPrice - discount);
}
调用 processRefunds 将多余的钱还给用户
function processRefunds() external {
// ...
for (uint256 i=_refundProgress; gasUsed < 5000000 && i < _bidIndex; i++) {
bids memory bidData = allBids[i];
if (bidData.finalProcess == 0) {
uint256 refund = (bidData.price - price) * bidData.bidsPlaced;
uint256 passes = mintPassOwner[bidData.bidder];
if (passes > 0) {
refund += mintPassDiscount * (bidData.bidsPlaced < passes ? bidData.bidsPlaced : passes);
}
// 这里将 finalProcess 设置为1,后面用户自己想提也提不出来
allBids[i].finalProcess = 1;
if (refund > 0) {
(bool sent, ) = bidData.bidder.call{value: refund}("");
require(sent, "Failed to refund bidder");
}
}
gasUsed += gasLeft - gasleft();
gasLeft = gasleft();
// BUG: 正确应该是 _refundProgress += bidData.bidsPlaced
_refundProgress++;
}
refundProgress = _refundProgress;
}
项目方调用 claimProjectFunds 提取受益
提现时 refundProgress = 3669
totalBids = 5495
, 永远不满足 refundProgress >= totalBids
。
function claimProjectFunds() external onlyOwner {
require(block.timestamp > expiresAt, "Auction still in progress");
/*
计算 refundProgress++
计算 totalBids += amount
有可能会一次 mint 多个导致这个一定不成立,项目方剩余的钱就永远提不出来
*/
require(refundProgress >= totalBids, "Refunds not yet processed");
require(akuNFTs.airdropProgress() >= totalBids, "Airdrop not complete");
(bool sent, ) = project.call{value: address(this).balance}("");
require(sent, "Failed to withdraw");
}
用户调用 emergencyWithdraw 提取未拍到 NFT 的余额
猜测本来的功能是如果项目方没有给用户发 NFT,剩余的余额可以自己提出来,但是 processRefunds 方法会把所有用户都标记一遍,只要有人调过一次该方法,钱都提不出去了。
function emergencyWithdraw() external {
require(block.timestamp > expiresAt + 3 days, "Please wait for airdrop period.");
bids memory bidData = allBids[personalBids[msg.sender]];
require(bidData.bidsPlaced > 0, "No bids placed");
//0: Not processed, 1: refunded, 2: withdrawn
require(bidData.finalProcess == 0, "Refund already processed");
allBids[personalBids[msg.sender]].finalProcess = 2;
(bool sent, ) = bidData.bidder.call{value: bidData.price * bidData.bidsPlaced}("");
require(sent, "Failed to refund bidder");
}
总结
- 合约代码能力很差,感觉是大学生级别,看起来是在 remix 上写写就完事了
- 没有好好做测试或者审计,只要稍微试一下就可以发现这个问题
B圈
赚钱真容易,放在互联网上千万美金的生意,基本不可能出现这种低级错误- 关键
processRefunds
退款方法居然没有做权限限制