令人惊讶代码的危险性
在软件开发中,比一个导致一切崩溃的Bug更糟糕的,是那种只悄无声息破坏某一功能的Bug。
如果你从事软件工程,很可能至少听说过一条软件工程原则。虽然我不主张教条式地遵循每一条原则,但确实有几条值得认真关注。今天我要谈的是“最少惊讶原则”。它名字听起来很高深,但理念非常简单:当用户面对一段声称要完成某功能的代码时,大多数人会对其行为方式做出假设。因此,开发者的工作就是编写符合这些假设的代码,以免给用户带来“惊喜”。
这是一个很好的原则,因为开发者喜欢对事物做假设。如果你导出一个名为calculateScore(GameState)的函数,很多人会理所当然地假设这个函数只会读取游戏状态。如果你还修改了游戏状态,就会让许多困惑的人感到惊讶,他们试图弄明白为什么自己的游戏状态会随机损坏。即使你在文档中注明了这一点,也不能保证人们会看到,所以最好从一开始就确保你的代码不会令人惊讶。
更安全就更好,对吗?
2018年初起草ERC-721标准时,有人建议实施转账安全检查,以确保代币不会被发送到未设计处理它们的接收合约中。为此,提案作者修改了转账函数的行为,以检查接收方是否能够支持代币转移。他们还引入了unsafeTransfer函数,如果发送方愿意,可以绕过此检查。
然而,由于向后兼容性的考虑,这些函数在后续提交中被重命名。这使得transfer函数在ERC-20和ERC-721代币中的行为保持一致。但是,接收方检查需要移到其他地方。因此,引入了“安全”类函数:safeTransfer和safeTransferFrom。
这是一个解决实际问题的方案,因为已经有许多ERC-20代币被意外转移到从未预期接收代币的合约中的例子(一个特别常见的错误是将代币转移到代币合约本身,从而永久锁定它)。因此,在起草ERC-1155标准时,它借鉴了ERC-721标准的思路,不仅在转账时,而且在铸造时也包含接收方检查,这并不奇怪。
在接下来的几年里,这些标准大多处于休眠状态,而ERC-20则保持其流行度。但最近,由于Gas费用飙升以及对NFT的兴趣增加,ERC-721和ERC-1155标准的开发者使用量激增。考虑到所有这些新的关注,这些标准在设计时考虑到安全性,这当然是幸运的,对吧?
更安全就更好,对吗?
好吧,但“安全”的转账或铸造到底意味着什么?不同的参与方对安全有不同的解释。对开发者来说,安全的函数可能意味着它不包含任何错误或引入额外的安全问题。对用户来说,这可能意味着它包含了额外的防护措施,以保护他们不会意外地伤害自己。
事实证明,在这种情况下,这些函数更像是后者,而非前者。这尤其不幸,因为在transfer和safeTransfer之间选择时,你为什么不选择“安全”的那个呢?它就在名字里啊!
好吧,一个原因可能是我们的老朋友——重入攻击,或者我一直试图重新命名的:“不安全的外部调用”。请记住,如果接收方由攻击者控制,任何外部调用都可能是不安全的,因为攻击者可能能够导致你的合约进入未定义状态。设计上,这些“安全”函数会对代币接收方执行外部调用,而在铸造或转账期间,接收方通常由发送方控制。换句话说,这实际上是不安全外部调用的教科书式例子。
但是,你可能会问自己,允许接收方合约拒绝它们无法处理的转账,最坏的情况能发生什么?好吧,让我用两个快速案例研究来回答这个问题。
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标准变得越来越流行和普及,这很可能会成为一个日益频繁的现象。开发者需要考虑使用“安全”类函数的风险,并确定外部调用可能如何与他们编写的代码交互。