令人惊讶的代码带来的危险

本文探讨了智能合约开发中"最小惊讶原则"的重要性,通过ERC-721和ERC-1155标准的具体案例,分析了安全函数中隐藏的重入攻击风险,揭示了表面安全的代码可能带来的严重安全隐患。

令人惊讶的代码带来的危险

比代码中破坏一切的bug更糟糕的是,代码中微妙地破坏某件事的bug。

如果你在软件工程领域工作,很可能至少听说过一个软件工程原则。虽然我不主张严格遵守每个原则,但确实有几个原则值得关注。今天我想讨论的是最小惊讶原则。它有一个花哨的名字,但概念非常简单。它指出,当面对声称要做某事的代码时,大多数用户会对它如何完成该事情做出假设。因此,作为开发人员,你的工作是编写符合这些假设的代码,这样用户就不会遇到令人不快的意外。

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

更安全更好,对吗?

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

然而,由于对向后兼容性的担忧,这些函数在后续提交中被重命名。这使得传输函数在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);
    }

    /**
    * 随机性来源。理论上矿工可能操纵,但在实际意义上应该足够
    */
    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 设计