只有当你的智能合约通过函数调用或发送以太币(ether)来调用另一个智能合约时,才会发生重入(Reentrancy)。
如果你在执行过程中没有调用其他合约或发送以太币,你就不会交出执行控制权,重入也就无法发生。
function proxyVote(uint256 voteChoice) external {
voteContract.vote(voteChoice); // hands control to voteContract
alreadyVoted = true;
}
棘手的部分在于,你可能并不总是知道自己何时在调用另一个合约。例如,如果以下代码用在 ERC1155 合约中,它实际上是存在重入漏洞的。
function purchaseERC1155NFT() external {
_mint(msg.sender, TOKEN_ID, 1, "");
erc20Token.transferFrom(msg.sender, address(this));
}
为什么这个看起来无害的 mint 函数不安全呢?让我们看看 OpenZeppelin ERC1155 中的代码 这里。
function _mint(
address to,
uint256 id,
uint256 amount,
bytes memory data
) internal virtual {
require(to != address(0), "ERC1155: mint to the zero address");
address operator = _msgSender();
uint256[] memory ids = _asSingletonArray(id);
uint256[] memory amounts = _asSingletonArray(amount);
_beforeTokenTransfer(operator, address(0), to, ids, amounts, data);
_balances[id][to] += amount;
emit TransferSingle(operator, address(0), to, id, amount);
_afterTokenTransfer(operator, address(0), to, ids, amounts, data);
_doSafeTransferAcceptanceCheck(operator, address(0), to, id, amount, data);
}
Solidity 代码 ERC1155
_mint 调用了 _doSafeTransferAcceptanceCheck。让我们接着看 那个函数。
function _doSafeTransferAcceptanceCheck(
address operator,
address from,
address to,
uint256 id,
uint256 amount,
bytes memory data
) private {
if (to.isContract()) {
try IERC1155Receiver(to).onERC1155Received(operator, from, id, amount, data) returns (bytes4 response) {
if (response != IERC1155Receiver.onERC1155Received.selector) {
revert("ERC1155: ERC1155Receiver rejected tokens");
}
} catch Error(string memory reason) {
revert(reason);
} catch {
revert("ERC1155: transfer to non-ERC1155Receiver implementer");
}
}
}
Solidity 代码 IERC1155Receiver
在这里我们可以看到,_mint 最终会尝试在接收方上调用 onERC1155Received 函数。现在我们已经将控制权交给了另一个合约。
工具 slither 会自动检测外部函数调用,因此你应该使用它。
希望这不会让问题变得更加令人困惑,但一段看起来非常相似的代码:
function purchaseERC1155NFT() external {
_mint(msg.sender, AMOUNT);
erc20Token.transferFrom(msg.sender, address(this));
}
如果是派生自 ERC20,它是不可重入的。这是因为在底层,Solidity 中的 transferFrom 函数并不会对外部函数进行调用,正如你在其 实现 中所看到的。
function _transfer(
address from,
address to,
uint256 amount
) internal virtual {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(from, to, amount);
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
unchecked {
_balances[from] = fromBalance - amount;
// Overflow not possible: the sum of all balances is capped by totalSupply, and the sum is preserved by
// decrementing then incrementing.
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
ERC20 transfer 实现
ERC721
safeTransferFrom_safeMint
令人困惑的是,“safe” 这个词意味着它在检查接收地址是否为 智能合约(smart contract),然后尝试调用 “onERC721Received” 函数。transferFrom 和 _mint 函数不会这样做,所以你不需要担心重入问题。
这并不意味着你不应该使用 safeTransferFrom 或 _safeMint 方法,而是说如果你使用了它们,就应该使用检查-生效-交互模式(check-effects pattern)或重入锁(reentrancy guards)来防止重入。
下面是一个简单的 mint 函数示例,攻击者可以通过该函数为自己铸造所有的 NFT:
contract FooToken is ERC721 {
function mint() external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupply++;
_safeMint(msg.sender, totalSupply);
alreadyMinted[msg.sender] = true;
}
}
ERC1155
safeTransferFrom_mintsafeBatchTransferFrom_mintBatch
更令人费解的是,ERC1155 中的 _mint 行为并不像 ERC721 中的 _mint。它的行为类似 ERC721 中的 _safeMint。
在 ERC1155 中没有什么方法是“安全(safe)”的。每个方法都会调用接收方合约。这个设计选择本身没有错,它只是意味着你必须遵循检查-生效-交互模式或使用重入锁——就像你本来就应该做的那样。
以下是存在漏洞的 ERC1155 代码:
contract FooToken is ERC1155 {
function mint(uint256 tokenId) external payable {
require(msg.value == 0.1 ether);
require(!alreadyMinted[msg.sender]);
totalSupplyForTokenId[tokenId]++;
_mint(msg.sender, totalSupplyForTokenId[tokenId], 1, "");
alreadyMinted[msg.sender] = true;
}
}
ERC 223, 677, 777, 和 1363
我们无法在这里涵盖所有提议的 ERC20 变体。ERC20 的 transfer 和 transferFrom 不会导致重入问题确实很好,但这也会带来 UX(用户体验)问题,因为智能合约无法知道自己是否接收到了 ERC20 代币。上述列表是一些提议的 ERC20 变体,旨在通知接收方智能合约它们已收到代币。
这同时也是在与不受信任的 ERC20 代币交互时的一个警告。它们底层实际上可能是这些标准之一,并且有能力触发重入攻击。
这是 ERC777 在转移代币后调用合约的代码行:https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.8/contracts/token/ERC777/ERC777.sol#L499
function _callTokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
) private {
address implementer = _ERC1820_REGISTRY.getInterfaceImplementer(to, _TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
require(to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}
Solidity ERC777 触发重入的代码行
ERC 1363 在这方面拥有更好的 UX。普通的 transfer 函数表现得像正常的 ERC20,所以我们不会遇到任何隐蔽的重入问题。然而,如果我们想通知合约它收到了代币,我们会使用 transferAndCall 方法。
现实世界中已经发生过 ERC777 的重入攻击,并且可能是灾难性的。这里 是一个例子。
在设计与任意 ERC20 代币交互的应用时,不要假设 transfer 和 transferFrom 是不可重入的。
发送 Ether
当你通过 address.call(””) 发送 ether 时,你将控制权交给了另一个合约。
思考以下经典示例:
contract FaultyBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
msg.sender.call{value: balances[msg.sender]}("");
balances[msg.sender] = 0;
}
}
它可以被这种方式攻击:
contract RobTheBank {
IFaultyBank private bank;
constructor(IFaultyBank _bank) {
bank = _bank;
}
function attack() payable {
bank.deposit{value: 1 ether}()
bank.withdraw();
}
fallback() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(); // reenterancy attack here
}
}
}
由于 balances[msg.sender] 在发送余额之后才被设为零,攻击者可以不断提取 1 ether(从其他用户那里窃取),直到余额低于 1 ether。
transfer 和 send 是如何防止重入的,以及为什么你不应该使用它们
顺便说一句,transfer() 和 send() 方法是不可重入的,尽管它们可以触发 fallback 和 receive 函数。这是因为它们将转发的 gas 限制在 2300 gas 以内。这不足以让恶意合约重新进入受害者合约。
然而,使用这些方法通常被视为不好的实践(bad practice)。假设你有一个智能合约试图偿还另一个智能合约中的贷款。如果你使用 transfer 或 send 来偿还贷款,贷款合约将没有足够的 gas 来记录贷款已被还清。
2016 年的 The DAO 黑客事件对 Ethereum 生态系统来说几乎是致命的,因此设计者引入了这些函数来防止类似事件的发生。
Transfer 和 send 在被使用时仅转发 2300 gas。Ethereum 不允许在可用 gas 低于 2300 时进行变量存储(来源),这意味着攻击合约无法造成永久性的状态变更。
transfer 和 send 的问题在于,许多合约可能故意想对接收到的 ether 做出反应。例如,假设你有一个去中心化贷款人,你想要通过发送 Ether 来偿还该贷款人。贷款人合约看到 ether 来自借款人,并将其贷款标记为已偿还。但是,如果你让它缺乏 gas,它就无法做到这一点。你可以点击 这里 阅读更多关于为什么你不应该使用这些函数的内容。
Solidity 拥有不该被使用的功能似乎很奇怪,但这是我们对区块链最佳实践不断发展的理解的一部分。当时通过限制 gas 来防止重入似乎是个好主意,但事实证明我们无法预测未来的 gas 成本会是多少。硬编码 gas 被视为不好的实践,因为操作码(opcodes)的 gas 消耗值是会发生变化的。
跨函数重入。重入不必进入同一个函数
当受害者合约在错误的时间向外部合约发起函数调用时,攻击合约不一定非要重新进入调用它的同一个函数。事实上,如果两个函数都存在重入漏洞,攻击者可以在这些函数之间“蹦床(trampoline)”(也称为相互递归)。一些工程师将此称为跨函数重入(cross-function reentrancy)。下面是一个容易受到此类攻击的合约示例。
contract CrossFunctionReentrancyVulnerable {
// don't allow people to swap more than once every 24 hours
mapping(address => uint256) public lastSwap;
function swapAForB() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenAerc777.transferFrom(msg.sender, address(this));
tokenBerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
function swapBForA() {
require(block.timestamp - lastSwap[msg.sender] >= 1 days);
governanceTokenERC20.mint(msg.sender, AMOUNT);
tokenBerc777.transferFrom(msg.sender, address(this));
tokenAerc777.transferFrom(address(this), msg.sender);
lastSwap[msg.sender] = block.timestamp;
}
}
在上述代码中,用户可以将代币 A 交换为 B(反之亦然)并获得治理代币的奖励。然而,合约(试图)限制他们每 24 小时只能交换一次,这样治理代币就不会被过快地铸造完。
如前所述,ERC777 代币可能是可重入的,但在单个函数上执行简单的重入是不起作用的,因为攻击者会耗尽 tokenA 或 tokenB。
然而,如果攻击者重复将 A 换成 B,那么他们就可以为自己铸造出所有的治理代币。
在这个案例中,我们将治理代币设置为 ERC20 代币,因此攻击者无法重入到同一个函数中。然而,当 transferFrom(address(this), msg.sender) 执行时,攻击者在 lastSwap 映射更新之前获取了控制权。
只读重入(Read only Reentrancy),也称为跨合约重入(cross contract reentrancy)
只读重入在 2022 年进入了广大开发者的视野,当时 ETH Devcon 的一次 演讲 解释了 Curve finance 中的一个漏洞。
只读重入只是对一个已知漏洞——跨合约重入(cross contract reentrancy)——的重新包装。
如果合约 Foo 依赖于另一个合约 Bar 的状态,并且 Bar 在交易中途没有产生正确的状态值,那么 Foo 就可能被欺骗。
在 Curve finance 的案例中,被利用的并不是 Curve,而是依赖它的合约。其大致运作原理如下:
- 攻击者将 ether 和其他 ERC20 代币存入 Curve。Curve 为攻击者铸造流动性代币。
- 攻击者通过销毁流动性代币来提取流动性。
- Curve 会在发送回 ERC20 代币之前发送回 ether。
- 当 Curve 发送回 Ether 时,攻击者重新获得控制权,并在另一个合约上进行交易。
- 依赖 Curve 的合约向 Curve 请求查询流动性代币、ether 和其他 ERC20 代币之间的价格比例。因为流动性代币已经被销毁,且 Ethereum(以太币)已退还给攻击者,但 ERC20 代币仍然留在 Curve 中,所以在这个特定的时间状态下,价格的计算是不准确的。
- 交易完成,Curve 发送回 ERC20 代币,此时计算出的价格恢复正确。
只读重入与闪电贷攻击(flash loan attack)非常相似,通常需要借助闪电贷才能发挥作用。
防御只读重入或跨合约重入有两种方法。一种是将重入锁公开,或者使 view 函数也不可重入。在用户提取部分流动性的瞬间,报告价格的 view 函数处于不正确的状态。因此,交易所可以在提取流动性时阻止人们使用 view 函数。如果重入锁是公开的,那么依赖该 view 函数的应用就可以通过检查重入锁来判断流动性提取是否正在进行。如果 ether 已被送出,但 ERC20 代币尚未被提取,那么此时重入锁将会开启,因为提取流动性的函数还没有执行完。
请注意,这个漏洞需要发送一系列能够触发其他函数的资产。在上面概述的 Curve 案例中,他们在发送 ERC20 代币之前先发送了 Ether。然而,如果发送的是 ERC777 代币,类似的事情也可能发生。
更多资源
关于现实中重入攻击的最新列表:
https://github.com/pcaversaccio/reentrancy-attacks
2022 年之前关于跨合约重入(只读重入)的文档:
https://inspexco.medium.com/cross-contract-reentrancy-attack-402d27a02a15
实践练习:
ERC 223 重入:
https://capturetheether.com/challenges/miscellaneous/token-bank/
Ethernaut:
https://ethernaut.openzeppelin.com/level/10
(请注意,此重入攻击在 Solidity 0.8.0 或更高版本上不起作用,因为余额下溢会导致交易被回滚(revert))
有兴趣了解更多吗?查看我们的 Solidity Bootcamp!
最初发布于 2022年12月16日