背景

Akutars 一个利用了荷兰拍卖的合约 NFT 项目,在合约上出了一个 BUG,导致 3400W 美元的 ETH 永远被锁到合约不能提现,项目方少赚几千万。

设计

开始以 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 退款方法居然没有做权限限制