sherlock-S-locker System

升级合同,常量发生改变

现有正式合约中的 DOWNSCALER 为 1e16,而提议升级的合约中 DOWNSCALER 为 1e18,两者不一致,导致以下影响:

  • 升级合约后新加入的质押者,在 lockedStakedBalance 相同的情况下,所获得的单位数(units)明显少于老质押者,分配收益时不公平。

  • 升级合约后,如果已有质押者追加较少(少于100倍)的质押金额,其单位数反而会减少,获得的税收分成变少。

  • 质押者可能会提前操作,在合约升级前抢先质押,以获得更多单位数并获取更大的税收分成。

抢跑攻击,在知晓分配收益时,扩大自己的份额,获取更多的收益

在解锁获得惩罚金时,攻击者提前知道,然后通过提供流动性或者质押金额,去分配获益

  • 某个用户准备进行立即解锁(instant unlock)。

  • 恶意的 Locker 拥有者在此之前增加自己的流动性或质押额度。

  • _instantUnlock 被调用,惩罚金被分配给当前的 LP 和质押者。

  • 恶意 Locker 拥有者因持有单位(units)而获得部分惩罚金收益。

  • 获取收益后,立即撤回流动性或解除质押。

恶意的 Locker 拥有者可以通过前置操作抢跑,临时提高自己的持仓量,从而在惩罚金分配中获利,获得不公平优势。
建议对 FLUID.distribute 逻辑进行调整或增加锁仓时间/份额快照机制,防止抢跑获利

缺金额的有效验证

FluidLocker::provideLiquidity 函数缺少对 getAvailableBalance() 的校验,导致在 6 个月免税期结束后,质押代币可以被提取,但不需要调用 FluidLocker::unstake。这意味着质押奖励会继续累积,尽管代币已经不在合约中,造成质押奖励系统完整性的损失。

  • Locker 拥有者调用 FluidLocker::stake,将 locker 内所有可用代币进行质押。

  • Locker 拥有者调用 FluidLocker::provideLiquidity,用这些质押代币创建 Uniswap 流动性仓位。注意此步骤会将代币转移出 locker。

  • 6 个月后,Locker 拥有者调用 FluidLocker::withdrawLiquidity 触发免税提现路径。此时用于提供流动性的代币已经回到 Locker 拥有者地址,但 locker 仍认为这些代币处于质押状态。

分发金额错误,有些金额已经在之前的时段分发过了

block.timestamp > endDate - EARLY_PROGRAM_END 时,一部分初始存入的代币(initialDeposit)已在 block.timestamp - (endDate - EARLY_PROGRAM_END) 这段时间内被分发。
但是合约却仍然发送了全部的 initialDeposit

没有检查单位池是否有单位数

在区块链系统中,很多奖励或惩罚机制通过将代币或奖励“分发”到某个“单位池”(unit pool)来完成。但如果目标池中没有接收者(即单位数 units == 0),系统仍然执行分发操作,则这些代币会 没有接收者,永久卡在合约中 或 直接被销毁

分发奖励时,用户成为唯一的质押者,导致不用罚金

但如果用户是唯一的质押者或流动性提供者,那么惩罚金(penalty)通过分发逻辑又返回到了用户自己的 Locker 中:


// 向流动性提供者分发惩罚金
FLUID.distribute(address(this), LP_DISTRIBUTION_POOL, penaltyAmount * providerAllocation / BP_DENOMINATOR);

// 向质押者分发惩罚金
FLUID.distribute(address(this), STAKER_DISTRIBUTION_POOL, penaltyAmount - actualProviderDistributionAmount);

上述分发实际上又回到了当前调用的 Locker,也就是说,罚金并没有“损失”出去。
这意味着用户可以重复调用 instant unlock,通过多次操作,几乎免除应缴的 80% 惩罚金,只留下少量残余资金因最低解锁额度被锁在合约中(由于每次都有小额惩罚,但大部分都被返回)。

影响是:
用户可以绕过 80% 惩罚费用,立即取出大部分原本锁定的资金;
导致协议的惩罚机制失效,流动性和质押的经济模型失衡。

检查条件不完整

你有一笔钱放进了某个平台的“保险箱”里(这个保险箱就是合约),本来要锁6个月才能取出来。

但平台说:

“如果你急着用钱,可以立即取出来,但你要交一点‘罚金’。”

这个罚金不会烧掉,而是分给两个群体的人:

有人把钱放进平台来做质押(我们叫这些人“质押者”)

有人提供了交易对来让别人兑换代币(我们叫这些人“流动性提供者”)

⚠️ 那问题来了:
假如项目方设置说:

“罚金全给流动性提供者(LP),不给质押者”。

于是大家一看质押没奖励,就没人再质押了!

🧨 现在你要取钱!
你说:“我想立刻解锁我保险箱里的钱,我愿意交罚金!”

平台合约内部运行逻辑是:

看看现在质押池(staker pool)有没有人 → 哦!没人?不行,不让你取钱!

看看流动性池(LP pool)有没有人 → 有人?那继续执行。

你是不是觉得很奇怪?

明明 项目方说了不给质押者奖励(是 0%),现在没人质押本来是正常的,但合约却说:“没人质押,不能解锁”,是不是脑子有点问题?

🧱 这就是这个漏洞!
合约逻辑写死了:“只要没人质押就不给解锁”,但没考虑到某些时候奖池设置为 0%,本来就不该有人质押啊!

所以你无法解锁你的资金,卡死了!

俩个不同的量放在一起计算缓冲区buffer是错误的

合约有两个流:

  • fundingFlowRate:给员工的钱
  • subsidyFlowRate:税、补贴之类的钱

合约代码把这两个流加起来,一起算了一次 buffer:
buffer = getBufferAmountByFlowRate(fundingFlowRate + subsidyFlowRate);
但平台规则其实是:

每个流都要单独算 buffer,因为每个都得保证“不断流”。

buffer 是预留的“流动资金池”,你每开一个钱流,就要准备够它的“最低启动金”。这个漏洞就是因为错把两个钱流当成一个处理,导致算少了 buffer,启动失败。

通过直接读取本地的余额,导致用户可以事先转入,绕过某些检查

function provideLiquidity(uint256 supAmount) external payable nonReentrant onlyLockerOwner {
uint256 ethAmount = msg.value; // 1. 用户调用时发送的ETH数量(msg.value)

_pump(weth, ethAmount * BP_PUMP_RATIO / BP_DENOMINATOR);  // 2. 调用内部函数_pump,扣除一定比例的ETH作为“承诺金”

uint256 ethLPAmount = IERC20(weth).balanceOf(address(this));  // 3. 读取合约中当前WETH余额

TransferHelper.safeApprove(weth, address(NONFUNGIBLE_POSITION_MANAGER), ethLPAmount);  // 4. 授权NFT管理合约花费这些WETH
TransferHelper.safeApprove(address(FLUID), address(NONFUNGIBLE_POSITION_MANAGER), supAmount);  // 5. 授权FLUID代币给NFT管理合约

_createPosition(ethLPAmount, supAmount);  // 6. 创建流动性头寸,使用ethLPAmount数量的WETH和supAmount数量的FLUID

}

第3步,ethLPAmount 是读取合约当前 WETH余额,但这个余额可能包含之前用户直接转账给合约的WETH(用户绕过了调用时传ETH的限制)

用户可以先偷偷把很多WETH转给合约(绕过_pump的承诺金扣除),然后只用很少的ETH调用 provideLiquidity 函数

这样导致用户占用了大量流动性份额,却没有付出相应的“承诺金”,从而绕过了_pump设计的限制

零头没有处理好,导致卡住合约

用户调用 provideLiquidity()(提供流动性)时,合约会把用户的 ETH 和 SUP(代币)一起放到 Uniswap V3 池子里。

但 Uniswap 不一定能刚好用完用户所有的 SUP,可能会退回一小部分剩余的 SUP(叫做“dust”,即“零头”)。

  • 这部分剩余的 SUP 会退回到用户的 FluidLocker 合约里。

  • 按理说,这些退回来的 SUP 是“闲置”的,用户可以选择质押(stake)或解锁(unlock)它们。

  • 但是,在项目的初始阶段(即 UNLOCK_AVAILABLE == false),质押和解锁功能都被关闭了,用户根本不能动这些剩余的 SUP。