合约安全[4]:为啥说UniswapK值不那么守恒
2021-05-29

前言

诶嘿,刚审计了个Swap项目,项目的Swap函数手续费收了两次...
为了把问题描述的清晰一些,我仔细地追了一下swap中手续费的计算方法。

于是机缘巧合的发现了Uniswap K值检查的奇妙,和我想象中的检查方式有些差别。
本文做一个技术点的记录和分享。

前置知识

AMM的K值守恒

在AMM中进行交易,遵循K值守恒原则,Swap前后的reverse乘积不变。
详细兑换原理,可查看 uniswap解析与举例

问题发现

在描述问题过程之前,笔者奉上UniswapV2Pair:swap的代码以便查阅:

    // this low-level function should be called from a contract which performs important safety checks
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
        require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
        (uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
        require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');

        uint balance0;
        uint balance1;
        { // scope for _token{0,1}, avoids stack too deep errors
        address _token0 = token0;
        address _token1 = token1;
        require(to != _token0 && to != _token1, 'UniswapV2: INVALID_TO');
        if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out); // optimistically transfer tokens
        if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out); // optimistically transfer tokens
        if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);
        balance0 = IERC20Uniswap(_token0).balanceOf(address(this));
        balance1 = IERC20Uniswap(_token1).balanceOf(address(this));
        }
        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
        require(amount0In > 0 || amount1In > 0, 'UniswapV2: INSUFFICIENT_INPUT_AMOUNT');
        { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
        require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
        }

        _update(balance0, balance1, _reserve0, _reserve1);
        emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
    }

根据AMM的K值守恒要求,当Swap完成时,uniswap底层应检查一下K值是否与交易前相等。
代码大概是 balance0 * balance1 == K这样子,至少应该是个等于号
但是当我们跟进到UniswapV2Pair:swap函数时,发现检查K值守恒的用的是>=号,对应代码是

require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');

嘿嘿,神奇啊。这和预期的K值守恒完全不是一个意思呀。
带着充满好奇的满心欢喜,我们瞅瞅上面代码是咋写的。
等号左边儿咋害能比K值大呢?

        uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
        uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));

瞅这两行,balance0Adjustedbalance1Adjusted是pair新余额的校准值。
校准的部分是刨去手续费的amountIn
看到这里我就更懵了: 这一Adjust后,balance更小了。
这乘积肯定比K值小啊,咋害能大呢。

为啥不是小于K值

我们仔细看一下amountIn的来源,

uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;

可见amountIn真实balance - (reverse - amountOut)
所以amountIn 的含义是 真实的输入量,即包含0.3%手续费的那个100%AmountIn

这么一看我就悟了。
哦,我的老伙计。
用户把钱打进来的地方可是在swap函数调用之前呀。
UniswapV2Router02.sol:swapExactTokensForTokens:

    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external virtual override ensure(deadline) returns (uint[] memory amounts) {
        amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path);
        require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
        TransferHelper.safeTransferFrom(
            path[0], msg.sender, UniswapV2Library.pairFor(factory, path[0], path[1]), amounts[0]
        );
        _swap(amounts, path, to);
    }
  • 打钱的地方是TransferHelper.safeTransferFrom
  • 这时候balance发生变化,增加了100%AmountIn
  • 但是下面_swap兑换的时候,用的AmountOutgetAmountOutreturn的那个99.7%AmountOut
  • 剩余的0.3%feeValue被留在了pair里面,造成了输入币reverse的增大。

后面pair.swap时,通过校准将这0.3%异常抹除。
这时再比较K值,就不是小于而是相等啦。

为啥还能大于K值

ok 不是小于而是相等的问题解决了。
剩下就是 Uniswap为啥还留了个大于号

require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');

要搞清楚这个问题,我们需要回到Uniswap第一次开始检查K值守恒的时间点:
commit:580787051ed66b75210c14269a3a48b4bcdee620

在这个版本中,人类历史上首次提出了"UniswapV2: K"的要求

        } else {
            ...
            require(amountIn.mul(reserve0 - amountOut).mul(997) >= amountOut.mul(reserve1).mul(1000), "UniswapV2: K");
            ...
        }

哭辽,原来swap方法从一开始就没想过等价交换或者什么K值守恒
它只是要求 扣除手续费后的AmountIn 要大于等于 取走的AmountOut
换句话说:
你往pair里面多扔钱我不拦着;想多拿走?绝不可能!(╬ ̄皿 ̄)=○

总结

这么一追,我悟了。
>=设计本身就是一个合约的利己行为。
Swap的K值守恒也只是限制user别换走额外的钱,
但你要是无偿提供点流动性,本合约绝不拦你~ ╰( ̄▽ ̄)╭

碎碎念

使用>=而不是==,放宽了K值守恒的限制,
这意味着攻击者有了更多的操作空间。
至于能怎么利用这个小trick,以后再想吧...

引用