Cobo安全团队——Kaoyaswap 被黑分析

此篇文章由 Cobo 区块链安全团队供稿,团队成员来自知名安全实验室,有多年网络安全与漏洞挖掘经验,曾协助谷歌、微软等厂商处理高危漏洞并获致谢,在微软 MSRC 最有价值安全研究员 Top榜单中取得卓越的成绩。团队目前重点关注智能合约安全、DeFi安全等方向,研究并分享前沿区块链安全技术。

我们也希望对加密数字货币领域有研究精神和科学方法论的终身迭代学习者可以加入我们的行列,向行业输出思考洞察与研究观点!

 此篇是Cobo Global 的第   15  篇文章



前言

2022 年 8 月 25 日, 据 Blocksec 消息,BSC DEX 协议 Kaoyaswap 遭遇攻击事件。本次攻击没有引起太多的分析和讨论,损失的金额也没有特别大,但是我认为其中的技术细节却有很多值得分析的点,同时此次的攻击经验也可以为我们提供很多值得防范和思考的点,避免同样的情况再次发生。相较于传统的 Uniswap DEX 架构,Kaoyaswap 自身采用了将全部代币池中的资金放到 router 中,正是这个架构导致了此次问题的发生。接下来我们来看详细的分析过程

攻击细节分析

本次攻击的哈希为  

https://bscscan.com/tx/0xc8db3b620656408a5004844703aa92d895eb3527da057153f0b09f0b58208d74,通过 Blocksec 的交易分析工具,不难发现本次攻击总共分为如下几个步骤,分别是 DODO FlashLoan, addLiquidity, SwapToken & Removeliquidity

Cobo安全团队——Kaoyaswap 被黑分析

也就是说,是这几个步骤构成了一次完整的攻击。如果只是分析交易行为的话,理论上以上的4个步骤都有可能是攻击入口,但是通过观察调用过程中传入的参数细节,不难发现在调用 SwapExactTokensForETHSupportingFeeOnTransferTokens 函数中,代币的兑换列表为 [TA, WBNB, TB, TA, WBNB]。很明显,这里是攻击者利用了 TA, TB 这两个自己创建的假币来进行兑换,从而掏空了合约中的资金。所以,根据分析,这里的我们应该重点分析 SwapExactTokensForETHSupportingFeeOnTransferTokens 接口, 并且由此可以推断,添加流动性只是为了代币兑换作准备。所以接下来的分析重点将会是 SwapExactTokensForETHSupportingFeeOnTransferTokens

在开始分析攻击之前,我们不妨先思考可能的获利方式,如果按照传统的 Uniswap DEX 架构的话,在一次兑换流程中,单边代币可以兑换出的数量的上限是取决于这个池中的这个代币流动性上限,也就是说你没有办法在添加了 2000 个 A Token 的情况下兑换出 2001 个甚至更多的 A Token,但是本次的攻击既然是发生在 SwapExactTokensForETHSupportingFeeOnTransferTokens 函数中,说明这次的兑换是打破这个规则的,或者说突破了这个规则下产生的其他限制。回想开头中关于 KaoyaSwap 架构上的介绍,由于它是将所有代币池的资金都放在了一个 Router 中,那么我们是不是理论上有可能通过 Swap 操作把其他池的资金提取出来呢?因为在这种架构下,某个代币的可兑出上限不再取决于已添加流动性的数量,而是取决于所有含该代币的交易对(pool)中该代币在 Router 合约中的总量。

思路正确,那么开始对 SwapExactTokensForETHSupportingFeeOnTransferTokens 函数的代码进行分析

function swapExactTokensForETHSupportingFeeOnTransferTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    
)
        external
        virtual
        override
        ensure(deadline)
    
{
        require(path[path.length - 1] == WETH, 'UniswapV2Router: INVALID_PATH');
        address pair = UniswapV2Library.pairFor(factory, path[0], path[1]);
        _transferIn(msg.sender,pair,path[0],amountIn);
        
        address lastPair = UniswapV2Library.pairFor(factory, path[path.length - 2], path[path.length - 1]);
        uint balanceBefore = getTokenInPair(lastPair,WETH);
        _swapSupportingFeeOnTransferTokens(path, address(this));
        uint balanceAfter = getTokenInPair(lastPair,WETH);
        uint amountOut = balanceBefore.sub(balanceAfter);
        require(amountOut >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
        _transferETH(to, amountOut);
    }

函数逻辑中并不存在较为复杂的逻辑,函数总体的逻辑是先将要兑换的代币通过 transferIn 函数转入到合约中,然后获取兑换列表中最后一个交易对中 WETH 的数量,然后调用 _swapSupportingFeeOnTransferTokens 进行一个内部的兑换,在兑换结束之后再次获取兑换列表中最后一个交易对中 WETH 的数量,最后将其中的 WETH 差值转给用户。这里解释下,由于这个函数是一个跨代币池的兑换,所以代币在兑换的过程中其实是在不同的池之间流转, 然后最后兑换出来的代币是发送到 router 地址上,并且最后兑换出来的代币一定是来自兑换路径中的最后一个代币池,所以这里用兑换前后最后一个代币池中的代币差值来决定最后发送给用户的代币数量是合理的,并没有太多的问题。除此之外,这里值得分析的地方还有这个 _transferIn 函数

function _transferIn(address from,address pair, address token, uint amountinternal {
        uint beforeBalance = IERC20(token).balanceOf(address(this));
        TransferHelper.safeTransferFrom(token, from, address(this), amount);
        _pools[pair][token] = _pools[pair][token].add(IERC20(token).balanceOf(address(this))).sub(beforeBalance);
    }

通过分析函数逻辑,可以发现有趣的是,Kaoyaswap 在代币转入池中的过程采用的是把代币转入 Router 地址的形式,然后把代币加到对应的 _pools 变量中,从这个逻辑中,不难发现全部池的信息其实是记录在 _pool 变量中的。然后通过对应的 pool address 进行路由,但是单从这个兑换过程看还是不能发现太多的问题,因为从逻辑上看,兑换出的 WETH 数量还是受限于每个代币池中代币总量的上限,所以要分析问题的成因,就需要继续对 _swapSupportingFeeOnTransferTokens 进行分析

function _swapSupportingFeeOnTransferTokens(address[] memory path, address _tointernal virtual {
        for (uint i; i < path.length - 1; i++) {
            (address input, address output) = (path[i], path[i + 1]);
            (address token0,) = UniswapV2Library.sortTokens(input, output);
            IUniswapV2Pair pair = IUniswapV2Pair(UniswapV2Library.pairFor(factory, input, output));
            uint amountInput;
            uint amountOutput;
            { // scope to avoid stack too deep errors
            (uint reserve0, uint reserve1,) = pair.getReserves();
            (uint reserveInput, uint reserveOutput) = input == token0 ? (reserve0, reserve1) : (reserve1, reserve0);
            amountInput = getTokenInPair(address(pair),input).sub(reserveInput);
            amountOutput = UniswapV2Library.getAmountOut(amountInput, reserveInput, reserveOutput);
            }
            (uint amount0Out, uint amount1Out) = input == token0 ? (uint(0), amountOutput) : (amountOutput, uint(0));
            address to = i < path.length - 2 ? address(this) : _to;
            _transferOut(address(pair), output, amountOutput, to);
            if(i < path.length - 2){
                address nextPair = UniswapV2Library.pairFor(factory, output, path[i + 2]);
                _pools[nextPair][output]=_pools[nextPair][output].add(amountOutput);
            }
            pair.swap(amount0Out, amount1Out, to, new bytes(0));
        }
    }

通过查看函数的代码,不难发现核心的逻辑在 #12 行开始,根据 amountsIn 的大小来先对输出代币数量进行一个模拟,然后在 #16 行中的 _transferOut 函数扣除对应 _pool[sourcePair] 中的代币,最后后将输出的结果添加到 _pools[dstPair] 变量中,作为下一次兑换中的输入,后续再通过 #21 行的 swap 函数进行兑换。整体流程和 Uniswap 一致,唯一不同的地方是跨池代币兑换在这里不再涉及代币的转移,而只是 Router 合约中对应 _pools 变量的修改。再沿着这个思路继续想。由于代币没有发生转移,只是数据发生了转移,那么如果兑换过程中的列表中的代币都是用户可以控制的话,用户是否可以从别的池中转入代币到用户自己创建的池,把兑换出来的代币数量的数据记录在用户创建池的 _pools 变量上,然后又完成了上一个池的兑换呢 :D,这种情况下,我既从上个池完成了代币的兑换,又把别的池的代币数据加到用户自己的池的数据中,这个时候如果后续用户提现,就可以成功把别的池的资金从自己的创建的池取出来了。这里可能有点绕,一旦想通了这一点,再回看攻击者的操作,其实就是这个思路了。

我们还可以通过下面的图来有更加详细的理解:

Cobo安全团队——Kaoyaswap 被黑分析

上图可以更清晰的了解整个流程,我们用 [AB] 池替代攻击过程中的 [TA, WBNB]池,用 [BC] 替代 [WBNB, TB] 池。由于在调用 SwapExactTokensForETHSupportingFeeOnTransferTokens 函数时, Router 会根据兑换路径计算所需代币兑换前后在最后一个代币池中的差值,根据攻击者的兑换参数 [TA, WBNB, TB, TA, WBNB], 在兑换过程中,从 [TA, WBNB] 中兑换出的 WBNB 会首先加到 [WBNB, TB] 池中,然后进行下一个池的兑换,最后再流经[TA, WBNB]池的时候,WBNB 的数量进一步缩小,此时 [TA, WBNB] 作为 lastPair, 将会计算前后的 WBNB 差值,然后所兑换中的差值发送给用户,然而,第一步兑换中 WBNB 的流动性还是停留在了 [WBNB, TB] 中,然后当你移除[WBNB, TB]池的流动性的时候,就又可以把原本[TA, WBNB] 池失去的代币再提一次出来,也就是说,整个过程中,代币提了2次。

更多的攻击路径

本次的问题主要是在于共享流动性的问题,把所有的资金放在了同一个 Router 中,从而产生了可以挪动合约中其他池资金的可能性。同时,除了这个攻击者用到的方法外,其实还存在其他的攻击更多的攻击入口,如swapExactTokensForETH 函数

function swapExactTokensForETH(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline)
        external
        virtual
        override
        ensure(deadline)
        returns (uint[] memory amounts)
    
{
        require(path[path.length - 1] == WETH, 'UniswapV2Router: INVALID_PATH');
        amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
        require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
        _transferIn(msg.sender,UniswapV2Library.pairFor(factory, path[0], path[1]),path[0],amounts[0]);
        _swap(amounts, path, address(this));
        _transferETH(to, amounts[amounts.length - 1]);
    }

通过逻辑分析,不难发现这里在 #8 行会先根据兑换代币列表进行一个预计算,最后会在 #13 行转出预计算结果数量的ETH,但是这里会存在一个问题,如果这里代入和攻击者一样的兑换列表[TA, WBNB, TB,TA,WBNB] 的话,由于最后的数量是预先算出来的,这个时候还没有进行兑换,所以兑换路径中所有池的状态都是兑换前的状态,如果兑换路径中存在相同的交易对,理论上预先计算的数量和最终的数量会产生偏差,相对于预先计算的结果,最终的结果会兑换更少出来,导致无法满足预计算得出的最终的输出数量从而导致交易的失败。在原来的 Uniswap 的架构中,由于 Router 本身并不存储任何代币,所以最后的调用是会失败的,但是如果结合 Kaoyaswap 的架构来看的话,并不会因为资金的问题导致最后结果的失败。

Uniswap fork 的其他风险

目前很多项目为了节约开发成本,很多时候会选择直接 fork 别人的架构然后对某个部分进行自己的修改,导致出现各种各样与原来架构产生兼容性的问题,除了上述的合并流动池的风险之外,以下简单罗列几个目前比较常见的风险:

  1. 交易挖矿问题 某些项目为了吸引更多的用户来兑换,会开启交易挖矿功能,这个功能一般是在 Routerswap 函数中添加对应的奖励逻辑,然后根据交易的数量来决定奖励代币的数量,这种情况下会有以下风险
    • 通过 UniswapV2 本身自带的闪电贷功能,通过闪电贷来刷交易量,只要奖励代币的价值可以覆盖最后的手续费,就可以通过不停的闪电贷来获取更多的奖励代币。
  2. 在swap过程中精度缺失问题
    • 某些项目为了修改 UniswapV2 中原生的兑换精度,原生的精度为 10000,但是忘记修改在 K 值计算时对应的精度,导致 K 值计算错误产生的池资产损失问题,对应的案例有 Impossible Finance 被黑等
  3. 手续费精度设置错误问题
    • 由于在 solidity 中是没有浮点数的概念的,所以 550/500 和 500/500 的结果一样都是 1,这种错误是很难发现的,如果在修改手续费的时候意外引入了浮点数,将会导致手续费数量计算错误的问题。相关的案例有 pancakeswap v2 的精度设置问题

总结

通过分析本次 Kaoyaswap 的攻击事件,我们应该清楚的明白到,在某些协议中,有一些架构设计本身其实已经包含了一定的安全考量,在对原有的架构进行改动的时候,需要充分考量原有架构下的安全假设,并在尽量不改动对应安全假设的前提下对架构进行更改。

值得一提的是,Cobo 区块链安全团队一直在持续关注跟踪业界最新的攻击事件,并及时对新型安全漏洞与攻击手法进行研究解析与技术分享,利用自身的安全积累为 Cobo 投研部门进行投资候选项目的安全评审,对区块链、DeFi 相关产品进行安全审计,尽最大努力保障客户的资产安全。


Cobo是亚太地区最大的加密货币托管机构,自成立以来已为超过300家行业顶尖机构以及高净值人士提供卓越的服务,在保证加密资产安全存储的前提下,同时兑现了加密资产的稳健增益,深受全球用户信赖。Cobo专注于搭建可扩展的基础设施,为机构管理多类型的资产提供安全托管、资产增值、链上交互以及跨链跨层等多重解决方案,为机构迈向 Web 3.0 转型提供最强有力的技术底层支持和赋能。Cobo 旗下包含Cobo Custody、Cobo DaaS、Cobo MaaS、Cobo  StaaS、Cobo Ventures、Cobo DeFi Yield Fund等业务板块,满足您的多种需求。



原文始发于微信公众号(Cobo Global):Cobo安全团队——Kaoyaswap 被黑分析

版权声明:admin 发表于 2022年9月20日 下午3:25。
转载请注明:Cobo安全团队——Kaoyaswap 被黑分析 | CTF导航

相关文章

暂无评论

暂无评论...