给人惊吓的代码

给人惊吓的代码可能造成恶劣的后果,在两个案例中,开发者都理性地假设了 safe 函数族(至少)会跟普通的函数一样安全而不会增加攻击面。随着 ERC-721 和 ERC-1155 标准变得更加流行,这种攻击可能会越来越频繁。开发者需要考虑使用 safe 函数族的风险,并考虑外部调用会怎样跟自己写下的代码交互。

![](https://img.learnblockchain.cn/2021/08/20/16294429354192.jpg) 如果你也是软件工程师,你应该听过一些软件工程的格言。虽然我并不主张严格遵守每一句格言,但有一些值得你放在心上。 今天我想讲的是 “ *最少惊吓原理* ”。名字挺别扭,但意思很简单:在面对一份声称可以做某事的代码时,大多数用户都会对它的工作方式作一些假设;因此,你作为开发者,要做的事情就是确保自己的代码与这些假设相匹配,这样你的用户就不会惊吓连连。 这是个很棒的原则,因为开发者自己也要对一些东西作假设。如果你放出一个叫做 `calculateScore(GameState)` 的函数(字面意思为 “根据游戏状态计算分数”),那么大部人都会正确地预期,这个函数只会从 “游戏状态” 中读取数据。如果你这个函数同时也修改了游戏的状态,你就给了大部分用户一个惊吓,他们会很困惑,想搞清楚为什么游戏的状态会随机地变掉。即使你在解释文档中说了,也没法保证大家都看过文档,所以,最好从一开始就保证你的代码不会给人惊吓。 ![](https://img.learnblockchain.cn/2021/08/20/16294429433957.jpg) \- “调试代码 6 个钟,就能省下 5 分钟阅读文档的时间哟” - ## 更安全就更好,对吗? 回到 2018 年头,ERC-721 还在草案阶段,有一个建议是实现 “[转账安全性](https://github.com/ethereum/EIPs/commit/74dadccc858545aa89edaf6ec1cb5857cd261083)”,以确保 token 不会被困在(没有设计好处理这种 token)接收方合约里。为此,提案的作者修改了 `transfer` 函数的行动,检查类接收方是否有能力支持这种 token 的转账。这也引入了一个 `unsafeTransfer` 函数,它可以跳过这个检查,如果发送者自己要求的话。 但是,因为担心向后兼容的问题,这个函数在后续的修改中[重新命名](https://github.com/ethereum/EIPs/commit/2bddd126def7c046e1e62408dc2b51bdd9e57f0f)了。这使得 `transfer` 函数的表现在 ERC-20 和 ERC-721 上是完全相同的。但是,现在接收方的能力检查就要挪到别的地方去了。因此, `safe` 函数被发明出来:`safeTransfer` 和 `safeTransferFrom`。 这个问题是合理的,在此之前,已经有很多 ERC-20 代币被意外转入根本没有预期会接收代币的合约,导致这些 token 被锁死的案例(一个很常见的错误是把代币转给它所属的代币合约,导致这些代币完全锁死)。毫不意外,当 ERC-1155 标准在草案阶段时,他们吸收了 ERC-721 标准的启发,不仅在转账中引入了接收方检查,在铸造中也加入了检查。 这些标准在接下来几年中大部分都处于无人问津的状态,而 ERC-20 标准在隔壁独自精彩,直到最近,NFT 引发的 gas 价格暴涨表明,ERC-721 和 ERC-1155 标准迎来了开发者使用量上的暴涨。开发者的兴趣卷土重来,而这些标准在设计时都考虑到了安全性,这当然是一件幸事,对吧? ## 再问一次,更安全,一定更好吗? OK,你们考虑是考虑到了,但这些函数如何能让转账或者铸造变得安全呢?不同的团队对 “安全” 的理解各有不同。对于一个开发者来说,安全的函数意味着这个函数里面没有 bug、不会引入额外的安全担忧。对于用户来说,安全性可能意味着程序做了充分的措施,可以保护他们不会不小心搬起石头砸自己的脚。 事实证明,要按这样来区分的话,这些函数更多是后者(保护用户不受错误操作的困扰),而不是前者。因为,它给了开发者两个选择: `transfer` 和 `safeTransfer` ,为什么你不用 “安全” 的那个?名字里面都写好了嘛。 嗯,一个理由是我们的老朋友,可重入漏洞(我一直在尽最大努力希望它能重命名为 “不安全的外部调用”)。回想一下,任何外部调用都可能是不安全的,只要接受方账户是由攻击者控制的;因为攻击者也许可以让你的合约转变成一种没有得到定义的状态。从设计上来说,这些 “safe” 函数扮演着对代币接收方的一个外部调用,这个调用通常是由铸造代币或转移代币的发送者控制的。换句话说,这就是不安全调用的一个典型案例。 你可能会问,就是允许一个接收方拒绝一笔自己没法处理的转账而已,能有多大事呢?我们用两个简单的案例来回答这个问题。 ## Hashmasks Hashmasks 是一种供给量有限的 NFT。用户在单笔交易中最多可以购买 20 个 NFT,虽然这些 NFT 口罩在几个月前就卖光了。这里我们看看买口罩的函数: ``` 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` 调用里面藏着一些可怕的东西。 ``` 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"); } ``` 从安全性出发,这个函数对代币的接收方调用了一个 callback 函数,来确认接受方愿不愿意接收这个代币。但是,如果你是代币的接受方,那么收到 callback 调用的时候,要采取什么行动完全是随你的便,这其中就包括再次调用 `mintNFT`。若是这么做了,我们只需铸造一个 NFT 之后,就可以重入这个函数,意味着我们可以请求铸造另外 19 个口罩 NFT。结果是我可以铸造出 39 个口罩,即使本来单次可以铸造的最大数量是 20 个。 ## ENS 域名封装器 最近,ENS 团队的 Nick Johnson 联系了我们,希望我们看看他们正在开发的一个 ENS 域名封装器。这个封装器允许用户将手上的域名代币化为一个新的 ERC-1155 代币,以此支持更细粒度的权限和更一致的 API。 抽象地说,为了封装任意的 ENS 域名(准确来说是任意并非二级域名的 .eth 域名),你必须先允许域名封装器访问你的 ENS 域名。然后你调用 `wrap(bytes,address,uint96,address)` 函数,一边铸造一个 ERC-1155 代币,另一边托管了底层的 ENS 域名。 这里是封装函数,可以说非常直接。首先,调用 `_wrap` 函数做一些逻辑计算,返回哈希化的域名名称。然后保证交易的发送者就是这个 ENS 域名的拥有者,然后托管这个域名。注意,如果发送者并不拥有这个底层的 ENS 域名,那么整个交易应该回滚,取消 `_wrap` 函数造成的所有变更。 ``` 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` 函数,看起来没有任何特别的。 ``` 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 的基础上稍微修改而来),我们可以看到确实是这样的。 ``` 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, "" ); } ``` 但是这对我们来说到底有什么用呢?这又是一个不安全的外部调用,我们可以用来激发重入的漏洞。具体来说,请注意,在 callback 执行期间,我们还持有表示这个 ENS 域名的 ERC-1155 代币,但域名封装器还没验证完成我们是这个 ENS 域名的所有者。这时候我们可以直接操作这个 ENS 域名而无需是其所有者。举个例子,我们可以要求域名封装器解封这个域名,烧掉这个我们铸造出来的代币,然后获得它所代表的 ENS 域名。 ``` 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 域名了,可以为所欲为了,比如我可以注册一个子域名,或者重设解析器。完成之后再退出 call back 函数。域名封装器这时候会获取这个 ENS 域名的所有者,也就是我们,发现匹配之后验证完成,交易成功。就像这样,我们可以暂时获取向域名封装器授权的任何 ENS 域名的所有权并执行任意的修改。 ## 结论 给人惊吓的代码可能造成恶劣的后果,在两个案例中,开发者都理性地假设了 `safe` 函数族(至少)会跟普通的函数一样安全而不会增加攻击面。随着 ERC-721 和 ERC-1155 标准变得更加流行,这种攻击可能会越来越频繁。开发者需要考虑使用 `safe` 函数族的风险,并考虑外部调用会怎样跟自己写下的代码交互。 (完) --- **原文链接:** [https://samczsun.com/the-dangers-of-surprising-code/](https://samczsun.com/the-dangers-of-surprising-code/) **作者:** samczsun **翻译:** 阿剑 ---

如果你也是软件工程师,你应该听过一些软件工程的格言。虽然我并不主张严格遵守每一句格言,但有一些值得你放在心上。

今天我想讲的是 “ 最少惊吓原理 ”。名字挺别扭,但意思很简单:在面对一份声称可以做某事的代码时,大多数用户都会对它的工作方式作一些假设;因此,你作为开发者,要做的事情就是确保自己的代码与这些假设相匹配,这样你的用户就不会惊吓连连。

这是个很棒的原则,因为开发者自己也要对一些东西作假设。如果你放出一个叫做 calculateScore(GameState) 的函数(字面意思为 “根据游戏状态计算分数”),那么大部人都会正确地预期,这个函数只会从 “游戏状态” 中读取数据。如果你这个函数同时也修改了游戏的状态,你就给了大部分用户一个惊吓,他们会很困惑,想搞清楚为什么游戏的状态会随机地变掉。即使你在解释文档中说了,也没法保证大家都看过文档,所以,最好从一开始就保证你的代码不会给人惊吓。

- “调试代码 6 个钟,就能省下 5 分钟阅读文档的时间哟” -

更安全就更好,对吗?

回到 2018 年头,ERC-721 还在草案阶段,有一个建议是实现 “转账安全性”,以确保 token 不会被困在(没有设计好处理这种 token)接收方合约里。为此,提案的作者修改了 transfer 函数的行动,检查类接收方是否有能力支持这种 token 的转账。这也引入了一个 unsafeTransfer 函数,它可以跳过这个检查,如果发送者自己要求的话。

但是,因为担心向后兼容的问题,这个函数在后续的修改中重新命名了。这使得 transfer 函数的表现在 ERC-20 和 ERC-721 上是完全相同的。但是,现在接收方的能力检查就要挪到别的地方去了。因此, safe 函数被发明出来:safeTransfersafeTransferFrom

这个问题是合理的,在此之前,已经有很多 ERC-20 代币被意外转入根本没有预期会接收代币的合约,导致这些 token 被锁死的案例(一个很常见的错误是把代币转给它所属的代币合约,导致这些代币完全锁死)。毫不意外,当 ERC-1155 标准在草案阶段时,他们吸收了 ERC-721 标准的启发,不仅在转账中引入了接收方检查,在铸造中也加入了检查。

这些标准在接下来几年中大部分都处于无人问津的状态,而 ERC-20 标准在隔壁独自精彩,直到最近,NFT 引发的 gas 价格暴涨表明,ERC-721 和 ERC-1155 标准迎来了开发者使用量上的暴涨。开发者的兴趣卷土重来,而这些标准在设计时都考虑到了安全性,这当然是一件幸事,对吧?

再问一次,更安全,一定更好吗?

OK,你们考虑是考虑到了,但这些函数如何能让转账或者铸造变得安全呢?不同的团队对 “安全” 的理解各有不同。对于一个开发者来说,安全的函数意味着这个函数里面没有 bug、不会引入额外的安全担忧。对于用户来说,安全性可能意味着程序做了充分的措施,可以保护他们不会不小心搬起石头砸自己的脚。

事实证明,要按这样来区分的话,这些函数更多是后者(保护用户不受错误操作的困扰),而不是前者。因为,它给了开发者两个选择: transfersafeTransfer ,为什么你不用 “安全” 的那个?名字里面都写好了嘛。

嗯,一个理由是我们的老朋友,可重入漏洞(我一直在尽最大努力希望它能重命名为 “不安全的外部调用”)。回想一下,任何外部调用都可能是不安全的,只要接受方账户是由攻击者控制的;因为攻击者也许可以让你的合约转变成一种没有得到定义的状态。从设计上来说,这些 “safe” 函数扮演着对代币接收方的一个外部调用,这个调用通常是由铸造代币或转移代币的发送者控制的。换句话说,这就是不安全调用的一个典型案例。

你可能会问,就是允许一个接收方拒绝一笔自己没法处理的转账而已,能有多大事呢?我们用两个简单的案例来回答这个问题。

Hashmasks

Hashmasks 是一种供给量有限的 NFT。用户在单笔交易中最多可以购买 20 个 NFT,虽然这些 NFT 口罩在几个月前就卖光了。这里我们看看买口罩的函数:

function mintNFT(uint256 numberOfNfts) public payable {
    require(totalSupply() &lt; MAX_NFT_SUPPLY, "Sale has already ended");
    require(numberOfNfts > 0, "numberOfNfts cannot be 0");
    require(numberOfNfts &lt;= 20, "You may not buy more than 20 NFTs at once");
    require(totalSupply().add(numberOfNfts) &lt;= MAX_NFT_SUPPLY, "Exceeds MAX_NFT_SUPPLY");
    require(getNFTPrice().mul(numberOfNfts) == msg.value, "Ether value sent is not correct");

    for (uint i = 0; i &lt; numberOfNfts; i++) {
        uint mintIndex = totalSupply();
        if (block.timestamp &lt; 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 调用里面藏着一些可怕的东西。

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");
    }

从安全性出发,这个函数对代币的接收方调用了一个 callback 函数,来确认接受方愿不愿意接收这个代币。但是,如果你是代币的接受方,那么收到 callback 调用的时候,要采取什么行动完全是随你的便,这其中就包括再次调用 mintNFT。若是这么做了,我们只需铸造一个 NFT 之后,就可以重入这个函数,意味着我们可以请求铸造另外 19 个口罩 NFT。结果是我可以铸造出 39 个口罩,即使本来单次可以铸造的最大数量是 20 个。

ENS 域名封装器

最近,ENS 团队的 Nick Johnson 联系了我们,希望我们看看他们正在开发的一个 ENS 域名封装器。这个封装器允许用户将手上的域名代币化为一个新的 ERC-1155 代币,以此支持更细粒度的权限和更一致的 API。

抽象地说,为了封装任意的 ENS 域名(准确来说是任意并非二级域名的 .eth 域名),你必须先允许域名封装器访问你的 ENS 域名。然后你调用 wrap(bytes,address,uint96,address) 函数,一边铸造一个 ERC-1155 代币,另一边托管了底层的 ENS 域名。

这里是封装函数,可以说非常直接。首先,调用 _wrap 函数做一些逻辑计算,返回哈希化的域名名称。然后保证交易的发送者就是这个 ENS 域名的拥有者,然后托管这个域名。注意,如果发送者并不拥有这个底层的 ENS 域名,那么整个交易应该回滚,取消 _wrap 函数造成的所有变更。

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 函数,看起来没有任何特别的。

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 的基础上稍微修改而来),我们可以看到确实是这样的。

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,
            ""
        );
    }

但是这对我们来说到底有什么用呢?这又是一个不安全的外部调用,我们可以用来激发重入的漏洞。具体来说,请注意,在 callback 执行期间,我们还持有表示这个 ENS 域名的 ERC-1155 代币,但域名封装器还没验证完成我们是这个 ENS 域名的所有者。这时候我们可以直接操作这个 ENS 域名而无需是其所有者。举个例子,我们可以要求域名封装器解封这个域名,烧掉这个我们铸造出来的代币,然后获得它所代表的 ENS 域名。

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 域名了,可以为所欲为了,比如我可以注册一个子域名,或者重设解析器。完成之后再退出 call back 函数。域名封装器这时候会获取这个 ENS 域名的所有者,也就是我们,发现匹配之后验证完成,交易成功。就像这样,我们可以暂时获取向域名封装器授权的任何 ENS 域名的所有权并执行任意的修改。

结论

给人惊吓的代码可能造成恶劣的后果,在两个案例中,开发者都理性地假设了 safe 函数族(至少)会跟普通的函数一样安全而不会增加攻击面。随着 ERC-721 和 ERC-1155 标准变得更加流行,这种攻击可能会越来越频繁。开发者需要考虑使用 safe 函数族的风险,并考虑外部调用会怎样跟自己写下的代码交互。

(完)

原文链接: https://samczsun.com/the-dangers-of-surprising-code/ 作者: samczsun 翻译: 阿剑

本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

  • 发表于 2021-08-20 15:07
  • 阅读 ( 474 )
  • 学分 ( 10 )
  • 分类:以太坊

评论