去年碰到的 NFT 漏洞

这篇文章咱水一篇去年碰到的 2 个 NFT 的漏洞,首先还是先上代码

function mintAllow(uint256 _tokenAmount, string memory name, bytes memory signature) public payable {
  uint256 s = totalSupply();
  require(check(name, signature) == msg.sender, "Signature Invalid"); //server side signature
  require(OGStatus, "OG sale is not active");
  require(_tokenAmount > 0, "Mint more than 0");
  require(_tokenAmount <= maxMintPerTxOG, "Mint less");
  require(s + _tokenAmount <= maxSupply, "Mint less");
  require(msg.value >= priceOG * _tokenAmount, "ETH input is wrong");
  require(allowedMintCountOG(msg.sender) >= _tokenAmount, "You minted too many");
  require(tx.origin == msg.sender);
  for (uint256 i = 0; i < _tokenAmount; ++i) {
    _safeMint(msg.sender, s + i, "");
  }
  delete s;
  updateMintCountOG(msg.sender, _tokenAmount);
}
function check(string memory name, bytes memory signature) public view returns(address) {
  return _verify(name, signature);
}
function _verify(string memory name, bytes memory signature) internal view returns(address) {
  bytes32 digest = _hash(name);
  return ECDSA.recover(digest, signature);
}
function allowedMintCountOG(address minter) public view returns(uint256) {
  return maxMintPerWalletOG - mintCountMapOG[minter];
}
function updateMintCountOG(address minter, uint256 count) private {
  mintCountMapOG[minter] += count;
}

项目就不说了,全部代码也不贴了,mintAllow 是 mint 入口,这是一个白名单机制的 mint,来看这个 mint 会有什么问题。

他的主要验证白名单的机制是 require(check(name, signature) == msg.sender, "Signature Invalid"); 但是我们查看 check 函数后发现这个 signature 其实是 msg.sender 自己的签名,用户可以随便去签,完全没有限制作用。并且这里的签名还是用的 eth_sign 这是个很危险的操作,用户不应该去签任何 eth_sign 的签名。还有没有其它问题。其实是有的,updateMintCountOG 更新用户已经 mint 的数量放到了函数最后,这会导致可能的重入攻击,万幸是了 require(tx.origin == msg.sender); 禁止掉了使用合约进入 mint。

那么这么一段代码会有三个问题

1.链下不应该用 eth_sign 签名,链下签名应该用 personal_sign

2.ECDSA.recover 用法错误,正确用法是使用由开发者控制的私钥进行签名。

3.交换代码顺序,先更新变量,再交互 mint

这个项目最后结果是找到了项目方,关闭了 mint 通过其它方式来进行发售,结局很好。

address private _signerAddress;
function presaleBuy(bytes32 hash, bytes memory sig, uint256 qty, string memory nonce) external payable nonReentrant {
  require(presaleLive, "presale not live");
  require(matchAddresSigner(hash, sig), "no direct mint");
  require(qty <= 5, "no more than 5");
  require(hashTransaction(msg.sender, qty, nonce) == hash, "hash check failed");
  require(totalSupply() + qty <= maxPresale, "presale out of stock");
  require(pricePerToken * qty == msg.value, "exact amount needed");
  require(!_usedNonces[nonce], "nonce already used");

  _usedNonces[nonce] = true;
  for (uint256 i = 0; i < qty; i++) {
    _safeMint(msg.sender, totalSupply() + 1);
  }
}
function hashTransaction(address sender, uint256 qty, string memory nonce) private pure returns(bytes32) {
  bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(abi.encodePacked(sender, qty, nonce))));
  return hash;
}
function matchAddresSigner(bytes32 hash, bytes memory signature) private view returns(bool) {
  return _signerAddress == hash.recover(signature);
}

同样具体项目也不说,入口为 presaleBuy,初年像是 ECDSA.recover 的正确使用方法,但是 ECDSA.recover 在这种场景不是单独使用的,需要配合相应的验证,不然会导致重放攻击,这个里面就没有进行相应的验证 hashTransaction 像是在进行验证,但是没有起作用,实际变成了浪费 gas 的代码。

这段代码有 2个问题

1.matchAddresSigner 没有检查 msg.sender

2.matchAddresSigner 没有相关的配合检查代码,导致了重放

这个项目的结局是 mint 的很快,有些很少数量被重放利用,损失不大。

mirror 的编辑器挺难用的,不能直接让编辑 markdown code 吗。