本文探讨了智能合约中常见的输入处理和状态转换漏洞,这些漏洞通常由于开发者对输入类型和状态转换的潜意识假设而未被充分处理。文章列举了未经检查的两步所有权转移、意外匹配输入、意外空输入以及未经检查的返回值等多种攻击场景,并提供了相应的防范措施和实例。
智能合约中的输入处理和状态转换经常被忽视;开发者会下意识地对他们的函数将接收的输入类型做出假设,因此不会严格处理那些不符合这些假设的输入。
意外的输入和未检查的状态转换可能导致存储损坏和不变量失效,从而导致灾难性的后果。智能合约审计员应该有意识地问自己:开发者做出了哪些下意识的假设,导致了未处理的输入漏洞? 让我们看一些常见的“陷阱”,这些陷阱通过错误的开发者假设来利用智能合约。
这个漏洞来自 GeorgeHNTR 对 Metalabel 的审计。 在这里,两步所有权转移过程的第二步从未检查第一步是否已启动。 如果一个节点已经有一个所有者,并且还没有启动所有权转移,攻击者可以直接调用第二步,通过调用 NodeRegistry.completeNodeOwnerTransfer() 将所有者归零,从而有效地破坏该节点的所有权:
/// @notice 完成两步节点转移过程。 只能由
/// 由新所有者调用
// @audit 从未检查转移节点所有权的第一步是否实际启动。
// 攻击者可以将节点所有权设置为 0
function completeNodeOwnerTransfer(uint64 id) external {
// @audit 如果第一步从未启动,则 newOwner = 0
uint64 newOwner = pendingNodeOwnerTransfers[id];
// @audit 攻击者可以从AccountRegistry中未注册的地址发起攻击
// 因此 accountId = 0
uint64 accountId = accounts.resolveId(msg.sender);
// @audit 攻击者可以使此检查通过,因为 0 == 0
if (newOwner != accountId) revert NotAuthorizedForNode();
// @audit 攻击者现在已将节点所有权设置为 0
nodes[id].owner = newOwner;
delete pendingNodeOwnerTransfers[id];
emit NodeOwnerSet(id, newOwner);
}
为了防止这种攻击,所有权转移过程的第二步必须检查第一步是否已经启动。 智能合约开发者必须仔细考虑状态转换,并在他们的合约中实施检查,以确保只有有效的状态转换才有可能发生。 更多示例:[ 1]
对于下一个例子,我们将使用在 Cyfrin 的 Beanstalk Wells 审计 期间发现的意外匹配输入漏洞的简化版本。 考虑一个 vault 合约,它维护一个存储列表“_tokens”,这些 token 已经被存入 vault,并且允许通过一个函数 swap() 在它们之间进行交换。 假设 swap() 使用一个内部函数 _getTokenIndexes() 从其已存储的 token 映射中获取 token 的索引:
function _getTokenIndexes(IERC20 t1, IERC20 t2) internal pure
returns (uint i, uint j) {
for (uint k; k < _tokens.length; ++k) {
if (t1 == _tokens[k]) i = k;
// @audit 如果 t1==t2 则永远不会执行, 返回 (i, 0)
else if (t2 == _tokens[k]) j = k;
}
}
开发者下意识的假设是,用户将在不同的 token 之间进行交换,因此 t1 != t2。 通过使用 t1 == t2 调用这个函数,它将返回 (i, 0),因为 "else if" 语句将永远不会执行。 在这种情况下,Cyfrin 能够利用这个漏洞来使合约的不变量失效并耗尽资金。 开发者可以通过以下方式来防止这种情况:
考虑一下来自 Akshay Srivastav 的 审计 的简化代码:
function verifyAndSend(SigData[] calldata signatures) external {
for (uint i; i<signatures.length; i++) {
// 验证每个签名,如果一个签名验证失败则 revert
}
// @audit 攻击者可以传递一个空的签名数组,使得循环
// 永远不会执行,从而允许在不验证签名的情况下发送资金
(bool sent, bytes memory data) = payable(msg.sender).call{value: 1 ether}("");
require(sent, "Failed to send Ether");
}
开发者下意识地假设 verifyAndSend() 将被传递一个签名数组,但攻击者可以简单地传递一个空数组来绕过循环和签名验证。 开发者应该小心地首先验证接收到的输入; 在数组的情况下,在尝试循环通过它之前,验证 array.length > 0。 审计员应该检查,如果他们将空值传递给函数,控制流是如何运作的 —— 函数是否继续执行,并且这是否可以被利用成一个漏洞?
这个漏洞的另一个例子来自 Antonio Viggiano 对 Tempus Finance Raft 协议的审计。 在这里,攻击者可以 传递不同的或零值 作为抵押品,来清算一个不应该被清算的借款人:
function liquidate(IERC20 collateralToken, address position) external override {
// @audit collateralToken 从未被验证,可能对应一个空的 address(0) 对象
// 或者一个与 position 的抵押品无关的不同地址
(uint256 price,) = priceFeeds[collateralToken].fetchPrice();
// @audit 使用空的/不存在的抵押品,抵押品的价值将为 0
// 使用另一个地址,价值将是该价值,而不是
// 借款人实际抵押品的价值。 这允许借款人被清算
// 在他们违约之前,因为借款人实际抵押品的价值
// 从未被计算出来。
uint256 entirePositionCollateral = raftCollateralTokens[collateralToken].token.balanceOf(position);
uint256 entirePositionDebt = raftDebtToken.balanceOf(position);
uint256 icr = MathUtils._computeCR(entirePositionCollateral, entirePositionDebt, price);
这也是贷款/借款漏洞类别 违约前清算 的一个例子。 更多示例:[ 1, 2, 3]
考虑一下来自 Sherlock 的 TellerV2 竞赛(贷款/借款)的简化示例:
// AddressSet 来自 https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
// 一笔贷款必须至少有一个抵押品
// 并且每个 token 只允许一个金额
struct CollateralInfo {
EnumerableSetUpgradeable.AddressSet collateralAddresses;
// token => 金额
mapping(address => uint) collateralInfo;
}
// loanId -> 验证的抵押品信息
mapping(uint => CollateralInfo) internal _loanCollaterals;
function commitCollateral(uint loanId, address token, uint amount) external {
CollateralInfo storage collateral = _loanCollaterals[loanId];
// @audit 不检查 AddressSet.add() 的返回值
// 如果因为已经在集合中而没有添加,则返回 false
collateral.collateralAddresses.add(token);
// @audit 在贷款报价被创建和验证后,借款人可以调用
// commitCollateral(loanId, token, 0) 来覆盖抵押品记录
// 相同的 token 的 0 金额。 任何接受贷款报价的贷款人
// 如果借款人违约,将不会受到保护,因为没有抵押品
// 可以失去
collateral.collateralInfo[token] = amount;
}
AddressSet.add() 的返回值从未被检查; 如果 "token" 已经存在于集合 "collateralAddresses" 中,这个函数将会返回 false。 因为返回值从未被检查,当尝试添加相同的 token 时,add() 将会静默失败,允许一个借款人将他们的抵押品金额从最初的大额(为了获得贷款)覆盖为之后的 0 金额,这样他们就可以在贷款被接受时违约,而不会失去任何抵押品!
另一个常见的 未检查返回值 错误的例子是当通过 call() 发送 eth 时,正如在 Code4rena 的 Tessera 竞赛中看到的那样:
payable(msg.sender).call{value: contribution}("");
在这里,call() 的返回值从未被检查,因此如果 eth 被发送到一个 receive() 函数 revert 的智能合约,这段代码会假设 eth 已经成功发送,并继续以这个假设执行。 开发者应该总是检查可能返回 false 的函数调用的返回值,审计员应该注意不检查这些函数返回值的代码。 更多示例:[ 1]
- 原文链接: dacian.me/exploiting-dev...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!