By : Kong@慢霧安全團隊
背景
2020 年10 月8 號,去中心化錢包imToken 發推表示,用戶報告稱31 萬枚DAI 被盜,這與DeFi Saver Exchange 漏洞有關。 DeFi Saver 對此回應稱,被盜資金仍舊安全,正在聯繫受害用戶。截至目前,資金已全部歸還受害用戶。早在今年6 月份DeFi Saver 就表示該團隊發現DeFi Saver 應用系列中自有交易平台的一個漏洞,此次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 合約地址,因此將直接調用transferFrom 函數將數量為_amount 的DAI 轉入本合約。
2、接下來在代碼第11、12 行,通過takeFee 函數計算fee,最終計算結果都為0,這裡不做展開。
3、由於攻擊者傳入的_exchangeType 為4,因此將走代碼第22 行if (_exchangeType == 4) 的邏輯。在代碼中我們可以看出在此邏輯中調用了takeOrder 函數,並傳入了攻擊者自定的_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 合約的transferFrom 函數將用戶的DAI 直接轉出,具體都可以在_callData 中進行構造。
5、接下來由於返回的tokens[0] 為1,所以將走swapTokenToToken 函數代碼塊中第76 行以下的邏輯,可以看到都是使用if 判斷的邏輯,毫無疑問都能走通。
分析思路驗證
讓我們通過攻擊者的操作來驗證此過程是否如我們所想:
1、通過鏈上記錄可以看到,被盜的用戶歷史上有對SaverExchange 合約進行DAI 的授權,交易哈希如下:
0xdcf73848022ec1f730d9fdb90f4e8563f0dff48d9191aab19fc51241708eacf0
2、通過鏈上數據可以發現傳入的_callData 為:
23b872dd //SlowMist// transferFrom 函數簽名000000000000000000000000c001cd7a370524209626e28eca6abe6cfc09b0e50000000000000000000000005bb456cd09d85156e182d2c7797eb49a438401870000000000000000000000000000000000000000000041a522386d9b95c00000 //SlowMist// 310000e18
其中可以看出23b872dd 為transferFrom 函數簽名。
3、通過鏈上調用過程可看出攻擊者直接調用DAI 合約的transferFrom 函數將被盜用戶的31 萬枚DAI 轉走:
完整的攻擊流程如下
1、攻擊者調用swapTokenToToken 函數傳入_exchangeAddress 為DAI 合約地址,選擇_exchangeType 為 4,並將攻擊Payload 放在_callData 中傳入。
2、此時將走_exchangeType == 4 的邏輯,這將調用takeOrder 函數並傳入_callData。
3、takeOrder 函數將對傳入的_callData 進行具體調用,因此如果持有DAI 用戶在DAI 合約中對SaverExchange 合約進行過授權,則可以通過傳入的_callData 調用DAI 合約的transferFrom 函數將用戶的DAI直接轉出,具體都可以在_callData 中進行構造。
4、通過構造的_callData 與此前用戶對SaverExchange 合約進行過DAI 的授權, SaverExchange 合約可以通過調用DAI 合約的transferFrom 函數將用戶賬戶中的DAI 直接轉出至攻擊者指定的地址。
最後思考
此漏洞的關鍵在於攻擊者可以通過takeOrder 函數對目標合約_addresses[0] 的任意函數進行任意調用,而傳入takeOrder 函數的參數都是用戶可控的,且未對參數有任何檢查或限制。因此,為避免出現此類問題,建議項目方使用白名單策略對用戶傳入的_callData 等參數進行檢查,或者結合項目方具體的業務場景尋找更好的調用方式,而不是不做任何限制的進行隨意調用。
此漏洞不僅只影響到通過DAI 合約對SaverExchange 合約授權過的用戶,如果用戶歷史對SaverExchange 合約有進行過其他Token 的授權,則都會存在賬戶Token 被任意轉出風險。建議此前有對SaverExchange 合約進行過授權的用戶盡快取消授權(推薦使用https://approve.sh/ 網站自查授權情況),避免賬戶資產被惡意轉出。
相關參考鏈接如下:
https://medium.com/defi-saver/disclosing-a-recently-discovered-exchange-vulnerability-fcd0b61edffe
https://twitter.com/imTokenOfficial/status/1314126579971186688