本文深入探讨了Web3 DeFi借贷平台中智能合约的各种漏洞,包括:违约前清算、无法清算借款人、未偿还债务关闭、暂停还款时启用清算、停止现有还款和清算、恢复还款后立即清算、清算人以不足额还款获取抵押品、无限贷款展期、还款发送到零地址、借款人永久无法偿还贷款、借款人还款仅部分入账、没有动力清算小额头寸、清算使交易者更不健康等。同时,本文还列出了一些额外的参考资源,以供智能合约审计师和开发人员参考。
在 Web3 DeFi 中,智能合约已被用于实现各种借贷平台,市场参与者可以在这些平台上:
借出 token 以获取利息
借入 token 以进行其他活动,同时支付利息
借款人必须提供抵押品,这些抵押品存储在 DeFi 系统中的智能合约中,如果借款人未能在还款计划截止日期前还款,或者如果其抵押品的价值低于要求的阈值,则可以由贷款人或其他市场参与者进行清算。本次深入探讨旨在对 智能合约审计师 和开发人员应注意的借贷平台中的漏洞类型进行分类。
清算允许没收借款人的抵押品,并将其作为补偿给予贷款人或支付给清算人(或以某种方式在他们之间分配)。只有在以下情况下才应进行清算:
借款人未能履行其还款计划义务,例如延迟了预定的还款,
借款人的抵押品价值已跌至设定阈值以下
如果贷款人、清算人或其他市场参与者可以在借款人违约之前清算借款人的抵押品,则会导致借款人出现严重的资金损失漏洞。考虑一下我在 Sherlock 的 TellerV2 审计竞赛中 发现 的简化版本:
function lastRepaidTimestamp(Loan storage loan) internal view returns (uint32) {
return
// @audit 如果尚未进行任何还款,lastRepaidTimestamp()
// 将返回 acceptedTimestamp - 贷款被接受的时间
loan.lastRepaidTimestamp == 0
? loan.acceptedTimestamp
: loan.lastRepaidTimestamp;
}
function canLiquidateLoan(uint loanId) public returns (bool) {
Loan storage loan = loans[loanId];
// 确保贷款在未激活时无法被清算
if (loan.state != LoanState.ACCEPTED) return false;
return (uint32(block.timestamp) - lastRepaidTimestamp(loan) > paymentDefaultDuration);
// @audit 如果未进行任何还款:
// block.timestamp - acceptedTimestamp > paymentDefaultDuration
// 不检查 paymentCycleDuration(下次付款到期时间)
// 如果 paymentDefaultDuration < paymentCycleDuration,则可以被清算
// 在第一次付款到期*之前*。如果 paymentDefaultDuration 非常小,
// 则可以在获得贷款后很快被清算,远在第一次付款到期之前!
}
canLiquidateLoan() 不检查下次还款的到期时间;如果贷款是新的并且尚未进行首次还款(因为它在一段时间内 "paymentCycleDuration" 不会到期),如果 paymentDefaultDuration < paymentCycleDuration,则借款人可以在首次还款到期之前被清算。
如果 paymentDefaultDuration 很小,借款人可能在获得贷款后很快就被清算!清算阈值 paymentDefaultDuration 应该始终根据下次还款到期时间进行计算;只有在下次还款延迟 paymentDefaultDuration 时间后,才能清算借款人。
此漏洞的另一个例子来自 Hats Finance Tempus 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);
这也是一个 意外/未检查的输入漏洞 的例子。
如果借款人可以设计一种贷款方案,导致其抵押品无法被清算,则会发生另一个严重的漏洞。请看下面这个同样来自 Sherlock 的 TellerV2 审计的简化示例:
// 来自 https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable 的 AddressSet
// 贷款必须至少有一个抵押品
// 并且每个 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,因为该 token 已经存在于集合中
collateral.collateralAddresses.add(token);
// @audit 在创建并验证贷款要约后,借款人可以调用
// commitCollateral(loanId, token, 0) 以覆盖抵押品记录
// 对于相同 token 的金额为 0。如果任何贷款人接受贷款要约
// 如果借款人违约,将不会受到保护,因为没有抵押品
// 损失
collateral.collateralInfo[token] = amount;
}
此代码包含一个 未检查的返回值漏洞,因为从未检查 AddressSet.add() 的返回值;如果 token 已经在集合中,这将返回 false。由于未检查此项,代码将继续执行,并且现有抵押品 token 的金额可以简单地被新值 0 覆盖!更多示例:[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
通常,为了取回他们的抵押品,借款人必须向贷款人偿还本金 + 利息。如果借款人可以在未偿还全部金额的情况下关闭债务,并保留他们的抵押品,这将导致贷款人出现严重的资金损失漏洞。请看 Code4rena 的 DebtDAO 审计中的以下代码 [ 1, 2]:
// 在信贷额度工具上的未结信贷额度的数量
uint256 private count;
// id -> 由单个贷款人为给定 token 在信贷额度上提供的信贷额度
mapping(bytes32 => Credit) public credits;
// @audit 攻击者 calls close() with 不存在的 id
function close(bytes32 id) external payable override returns (bool) {
// @audit 不检查 id 是否存在于 credits 中,如果不存在
// 存在,将返回具有默认值的空 Credit
Credit memory credit = credits[id];
address b = borrower; // gas 节省
// @audit 借款人attacker 将通过此检查
if(msg.sender != credit.lender && msg.sender != b) {
revert CallerAccessDenied();
}
// 确保所有欠款都已计算在内。由于已还清本金,因此应计工具费用
credit = _accrue(credit, id);
uint256 facilityFee = credit.interestAccrued;
if(facilityFee > 0) {
// 只允许偿还利息,因为他们跳过了还款队列。
// 如果仍欠本金,则 _close() 必须失败
LineLib.receiveTokenOrETH(credit.token, b, facilityFee);
credit = _repay(credit, id, facilityFee);
}
// @audit 使用空信用额度调用 _closed(),不存在的 id
_close(credit, id); // 已删除;无需保存到存储
return true;
}
function _close(Credit memory credit, bytes32 id) internal virtual returns (bool) {
if(credit.principal > 0) { revert CloseFailedWithPrincipal(); }
// 返回贷款人的资金,这些资金正在被偿还
if (credit.deposit + credit.interestRepaid > 0) {
LineLib.sendOutTokenOrETH(
credit.token,
credit.lender,
credit.deposit + credit.interestRepaid
);
}
delete credits[id]; // gas 退款
// 从活动列表中删除
ids.removePosition(id);
// @audit 使用不存在的 id 调用仍然会减少计数,可以
// 继续使用不存在的 id 调用 close() 直到计数减少到 0
// 并且贷款标记为已偿还!
unchecked { --count; }
// 如果所有信贷额度都已关闭,则整体信贷额度工具将声明为“已偿还”。
if (count == 0) { _updateStatus(LineLib.STATUS.REPAID); }
emit CloseCreditPosition(id);
return true;
}
借款人可以简单地使用不存在的 id 调用 close(),并且每次调用最终都会减少 count。这样做直到 count == 0 会导致贷款被标记为已偿还!这也是 意外空输入漏洞 的一个例子,其中开发者没有期望传递一个不存在的值,因此没有正确处理该值。更多示例:[ 1, 2, 3]
借贷 DeFi Platform 永远不应该进入一种 暂停还款但启用清算 的状态,因为这将不公平地阻止借款人进行还款,同时仍然允许清算他们。如果可以暂停还款,那么也必须同时暂停清算。检查来自 Sherlock 的 Blueberry 竞赛的 repay() 函数表明可以开启/关闭还款,但在 liquidate() 中没有类似的检查。
function liquidate(uint256 positionId, address debtToken, uint256 amountCall)
external override lock poke(debtToken) {
function repay(address token, uint256 amountCall)
external override inExec poke(token) onlyWhitelistedToken(token) {
if (!isRepayAllowed()) revert REPAY_NOT_ALLOWED();
借贷 Platform 的开发者应确保如果暂停还款,则还必须暂停清算,并且审计员应检查是否可以违反此不变量。更多示例:[ 1, 2, 3]
一些借贷 Platform 允许治理部门不允许接受先前允许的 token,无论是还款 token 还是抵押 token。如果这也阻止了使用该 token 的现有贷款被偿还或清算,则可能导致贷款人和/或协议出现严重的资金损失漏洞。
BlueBerry 通过将相同的 isRepayAllowed() 调用添加到 liquidate() 中来解决之前的 " 还款恢复但允许清算" 问题,使得这两个函数现在看起来像这样:
function liquidate(uint256 positionId, address debtToken, uint256 amountCall)
external override lock poke(debtToken) {
if (!isRepayAllowed()) revert Errors.REPAY_NOT_ALLOWED();
function repay(address token, uint256 amountCall)
external override inExec poke(token) onlyWhitelistedToken(token) {
if (!isRepayAllowed()) revert Errors.REPAY_NOT_ALLOWED();
在完成本 Deep Dive 的初始版本后,在 Sherlock 的 BlueBerry Update 1 竞赛 中,我 发现 了另一种达到借款人无法偿还但可以被清算的相同状态的方法。在这种替代路径中,还款永远不会暂停,而是不允许先前允许的 token。onlyWhitelistedToken() 修饰符的不一致使用导致具有现有头寸的借款人无法偿还,但仍然可以被清算。
治理部门不允许先前允许的 token 应该只适用于新贷款,但使用不允许的 token 的现有贷款必须继续能够偿还和清算。更多示例:[ 1, 2]
让我们重新检查 Sherlock 的 Blueberry Update 1 竞赛中的代码,并且我们还将按照上一节的建议删除不一致的 onlyWhitelistedToken 修饰符:
function liquidate(uint256 positionId, address debtToken, uint256 amountCall)
external override lock poke(debtToken) {
if (!isRepayAllowed()) revert Errors.REPAY_NOT_ALLOWED();
function repay(address token, uint256 amountCall)
external override inExec poke(token) {
if (!isRepayAllowed()) revert Errors.REPAY_NOT_ALLOWED();
此代码现在可以正确地防止借款人在无法偿还的情况下被清算,因为暂停还款也会暂停清算。如果暂停还款,liquidate() 将恢复,并且如果不允许先前允许的 token,则具有该 token 的现有头寸的借款人可以继续偿还并被清算。
但是,还有一个问题仍然存在;如果暂停还款,在此暂停期间,市场波动可能导致借款人因无法偿还 () 而受到清算。一旦恢复还款,这样的借款人将立即被清算机器人清算,唯一可以挽救其头寸的可能性是借款人自己运行一个还款机器人并且可以成功地抢先清算机器人。
这种情况不公平地损害了借款人的利益,因为此类借款人因自身过错而受到清算。在 恢复还款后,借款人将立即被清算,不公平地损害了借款人的利益,并为清算人提供了巨大的优势。
为了修复博弈论,使得借款人和清算人都不会受到不公平的偏袒,在恢复还款后,应该有一个宽限期,在此期间借款人不能被清算。此宽限期可以等于暂停还款的期间,但硬性上限为最大小时数,这为借款人和清算人都提供了公平性。更多示例:[ 1]
当借款人违约时,可能会发生两件事:
贷款人通过放弃偿还贷款并没收抵押品来清算借款人,
清算人偿还借款人的坏账并没收抵押品
在第二种情况下,高级 Platform 允许清算人部分偿还借款人的坏账并获得相应比例的抵押品。如果清算人可以 以不足(或没有)的还款拿走抵押品,这表示贷款人出现严重的资金损失漏洞。考虑一下来自 Blueberry 的 Sherlock 审计的 抵押品份额计算:
function liquidate(uint256 positionId, address debtToken, uint256 amountCall)
external override lock poke(debtToken) {
// 检查
if (amountCall == 0) revert ZERO_AMOUNT();
if (!isLiquidatable(positionId)) revert NOT_LIQUIDATABLE(positionId);
// @audit 获取要由清算人偿还的头寸,但是
// 借款人可能有多个债务头寸
Position storage pos = positions[positionId];
Bank memory bank = banks[pos.underlyingToken];
if (pos.collToken == address(0)) revert BAD_COLLATERAL(positionId);
// @audit oldShare & share 正在清算的一个头寸的比例
uint256 oldShare = pos.debtShareOf[debtToken];
(uint256 amountPaid, uint256 share) = repayInternal(
positionId,
debtToken,
amountCall
);
// @audit 使用
// share / oldShare 计算要给予清算人的抵押品份额,
// 这仅对应于清算的一个头寸,
// 不对应于借款人的总债务(可以在多个头寸中)
uint256 liqSize = (pos.collateralSize * share) / oldShare;
uint256 uTokenSize = (pos.underlyingAmount * share) / oldShare;
uint256 uVaultShare = (pos.underlyingVaultShare * share) / oldShare;
// @audit 如果借款人有多个债务头寸,清算人
// 可以通过仅偿还最低价值的债务头寸来拿走所有抵押品,
// 因为份额仅从一个
// 正在清算的头寸计算,而不是从总债务计算,总债务可以
// 分散在多个头寸中
share / oldShare 是清算人偿还的一个债务头寸的比例,而不是可以分散在多个头寸中的借款人的全部债务。因此,如果借款人的债务分散在多个头寸中,清算人可以通过仅偿还最小的债务头寸来拿走所有抵押品。
如果借款人可以展期他们的贷款,贷款人也必须能够通过限制次数、时间长度或其他参数来限制展期。如果借款人可以 无限期地展期他们的贷款,这意味着贷款人面临着严重的资金损失风险,他们可能永远无法获得偿还,也永远无法通过清算借款人来拿走他们的抵押品。
在实施还款代码时必须小心,以免因将其 发送到零地址而丢失还款。请看来自 Cooler 的 Sherlock 审计中的以下代码:
function repay (uint256 loanID, uint256 repaid) external {
Loan storage loan = loans[loanID];
if (block.timestamp > loan.expiry)
revert Default();
uint256 decollateralized = loan.collateral * repaid / loan.amount;
// @audit loans[loanID] 在此处删除
// 这意味着 loan 指向 loans[loanID]
// 将是一个具有默认/0 成员值的空对象
if (repaid == loan.amount) delete loans[loanID];
else {
loan.amount -= repaid;
loan.collateral -= decollateralized;
}
// @audit loan.lender = 0,因为上面的删除
// 因此还款将发送到零地址
// 一些 erc20 token 将恢复,但许多会很乐意
// 执行,还款将永远丢失
debt.transferFrom(msg.sender, loan.lender, repaid);
collateral.transfer(owner, decollateralized);
}
"loan" 指向存储 loans[loanID],但 loans[loanID] 被删除,然后还款被转移到 loan.lender,由于之前的删除,这将为 0。一些 ERC20 token 将恢复,但许多会很乐意执行,导致还款被发送到零地址并永远丢失。更多示例:[ 1]
如果系统可以进入一种 借款人永久无法偿还贷款 的状态,因为 repay() 函数恢复,这表示借款人面临着严重的资金损失漏洞,他们的抵押品将被清算,并且贷款人也永远无法获得偿还。开发者应测试,审计员应验证借款人是否可以在贷款的各个阶段(活跃、逾期等)偿还贷款,除非贷款已被清算。更多示例:[ 1]
借贷系统可以允许借款人提取多笔贷款。然后,借款人可以尝试通过一次调用repay()函数来偿还尽可能多的贷款,其想法是,如果还款金额可以偿还第一笔贷款,那么任何还款金额都应该用于偿还第二笔贷款,依此类推。
如果一旦第一笔贷款已还清,溢出金额不会用于至少部分偿还第二笔贷款,但贷款人收到全部金额,则会对借款人造成严重的资金损失错误,导致借款人的还款仅部分入账。
开发者应该测试,审计员应该验证批量还款功能是否确实可以偿还尽可能多的贷款,并且不会损失任何还款金额。
及时清算抵押品价值已跌破贷款清算阈值的头寸对于借贷协议的偿付能力至关重要。清算人通过收取清算费来激励及时清算此类“水下”头寸。
由于以太坊主网等热门网络上的gas成本不断上升,清算费可能小于清算小额头寸所需的gas成本。如果没有激励去清算小额头寸,这些水下头寸将在系统中累积,威胁到协议的偿付能力。
清算应该始终改善交易者的健康评分;如果清算后交易者的状况比以前更差,这会使交易者更有可能在后续头寸中被清算。这个问题可能尤其体现在以下协议中:
允许部分清算,并且部分清算可能导致剩余仓位的健康评分降低
支持多种抵押品,并且清算错误地首先使用更稳定的抵押品,从而使交易者拥有更不健康、风险更高的剩余抵押品组合
智能合约审计员应确保部分清算永远不会降低剩余仓位的健康评分,并且清算始终优先考虑不太稳定、风险更高的抵押品。更多示例:[ 1]
38
4
- 原文链接: dacian.me/lending-borrow...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!