2022 年 8 月 25 日, 据 Blocksec
消息,BSC DEX
协议 Kaoyaswap
遭遇攻击事件。本次攻击没有引起太多的分析和讨论,损失的金额也没有特别大,但是我认为其中的技术细节却有很多值得分析的点,同时此次的攻击经验也可以为我们提供很多值得防范和思考的点,避免同样的情况再次发生。相较于传统的 Uniswap DEX
架构,Kaoyaswap
自身采用了将全部代币池中的资金放到 router
中,正是这个架构导致了此次问题的发生。接下来我们来看详细的分析过程
本次攻击的哈希为
https://bscscan.com/tx/0xc8db3b620656408a5004844703aa92d895eb3527da057153f0b09f0b58208d74,通过 Blocksec 的交易分析工具,不难发现本次攻击总共分为如下几个步骤,分别是 DODO FlashLoan
, addLiquidity
, SwapToken
& Removeliquidity
也就是说,是这几个步骤构成了一次完整的攻击。如果只是分析交易行为的话,理论上以上的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 amount) internal {
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 _to) internal 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,这种情况下,我既从上个池完成了代币的兑换,又把别的池的代币数据加到用户自己的池的数据中,这个时候如果后续用户提现,就可以成功把别的池的资金从自己的创建的池取出来了。这里可能有点绕,一旦想通了这一点,再回看攻击者的操作,其实就是这个思路了。
我们还可以通过下面的图来有更加详细的理解:
上图可以更清晰的了解整个流程,我们用 [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
的架构来看的话,并不会因为资金的问题导致最后结果的失败。
目前很多项目为了节约开发成本,很多时候会选择直接 fork
别人的架构然后对某个部分进行自己的修改,导致出现各种各样与原来架构产生兼容性的问题,除了上述的合并流动池的风险之外,以下简单罗列几个目前比较常见的风险:
Router
的 swap
函数中添加对应的奖励逻辑,然后根据交易的数量来决定奖励代币的数量,这种情况下会有以下风险UniswapV2
本身自带的闪电贷功能,通过闪电贷来刷交易量,只要奖励代币的价值可以覆盖最后的手续费,就可以通过不停的闪电贷来获取更多的奖励代币。UniswapV2
中原生的兑换精度,原生的精度为 10000
,但是忘记修改在 K
值计算时对应的精度,导致 K
值计算错误产生的池资产损失问题,对应的案例有 Impossible Finance
被黑等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等业务板块,满足您的多种需求。