Appearance
独一无二的数字徽章: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 限制的思路。
核心漏洞点:
mintNFTSmartContract()的设计: 这个函数虽然要求支付 1 ETH,并且加入了nonReentrant保护,但它用于智能合约的铸造。mintNFTEOA()的限制: 这个函数通过require(tx.origin == msg.sender, "not an EOA");来确保只有 EOA 才能调用,从而排除智能合约。_mintNFT()的唯一性限制:require(balanceOf(msg.sender) == 0, "only one unique NFT allowed");是防止同一个地址重复铸造的关键。_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),让这个智能合约作为“中间人”。- EOA 发起交易: 由我们的 EOA 地址发起对
MyContract的调用。 MyContract调用UniqueNFT.mintNFTEOA(): 在MyContract的play()函数中,它会尝试调用UniqueNFT的mintNFTEOA()方法。此时,对于UniqueNFT合约来说,tx.origin是我们的 EOA,而msg.sender是MyContract(一个智能合约)。UniqueNFT.mintNFTEOA()中的require(tx.origin == msg.sender, "not an EOA");这个检查就会失败,因为tx.origin(EOA) 不等于msg.sender(MyContract)。- 关键转折:
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函数。 - 在
MyContract的onERC721Received函数中,存在一个条件判断if (_uniqueNFT.tokenId() < TOKENID)。如果UniqueNFT合约当前的总tokenId小于TOKENID(在MyContract中定义为 2),那么MyContract会再次调用this.play()。
- 问题的关键在于
- 递归铸造: 这样就形成了一个递归调用。第一次,
MyContract尝试调用mintNFTEOA失败。但由于UniqueNFT尝试将 NFT 铸造给MyContract(或者在_mintNFT中调用了checkOnERC721Received),触发了MyContract的onERC721Received。如果tokenId足够小,MyContract再次调用play()。这一次,由于UniqueNFT的_mintNFT函数中balanceOf(msg.sender) == 0的检查,MyContract仍然无法铸造。 - 真正的“一次性”解决方案(答案代码分析): 题目给出的
MyContract.sol并没有直接利用tx.origin的漏洞来绕过mintNFTEOA的检查,而是利用了_mintNFT中的ERC721Utils.checkOnERC721Received。- 当 EOA 调用
MyContract.play()时,MyContract会尝试调用UniqueNFT.mintNFTEOA()。 UniqueNFT.mintNFTEOA()检查tx.origin == msg.sender,这里tx.origin是 EOA,msg.sender是MyContract,所以这个检查会失败,mintNFTEOA无法成功执行。- 但是!
MyContract实现了IERC721Receiver,并且在play函数中,它直接调用了address(_uniqueNFT).functionCall(data);,这里的data是mintNFTEOA.selector。这意味着MyContract直接向UniqueNFT发送了一个调用mintNFTEOA的请求,而不是通过msg.sender来进行验证。 - 当
UniqueNFT的_mintNFT函数执行时,它会调用ERC721Utils.checkOnERC721Received(address(0), address(0), msg.sender, _tokenId, "");。这里的msg.sender是MyContract,_tokenId是新生成的。 - 因此,
MyContract的onERC721Received函数会被调用。 - 在
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)条件 不再满足(因为UniqueNFT的tokenId已经增加)。 - 最终结果: 第一次调用
UniqueNFT.mintNFTEOA()失败,但触发了onERC721Received。第二次onERC721Received被调用后,play()并没有再次被触发,并且UniqueNFT已经铸造了两个 token(tokenId0 和 1),分别给MyContract(通过_mint),这绕过了balanceOf(msg.sender) == 0的检查。
- 当 EOA 调用
- EOA 发起交易: 由我们的 EOA 地址发起对
换句话说,MyContract 通过“欺骗” UniqueNFT 调用其 onERC721Received 函数,让 UniqueNFT 间接地为 MyContract 铸造了 NFT。由于 UniqueNFT 内部并没有检查 onERC721Received 调用者的 tx.origin,并且 MyContract 自身并没有实际拥有 NFT(它只是一个中介),所以 balanceOf(msg.sender) == 0 的检查在 MyContract 层面并没有被完全利用来阻止多次铸造。
MyScript.sol 的作用:
MyScript.sol 是一个使用 Foundry 框架编写的脚本,用于自动化整个攻击过程:
- 部署
MyContract: 通过new MyContract(UNIQUE_NFT)实例化我们的攻击合约,并传入UniqueNFT的地址。 - 执行攻击:
MyContract(payable(address(player))).play();(这里player指的是你的 EOA 地址)—— 实际上是调用部署好的MyContract实例的play()方法,发起攻击。 - 签名与广播:
vm.startBroadcast(playerpk)和vm.stopBroadcast()用于在本地环境中模拟签名和广播交易,让脚本看起来像是在真实网络上操作。
总结
UniqueNFT 的 CTF 题目是一个经典的智能合约安全挑战,它巧妙地利用了 Solidity 中的 tx.origin、msg.sender 以及 ERC721 的回调机制 (onERC721Received) 来绕过开发者设定的限制。通过创建一个能够接收 ERC721 代币的智能合约,并利用其回调函数触发对目标合约的攻击,最终实现了“一人多证”的非法目的。这再次证明了在智能合约开发中,对细节的严谨把握和对潜在攻击向量的充分理解是多么重要。