20250823 - Equilibria 攻击事件分析:奖励计算公式不要轻易采用 balanceOf 做参数呀!
背景信息
Equilibria Finance 是一个为 $PENDLE 代币持有者提高收益的 DeFi 协议。其主要功能包括将 PENDLE 代币转换为 ePENDLE(PENDLE --lock--> vePENDLE --mint--> ePENDLE),将 ePENDLE 进行 stake 后获得 stk-ePENDLE 等,目的是提升流动性提供者的收益。除此以外,Equilibria 还为 stk-ePENDLE 的持有者提供了 xEQB 和 vlEQB 代币激励。
本次攻击的原因是 stk-ePENDLE 相关的奖励计算逻辑错误,在 stk-ePENDLE 代币可以转移的前提下,根据用户持有的 stk-ePENDLE 代币余额来进行奖励计算。使得攻击者可以通过一笔 stk-ePENDLE 代币在多个账户间重复利用来领取超额的奖励。
- 攻击交易:https://app.blocksec.com/explorer/tx/eth/0x185a16017fb4d9b2fefdf5935435253d53d4758238275426b507fe54eb4fe97a
- 漏洞合约:https://etherscan.io/address/0xd30d6fd662c0d92b49f3c3e478e125ba1d968059#code
- Alert:
Trace 分析
- 攻击合约通过一系列的代币转换获取价值 0.01 ETH 的 ePENDLE 代币
- 调用
stk-ePendle.harvest()
从 ePENDLE 奖励池中收集奖励代币,将收集到的 WETH 和 PENDLE 兑换成 ePendle 代币后进行 stake。
此时可以看到 stk-ePendle 合约中存在 13.6 ETH
- 随后通过闪电贷
Balancer.receiveFlashLoan()
发起攻击。
Balancer.receiveFlashLoan()
先通过闪电贷获取了大量的 ePENDLE,然后 deposit 获得 stk-PENDLE,所有准备工作已经完成。
执行攻击部分,
- 创建一个新的合约
- 将 stk-PENDLE 发送到该合约
- 调用
getReward()
函数获取 EQB,xEQB 和 ETH 等奖励代币 - 把 ETH 汇总到主攻击合约
- 把 stk-PENDLE 发送到主攻击合约
- 把 EQB,xEQB 返还给 stk-PENDLE 合约
随后将这个攻击流程重复进行了 20 次,每次获利约 0.664 ETH,总计获利 13.27 ETH。
攻击者通过漏洞获取到奖励代币后,只要 ETH,把 EQB,xEQB 返还给 stk-PENDLE 合约,是为了重复 20 次的奖励获取能够正常运行下去。因为合约中持有 EQB 的数量是 5538 个,而每次获取 EQB 383 个,20 次就是 7660 个。所以需要每次都将获得的 EQB 和 xEQB 返还给 stk-PENDLE 合约。
代码分析
在 Trace 分析中可以了解到,攻击者利用同一笔 stk-PENDLE 发送到不同的合约中进行重复的奖励领取,多半是计算奖励的逻辑中采用了 balanceOf
的方法来计算奖励权重。
stk-PENDLE 合约:https://etherscan.deth.net/address/0xd30d6fd662c0d92b49f3c3e478e125ba1d968059
在 getReward()
函数中利用 updateReward()
计算用户奖励。
而在 earned()
函数中,通过 balanceOf
获取目标账户 stk-PENDLE 的余额,余额越大,奖励越多。所以攻击者可以通过同一笔 stk-PENDLE 代币重复获取大量的奖励。
后记
漏洞修复
对于这个漏洞修复方案,看到社区也存在一些讨论,主要是围绕着以下几个方面
- 避免直接采用
balanceOf
来计算收益。 - 如果需要采用
balanceOf
来计算收益,需要限制 stk-PENDLE 代币不允许transfer
。 - stk-PENDLE 代币可以
transfer
的场景下,则需要在代币转移前后,更新sender
和recipient
的奖励累计情况。
如果想要较为优雅地解决这个问题,推荐是选择第三个方法,在 _beforeTokenTransfer()
和 _afterTokenTransfer()
中设计好奖励的更新逻辑。
stk-ePendle.harvest() 的作用
在攻击之前,攻击者专门调用了一次 stk-ePendle.harvest()
,其主要目的是为了通过 _queueNewRewards()
函数累加 rewardInfo.rewardPerTokenStored
的值,以谋求更多的收益。但是在实际执行的 Trace 中,由于 harvestAmount = 0
,所以 continue
跳过了 _queueNewRewards()
函数的执行步骤。虽然从事后的角度来看这个操作并没有生效,但是漏洞利用的设计与执行上存在这个环节还是必要的。