说明 NFT发行一般都是先白名单Mint,然后才是可以公开Mint。白名单Mint的过程中必然涉及到对白名单的校验。然而部分NFT项目放对于白名单校验这块并没有处理得很好,导致出现白名单失效、无限Mint、校验失败可以任意Mint等等情况。
本篇文章就是对常见的错误校验方式的一个总结。
有关有关NFT正确的校验方法,参考之前的两篇文章。NFT签名验证 和NFT使用Merkle Tree校验
正确的校验方法 下面还是以实际合约中的例子来展示正确的校验做法,分别展示签名验证和Merkle验证。
cheerups
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function whitelistSale(uint64 numberOfTokens_, bytes32[] calldata signature_) external payable callerIsUser nonReentrant { require(isWhitelistSaleEnabled(), "whitelist sale has not enabled"); require(isWhitelistAddress(_msgSender(), signature_), "caller is not in whitelist or invalid signature"); uint64 whitelistMinted = _getAux(_msgSender()) + numberOfTokens_; require(whitelistMinted <= whitelistSaleConfig.mintQuota, "max mint amount per wallet exceeded"); _sale(numberOfTokens_, getWhitelistSalePrice()); _setAux(_msgSender(), whitelistMinted); } function isWhitelistAddress(address address_, bytes32[] calldata signature_) public view returns (bool) { if (whitelistSaleConfig.merkleRoot == "") { return false; } return MerkleProof.verify( signature_, whitelistSaleConfig.merkleRoot, keccak256(abi.encodePacked(address_)) ); } // 参考:https://etherscan.io/address/0xa5bb28eecc6134f89745e34ec6ab5d5bcb16dad7#code
在isWhitelistAddress中调用了MerkleProof.verify()方法,采用的是Merkle Tree校验方法。
More Than Gamers
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function mint_whitelist(uint8 _mintAmount, bytes memory signature) public payable { uint256 supply = totalSupply(); require(!paused); require(stage > 0, "Sale not started"); require(isWhitelisted(msg.sender, signature), "Must be whitelisted"); require(stage == 1 || stage == 2, "invalid stage"); require(_mintAmount > 0, "Must mint at least 1"); require(supply + _mintAmount <= presaleSupply, "Mint exceed presale supply"); require(msg.value >= presalePrice * _mintAmount, "Insufficient amount sent"); require(_mintAmount + presaleMintCount[msg.sender] <= (stage == 1 ? presaleMintMax : clearanceMintMax), "Cannot mint more than 2"); presaleMintCount[msg.sender] += _mintAmount; currentSupply += _mintAmount; for (uint256 i = 1; i <= _mintAmount; i++) { _safeMint(msg.sender, supply + i); } } function isWhitelisted(address _addr, bytes memory signature) public view returns(bool){ return _signer == ECDSA.recover(keccak256(abi.encodePacked("WL", _addr)), signature); }
在mint_whitelist中调用了isWhitelisted()方法判断是不是在白名单中。isWhitelisted()方法通过ECDSA.recover()判断,这就是一个典型的使用签名校验的方式。
错误的校验方法 下面来看几种错误的校验方式。
不同Mint方法使同一校验方式 NFT项目有时候会根据不同的场景对应有不同的Mint方法。比如针对项目成员,调用的是private_mint()。针对有白名单的用户,调用的是whitelist_mint()。针对普通用户,则调用的是public_mint()。项目成员和白名单用户mint时可能会有一些额外的好处,所以针对这两类用户就需要额外进行校验。但是如果whitelist_mint()和private_mint()的校验逻辑一样,这样就会导致这些白名单用户还可以调用合约的private_mint()用法。
下面的这个合约就是一个典型的例子。
Lil Heroes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 uint256 public whitelistMaxMintsPerWallet = 2; // Maximum number of mint per wallet uint256 public privateMaxMintsPerWallet = 50; // Maximum number of mint per wallet function mintPrivate(uint256 numToMint, bytes memory signature) external payable { require(block.timestamp > privateStartTime, "minting not open yet"); require(privateIsActive == true, "minting not activated"); require(verify(signature, msg.sender), "wallet is not whitelisted"); require(privateAmountMinted[msg.sender] + numToMint <= privateMaxMintsPerWallet, "too many tokens allowed per wallet"); require(msg.value >= price * numToMint, "not enough eth provided"); require(numToMint > 0, "not enough token to mint"); require((numToMint + totalSupply) <= maxItems, "would exceed max supply"); require(numToMint <= privateMaxMintsPerTx, "too many tokens per tx"); for(uint256 i=totalSupply; i < (totalSupply + numToMint); i++){ _mint(msg.sender, i+1); } privateAmountMinted[msg.sender] = privateAmountMinted[msg.sender] + numToMint; totalSupply += numToMint; } function mintWhitelist(uint256 numToMint, bytes memory signature) external payable { require(block.timestamp > whitelistStartTime, "minting not open yet"); require(whitelistIsActive == true, "minting not activated"); require(verify(signature, msg.sender), "wallet is not whitelisted"); require(whitelistAmountMinted[msg.sender] + numToMint <= whitelistMaxMintsPerWallet, "too many tokens allowed per wallet"); require(msg.value >= price * numToMint, "not enough eth provided"); require(numToMint > 0, "not enough token to mint"); require((numToMint + totalSupply) <= maxItems, "would exceed max supply"); require(numToMint <= whitelistMaxMintsPerTx, "too many tokens per tx"); for(uint256 i=totalSupply; i < (totalSupply + numToMint); i++){ _mint(msg.sender, i+1); } whitelistAmountMinted[msg.sender] = whitelistAmountMinted[msg.sender] + numToMint; totalSupply += numToMint; } function verify(bytes memory signature, address target) public view returns (bool) { uint8 v; bytes32 r; bytes32 s; (v, r, s) = splitSignature(signature); bytes32 senderHash = keccak256(abi.encodePacked(target)); //return (owner() == address(ecrecover(senderHash, v, r, s))); return (__walletSignature == address(ecrecover(senderHash, v, r, s))); }
可以看到在mintPrivate和mintWhitelist都是调用verify()函数对用户身份校验。但是由于这两者的verify()函数没有区分是private还是whitelist。这样就导致whitelist的用户也可以调用mintPrivate()通过验证。
根据合约中的设置privateMaxMintsPerTx可以 mint 50 个NFT,whitelistMaxMintsPerWallet可以mint 2个NFT。这个漏洞就让白名单用户通过mintPrivate可以额外mingt50个。
这种逻辑漏洞就类似于Web2中的越权漏洞,本质上就是对用户的身份没有进行很好的鉴别和控制,导致一般用户具有了其他更高阶的用户的权限。
正确的做法,mintPrivate和mintWhitelist应该分别调用不同的verify方法(比如privateVerify和whitelistVerify)。不同的verify()方法中最终反解出来的__walletSignature应该不一样。修复示例代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function privateVerify(bytes memory signature, address target) public view returns (bool) { uint8 v; bytes32 r; bytes32 s; (v, r, s) = splitSignature(signature); bytes32 senderHash = keccak256(abi.encodePacked(target)); //return (owner() == address(ecrecover(senderHash, v, r, s))); return (__privateWalletSignature == address(ecrecover(senderHash, v, r, s))); } function whitelistVerify(bytes memory signature, address target) public view returns (bool) { uint8 v; bytes32 r; bytes32 s; (v, r, s) = splitSignature(signature); bytes32 senderHash = keccak256(abi.encodePacked(target)); //return (owner() == address(ecrecover(senderHash, v, r, s))); return (__whitelistWalletSignature == address(ecrecover(senderHash, v, r, s))); }
没有关联msg.sender 校验很重要的一点就是需要防止重放。因为区块链中所有的信息全部都会上链,意味着是完全公开的。如果校验方法无法防止重放,那么其他人利用签名信息都可以通过验证。
对于每一个用户来说,他们的账户地址都是唯一的。那么在对用户信息签名时,都会将用户的账户地址签名进去。同时合约最终确认信息的时候获取msg.sender来验证签名信息。由于不同用户调用合约的msg.sender都不一样,通过这种方式就可以预防重发。
案例1 而下面的暴雪猴就是对msg.sender进行签名,导致被重放。
Monster Ape Club
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function buy(uint256 _timestamp, uint256 _amount, bytes memory _signature) public payable { address signer = ECDSA.recover(keccak256(abi.encode(_timestamp, _amount)), _signature); require(signer == root, "Not authorized to mint"); require(block.timestamp >= start, "Sale not started yet"); require(block.timestamp <= end, "Sale ended"); require((_amount + purchased[_msgSender()]) <= batchLimit, "Each address can only purchase up to 2 apes"); // Increment the mint limit to verify the stock because nextId start counting from 1 require(nextId.add(_amount) <= mintLimit + 1, "Purchase would exceed apes supply"); require(price.mul(_amount) <= msg.value, "Apes price is not correct"); uint256 i; for (i = 0; i < _amount; i++) { _safeMint(_msgSender(), nextId + i); } nextId += i; purchased[_msgSender()] += i; }
其中的校验的关键代码是address signer = ECDSA.recover(keccak256(abi.encode(_timestamp, _amount)), _signature);,可以看到只对_timestamp和_amount进行了签名,没有包含用户的地址信息。
所以当这条交易上链之后,攻击者看到了这条信息就直接重放这个交易信息,同样就可以完成交易。
案例2 同样的,除了暴雪猴之外,NBA的NFT项目也是存在一样的问题。
The Association NFT
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 function mint_approved( vData memory info, uint256 number_of_items_requested, uint16 _batchNumber ) external { require(batchNumber == _batchNumber, "!batch"); address from = msg.sender; require(verify(info), "Unauthorised access secret"); _discountedClaimedPerWallet[msg.sender] += 1; require( _discountedClaimedPerWallet[msg.sender] <= 1, "Number exceeds max discounted per address" ); presold[from] = 1; _mintCards(number_of_items_requested, from); emit batchWhitelistMint(_batchNumber, msg.sender); } function verify(saleInfo calldata sp) internal view returns (bool) { require(sp.token_address != address(0), "INVALID_TOKEN_ADDRESS"); bytes memory cat = abi.encode( sp.token_address, sp.start, sp.end, sp.price, sp.max_per_user, sp.total, sp.oneForOne ); bytes32 hash = keccak256(cat); require(sp.signature.length == 65, "Invalid signature length"); bytes32 sigR; bytes32 sigS; uint8 sigV; bytes memory signature = sp.signature; // ecrecover takes the signature parameters, and the only way to get them // currently is to use assembly. assembly { sigR := mload(add(signature, 0x20)) sigS := mload(add(signature, 0x40)) sigV := byte(0, mload(add(signature, 0x60))) } bytes32 data = keccak256( abi.encodePacked("\x19Ethereum Signed Message:\n32", hash) ); address recovered = ecrecover(data, sigV, sigR, sigS); return cwc_signer == recovered; }
其中的函数verify中的关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 bytes memory cat = abi.encode( sp.token_address, sp.start, sp.end, sp.price, sp.max_per_user, sp.total, sp.oneForOne ); bytes32 hash = keccak256(cat);
其中hash信息只是关联了token_address,price,max_per_user等等其他的信息,并没有和用户的信息关联。同样导致可以进行重放。
前端签名接口暴露 对于通过Merkle Tree进行校验来说,一般是获得所有的账户白名单然后构建一个Merkle Tree。将Merkle Tree的root节点信息设置到合约中。Merkle都是提前知道账户地址来构建Tree,所以这种方式一般都不会出错。
对于签名校验来说,根据[]的说明,需要对于传入的账户地址判断是否是在白名单中,如果在白名单中才进行签名发送到合约进行交易。这个过程中,后端对于白名单的校验就非常重要。因为一旦后端签名通过,那么合约校验必然就没有问题。所以对于使用签名校验的方法来说,有两个问题很重要:
签名的接口不要轻易暴露
后端对于账户地址的判断逻辑需要很严谨
应该举的那些例子,本质上都是合约中没有处理好签名逻辑。这个问题,本质上还是Web2业务安全的问题。但就是这个问题,Netflix却翻车了。
会员在阅读影片的过程中,会随机出现一个二维码,结合用户的以太坊地址后,官方会签名信息,用户将可以得到一个signature 数值,而有了这个值,即可在netflix官方发布的NFT合约中,铸造一枚nft。
然后这个签名过程中确实没有任何保护。通过接口信息,就可以自行签名。只需要构建以下的请求,将自己的以太坊地址和目标中的系列号写入,就可以获得对应的signature。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ curl 'https://us-central1-ldr-prod.cloudfunctions.net/api/sign' \ -H 'authority: us-central1-ldr-prod.cloudfunctions.net' \ -H 'accept: application/json, text/plain, */*' \ -H 'accept-language: zh-CN,zh;q=0.9,en;q=0.8' \ -H 'cache-control: no-cache' \ -H 'content-type: application/json' \ -H 'dnt: 1' \ -H 'origin: https://lovedeathandart.com' \ -H 'pragma: no-cache' \ -H 'referer: https://lovedeathandart.com/' \ -H 'sec-fetch-dest: empty' \ -H 'sec-fetch-mode: cors' \ -H 'sec-fetch-site: cross-site' \ -H 'user-agent: Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1' \ --data-raw '{"address":"以太坊地址","category":"系列号0-9"}' \ --compressed
获得签名信息之后,就可以调用合约的mint函数进行mint操作。
其中的参数信息如下:
_category, 和前端传入的category保持一致
_data , 任意填写
_signature,前端的签名信息
分析合约代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 function mint( uint256 _category, bytes memory _data, bytes memory _signature ) external nonReentrant whenNotPaused { require(isSignatureValid(_category, _signature), "LDRT: Invalid signature"); require(_category >= 1, "LDRT: Invalid category. It is less than 1."); require(_category <= 9, "LDRT: Invalid category. It is greater than 9."); bytes32 hashAdrrCategory = keccak256(abi.encodePacked(msg.sender, _category)); bool hasMinted = categoriesMinted[hashAdrrCategory]; require( !hasMinted, "LDRT: Address already has token for that category." ); categoriesMinted[hashAdrrCategory] = true; // 1 is because it will mint 1 token for that category _mint(msg.sender, _category, 1, _data); } function isSignatureValid(uint256 _category, bytes memory _signature) internal view returns (bool) { bytes32 result = keccak256(abi.encodePacked(msg.sender, _category)); bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", result)); return signer.isValidSignatureNow(hash, _signature); }
最终的isSignatureValid最终是调用keccak256(abi.encodePacked(msg.sender, _category))同时对用户账户地址和系列号签名,没有任何问题。合约的校验逻辑没有问题。
所以,本质上使用签名校验需要前端,后端,合约三者都是完美匹配才行。
总结 针对NFT白名单校验逻辑,一共写了三篇文章。发现虽然校验方法只有两种,同时也有openzeppelin这样优秀的第三方合约库,但是还是无法避免会出现各种校验逻辑的问题,其中不乏像NBA和Netflix这样的大机构和大公司,当然NFT的漏洞也远远不止本篇文章介绍的这些。
还是回归到安全核心问题上,白名单校验的最佳实践是什么呢?通过分析的这些合约来看,大部分都是出现在使用签名校验方法上。签名合约涉及到签名信息以及前端后端的配合上,会出现签名信息没有包含用户账户地址,前端泄漏签名接口,后端没有校验白名单地址等等问题。而相对来说,使用Merkle Tree的方法就不会存在这样的问题,相对来说对合约开发者也更加友好,不会容易出错。所以,当一个NFT项目可以同时采用签名验证或者是Merkle Tree验证时,优先采用Merkle Tree的验证方法。
参考 https://www.youtube.com/watch?v=UVhzD5Ch5kU https://blocksecteam.medium.com/how-to-verify-a-signature-in-a-wrong-way-the-associationnft-case-5a913e9b8a1d https://mp.weixin.qq.com/s/4pAT_AbUn7XrY9z0yRHfag