DeFi Saver 用户 31 万 DAI 是如何被盗的?慢雾技术拆解攻击细节

温馨提示:这篇文章已超过853天没有更新,请注意相关的内容是否还可用!

用户 31 万 DAI 被盗与 DeFi Saver 今年 6 月公布的漏洞有关,此漏洞还将影响其它向 SaverExchange 授权的合约代币。

原文标题:《慢雾:DeFi Saver 用户的 31 万枚 DAI 是如何被盗的?》
撰文:Kong,就职于慢雾安全团队

2020 年 10 月 8 号,去中心化钱包 imToken 发推表示,用户报告称 31 万枚 DAI 被盗,这与 DeFi Saver Exchange 漏洞有关。 DeFi Saver 对此回应称,被盗资金仍旧安全,正在联系受害用户。截至目前,资金已全部归还受害用户。早在今年 6 月份 DeFi Saver 就表示该团队发现 DeFi Saver 应用系列中自有交易平台的一个漏洞,此次 31 万枚 DAI 被盗也与此前的 SaverExchange 合约漏洞有关。慢雾安全团队在收到情报后,针对此次 31 万枚 DAI 被盗事件展开具体的分析。

攻击过程分析

查看这笔 攻击交易

其中可以看到被盗用户 0xc0 直接转出 31 万枚 DAI 到攻击合约 0x5b。

我们可以使用 OKO 浏览器 查看具体的交易细节:

从中可以看出攻击者通过调用 swapTokenToToken 函数传入 _exchangeAddress,_hide,_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 函数签名
000000000000000000000000c001cd7a
370524209626e28eca6abe6cfc09b0e5
0000000000000000000000005bb456cd
09d85156e182d2c7797eb49a43840187
00000000000000000000000000000000
00000000000041a522386d9b95c00000 //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://medium.com/defi-saver/disclosing-a-recently-discovered-exchange-vulnerability-fcd0b61edffe

https://twitter.com/imTokenOfficial/status/1314126579971186688

来源链接:mp.weixin.qq.com