Skip to content
On this page

独一无二的数字徽章:UniqueNFT 的秘密与挑战

想象一下,在这个数字世界里,你能拥有一个独一无二、永不褪色的数字徽章,证明你就是你。这就是 UniqueNFT 的魅力所在——一个让你我都能轻松获取属于自己的闪亮数字身份的平台。

欢迎来到 UniqueNFT

在 UniqueNFT 的世界里,一切都围绕着“身份”和“唯一性”展开。

  • 人类玩家(EOA)的特权: 如果你是一位普通的用户,也就是我们常说的 EOA(Externally Owned Account,外部拥有账户),那么恭喜你,你可以免费领取你的专属数字徽章,无需任何条件,就像 Proof of Humanity (POH) 一样,简单直接。
  • 智能合约玩家的“通行费”: 但如果你是一个智能合约,想要在这个游戏中分一杯羹,那么对不起,你需要支付“通行费”——一整枚以太币!这是为了防止机器人滥用资源,确保游戏的公平性。

隐藏的挑战:规则的艺术

UniqueNFT 的核心理念是“独一无二”和“不可转让”,这体现在以下几点:

  • 一人一证: 每个地址只能拥有一个 UniqueNFT 徽章,不能贪婪地囤积。
  • 永不分离: 徽章一旦属于你,就如同区块链上的纹身,永远无法交易或转移。

这听起来很美好,对吧?但是,“Think you can outsmart the rules and own more a single NFT? Prove it.” 这句话才是 CTF 题目(UniqueNFT)的核心所在。它抛出了一个挑战:你是否能突破“一人一证”的限制,证明你能拥有不止一个 NFT?

破解之道:智能合约的“反客为主”

根据提供的 CTF 题目内容和代码,我们可以窥探到破解 UniqueNFT 限制的思路。

核心漏洞点:

  1. mintNFTSmartContract() 的设计: 这个函数虽然要求支付 1 ETH,并且加入了 nonReentrant 保护,但它用于智能合约的铸造。
  2. mintNFTEOA() 的限制: 这个函数通过 require(tx.origin == msg.sender, "not an EOA"); 来确保只有 EOA 才能调用,从而排除智能合约。
  3. _mintNFT() 的唯一性限制: require(balanceOf(msg.sender) == 0, "only one unique NFT allowed"); 是防止同一个地址重复铸造的关键。
  4. _update() 的转移限制: require(from == address(0), "transfers not allowed"); 明确禁止了 NFT 的转移。

破解思路:

题目作者巧妙地利用了智能合约的递归调用和**tx.origin 的特性**,来绕过“一人一证”的限制。

  • tx.origin 的“陷阱”: tx.origin 指的是发起整个交易的 EOA 地址,而 msg.sender 则是当前合约调用的发起者。如果一个 EOA 调用一个智能合约 A,智能合约 A 再调用另一个智能合约 B,那么在智能合约 B 中,tx.origin 仍然是最初的 EOA,而 msg.sender 则是智能合约 A。
  • “反客为主”的策略: 我们可以创建一个自己的智能合约 (MyContract),让这个智能合约作为“中间人”。
    1. EOA 发起交易: 由我们的 EOA 地址发起对 MyContract 的调用。
    2. MyContract 调用 UniqueNFT.mintNFTEOA()MyContractplay() 函数中,它会尝试调用 UniqueNFTmintNFTEOA() 方法。此时,对于 UniqueNFT 合约来说,tx.origin 是我们的 EOA,而 msg.senderMyContract(一个智能合约)。UniqueNFT.mintNFTEOA() 中的 require(tx.origin == msg.sender, "not an EOA"); 这个检查就会失败,因为 tx.origin (EOA) 不等于 msg.sender (MyContract)。
    3. 关键转折:onERC721Received 的“后门”: CTF 题目中,MyContract 实现了 IERC721Receiver 接口,并重写了 onERC721Received 函数。这个函数会在 MyContract 接收一个 ERC721 代币时被调用(例如,当 UniqueNFT 尝试将代币铸造给 MyContract 时,尽管 UniqueNFT._mint 是直接给 msg.sender)。
      • 问题的关键在于 UniqueNFT._mintNFT() 中的 ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, ""); 这行代码。它会调用接收方的 onERC721Received 函数。
      • MyContractonERC721Received 函数中,存在一个条件判断 if (_uniqueNFT.tokenId() < TOKENID)。如果 UniqueNFT 合约当前的总 tokenId 小于 TOKENID(在 MyContract 中定义为 2),那么 MyContract 会再次调用 this.play()
    4. 递归铸造: 这样就形成了一个递归调用。第一次,MyContract 尝试调用 mintNFTEOA 失败。但由于 UniqueNFT 尝试将 NFT 铸造给 MyContract(或者在 _mintNFT 中调用了 checkOnERC721Received),触发了 MyContractonERC721Received。如果 tokenId 足够小,MyContract 再次调用 play()。这一次,由于 UniqueNFT_mintNFT 函数中 balanceOf(msg.sender) == 0 的检查,MyContract 仍然无法铸造。
    5. 真正的“一次性”解决方案(答案代码分析): 题目给出的 MyContract.sol 并没有直接利用 tx.origin 的漏洞来绕过 mintNFTEOA 的检查,而是利用了 _mintNFT 中的 ERC721Utils.checkOnERC721Received
      • 当 EOA 调用 MyContract.play() 时,MyContract 会尝试调用 UniqueNFT.mintNFTEOA()
      • UniqueNFT.mintNFTEOA() 检查 tx.origin == msg.sender,这里 tx.origin 是 EOA,msg.senderMyContract,所以这个检查会失败mintNFTEOA 无法成功执行。
      • 但是! MyContract 实现了 IERC721Receiver,并且在 play 函数中,它直接调用了 address(_uniqueNFT).functionCall(data);,这里的 datamintNFTEOA.selector。这意味着 MyContract 直接UniqueNFT 发送了一个调用 mintNFTEOA 的请求,而不是通过 msg.sender 来进行验证
      • UniqueNFT_mintNFT 函数执行时,它会调用 ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");。这里的 msg.senderMyContract_tokenId 是新生成的。
      • 因此,MyContractonERC721Received 函数会被调用。
      • onERC721Received 中,它检查 _uniqueNFT.tokenId() < TOKENID。如果为真(比如 TOKENID 设置为 2,而 UniqueNFT 已经铸造了 0 和 1),它就会再次调用 this.play()
      • 第二次调用 this.play() 此时,MyContract 再次调用 address(_uniqueNFT).functionCall(data);,发送 mintNFTEOA.selector
      • UniqueNFT._mintNFT() 再次被调用。这时,balanceOf(msg.sender) (即 MyContract 的余额) 仍然是 0
      • ERC721Utils.checkOnERC721Received 再次被调用。MyContract.onERC721Received 再次被调用。
      • 关键点: MyContract.onERC721Received 中的 if (_uniqueNFT.tokenId() < TOKENID) 条件 不再满足(因为 UniqueNFTtokenId 已经增加)。
      • 最终结果: 第一次调用 UniqueNFT.mintNFTEOA() 失败,但触发了 onERC721Received。第二次 onERC721Received 被调用后,play() 并没有再次被触发,并且 UniqueNFT 已经铸造了两个 token(tokenId 0 和 1),分别给 MyContract(通过 _mint),这绕过了 balanceOf(msg.sender) == 0 的检查。

换句话说,MyContract 通过“欺骗” UniqueNFT 调用其 onERC721Received 函数,让 UniqueNFT 间接地为 MyContract 铸造了 NFT。由于 UniqueNFT 内部并没有检查 onERC721Received 调用者的 tx.origin,并且 MyContract 自身并没有实际拥有 NFT(它只是一个中介),所以 balanceOf(msg.sender) == 0 的检查在 MyContract 层面并没有被完全利用来阻止多次铸造。

MyScript.sol 的作用:

MyScript.sol 是一个使用 Foundry 框架编写的脚本,用于自动化整个攻击过程:

  1. 部署 MyContract 通过 new MyContract(UNIQUE_NFT) 实例化我们的攻击合约,并传入 UniqueNFT 的地址。
  2. 执行攻击: MyContract(payable(address(player))).play(); (这里 player 指的是你的 EOA 地址)—— 实际上是调用部署好的 MyContract 实例的 play() 方法,发起攻击。
  3. 签名与广播: vm.startBroadcast(playerpk)vm.stopBroadcast() 用于在本地环境中模拟签名和广播交易,让脚本看起来像是在真实网络上操作。

总结

UniqueNFT 的 CTF 题目是一个经典的智能合约安全挑战,它巧妙地利用了 Solidity 中的 tx.originmsg.sender 以及 ERC721 的回调机制 (onERC721Received) 来绕过开发者设定的限制。通过创建一个能够接收 ERC721 代币的智能合约,并利用其回调函数触发对目标合约的攻击,最终实现了“一人多证”的非法目的。这再次证明了在智能合约开发中,对细节的严谨把握和对潜在攻击向量的充分理解是多么重要。

Built with AiAda