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,_hide,_dest 为 DAI 合约地址,选择_exchangeType 为 4,并传入自定的_callData 。可以猜测这是攻击成功的关键函数,接下来对其进行具体的分析: function swapTokenToToken(address_hide, 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,_hide,_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_hide 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 为: * 23 b872dd //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 往期回顾 国家区块链漏洞库发布《区块链漏洞定级细则》,慢雾重点贡献《外围系统漏洞定级细则》 慢雾为香港浸会大学金融硕士课程赞助“慢雾网络安全奖” 喜讯 | 慢雾科技荣获“区块链行业高成长企业奖”,创始人余弦荣获“行业先锋人物奖” ForTube 智能合约已通过慢雾科技安全审计 Haven Protocol 通过慢雾科技安全审计 慢雾导航 慢雾科技官网 https://www.slowmist.com/ 慢雾区官网 https://slowmist.io/ 慢雾 GitHub https://github.com/slowmist Telegram https://t.me/slowmistteam https://twitter.com/@slowmist_team Medium https://medium.com/@slowmist 币乎 https://bihu.com/people/586104 知识星球 https://t.zsxq.com/Q3zNvvF 火星号 http://t.cn/AiRkv4Gz 链闻号 https://www.chainnews.com/u/958260692213.htm 来源链接:mp.weixin.qq.com —- 编译者/作者:慢雾科技 玩币族申明:玩币族作为开放的资讯翻译/分享平台,所提供的所有资讯仅代表作者个人观点,与玩币族平台立场无关,且不构成任何投资理财建议。文章版权归原作者所有。 |
慢雾:DeFi Saver 用户的 31 万枚 DAI 是如何被盗的?
2020-10-10 慢雾科技 来源:链闻
LOADING...
相关阅读:
- SEC“加密妈妈”:DeFi具有变革性潜力,代币空投无法脱离监管框架2020-10-10
- 报告:Q3是DeFi的夏天,DEX总交易量增长了700%2020-10-10
- 【eos defi】大宝的比特币交易对底池初步建立,正在考虑从柚子-美元向2020-10-10
- DeFi还会火下去吗 接下来会把以太坊带向何方?2020-10-10
- 行情分析:假期结束就开涨,DEFI币种火热2020-10-10