什么时候会发生重入攻击
重入攻击只能在你的智能合约通过函数调用或发送以太币调用另一智能合约时发生。
如果你在执行过程中不调用另一个合约或发送以太币,则无法交出执行控制,重入攻击也无法发生。
function proxyVote(uint256 voteChoice) external {
voteContract.vote(voteChoice); // 将控制权交给 voteContract
alreadyVoted = true;
}
棘手的部分在于,你可能并不总是知道何时调用了另一个合约。例如,如果这段代码在一个 ERC1155 合约内部使用,实际上是具有重入性的。
function purchaseERC1155NFT() external {
_mint(msg.sender, TOKEN_ID, 1, "");
erc20Token.transferFrom(msg.sender, address(this));
}
为什么这看似无害的铸造操作不安全?让我们看看 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;
// 溢出不可能:所有余额的总和受限于 totalSupply,且通过
// 先递减再递增保持和。
_balances[to] += amount;
}
emit Transfer(from, to, amount);
_afterTokenTransfer(from, to, amount);
}
这是ERC20 转移实现
safeTransferFrom
_safeMint
令人困惑的是,“安全”一词意味着它在检查接收地址是否是一个 智能合约,然后尝试调用 onERC721Received
函数。transferFrom
和 _mint
函数不这样做,因此你不用担心重入攻击。
这并不意味着你不应该使用 safeTransferFrom
或 _safeMint
方法,而是意味着如果使用它,你应该遵循检查-影响模式或使用重入保护以防止重入攻击。
下面是一个简单的铸造函数的示例,攻击者可以为自己铸造所有 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;
}
}
safeTransferFrom
_mint
safeBatchTransferFrom
_mintBatch
更加令人困惑的是,ERC1155 中的 _mint
并不像 ERC721 中的 _mint
那样工作。它的行为类似于 ERC721 中的 _safeMint
。
在 ERC1155 中没有任何东西是“安全”的。每个方法都会调用接收合约。这样的设计选择没有错,只是意味着你必须遵循检查-影响模式或使用重入保护——这本来就是应该做的。
下面是 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;
}
}
我们不能在此涵盖每个提议的 ERC20 变体。ERC20 的 transfer
和 transferFrom
不会导致重入攻击是很好的,但这也会造成用户体验问题,因为智能合约无法知道它已经接收到 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 对此提供了更好的用户体验。常规的转账函数表现得像一个普通的 ERC20,因此我们不会遇到阴险的重入攻击问题。然而,如果我们希望通知合约它接收了一些代币,我们会使用 transferAndCall
方法。
ERC777 的重入攻击在现实世界中发生过,且后果相当严重。这里 有一个示例。
在设计与任意 ERC20 代币交互的应用程序时,不要假设 transfer
和 transferFrom
是非重入的。
当通过 address.call(””)
发送以太币时,你将控制权交给了其他合约。
考虑以下经典示例
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(); // 重入攻击发生在这里
}
}
}
因为在发送余额后 balances[msg.sender]
被设置为零,所以攻击者可以不断提取 1 以太币(从其他用户那里窃取),直到余额低于 1 以太币。
顺便提一下,transfer()
和 send()
这两个方法是非重入的,尽管它们可以触发 fallback 和 receive 函数。这是因为它们将转发的 gas 限制在 2300 gas。对于恶意合约来说,这不足以重新进入受害合约。
然而,通常认为使用这些方法是一种不良实践。假设你有一个智能合约试图在另一个智能合约中偿还贷款。如果你通过 transfer 或 send 偿还贷款,借款合约将没有足够的 gas 记录贷款已经偿还。
2016 年的 DAO 黑客攻击对以太坊生态系统几乎是致命的,因此设计者引入了这些函数以防止这种情况的发生。
Transfer 和 send 在使用时仅转发 2300 gas。以太坊不允许在 gas 小于 2300 的情况下进行变量存储( 源 ),因此这意味着攻击合约无法造成永久状态的改变。
transfer 和 send 的问题在于,许多合约可能希望在接收以太币时做出反应。例如,假设你有一个去中心化的借贷方,并希望通过发送以太币来偿还借贷方。借贷合约会检测到以太币来自借款人,并标记其贷款为已偿还。然而,如果你不给它足够的 gas,就无法这样做。你可以在这里了解更多关于为什么不应该使用这些函数的信息。
看起来 Solidity 有一些不应该使用的功能可能会让人感到奇怪,但这正是我们对区块链最佳实践不断演变的理解。当时限制造成重入的 gas 似乎是个好主意,但事实证明我们无法预测未来的 gas 成本。硬编码 gas 被认为是不良实践,因为操作码的 gas 值可能会发生变化。
当受害合约在错误的时间向外部合约发出函数调用时,攻击合约不一定要重新进入调用它的同一个函数。事实上,如果两个函数是重入的,攻击者可以在函数之间进行“跳板”(也称为互相递归)。一些工程师将其称为跨函数重入攻击。下面是一个易受攻击的合约示例。
contract CrossFunctionReentrancyVulnerable {
// 不允许人们每 24 小时交换一次
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
映射更新之前获得了控制权。
只读重入在 2022 年受到开发者的广泛关注,当时在 ETH Devcon 的一次演讲中解释了一个在 Curve finance 中的漏洞。
只读重入只是对已经认识的漏洞——跨合约重入的重新命名。
如果合约 Foo 依赖于另一个合约 Bar 的状态,而 Bar 在事务中没有产生正确的状态值,那么 Foo 就可能被欺骗。
在 Curve finance 的案例中,受到攻击的不是 Curve,而是依赖于它的合约。其工作过程大致如下:
只读重入攻击与闪电贷攻击非常相似,通常需要闪电贷才能有效。
防御只读重入或跨合约重入有两种方法。一种是将重入锁设置为公共,或者使视图函数也不可重入。报告价格的视图函数在用户提取部分流动性时处于不正确的状态。因此,交易所可以在流动性被提取时阻止人们使用视图函数。如果重入锁是公共的,那么依赖于视图函数的应用可以通过检查重入锁来判断流动性提取是否正在进行。如果以太币已经发送,但 ERC20 代币尚未提取,那么重入锁将处于开启状态,因为提取流动性函数尚未完成。
请注意,这个漏洞需要发送一系列资产以触发其他函数。在上述 Curve 的案例中,他们在发送 ERC20 代币之前发送了以太币。然而,如果发送了 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 或更高版本上不起作用,因为余额下溢将导致交易回滚)
想了解更多吗?查看我们的 Solidity Bootcamp!
最初发布于 2022 年 12 月 16 日
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!