本文探讨了智能合约中常见的重入漏洞,重点关注了外部调用可能导致的风险。通过分析Papr和Hypercerts两个项目的实际漏洞案例,强调了在进行外部调用前后,验证检查和状态更新的顺序至关重要,并提供了防范此类漏洞的建议。
真实世界的智能合约经常会对其他的智能合约进行外部调用。一个智能合约审计员或者攻击者必须总是检查这些外部调用,看看是否存在一个Hook,允许他们在交易过程中重新进入目标合约并利用它。
一个需要特别注意的危险信号是,在外部调用之后执行验证或检查的代码;攻击者是否可以钩住外部调用来劫持交易流程,并执行一次攻击,使得在攻击结束后通过那些后续的检查,而如果执行流程正常进行就不会通过这些检查呢?
审计人员和攻击者应该特别注意对 ERC 标准进行的外部调用,例如:ERC1155._mintBatch() 或 ERC721.safeTransferFrom(),合约开发者可能没有意识到这些调用可以通过攻击合约的回调机制被钩住。
这个漏洞来自Papr Code4rena contest。PaprController.sol 允许用户存入 NFT 作为抵押品并借入 Papr 代币。用户可以添加和移除抵押品,如果他们的抵押品价值低于相对于他们借款的给定阈值,其他用户可以触发对其抵押品的清算拍卖。
以下函数调用 ERC721.SafeTransferFrom() 将 NFT 返回给其所有者,然后检查这是否会由于用户抵押品减少而超过允许的最大债务。攻击者可以通过使用攻击合约来控制执行流程,因为如果 sendTo 是一个合约,ERC721.SafeTransferFrom() 会调用 sendTo.onERC721Received()。
这个Hook在执行最大债务检查之前将执行流程返回给攻击者,允许攻击者重新进入合约并执行其他操作,完成这些操作后可以满足最大债务检查,并使攻击者获得丰厚的利润。
function _removeCollateral(address sendTo, IPaprController.Collateral calldata collateral,
uint256 oraclePrice,uint256 cachedTarget) internal {
if (collateralOwner[collateral.addr][collateral.id] != msg.sender) {
revert IPaprController.OnlyCollateralOwner();
}
delete collateralOwner[collateral.addr][collateral.id];
uint16 newCount;
unchecked {
newCount = _vaultInfo[msg.sender][collateral.addr].count - 1;
_vaultInfo[msg.sender][collateral.addr].count = newCount;
}
//
// @audit CRITICAL Re-Entrancy attack due to not following Check-Effects-Interaction pattern
// 由于没有遵循检查-效果-交互模式而导致的严重重入攻击
// ERC721.safeTransferFrom() calls sendTo.onERC721Received() if sendTo is a contract.
// 如果 sendTo 是一个合约,ERC721.safeTransferFrom() 会调用 sendTo.onERC721Received()。
//
// 1) attacker deposits multiple nfts as collateral into one vault & borrows max amount against them
// 1) 攻击者将多个 NFT 作为抵押品存入一个 vault,并借入针对它们的最大金额
//
// 2) attacker calls removeCollateral() for first nft, then in AttackContract.onERC721Received()
// calls removeCollateral() for second nft & so on until only 1 nft left in vault as collateral
// 2) 攻击者对第一个 NFT 调用 removeCollateral(),然后在 AttackContract.onERC721Received() 中
// 对第二个 NFT 调用 removeCollateral(),依此类推,直到 vault 中只剩下一个 NFT 作为抵押品
//
// 3) for last nft AttackContract.onERC721Received() calls startLiquidationAuction()
// then purchaseLiquidationAuctionNFT() to buy their own nft via liquidation auction.
// As this was the vault's last collateral nft, the vault debt will be set to 0 which passes the
// subsequent checks that would have never passed.
// 3) 对于最后一个 NFT,AttackContract.onERC721Received() 调用 startLiquidationAuction()
// 然后 purchaseLiquidationAuctionNFT() 通过清算拍卖购买他们自己的 NFT。
// 由于这是 vault 的最后一个抵押品 NFT,vault 的债务将被设置为 0,从而通过了
// 后续的检查,而这些检查永远不会通过。
//
collateral.addr.safeTransferFrom(address(this), sendTo, collateral.id);
uint256 debt = _vaultInfo[msg.sender][collateral.addr].debt;
uint256 max = _maxDebt(oraclePrice * newCount, cachedTarget);
if (debt > max) {
revert IPaprController.ExceedsMaxDebt(debt, max);
}
emit RemoveCollateral(msg.sender, collateral.addr, collateral.id);
}
验证检查应该总是在调用可能被攻击者钩住以劫持执行流程并重新进入合约的外部函数之前执行。
另一个需要注意的危险信号是,在调用外部函数之后发生的存储写入 - 你可以通过钩住外部调用并重新进入合约来劫持执行流程,利用存储写入尚未发生这一事实吗?
下一个漏洞来自Pashov 对 Hypercerts 的审计。HypercertMinter.splitValue() 允许将 claim token 分割成多个部分。这个函数调用 SemiFungible1155._splitValue(),后者在将减少的 valueLeft 写入存储之前调用 ERC1155._mintBatch()。
攻击合约可以钩住执行流程,因为如果 _account 是一个合约,ERC1155._mintBatch() 会调用 _account.onERC1155BatchReceived();攻击者然后可以调用 HypercertMinter.splitValue() 来多次分割相同的 tokenId,从而使用相同的 tokenId 创建大量的 fractions。
/// @dev Split the units of `_tokenID` owned by `account` across `_values`
/// @dev `_values` must sum to total `units` held at `_tokenID`
/// @dev 将 `account` 拥有的 `_tokenID` 的 units 分割到 `_values` 中
/// @dev `_values` 必须加总到 `_tokenID` 处持有的总 `units`
function _splitValue(address _account, uint256 _tokenID, uint256[] calldata _values) internal {
// ... //
uint256 valueLeft = tokenValues[_tokenID];
// ... //
for (uint256 i; i < len; ) {
valueLeft -= values[i];
tokenValues[toIDs[i]] = values[i];
unchecked {
++i;
}
}
//
// @audit CRITICAL Re-entrancy attack due to not following the Check-Effects-Interaction pattern
// @audit CRITICAL 由于没有遵循检查-效果-交互模式而导致的严重重入攻击
//
// ERC1155._mintBatch() will call _account.onERC1155BatchReceived() if
// _account is contract. AttackContract.onERC1155BatchReceived() can hijack execution
// flow by re-entering _splitValue() via HypercertMinter.splitValue() many times to mint a huge amount
// of fractions for the same _tokenID as the decreased valueLeft has not been written to storage before
// calling ERC1155._mintBatch()
// 如果 _account 是一个合约,ERC1155._mintBatch() 将调用 _account.onERC1155BatchReceived()。
// AttackContract.onERC1155BatchReceived() 可以通过经由 HypercertMinter.splitValue() 多次重新进入 _splitValue() 来劫持执行流程,
// 从而为同一个 _tokenID 创建大量的 fractions,因为在调用 ERC1155._mintBatch() 之前,减少的 valueLeft 尚未写入存储
//
_mintBatch(_account, toIDs, amounts, "");
tokenValues[_tokenID] = valueLeft;
emit BatchValueTransfer(typeIDs, fromIDs, toIDs, values);
}
为了防止这种情况,valueLeft 应该在调用 ERC1155._mintBatch() 之前写入存储。更多例子:[ 1, 2, 3]
13
- 原文链接: dacian.me/re-entrancy-at...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!