DeFi安全漏洞|解析:DeFi Saver用戶的「31萬枚DAI」是如何被盜的?

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 對此回應稱,被盜資金仍舊安全,正在接觸受害用戶。

截至目前,資金已全部歸還受害用戶。

早在今年 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 
000000000000000000000000c001cd7a
370524209626e28eca6abe6cfc09b0e5
0000000000000000000000005bb456cd
09d85156e182d2c7797eb49a43840187
00000000000000000000000000000000
00000000000041a522386d9b95c00000 

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 不定期為大家服務

加入好友

加入好友