NFT常见认证漏洞总结

说明

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)));
}

可以看到在mintPrivatemintWhitelist都是调用verify()函数对用户身份校验。但是由于这两者的verify()函数没有区分是private还是whitelist。这样就导致whitelist的用户也可以调用mintPrivate()通过验证。

根据合约中的设置privateMaxMintsPerTx可以 mint 50 个NFT,whitelistMaxMintsPerWallet可以mint 2个NFT。这个漏洞就让白名单用户通过mintPrivate可以额外mingt50个。

这种逻辑漏洞就类似于Web2中的越权漏洞,本质上就是对用户的身份没有进行很好的鉴别和控制,导致一般用户具有了其他更高阶的用户的权限。

正确的做法,mintPrivatemintWhitelist应该分别调用不同的verify方法(比如privateVerifywhitelistVerify)。不同的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

文章作者: Oooverflow
文章链接: https://www.oooverflow.com/2022/09/18/nft-verify-vuln/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Oooverflow