令人意外的代码:安全的代价

本文探讨了在智能合约开发中,遵循“最少意外原则”的重要性。通过分析ERC-721和ERC-1155标准中的`safeTransfer`和`safeMint`函数,揭示了这些旨在提升安全性的设计,反而因为执行外部回调而引入了可重入攻击的风险,并通过Hashmasks和ENS Name Wrapper两个实际案例进行了详细说明。

令人意外的代码之危险

在软件工程中,比一个导致一切崩溃的bug更糟糕的,是一个悄无声息地破坏单一功能的bug。

如果你从事软件工程工作,很可能至少听说过一条软件工程原则。虽然我不主张教条式地遵循每一条原则,但其中确实有一些值得认真关注。今天我想讨论的是最少意外原则。它名字花哨,但理念非常简单。其核心思想是:当面对一段声称要实现某个功能的代码时,大多数用户会对其行为方式做出假设。因此,作为开发者,你的工作就是编写符合这些假设的代码,以免给用户带来糟糕的意外。

这是一个值得遵循的好原则,因为开发者喜欢对事物做出假设。如果你导出了一个名为calculateScore(GameState)的函数,很多人会理所当然地认为该函数只会读取游戏状态。如果你同时还修改了游戏状态,就会让许多试图弄清楚为什么他们的游戏状态会随机损坏的困惑者感到意外。即使你把它写进了文档,也不能保证人们会看到它,所以最好从一开始就确保你的代码不会让人意外。

6小时的调试可以节省5分钟的阅读文档时间。 — Jakob ☘️ (@jcsrb) 2021年5月12日

更安全就是更好,对吗?

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

然而,由于对向后兼容性的担忧,在后续的提交中这些函数被重命名。这使得transfer函数在ERC-20和ERC-721代币中的行为变得相同。但是,现在接收方检查需要移到别处。因此,引入了安全类函数:safeTransfersafeTransferFrom

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

在接下来的几年里,这些标准大多处于休眠状态,而ERC-20则保持着其流行度。但最近,燃料成本的飙升以及对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);
    }

    /**
    * Source of randomness. Theoretical miner withhold manipulation possible but should be sufficient in a pragmatic sense
    */
    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函数本身,它相当直接。首先,我们调用_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 and unwrap old token of old owner
            _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 token and fuse data
        _burn(uint256(node));
        ens.setOwner(node, newOwner);

        emit NameUnwrapped(node, newOwner);
    }

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

结论

令人意外的代码可能会以灾难性的方式破坏事物。在这两个案例中,合理假设安全类函数使用起来(至少同样)安全的开发者,反而无意中增加了他们的攻击面。随着ERC-721和ERC-1155标准变得越来越流行和广泛,这种情况很可能会越来越频繁地发生。开发者需要考虑使用安全类函数的风险,并确定外部调用会如何与他们编写的代码交互。

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