警惕“安全”代码中的惊险陷阱:ERC标准中的重入攻击风险

本文通过分析ERC-721和ERC-1155标准中“安全”函数的设计缺陷,揭示了隐藏的重入攻击风险。以Hashmasks NFT铸造和ENS域名包装器的实际案例,深入探讨了违反“最少惊讶原则”的代码如何导致严重的安全漏洞。

令人惊讶代码的危险性

在软件开发中,比一个导致一切崩溃的Bug更糟糕的,是那种只悄无声息破坏某一功能的Bug。

如果你从事软件工程,很可能至少听说过一条软件工程原则。虽然我不主张教条式地遵循每一条原则,但确实有几条值得认真关注。今天我要谈的是“最少惊讶原则”。它名字听起来很高深,但理念非常简单:当用户面对一段声称要完成某功能的代码时,大多数人会对其行为方式做出假设。因此,开发者的工作就是编写符合这些假设的代码,以免给用户带来“惊喜”。

这是一个很好的原则,因为开发者喜欢对事物做假设。如果你导出一个名为calculateScore(GameState)的函数,很多人会理所当然地假设这个函数只会读取游戏状态。如果你还修改了游戏状态,就会让许多困惑的人感到惊讶,他们试图弄明白为什么自己的游戏状态会随机损坏。即使你在文档中注明了这一点,也不能保证人们会看到,所以最好从一开始就确保你的代码不会令人惊讶。

更安全就更好,对吗?

2018年初起草ERC-721标准时,有人建议实施转账安全检查,以确保代币不会被发送到未设计处理它们的接收合约中。为此,提案作者修改了转账函数的行为,以检查接收方是否能够支持代币转移。他们还引入了unsafeTransfer函数,如果发送方愿意,可以绕过此检查。

然而,由于向后兼容性的考虑,这些函数在后续提交中被重命名。这使得transfer函数在ERC-20和ERC-721代币中的行为保持一致。但是,接收方检查需要移到其他地方。因此,引入了“安全”类函数:safeTransfersafeTransferFrom

这是一个解决实际问题的方案,因为已经有许多ERC-20代币被意外转移到从未预期接收代币的合约中的例子(一个特别常见的错误是将代币转移到代币合约本身,从而永久锁定它)。因此,在起草ERC-1155标准时,它借鉴了ERC-721标准的思路,不仅在转账时,而且在铸造时也包含接收方检查,这并不奇怪。

在接下来的几年里,这些标准大多处于休眠状态,而ERC-20则保持其流行度。但最近,由于Gas费用飙升以及对NFT的兴趣增加,ERC-721和ERC-1155标准的开发者使用量激增。考虑到所有这些新的关注,这些标准在设计时考虑到安全性,这当然是幸运的,对吧?

更安全就更好,对吗?

好吧,但“安全”的转账或铸造到底意味着什么?不同的参与方对安全有不同的解释。对开发者来说,安全的函数可能意味着它不包含任何错误或引入额外的安全问题。对用户来说,这可能意味着它包含了额外的防护措施,以保护他们不会意外地伤害自己。

事实证明,在这种情况下,这些函数更像是后者,而非前者。这尤其不幸,因为在transfersafeTransfer之间选择时,你为什么不选择“安全”的那个呢?它就在名字里啊!

好吧,一个原因可能是我们的老朋友——重入攻击,或者我一直试图重新命名的:“不安全的外部调用”。请记住,如果接收方由攻击者控制,任何外部调用都可能是不安全的,因为攻击者可能能够导致你的合约进入未定义状态。设计上,这些“安全”函数会对代币接收方执行外部调用,而在铸造或转账期间,接收方通常由发送方控制。换句话说,这实际上是不安全外部调用的教科书式例子。

但是,你可能会问自己,允许接收方合约拒绝它们无法处理的转账,最坏的情况能发生什么?好吧,让我用两个快速案例研究来回答这个问题。

Hashmasks

Hashmasks是供应量有限的NFT。用户每笔交易最多可以购买20个面具,尽管它们已经售罄好几个月了。以下是购买面具的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function mintNFT(uint256 numberOfNfts) public payable {
    require(totalSupply() < MAX_NFT_SUPPLY, "Sale has already ended");
    require(numberOfNfts > 0, "numberOfNfts cannot be 0");
    require(numberOfNfts <= 20, "You may not buy more than 20 NFTs at once");
    require(totalSupply().add(numberOfNfts) <= MAX_NFT_SUPPLY, "Exceeds MAX_NFT_SUPPLY");
    require(getNFTPrice().mul(numberOfNfts) == msg.value, "Ether value sent is not correct");

    for (uint i = 0; i < numberOfNfts; i++) {
        uint mintIndex = totalSupply();
        if (block.timestamp < REVEAL_TIMESTAMP) {
            _mintedBeforeReveal[mintIndex] = true;
        }
        _safeMint(msg.sender, mintIndex);
    }

    /**
    * 随机性来源。理论上矿工可能进行操纵,但从实用角度来说应该足够了
    */
    if (startingIndexBlock == 0 && (totalSupply() == MAX_NFT_SUPPLY || block.timestamp >= REVEAL_TIMESTAMP)) {
        startingIndexBlock = block.number;
    }
}

如果你没有预料到,这个函数可能看起来完全合理。然而,正如你可能已经预测到的,那个_safeMint调用中隐藏着一些险恶的东西。让我们看一看。

1
2
3
4
    function _safeMint(address to, uint256 tokenId, bytes memory _data) internal virtual {
        _mint(to, tokenId);
        require(_checkOnERC721Received(address(0), to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer");
    }

为了安全起见,此函数会回调代币接收方以检查他们是否愿意接受转账。然而,我们就是代币的接收方,这意味着我们刚刚获得了一个回调,此时我们可以做任何我们想做的事情,包括再次调用mintNFT。如果我们这样做,我们只铸造了一个面具后就重新进入了该函数,这意味着我们可以请求再铸造19个面具。这导致总共铸造了39个面具,而最大允许量仅为20个。

ENS名称包装器

最近,来自ENS的Nick Johnson联系我,希望我看看他们正在开发的ENS名称包装器。该名称包装器允许用户将他们的ENS域名代币化为一种新的ERC-1155代币,该代币支持细粒度权限和更一致的API。

在高层次上,要包装一个任意的ENS域名(更具体地说,任何非2LD .eth域名),你必须首先授权名称包装器访问你的ENS域名。然后你调用wrap(bytes,address,uint96,address),该函数既为你铸造一个ERC-1155代币,又托管底层的ENS域名。

这是包装函数本身,它相当简单。首先,我们调用_wrap,它执行一些逻辑并返回哈希后的域名。然后,在托管该域名之前,我们确保交易发送者确实是该ENS域名的所有者。请注意,如果发送者不拥有底层的ENS域名,则整个交易应该回滚,撤销在_wrap中所做的任何更改。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    function wrap(
        bytes calldata name,
        address wrappedOwner,
        uint96 _fuses,
        address resolver
    ) public override {
        bytes32 node = _wrap(name, wrappedOwner, _fuses);
        address owner = ens.owner(node);

        require(
            owner == msg.sender ||
                isApprovedForAll(owner, msg.sender) ||
                ens.isApprovedForAll(owner, msg.sender),
            "NameWrapper: Domain is not owned by the sender"
        );
        ens.setOwner(node, address(this));
        if (resolver != address(0)) {
            ens.setResolver(node, resolver);
        }
    }

这是_wrap函数本身。这里没有什么特别的事情发生。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    function _wrap(
        bytes memory name,
        address wrappedOwner,
        uint96 _fuses
    ) private returns (bytes32 node) {
        (bytes32 labelhash, uint256 offset) = name.readLabel(0);
        bytes32 parentNode = name.namehash(offset);

        require(
            parentNode != ETH_NODE,
            "NameWrapper: .eth domains need to use wrapETH2LD()"
        );

        node = _makeNode(parentNode, labelhash);

        _mint(node, name, wrappedOwner, _fuses);
        emit NameWrapped(node, name, wrappedOwner, _fuses);
    }

不幸的是,正是在_mint函数本身,毫无戒备的开发者可能会遭遇一个令人不快的惊喜。ERC-1155规范规定,在铸造代币时,应咨询接收方是否愿意接受该代币。深入研究库代码(这是从OpenZeppelin基础库轻量修改而来)后,我们发现情况确实如此。

 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
    function _mint(
        bytes32 node,
        bytes memory name,
        address wrappedOwner,
        uint96 _fuses
    ) internal {
        names[node] = name;

        address oldWrappedOwner = ownerOf(uint256(node));
        if (oldWrappedOwner != address(0)) {
            // 销毁并解包旧所有者的旧代币
            _burn(uint256(node));
            emit NameUnwrapped(node, address(0));
        }
        _mint(node, wrappedOwner, _fuses);
    }
    
    function _mint(
        bytes32 node,
        address newOwner,
        uint96 _fuses
    ) internal virtual {
        uint256 tokenId = uint256(node);
        address owner = ownerOf(tokenId);
        require(owner == address(0), "ERC1155: mint of existing token");
        require(newOwner != address(0), "ERC1155: mint to the zero address");
        require(
            newOwner != address(this),
            "ERC1155: newOwner cannot be the NameWrapper contract"
        );
        _setData(tokenId, newOwner, _fuses);
        emit TransferSingle(msg.sender, address(0x0), newOwner, tokenId, 1);
        _doSafeTransferAcceptanceCheck(
            msg.sender,
            address(0),
            newOwner,
            tokenId,
            1,
            ""
        );
    }

但这对我们到底有什么好处呢?嗯,我们再次遇到了一个不安全的外部调用,我们可以用它来执行重入攻击。具体来说,请注意,在回调期间,我们拥有代表ENS域名的ERC-1155代币,但名称包装器尚未验证我们是否拥有底层的ENS域名本身。这允许我们在不实际拥有ENS域名的情况下对其进行操作。例如,我们可以要求名称包装器解包我们的域名,销毁我们刚刚铸造的代币并获取底层的ENS域名。

 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
    function unwrap(
        bytes32 parentNode,
        bytes32 label,
        address newController
    ) public override onlyTokenOwner(_makeNode(parentNode, label)) {
        require(
            parentNode != ETH_NODE,
            "NameWrapper: .eth names must be unwrapped with unwrapETH2LD()"
        );
        _unwrap(_makeNode(parentNode, label), newController);
    }
    
    function _unwrap(bytes32 node, address newOwner) private {
        require(
            newOwner != address(0x0),
            "NameWrapper: Target owner cannot be 0x0"
        );
        require(
            newOwner != address(this),
            "NameWrapper: Target owner cannot be the NameWrapper contract"
        );
        require(
            !allFusesBurned(node, CANNOT_UNWRAP),
            "NameWrapper: Domain is not unwrappable"
        );

        // 销毁代币和保险丝数据
        _burn(uint256(node));
        ens.setOwner(node, newOwner);

        emit NameUnwrapped(node, newOwner);
    }

现在我们拥有了底层的ENS域名,我们可以对它做任何我们想做的事情,例如注册新的子域名或设置解析器。完成后,我们只需退出回调。名称包装器将获取底层ENS域名的当前所有者(也就是我们),并完成交易。就这样,我们临时获取了名称包装器已被授权的任何ENS域名的所有权,并对其进行了任意更改。

结论

令人惊讶的代码可能会以灾难性的方式破坏事物。在这两个案例中,那些合理假设“安全”类函数(至少)使用安全的开发者,反而无意中增加了他们的攻击面。随着ERC-721和ERC-1155标准变得越来越流行和普及,这很可能会成为一个日益频繁的现象。开发者需要考虑使用“安全”类函数的风险,并确定外部调用可能如何与他们编写的代码交互。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计