闪电贷和预言机

 

闪电贷和预言机

与传统的金融借贷不同,闪电贷允许用户无需抵押无需任何凭证,任何KYC流程即可贷出任意数额的资金,只要用户满足在一笔交易内完成借款还款的逻辑,闪电贷即可顺利执行。

由此一来产生了很多玩法,之前繁琐的杠杆业务就被简化了,每个人可以有巨大的本金来进行一些套利操作,且风险很低。

在defi的攻击事件中,闪电贷也常常做为黑客的工具,作为提供实施攻击本金的工具用于种种的攻击活动中。

然而针对于闪电贷攻击这种特定的攻击形式,一般泛指的是攻击者通过闪电贷来操纵币价,进一步套利的操作。而如何操纵币价,那么就不得不谈预言机(oracle)这一defi生态中重要的功能组件。

币价是怎么算的

宏观概念

在讨论虚拟货币的币价时,我们先明确一个概念:虚拟货币是一种货币,货币本身不具有任何价值,他只是充当了交易的媒介,是一种社会和市场上共认的交易协议。

拿现实货币的纸币来说,其本身就是一张纸,并没有任何的使用价值。虚拟货币也是一样。

那么一张纸币的价值如何确定呢?

拿美元来作为例子,众所周知美元是对标的黄金储备。那么如果银行中有10吨的黄金储备,银行一共发行了10美元,那么每一美元的价值就是1吨黄金的价值;同样,如果银行有10吨黄金储备,发行了100美元,那么一美元就等于100kg的黄金的价值。

当然实际的价格计算会比上述例子复杂很多,不过其本质都是一样的,即:币价 = 资金池内的价值总和/货币发行量。

币价操控

由上述的币价计算可以得知,货币的价格是由对标的资产进行恒定,那么在上面的例子中,黄金储备和发行量就直接影响了币价。

如果银行黄金储备增加,在货币发行量不变的条件下,一美元对应的黄金量就会增加;反之黄金储备减少,那么美元价格必然会下跌。

同理,如果大量发行货币,必然导致币价下跌。

所以说,在本例中操纵货币价格的本质,其实就是操纵美元发行量和黄金储备的比值。

虚拟币的币价操控

在上面例子中,我们只考虑了美元可以换多少黄金,但是如果我们拓展一下概念,将黄金也做为货币来看的话(虽然黄金很长时间就是做为货币使用),那么就很好理解虚拟市场的币价操纵了。

为了方便阐述,我们以Uniswap类的交易对为例,将黄金和美元看作两个虚拟货币,交易池中的黄金储备和美元储备的比值,就代表了用黄金(美元)可以换取多少美元(黄金)。

比如,池子中黄金和美元的比率为1:100,那么用1个黄金就可以换100个美元,用一个美元只能换0.01个黄金。

当然,在uniswap中,这一换算关系是由恒定乘积公式来决定的(x*y = k),除非用户换取的金额将近流动资产数额,否则一般情况下换取的比率和上述比率相差不多。

比如池中有10000美元和100黄金,那么此时k=1000000,用户这时拿了1个黄金来换美元,在不考虑手续费的情况下,用户最终会得到:10000 - (k / 101) = 99.01美元,和上面的比率几乎一样。

那么此时,如果用户想用50个黄金换取美元,能换多少黄金呢?

这时我们计算一下,最终用户只能换取3333.33美元,和之前的预期比率相差比较远。因为50黄金已经和总储备100黄金是一个数量级的概念了,用户投入的资金过多,也就导致了币价的不稳定。

那么如果用户并不是换取资产,而是投入流动资金呢?

如果用户向交易池中投入了10000美元,黄金仍是100个,那么此时的k = 2000000,此时二者的汇率就变成了200:1,也就是变相的增发了货币。

那么其他用户在这一交易池来进行交易时,用美元换取的黄金就会比其他交易池少。

如果攻击者利用这一点,在某一交易池操纵了币价,那么这时候美元就相对贬值,攻击者可以用黄金换取更多的美元,再在别的价格稳定的交易所购买回更多的黄金,以此获利。

如何防止币价操纵

由上面的分析可得,操纵币价的本质是向交易池内提供大量的流动资金,进而影响币价,也就是兑换比率。

那么攻击的重点就在于大量的定义上,如果流动性资金很多,那么相应的攻击者就要提供更多的资金;少的话则对应攻击者也不必拥有巨额资产。

在传统金融领域,想要实施类似的操纵币价股价等攻击需要巨额的资金支持,所以在以太坊诞生和aave提出闪电贷之前,类似的攻击者不会是草根黑客。

但是由于闪电贷的出现,巨额资金的门槛不复存在,任何人都有机会在一笔交易中动用大量资金,所以每个月都会出现一批闪电贷攻击的受害者。于是乎,用于计算币价的预言机也开始换代升级。

防御的本质

对于闪电贷攻击,其本质的效果就是在短时间内发生了巨大的币价波动。

那么防御就可以从时间和空间两个角度进行:

  1. 时间:由于闪电贷攻击都是在一个区块内的价格操控,所以可以采用历史币价来防止短期波动。
  2. 空间:闪电贷攻击都是针对于单个交易池,可以通过多方预言机报价均值来进行最终报价计算。

也可以理解为,将攻击者的闪电贷资金带来的波动,在时间上平摊开或者在多个交易池平摊开。

预言机的早期阶段

与前面例子讲述的思想相同,正如互联网早期的软件,预言机一开始也没有考虑闪电贷攻击,只是用最朴素的方式实现了价格计算。

如uniswap v1:

    function getPrice() public view returns (uint price){
        (uint112 reserve0, uint112 reserve1 , ) = WETH_USDT.getReserves();
        console.log("LP Reserve: WETH", reserve0, "USDT", reserve1);
        price = reserve1*10**12/reserve0; // WETH decimals 18, USDT decimals 6
        console.log("Price USDT/WETH=", price);
    }

再如这个月发生的deus finance攻击中的预言机:

//https://ftmscan.com/address/0xd5403ff59BFFdd433B12859aF237548Ed059b1dd#code
    function getPrice() external view returns (uint256) {
        return
            ((dei.balanceOf(address(pair)) + (usdc.balanceOf(address(pair)) * 1e12)) *
                1e18) / pair.totalSupply();
    }

都是直接了当的没有任何防护,类似的预言机报价在闪电贷攻击面前形同虚设。

时间上的防御机制——TWAP

针对于uniswap v1版本的预言机设计缺陷,uniswap v2对于预言机进行了改进,采用了TWAP方法来进行价格计算。

TWAP即 time-weighted average price,将价格存在的时长作为权重取一段时间内的平均值做为最终的报价。

代码如下:

    // update reserves and, on the first call per block, price accumulators
    function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
        require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');
        uint32 blockTimestamp = uint32(block.timestamp % 2**32);
        uint32 timeElapsed = blockTimestamp - blockTimestampLast; // overflow is desired
        if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {  
            // * never overflows, and + overflow is desired
            price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
            price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
        }
        reserve0 = uint112(balance0);
        reserve1 = uint112(balance1);
        blockTimestampLast = blockTimestamp;
        emit Sync(reserve0, reserve1);
    }
    function currentCumulativePrices(
        address pair
    ) internal view returns (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) {
        blockTimestamp = currentBlockTimestamp();
        price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast();
        price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast();

        // if time has elapsed since the last update on the pair, mock the accumulated price values
        (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) = IUniswapV2Pair(pair).getReserves();
        if (blockTimestampLast != blockTimestamp) {
            // subtraction overflow is desired
            uint32 timeElapsed = blockTimestamp - blockTimestampLast;
            // addition overflow is desired
            // counterfactual
            price0Cumulative += uint(FixedPoint.fraction(reserve1, reserve0)._x) * timeElapsed;
            // counterfactual
            price1Cumulative += uint(FixedPoint.fraction(reserve0, reserve1)._x) * timeElapsed;
        }
    }
    function getTWAP(address pair) public returns (uint price0, uint price1){
        (uint price0Cumulative, price0Cumulative, uint32 blockTimestamp) =
            UniswapV2OracleLibrary.currentCumulativePrices(address(pair));

        uint timeElapsed = blockTimestamp - blockTimestampLast;
        require(timeElapsed > 0, "TimeElapsed is 0");
        uint avg0 = (price0Cumulative - price0CumulativeLast) / timeElapsed;
				uint avg1 = (price1Cumulative - price1CumulativeLast) / timeElapsed;
        price0Average = FixedPoint.uq112x112(uint224(avg0));
				price1Average = FixedPoint.uq112x112(uint224(avg1));
        price0 = price0Average.mul(10**12).decode144();
				price1 = price1Average.mul(10**12).decode144();
        price0CumulativeLast = price0Cumulative;
				price1CumulativeLast = price1Cumulative;
        blockTimestampLast = blockTimestamp;
    }

当一个区块中发生注入流动性或者进行swap产生交易池内流动资金变化时,都会将当前两种代币比值(即兑换比率,或者价格)以及当前区块时间与上次更新时间的差值的乘积累加到变量中。这个更新在每个区块只发生一次,且数据来源为上一个区块。

if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {  
            // * never overflows, and + overflow is desired
            price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
            price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
        }

在进行报价时,会将当前报价持续时间乘积与之前报价持续时间乘积进行一个平均处理,即:

TWAP = (Ph * Th + Pn * Tn) / (Th + Tn)

短期的价格波动由于持续时间少,所以不会对最终报价产生过大的波动(有点像POS挖矿)。

如果攻击者想要攻击TWAP模式的预言机,那么攻击者需要持续的攻击维持价格,这一点闪电贷是做不到的。

空间上的防御——多源报价

由于闪电贷攻击面向的都是单个交易池,而chainlink价格预言机的报价价格是通过多个层级的数据聚合得到的。

首先数据提供商会从链下的中心化交易所如币安以及一些链上的中心化交易所如Uniswap获取最初的价格数据。

而后chainlink节点将数据进行聚合,包括剔除一些无效数据、加权平均等操作。

最后这些报价信息才会传入价格语言机,供给defi项目使用。

攻击者如果想进行币价操纵,则要同时攻击大量的交易同时操纵币价,这是几乎不可能的。

总结

本文中只举了两个代表性的例子:uniswap和chainlink语言机。两种防御方式都能抵抗闪电贷攻击带来的币价波动,本质就是将闪电贷短暂大量的资金涌入或在空间或在时间上进行平摊。

同时,本文只介绍了预言机的报价功能以及常规闪电贷操纵币价的原理,对于预言机的其他功能和其他的闪电贷攻击形式没有提及,如有不对之处还请斧正。

了解了闪电贷操纵币价的原理以及相应的防御措施,在审计项目时可以针对性的分析预言机实现,见到没有使用chailink或者TWAP的预言机,就可以放心大胆的提交漏洞了。