2020 年 10 月 8 號,去中心化錢包 imToken 發布推文表示,用戶報告稱 31 萬枚 DAI 被盜,這與 DeFi Saver Exchange 衝突有關。DeFi Saver 對此回應稱,被盜資金仍舊安全,正在接觸受害用戶。截至目前,資金已全部歸還受害用戶。筆者在收到情報後,針對這次發布 31 萬枚 DAI 被盜事件展開具體的分析。本文由專欄作者 慢霧科技 撰稿,不代表動區立場。
(相關報導:資安月報|DeFi 詐騙、跑路頻發!9 月安全事件共 33 起,危害程度評級 HIGH)
背景
2020 年 10 月8號,去中心化錢包 imToken 發布推文表示,用戶報告稱 31 萬枚 DAI 被盜,這與 DeFi Saver Exchange 衝突有關。
DeFi Saver 對此回應稱,被盜資金仍舊安全,正在接觸受害用戶。
⚠️ We received a user report of 310,000 DAI got drained which related to DeFi Saver Exchange vulnerability. Someone is front running the white hack. We suggest @DefiSaver suicide the contract to save people's money, immediately!https://t.co/OR9gA3hngi https://t.co/uJuLvJbSih
— imToken – Web3 Wallet (@imTokenOfficial) October 8, 2020
截至目前,資金已全部歸還受害用戶。
早在今年 6 月份 DEFI Saver 就表示該團隊發現 DEFI Save 應用系列中自有交易平台的一個漏洞,此次 31 萬枚 DAI 被盜也與此前的 SaverExchange 合約漏洞有關。筆者在收到情報後,針對這次發布 31 萬枚 DAI 被盜事件展開具體的分析。
攻擊過程分析
查看這筆攻擊交易:
https://etherscan.io/tx/0xcd9dad40b409897d05fa0e60ed4e58eb99876febf94bc97679b7f45837ea86b7
其中可以看到被盜用戶 0xc0 直接轉出 31 萬枚 DAI 到攻擊合約 0x5b。
我們可以使用 OKO 瀏覽器查看具體的交易細節:
https://oko.palkeo.com/0xcd9dad40b409897d05fa0e60ed4e58eb99876febf94bc97679b7f45837ea86b7
(以下不需閱讀代碼分析的讀者,可直接跳到總結整理的部分。)
從中可以插入攻擊者通過調用 swapTokenToToken 函數插入_exchangeAddress,_src,_dest 為 DAI 合約地址,選擇_exchangeType 為 4,並自定的 _callData。
進行具體的分析:(以下為詳細程式碼)
function swapTokenToToken(address _src, address _dest, uint _amount, uint _minPrice, uint _exchangeType, address _exchangeAddress, bytes memory _callData, uint _0xPrice) public payable {
// use this to avoid stack too deep error
address[3] memory orderAddresses = [_exchangeAddress, _src, _dest];
if (orderAddresses[1] == KYBER_ETH_ADDRESS) {
require(msg.value >= _amount, "msg.value smaller than amount");
} else {
require(ERC20(orderAddresses[1]).transferFrom(msg.sender, address(this), _amount), "Not able to withdraw wanted amount");
}
uint fee = takeFee(_amount, orderAddresses[1]);
_amount = sub(_amount, fee);
// [tokensReturned, tokensLeft]
uint[2] memory tokens;
address wrapper;
uint price;
bool success;
// at the beggining tokensLeft equals _amount
tokens[1] = _amount;
if (_exchangeType == 4) {
if (orderAddresses[1] != KYBER_ETH_ADDRESS) {
ERC20(orderAddresses[1]).approve(address(ERC20_PROXY_0X), _amount);
}
(success, tokens[0], ) = takeOrder(orderAddresses, _callData, address(this).balance, _amount);
// either it reverts or order doesn't exist anymore, we reverts as it was explicitely asked for this exchange
require(success && tokens[0] > 0, "0x transaction failed");
wrapper = address(_exchangeAddress);
}
if (tokens[0] == 0) {
(wrapper, price) = getBestPrice(_amount, orderAddresses[1], orderAddresses[2], _exchangeType);
require(price > _minPrice || _0xPrice > _minPrice, "Slippage hit");
// handle 0x exchange, if equal price, try 0x to use less gas
if (_0xPrice >= price) {
if (orderAddresses[1] != KYBER_ETH_ADDRESS) {
ERC20(orderAddresses[1]).approve(address(ERC20_PROXY_0X), _amount);
}
(success, tokens[0], tokens[1]) = takeOrder(orderAddresses, _callData, address(this).balance, _amount);
// either it reverts or order doesn't exist anymore
if (success && tokens[0] > 0) {
wrapper = address(_exchangeAddress);
emit Swap(orderAddresses[1], orderAddresses[2], _amount, tokens[0], wrapper);
}
}
if (tokens[1] > 0) {
// in case 0x swapped just some amount of tokens and returned everything else
if (tokens[1] != _amount) {
(wrapper, price) = getBestPrice(tokens[1], orderAddresses[1], orderAddresses[2], _exchangeType);
}
// in case 0x failed, price on other exchanges still needs to be higher than minPrice
require(price > _minPrice, "Slippage hit onchain price");
if (orderAddresses[1] == KYBER_ETH_ADDRESS) {
(tokens[0],) = ExchangeInterface(wrapper).swapEtherToToken.value(tokens[1])(tokens[1], orderAddresses[2], uint(-1));
} else {
ERC20(orderAddresses[1]).transfer(wrapper, tokens[1]);
if (orderAddresses[2] == KYBER_ETH_ADDRESS) {
tokens[0] = ExchangeInterface(wrapper).swapTokenToEther(orderAddresses[1], tokens[1], uint(-1));
} else {
tokens[0] = ExchangeInterface(wrapper).swapTokenToToken(orderAddresses[1], orderAddresses[2], tokens[1]);
}
}
emit Swap(orderAddresses[1], orderAddresses[2], _amount, tokens[0], wrapper);
}
}
// return whatever is left in contract
if (address(this).balance > 0) {
msg.sender.transfer(address(this).balance);
}
// return if there is any tokens left
if (orderAddresses[2] != KYBER_ETH_ADDRESS) {
if (ERC20(orderAddresses[2]).balanceOf(address(this)) > 0) {
ERC20(orderAddresses[2]).transfer(msg.sender, ERC20(orderAddresses[2]).balanceOf(address(this)));
}
}
if (orderAddresses[1] != KYBER_ETH_ADDRESS) {
if (ERC20(orderAddresses[1]).balanceOf(address(this)) > 0) {
ERC20(orderAddresses[1]).transfer(msg.sender, ERC20(orderAddresses[1]).balanceOf(address(this)));
}
}
}
1. 在代碼第 5 行可以看到先對 orderAddresses [1] 是否為 KYBER_ETH_ADDRESS 地址已確定,由 orderAddresses [1] 為 DAI 合約地址,因此將直接調用從函數將數量為 _amount 的 DAI 轉入本協議。
2. 接下來在代碼第 11,12 行,通過 takeFee 函數計算費用,最終計算結果都為 0 時,不做這裡展開。
3. 由於攻擊者攻擊的 _exchangeType 為 4,因此將走代碼第 22 行 if(_exchangeType == 4)的邏輯。在代碼中我們可以裁剪在此邏輯中調用了 order 函數,並進行了攻擊者自定的 _callData,注意這將是本次攻擊的關鍵點,隨後切入 takeOrder 函數:
function takeOrder(address[3] memory _addresses, bytes memory _data, uint _value, uint _amount) private returns(bool, uint, uint) {
bool success;
(success, ) = _addresses[0].call.value(_value)(_data);
uint tokensLeft = _amount;
uint tokensReturned = 0;
if (success){
// check how many tokens left from _src
if (_addresses[1] == KYBER_ETH_ADDRESS) {
tokensLeft = address(this).balance;
} else {
tokensLeft = ERC20(_addresses[1]).balanceOf(address(this));
}
// check how many tokens are returned
if (_addresses[2] == KYBER_ETH_ADDRESS) {
TokenInterface(WETH_ADDRESS).withdraw(TokenInterface(WETH_ADDRESS).balanceOf(address(this)));
tokensReturned = address(this).balance;
} else {
tokensReturned = ERC20(_addresses[2]).balanceOf(address(this));
}
}
return (success, tokensReturned, tokensLeft);
}
4. 在 takeOrder 函數中的第4行,我們可以直觀的修剪此邏輯可對目標 _addresses [0] 的函數進行調用,此時 _addresses [0] 為_exchangeAddress 即 DAI 合約地址,而具體的調用即攻擊者自定值的 _callData,因此如果持有 DAI 用戶在 DAI 合約中對 SaverExchange 合約進行過授權,則可以通過調用的 _callData 調用DAI 合約的 transfer 從函數將用戶的 DAI 直接轉出,具體都可以在_callData 中進行構造。
5. 接下來由於返回的令牌 [0] 為 1,所以將走 swapTokenToToken 函數代碼塊中第 76 行以下的邏輯,如果判斷的邏輯,可以看到都是使用,毫無疑問可以走通。
分析思路驗證
讓我們通過攻擊者的操作來驗證此過程是否如我們所想:
1. 通過鏈上記錄可以看到,被盜的用戶歷史上有對 SaverExchange合約進行 DAI 的授權,交易哈希如下:
0xdcf73848022ec1f730d9fdb90f4e8563f0dff48d9191aab19fc51241708eacf0
2. 通過鏈上數據可以發現預期的 _callData 為:
23b872dd //SlowMist// transferFrom 函数签名
000000000000000000000000c001cd7a
370524209626e28eca6abe6cfc09b0e5
0000000000000000000000005bb456cd
09d85156e182d2c7797eb49a43840187
00000000000000000000000000000000
00000000000041a522386d9b95c00000 //SlowMist// 310000e18
3. 通過鏈上調用過程可研磨攻擊者直接調用 DAI 合約的 transferFrom 函數將被盜用戶的 31 萬枚 DAI 轉走:
完整的攻擊流程如下
1. 攻擊者調用 swapTokenToToken 函數為 _exchangeAddress 為 DAI 合約地址,選擇 _exchangeType 為 4,將攻擊有效載荷置於_callData 中。
2. 此時將走 _exchangeType == 4 的邏輯,這將調用 takeOrder 函數並寫入 _callData。
3. takeOrder 函數將對預期的 _callData 進行具體調用,因此如果持有 DAI 用戶在 DAI 合約中對 SaverExchange 合約進行過授權,則可以通過的 _callData 調用 DAI 合約的 transfer 來自函數將用戶的 DAI直接轉出,具體都可以在 _callData 中進行構造。
4. 通過構造的 _callData 與相對用戶對 SaverExchange 合約進行過 DAI 的授權,SaverExchange 合約可以通過調用 DAI 合約的 transfer從函數將用戶帳戶中的 DAI 直接轉出至攻擊者指定的地址。
最後思考
此突破的關鍵在於攻擊者可以通過 takeOrder 函數對目標合約_addresses [0] 的任意函數進行任意調用,而引入 takeOrder 函數的參數都是用戶可控的,並且未對參數進行任何檢查或限制。
因此,為避免出現此類問題,建議項目方使用白名單策略對用戶調用的 _callData 等參數進行檢查,或者結合項目方具體的業務場景尋找更好的調用方式,而不是不做任何限制的進行隨意調用。
此漏洞不僅只影響到通過 DAI 合約對 SaverExchange 合約授權過的用戶,如果用戶歷史對 SaverExchange 合約有進行過其他令牌的授權,則都會存在帳戶的令牌被任意轉出風險。
建議此前有對 SaverExchange 合約進行過授權的用戶盡快取消授權(推薦使用https://approve.sh/網站自查授權情況),避免帳戶資產被惡意轉出。
📍相關報導📍
Defi是洗錢天堂?加密犯罪若出圈到 DeFi,監管風險不容小覷
為什麼投資者不用擔心,KuCoin 遭駭近 2 億美元會讓以太坊崩跌?
Bitfinex駭客比特幣再轉移 7.5 億!4 年來累積轉出近 400 億贓款,恐成拋售危機
讓動區 Telegram 新聞頻道再次強大!!立即加入獲得第一手區塊鏈、加密貨幣新聞報導。
LINE 與 Messenger 不定期為大家服務