Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The rewards might be locked inside the contract due to the reentrancy attack. #28

Open
code423n4 opened this issue Aug 4, 2023 · 4 comments
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax

Comments

@code423n4
Copy link
Contributor

Lines of code

https://github.com/reserve-protocol/protocol/blob/9ee60f142f9f5c1fe8bc50eef915cf33124a534f/contracts/plugins/assets/erc20/RewardableERC20.sol#L111

Vulnerability details

Impact

The stakers might lose some rewards and the rewards might be locked inside the contract forever.

Proof of Concept

In RewardableERC20, users can claim their accumulated rewards using claimRewards().

    function claimRewards() external nonReentrant {
        _claimAndSyncRewards();
        _syncAccount(msg.sender);
        _claimAccountRewards(msg.sender);
    }

It claims and sync rewards from the staking contract first and transfers the rewards to users. Also, it has a nonReentrant modifier to prevent possible reentrancy approaches.

Due to this nonReentrant modifier, the reentrancy attack is impossible with claimRewards() only but users can call deposit() or withdraw inside the hook.

As a result, the attackers might manipulate lastRewardBalance like the below.

  1. We assume rewardToken is an ERC777 token with the _afterTokenTransfer hook.
  2. An attacker calls claimRewards(). Then _claimAccountRewards() is called after syncing rewards from the staking contract.
    function _claimAccountRewards(address account) internal {
        uint256 claimableRewards = accumulatedRewards[account] - claimedRewards[account];

        emit RewardsClaimed(IERC20(address(rewardToken)), claimableRewards);

        if (claimableRewards == 0) {
            return;
        }

        claimedRewards[account] = accumulatedRewards[account];

        uint256 currentRewardTokenBalance = rewardToken.balanceOf(address(this));

        // This is just to handle the edge case where totalSupply() == 0 and there
        // are still reward tokens in the contract.
        uint256 nonDistributed = currentRewardTokenBalance > lastRewardBalance
            ? currentRewardTokenBalance - lastRewardBalance
            : 0;

        rewardToken.safeTransfer(account, claimableRewards); //@audit possible reentrancy

        currentRewardTokenBalance = rewardToken.balanceOf(address(this));
        lastRewardBalance = currentRewardTokenBalance > nonDistributed
            ? currentRewardTokenBalance - nonDistributed
            : 0;
    }
  1. In _claimAccountRewards(), let's consider lastRewardBalance = 100, claimableRewards = 10.
  2. Inside the hook, the attacker calls deposit() and _claimAndSyncRewards() will be called within _beforeTokenTransfer().
  3. In _claimAndSyncRewards(), lastRewardBalance will be 100 as it isn't updated in _claimAccountRewards() yet.
    And balanceAfterClaimingRewards will be 100 - 10 + newlyClaimedRewards because claimableRewards = 10 was transfered already in _claimAccountRewards().
  4. The main assumption is newlyClaimedRewards is positive after the second call of _claimAssetRewards within the same transaction. It's because claimRewards() calls this function.
    This assumption might be possible to happen if the reward contract has a minimum threshold of deposit amount and it transfers the rewards during the second _claimAssetRewards after the attacker meets the threshold condition by depositing more funds.
  5. So if new rewards amount = 10, balanceAfterClaimingRewards = 90 + 10 = 100 and _rewardsPerShare won't be increased because balanceAfterClaimingRewards == _previousBalance although it has claimed new rewards.
  6. After the hook, lastRewardBalance will be set to 100.
    As a result, 10 rewards will be locked inside the contract.

So there are 2 assumptions to make this attack possible.

  • rewardToken is an ERC777 token. I think it's not so strong assumption because claimRewards() has a nonReentrant modifier already with this assumption.
  • _claimAssetRewards() claims some rewards during the second call in the same transaction after new deposit and
    it might be possible during the implementation for existing virtual functions.

Tools Used

Manual Review

Recommended Mitigation Steps

_claimAndSyncRewards() and _claimAccountRewards() should have a nonReentrant modifier individually instead of claimRewards.

Assessed type

Reentrancy

@code423n4 code423n4 added 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value bug Something isn't working labels Aug 4, 2023
code423n4 added a commit that referenced this issue Aug 4, 2023
@c4-judge
Copy link
Contributor

c4-judge commented Aug 5, 2023

thereksfour marked the issue as primary issue

@c4-judge c4-judge added the primary issue Highest quality submission among a set of duplicates label Aug 5, 2023
@carlitox477
Copy link

This issue is mentioned in #41 as MorphoTokenisedDeposit::rewardTokenBalance behavior can be affected by reentrancy though RewardableERC20::_claimAssetRewards

@c4-judge c4-judge added downgraded by judge Judge downgraded the risk level of this issue QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax and removed 2 (Med Risk) Assets not at direct risk, but function/availability of the protocol could be impacted or leak value labels Aug 6, 2023
@c4-judge
Copy link
Contributor

c4-judge commented Aug 6, 2023

thereksfour changed the severity to QA (Quality Assurance)

@thereksfour
Copy link

The attack is not profitable, consider QA.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working downgraded by judge Judge downgraded the risk level of this issue grade-a primary issue Highest quality submission among a set of duplicates QA (Quality Assurance) Assets are not at risk. State handling, function incorrect as to spec, issues with clarity, syntax
Projects
None yet
Development

No branches or pull requests

5 participants