Solidity极简入门 ERC721专题:4. BAYC合约严重漏洞

我最近在重新学solidity,巩固一下细节,也写一个“Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新1-3讲。

欢迎关注我的推特:@0xAA_Science

WTF技术社群discord,内有加微信群方法:链接

所有代码和教程开源在github(1024个star发课程认证,2048个star发社群NFT): github.com/AmazingAng/WTFSolidity

不知不觉我已经完成了Solidity极简教程的前13讲(基础),内容包括:Helloworld.sol,变量类型,存储位置,函数,控制流,构造函数,修饰器,事件,继承,抽象合约,接口,库,异常。在进阶内容之前,我决定做一个ERC721的专题,把之前的内容综合运用,帮助大家更好的复习基础知识,并且更深刻的理解ERC721合约。希望在学习完这个专题之后,每个人都能发行自己的NFT


TL;DR 太长不看

  • 在我查看BAYC合约的时候我发现了两个严重漏洞,其中一个可能会导致BAYC超发,超过设定的供应量上限10,000枚。
  • 超发漏洞是由合约中用于项目方预留BAYC NFTreserveApes函数没有检查是否超发,owner地址可以随时铸造,不受供应量上限的限制。
  • 两种情况下漏洞会被触发:项目方做恶(几乎不可能)和黑客盗取owner私钥(有可能)。
  • 受漏洞影响的包括BAYC以及复用其代码的其他NFT项目方。
  • 最简单的解决办法就是BAYC项目方放弃ownership
  • 发现这两个漏洞并不难,我肯定不是第一个发现的(见这篇去年10月的博客),但是这些漏洞并没有引起足够的重视。鉴于现在BAYC市值超过40亿美元,希望能引起项目方的重视。

声明:本文只是技术探讨,没有FUD BAYC。我很喜欢BAYC,希望漏洞永远不被触发。

BAYC

这是WTF Solidity极简入门ERC721专题的第4讲,我们将介绍BAYC合约及其漏洞。

无聊猿BAYC(Bored Ape Yacht Club)是最顶级的NFT项目,2021年4月底以0.08 ETH的价格发售,一共10000枚。目前地板价约130 ETH,涨了1000多倍,市值超过$40亿美元。

BAYC
BAYC

BAYC合约

BAYC的合约在etherscan上开源,所有人均可查看,今天我们就来仔细学习下它。

BAYC的合约在etherscan上开源
BAYC的合约在etherscan上开源

BAYC的合约是flat形式的,一个文件把所有父合约都包括了,一共2021行。但其实前面1900行都是父合约的内容,只有最后的100行是主合约,也是我们将重点学习的。

继承

pragma solidity ^0.7.0;
contract BoredApeYachtClub is ERC721, Ownable {

BAYC合约的solidity版本是0.7.0,继承了两个合约,ERC721OwnableERC721合约详见我的ERC721专题123讲,Ownable合约最重要的是实现了onlyOwner修饰器,使得特定函数只能由合约的owner地址调用,详见Solidity极简教程第8讲:构造函数和修饰器

状态变量

using SafeMath for uint256;

string public BAYC_PROVENANCE = "";

uint256 public startingIndexBlock;

uint256 public startingIndex;

uint256 public constant apePrice = 80000000000000000; //0.08 ETH

uint public constant maxApePurchase = 20;

uint256 public MAX_APES;

bool public saleIsActive = false;

uint256 public REVEAL_TIMESTAMP;

由于合约的solidity版本是0.7.0,尚未内置SafeMath,因此它用using-for声明了对uint256类型使用SafeMath库,防止溢出错误。

合约里的状态变量一共8个:

  • BAYC_PROVENANCE:把所有NFT图片的hash按一定顺序合并到一起。这个其实是个很巧妙的设计,可证明的把图片的内容和顺序确定下来,又不用在开图之前暴露图片信息,还可以解决NFT项目方偷把稀有度高的换给自己。Bug 1。但是BAYC项目方犯了个错误,他们加了一个setProvenanceHash函数,可以让owner无数次更改BAYC_PROVENANCE,与它的初衷相违背,并且留有做恶可能,比如偷换图片并更改BAYC_PROVENANCE。正确的改法是把BAYC_PROVENANCE设成immutable,在constructor里初始化后就不能再被修改:
string public immutable BAYC_PROVENANCE = "";
  • startingIndexBlock:开始发售的区块高度。
  • startingIndex:看起来像是个多余的变量?
  • apePrice:BAYC发售价格,0.08 ETH。
  • maxApePurchase:每次mint的数量限制,最多一次铸造20只。一笔交易mint多个NFT,比每次只能mint一个要节省gas
  • MAX_APESNFT的最大供给,10,000个。Bug 2
  • saleIsActive:是否开始公售。
  • REVEAL_TIMESTAMP:开图的区块高度。

函数

BAYC主合约定义了10个函数:

  • constuctor:构造函数,初始化代币名称,代号,MAX_APESREVEAL_TIMESTAMP
    constructor(string memory name, string memory symbol, uint256 maxNftSupply, uint256 saleStart) ERC721(name, symbol) {
        MAX_APES = maxNftSupply;
        REVEAL_TIMESTAMP = saleStart + (86400 * 9);
    }
  • withdraw:取出销售BAYC得到的ETH
    function withdraw() public onlyOwner {
        uint balance = address(this).balance;
        msg.sender.transfer(balance);
    }
  • reserveApes:**重要!**项目方给自己预留BAYC,每次调用给自己mint30个。只有合约的owner可以调用。Bug 2
    function reserveApes() public onlyOwner {        
        uint supply = totalSupply();
        uint i;
        for (i = 0; i < 30; i++) {
            _safeMint(msg.sender, supply + i);
        }
    }
  • setRevealTimestamp:更改REVEAL_TIMESTAMP
    function setRevealTimestamp(uint256 revealTimeStamp) public onlyOwner {
        REVEAL_TIMESTAMP = revealTimeStamp;
    } 
  • setProvenanceHash:更改BAYC_PROVENANCEBug 1
    function setProvenanceHash(string memory provenanceHash) public onlyOwner {
        BAYC_PROVENANCE = provenanceHash;
    }
  • setBaseURI:设定BAYCBaseURIERC721合约中的状态变量)。
    function setBaseURI(string memory baseURI) public onlyOwner {
        _setBaseURI(baseURI);
    }
  • flipSaleState:打开/暂停公售。
    function flipSaleState() public onlyOwner {
        saleIsActive = !saleIsActive;
    }
  • mintApe:**重要!**买家支付ETH并铸造BAYC。调用ERC721_safeMint函数。条件:
    • saleIsActivetrue,即公售开始。
    • numberOfTokens <= maxApePurchase,每次只能mint20个。
    • mint后的流通量小于总供给(10,000枚)。
    • 支付的ETH要大于0.08 * mint数量
    function mintApe(uint numberOfTokens) public payable {
        require(saleIsActive, "Sale must be active to mint Ape");
        require(numberOfTokens <= maxApePurchase, "Can only mint 20 tokens at a time");
        require(totalSupply().add(numberOfTokens) <= MAX_APES, "Purchase would exceed max supply of Apes");
        require(apePrice.mul(numberOfTokens) <= msg.value, "Ether value sent is not correct");
        
        for(uint i = 0; i < numberOfTokens; i++) {
            uint mintIndex = totalSupply();
            if (totalSupply() < MAX_APES) {
                _safeMint(msg.sender, mintIndex);
            }
        }

        // If we haven't set the starting index and this is either 1) the last saleable token or 2) the first token to be sold after
        // the end of pre-sale, set the starting index block
        if (startingIndexBlock == 0 && (totalSupply() == MAX_APES || block.timestamp >= REVEAL_TIMESTAMP)) {
            startingIndexBlock = block.number;
        } 
    }
  • setStartingIndex:看起来像是个多余的函数?
    function setStartingIndex() public {
        require(startingIndex == 0, "Starting index is already set");
        require(startingIndexBlock != 0, "Starting index block must be set");
        
        startingIndex = uint(blockhash(startingIndexBlock)) % MAX_APES;
        // Just a sanity case in the worst case if this function is called late (EVM only stores last 256 block hashes)
        if (block.number.sub(startingIndexBlock) > 255) {
            startingIndex = uint(blockhash(block.number - 1)) % MAX_APES;
        }
        // Prevent default sequence
        if (startingIndex == 0) {
            startingIndex = startingIndex.add(1);
        }
    }
  • emergencySetStartingIndexBlock:在紧急情况下,开始公售。将startingIndexBlock设为当前区块高度。
    function emergencySetStartingIndexBlock() public onlyOwner {
        require(startingIndex == 0, "Starting index is already set");
        
        startingIndexBlock = block.number;
    }

BAYC合约的严重漏洞

BAYC合约一共有两个严重漏洞,一个可能导致图片被调换,另一个更严重,会使BAYC超发,超过设定的10,000枚。

Bug 1:图片被换风险

用于证明图片没有被篡改、调换的BAYC_PROVENANCE变量可以被合约owner随意更改。攻击者(项目方或盗取私钥黑客)可以调换图片,利用setBaseURI函数设定新的metadata存放网址,然后算出新图片的BAYC_PROVENANCE并更新。这样,人们没法通过BAYC_PROVENANCE验证是否被篡改。

正确改写方法:将BAYC_PROVENANCE变量设定为immutable,并在构造函数中初始化,之后不能被更改。

Bug 2:增发风险

用于给项目方预留BAYCreserveApes函数没有像公售mintApe函数一样检查供应量,因此,即使最大供给量MAX_APES被设定为10,000,合约的owner(项目方或盗取私钥黑客)仍可以调用reserveApes铸造新的BAYC,使得BAYC#10000BAYC#10001等等被mint出来,没有上限。

正确改写办法:在reserveApes函数中加入最大供应量检查:

    function reserveApes() public onlyOwner {        
        require(totalSupply().add(30) <= MAX_APES, "Mint would exceed max supply of Apes");    
    
        uint supply = totalSupply();
        uint i;
        for (i = 0; i < 30; i++) {
            _safeMint(msg.sender, supply + i);
        }
    }

在什么情况下会被攻击

前面讲的BAYC合约中两个严重漏洞,都需要合约owner去执行。一种情况就是项目方做恶,去攻击漏洞。但很显然BAYC非常成功,项目方不做恶会获得更大的收益,完全没有做恶的动机,因此这种情况几乎不会发生。第二种情况就是owner钱包私钥被黑客盗取,黑客做恶调用reserveApes超发BAYC出售。鉴于BAYC官方ins前几天刚被盗,这种情况发生不是绝无可能。目前BAYC合约的owner地址为:0xaba7161a7fb69c88e16ed9f455ce62b791ee4d03

让我们默默祈祷。

如何解决?

其实这两个漏洞的方法很简单,就是BAYC项目方调用renounceOwnership放弃合约owner。因为这两个漏洞的相关函数都需要owner去调用,放弃owner权限之后将没人可以攻击漏洞。

其他的解决办法大家也可以讨论。

彩蛋:项目方预留的BAYC给了谁?

BAYC项目方总共只调用过一次reserveApes函数,一共给自己预留了30个BAYC,#0到#29,并发送给了30个地址,大家可以挖掘一下他们都是谁(按tokenId排序):

  1. emperortomatoketchup.eth
  2. 0x46efbaedc92067e6d60e84ed6395099723252496
  3. 0xc5c7b46843014b1591e9af24de797156cde67f08
  4. garga.eth
  5. 0xwave.eth
  6. 0xed7c0117d7d35850d71e2a3f390972406f8d5d46
  7. 0x898c4607809945b49d65ea51580101798931b241
  8. rdlwriter.eth
  9. bmouse.eth
  10. cryptorobo.eth
  11. puzzle.eth
  12. 0xddb338bc464fde06b382d28f37e57cb3727c2e1b
  13. chrishol.eth
  14. keltron.eth
  15. yourstruly.eth
  16. 0x7225fd5032038bcf49c36deba23a16262521ede9
  17. dmnets.eth
  18. nabito.eth
  19. tropofarmer.eth
  20. 0xc3fbc3f485f0d9b0bd21b13a4aaa8340160156cb
  21. nftfox.eth
  22. d34thst4lker.eth
  23. thephotographer.eth
  24. 0xed2d1254e79835bf5911aa8946e23bf508477da4
  25. 0xf9cb2a5944654b0c9b07d2311715728e30d3ee82
  26. billymcsmithers.eth
  27. web3ireland.eth
  28. yourstruly.eth
  29. 0x822a16309a9ee40f15e196898f11a010ecb1c963
  30. brulik.eth

总结

  • 这是Solidity极简教学ERC721专题的第4讲,我们介绍BAYC合约及其漏洞。
  • 在我查看BAYC合约的时候我发现了两个严重漏洞,其中一个可能会导致BAYC超发,超过设定的供应量上限10,000枚。
  • 超发漏洞是由合约中用于项目方预留BAYC NFTreserveApes函数没有检查是否超发,owner地址可以随时铸造,不受供应量上限的限制。
  • 两种情况下漏洞会被触发:项目方做恶(几乎不可能)和黑客盗取owner私钥(有可能)。
  • 受漏洞影响的包括BAYC以及复用其代码的其他NFT项目方。
  • 最简单的解决办法就是BAYC项目方放弃ownership
  • NFT项目方需要更严格的审计合约!

声明:本文只是技术探讨,没有FUD BAYC。我很喜欢BAYC,希望漏洞永远不被触发。

Subscribe to 0xAA
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.