Reserve Protocol - Invitational - bin2chen's results

A permissionless platform to launch and govern asset-backed stable currencies.

General Information

Platform: Code4rena

Start Date: 25/07/2023

Pot Size: $80,100 USDC

Total HM: 18

Participants: 7

Period: 10 days

Judge: cccz

Total Solo HM: 15

Id: 268

League: ETH

Reserve

Findings Distribution

Researcher Performance

Rank: 2/7

Findings: 4

Award: $0.00

QA:
grade-a

🌟 Selected for report: 3

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: bin2chen

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor acknowledged
H-03

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/e3d2681503499e81915797c77eeef8210352a138/contracts/plugins/assets/convex/vendor/ConvexStakingWrapper.sol#L296

Vulnerability details

Impact

After shutdown, checkpoints are stopped, leading to possible theft of rewards.

Proof of Concept

ConvexStakingWrapper No more checkpoints after shutdown, i.e. no updates reward.reward_integral_for[user]

    function _beforeTokenTransfer(
        address _from,
        address _to,
        uint256
    ) internal override {
@>      _checkpoint([_from, _to]);
    }

    function _checkpoint(address[2] memory _accounts) internal nonReentrant {
        //if shutdown, no longer checkpoint in case there are problems
@>      if (isShutdown()) return;

        uint256 supply = _getTotalSupply();
        uint256[2] memory depositedBalance;
        depositedBalance[0] = _getDepositedBalance(_accounts[0]);
        depositedBalance[1] = _getDepositedBalance(_accounts[1]);

        IRewardStaking(convexPool).getReward(address(this), true);

        _claimExtras();

        uint256 rewardCount = rewards.length;
        for (uint256 i = 0; i < rewardCount; i++) {
            _calcRewardIntegral(i, _accounts, depositedBalance, supply, false);
        }
    }    

This would result in, after shutdown, being able to steal rewards by transferring tokens to new users

Example: Suppose the current reward.reward_integral = 1000

When a shutdown occurs

  1. alice transfers 100 to the new user, bob. Since bob is the new user and _beforeTokenTransfer()->_checkpoint() is not actually executed Result. balanceOf[bob] = 100 reward.reward_integral_for[bob] = 0

  2. bob executes claimRewards() to steal the reward reward amount = balanceOf[bob] * (reward.reward_integral - reward.reward_integral_for[bob]) = 100 * (1000-0)

  3. bob transfers the balance to other new users, looping steps 1-2 and stealing all rewards

Tools Used

Still execute _checkpoint


    function _checkpoint(address[2] memory _accounts) internal nonReentrant {
        //if shutdown, no longer checkpoint in case there are problems
 -      if (isShutdown()) return;

        uint256 supply = _getTotalSupply();
        uint256[2] memory depositedBalance;
        depositedBalance[0] = _getDepositedBalance(_accounts[0]);
        depositedBalance[1] = _getDepositedBalance(_accounts[1]);

        IRewardStaking(convexPool).getReward(address(this), true);

        _claimExtras();

        uint256 rewardCount = rewards.length;
        for (uint256 i = 0; i < rewardCount; i++) {
            _calcRewardIntegral(i, _accounts, depositedBalance, supply, false);
        }
    }    

Assessed type

Context

#0 - c4-judge

2023-08-05T08:44:52Z

thereksfour marked the issue as primary issue

#1 - c4-sponsor

2023-08-30T16:47:38Z

pmckelvy1 marked the issue as sponsor confirmed

#2 - c4-sponsor

2023-08-30T16:47:50Z

pmckelvy1 marked the issue as sponsor acknowledged

#3 - c4-judge

2023-09-05T06:12:09Z

thereksfour marked the issue as satisfactory

#4 - c4-judge

2023-09-05T06:12:15Z

thereksfour marked the issue as selected for report

Findings Information

🌟 Selected for report: ronnyx2017

Also found by: bin2chen

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
duplicate-45

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/9ee60f142f9f5c1fe8bc50eef915cf33124a534f/contracts/plugins/assets/curve/CurveStableCollateral.sol#L146

Vulnerability details

Impact

curvePool.get_virtual_price() May be manipulated to cause malicious entry DISABLED

Proof of Concept

CurveVolatileCollateral._underlyingRefPerTok() return curvePool.get_virtual_price()

    function _underlyingRefPerTok() internal view virtual override returns (uint192) {
@>      return _safeWrap(curvePool.get_virtual_price());
    }

If curvePool contains ETH, it can be manipulated by the price. For more information:

https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/

_underlyingRefPerTok() Being manipulated could lead to entry DISABLED

Tools Used

Check for reentrancy as described in the article.

Since reentrancy locks were not publicly viewable by smart contracts, the reentrancy lock had to be triggered differently— by calling a state-altering function. The criteria for such a function were not to transfer funds from or to oracles and to be relatively cheap in terms of gas overhead. The solution identified most suitable was calling the pools’ withdraw_admin_fees function to trigger the reentrancy lock. Most protocols followed that protection pattern.


## Assessed type

Context

#0 - c4-judge

2023-08-05T14:31:08Z

thereksfour marked the issue as primary issue

#1 - tbrent

2023-08-08T20:30:58Z

We have considered this very issue before and have decided as a solution to avoid raw ETH and ERC777's entirely, and not try to detect reentrancy in the way described in the article. This is for a few reasons:

  1. It cannot be implemented uniformly. withdraw_admin_fees is not on all Curve pools, and in particular not on Tricrypto. https://etherscan.io/address/0xd51a44d3fae010294c616388b506acda1bfaae46
  2. Raw ETH presents other challenges. Even if we detect reentrancy in the way suggested, the assetRegistry making multiple asset refresh() calls means an attacker could gain execution under a different asset's refresh() and use that to manipulate refPerTok().

Also related: Our target unit system does not work well with volatile pools. Each pool would need its own target unit and therefore could not be backed up with any other collateral except identically/distributed pools. We plan to remove CurveVolatileCollateral entirely.

#2 - tbrent

2023-08-08T20:57:02Z

There is documentation on the website indicating to avoid ERC777 tokens, but this should probably be updated to include forbidding LP tokens that contain raw ETH. Though, it is a bit more complicated than that since some LP tokens offer withdrawal functions that automate the unwrapping of WETH into ETH.

https://reserve.org/protocol/rtokens/#non-compatible-erc20-assets

#3 - c4-sponsor

2023-08-30T16:48:53Z

pmckelvy1 marked the issue as sponsor confirmed

#4 - c4-judge

2023-09-05T06:24:22Z

thereksfour marked the issue as satisfactory

#5 - c4-judge

2023-09-05T06:24:26Z

thereksfour marked the issue as selected for report

#6 - 5z1punch

2023-09-05T11:34:15Z

Dup of https://github.com/code-423n4/2023-07-reserve-findings/blob/main/data/ronnyx2017-Q.md#7-curve-read-only-reentrancy-can-increase-the-price-of-some-curvestablecollateral . In fact The price manipulated mentioned in the https://github.com/code-423n4/2023-07-reserve-findings/issues/14 is Curve Read-Only Reentrancy attack. And I explained this attack in more detail in my QA report.

#7 - c4-judge

2023-09-05T12:12:30Z

thereksfour marked the issue as not selected for report

#8 - c4-judge

2023-09-05T12:14:23Z

thereksfour marked the issue as duplicate of #45

Findings Information

🌟 Selected for report: bin2chen

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor acknowledged
M-11

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/e3d2681503499e81915797c77eeef8210352a138/contracts/plugins/assets/aave/StaticATokenLM.sol#L359

Vulnerability details

Impact

transfer missing _updateRewards(),Resulting in the loss of from's reward

Proof of Concept

StaticATokenLM contains the rewards mechanism, when the balance changes, the global _accRewardsPerToken needs to be updated first to calculate the user's rewardsAccrued more accurately.

Example: mint()/burn() both call _updateRewards() to update _accRewardsPerToken

    function _deposit(
        address depositor,
        address recipient,
        uint256 amount,
        uint16 referralCode,
        bool fromUnderlying
    ) internal returns (uint256) {
        require(recipient != address(0), StaticATokenErrors.INVALID_RECIPIENT);
@>      _updateRewards();

...

        _mint(recipient, amountToMint);

        return amountToMint;
    }


    function _withdraw(
        address owner,
        address recipient,
        uint256 staticAmount,
        uint256 dynamicAmount,
        bool toUnderlying
    ) internal returns (uint256, uint256) {
...
@>      _updateRewards();

...
@>      _burn(owner, amountToBurn);

...
    }        

When transfer()/transerFrom(), the balance is also modified, but without calling _updateRewards() first The result is that if the user transfers the balance, the difference in rewards accrued by from is transferred to to along with it. This doesn't make sense for from.

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256
    ) internal override {
        if (address(INCENTIVES_CONTROLLER) == address(0)) {
            return;
        }
        if (from != address(0)) {
            _updateUser(from);
        }
        if (to != address(0)) {
            _updateUser(to);
        }
    }

Tools Used

_beforeTokenTransfer first trigger _updateRewards()

    function _beforeTokenTransfer(
        address from,
        address to,
        uint256
    ) internal override {
        if (address(INCENTIVES_CONTROLLER) == address(0)) {
            return;
        }
+       _updateRewards();        
        if (from != address(0)) {
            _updateUser(from);
        }
        if (to != address(0)) {
            _updateUser(to);
        }
    }

Assessed type

Context

#0 - c4-judge

2023-08-05T14:44:32Z

thereksfour marked the issue as primary issue

#1 - tbrent

2023-08-08T20:32:33Z

We will be reaching out to the Aave team to understand more about this. It seems there are multiple places in StaticATokenLM where reward steps are missing, and there may be reasons why.

#2 - julianmrodri

2023-08-28T14:52:00Z

We will mark this issue as Sponsor Acknowledged. It is true the situation described by the warden and that's the behavior we observe. However we will not be implementing any change in the code (besides adding some comments) for the following reasons:

  • We do not expect rewards in Aave V2 to come back
  • We checked with Aave and we believe the original reason for building it this way still holds, and that is for gas purposes. Even if for some reason rewards on AAve V2 come back the cost of updating the user rewards on every transfer outweighs the rewards that may be left "uncollected" after a transfer operation. It is important to remark that any deposit or withdraw done to the contract plus any call to collectRewards.., and any claim of rewards from the Reserve protocol, would setup the correct balances. So while it is true that transfers may in some cases not transfer rewards we expect this to only be slightly off.

To clarify this issue for users we will add a comment to the wrapper contract StaticATokenLM to clarify the situation with how rewards are handled on transfer.

#3 - c4-sponsor

2023-08-28T16:15:47Z

tbrent marked the issue as sponsor acknowledged

#4 - c4-judge

2023-09-05T06:24:05Z

thereksfour marked the issue as satisfactory

#5 - c4-judge

2023-09-05T06:24:09Z

thereksfour marked the issue as selected for report

Findings Information

🌟 Selected for report: bin2chen

Also found by: carlitox477

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor acknowledged
M-12

Awards

Data not available

External Links

Lines of code

https://github.com/reserve-protocol/protocol/blob/e3d2681503499e81915797c77eeef8210352a138/contracts/plugins/assets/aave/StaticATokenLM.sol#L459-L461

Vulnerability details

Impact

Incorrect determination of maximum rewards, which may lead to loss of user rewards

Proof of Concept

_claimRewardsOnBehalf() For users to retrieve rewards

    function _claimRewardsOnBehalf(
        address onBehalfOf,
        address receiver,
        bool forceUpdate
    ) internal {
        if (forceUpdate) {
            _collectAndUpdateRewards();
        }

        uint256 balance = balanceOf(onBehalfOf);
        uint256 reward = _getClaimableRewards(onBehalfOf, balance, false);
        uint256 totBal = REWARD_TOKEN.balanceOf(address(this));

@>      if (reward > totBal) {
@>          reward = totBal;
@>      }
        if (reward > 0) {
@>          _unclaimedRewards[onBehalfOf] = 0;
            _updateUserSnapshotRewardsPerToken(onBehalfOf);
            REWARD_TOKEN.safeTransfer(receiver, reward);
        }
    }

From the code above, we can see that if the contract balance is not enough, it will only use the contract balance and set the unclaimed rewards to 0: _unclaimedRewards[user]=0.

But using the current contract's balance is inaccurate, REWARD_TOKEN may still be stored in `INCENTIVES_CONTROLLER

_updateRewards() and _updateUser(), are just calculations, they don't transfer REWARD_TOKEN to the current contract, but _unclaimedRewards[user] is always accumulating

  1. _updateRewards() not transferable REWARD_TOKEN
function _updateRewards() internal { ... if (block.number > _lastRewardBlock) { ... address[] memory assets = new address[](1); assets[0] = address(ATOKEN); @> uint256 freshRewards = INCENTIVES_CONTROLLER.getRewardsBalance(assets, address(this)); uint256 lifetimeRewards = _lifetimeRewardsClaimed.add(freshRewards); uint256 rewardsAccrued = lifetimeRewards.sub(_lifetimeRewards).wadToRay(); @> _accRewardsPerToken = _accRewardsPerToken.add( (rewardsAccrued).rayDivNoRounding(supply.wadToRay()) ); _lifetimeRewards = lifetimeRewards; } }
  1. but _unclaimedRewards[user] always accumulating
    function _updateUser(address user) internal {
        uint256 balance = balanceOf(user);
        if (balance > 0) {
            uint256 pending = _getPendingRewards(user, balance, false);
@>          _unclaimedRewards[user] = _unclaimedRewards[user].add(pending);
        }
        _updateUserSnapshotRewardsPerToken(user);
    }

This way if _unclaimedRewards(forceUpdate=false) is executed, it does not trigger the transfer of REWARD_TOKEN to the current contract. This makes it possible that _unclaimedRewards[user] > REWARD_TOKEN.balanceOf(address(this)) According to the _claimedRewardsOnBehalf() current code, the extra value is lost.

It is recommended that if (reward > totBal) be executed only if forceUpdate=true, to avoid losing user rewards.

Tools Used

    function _claimRewardsOnBehalf(
        address onBehalfOf,
        address receiver,
        bool forceUpdate
    ) internal {
        if (forceUpdate) {
            _collectAndUpdateRewards();
        }

        uint256 balance = balanceOf(onBehalfOf);
        uint256 reward = _getClaimableRewards(onBehalfOf, balance, false);
        uint256 totBal = REWARD_TOKEN.balanceOf(address(this));

-       if (reward > totBal) {
+       if (forceUpdate && reward > totBal) {
            reward = totBal;
        }
        if (reward > 0) {
            _unclaimedRewards[onBehalfOf] = 0;
            _updateUserSnapshotRewardsPerToken(onBehalfOf);
            REWARD_TOKEN.safeTransfer(receiver, reward);
        }
    }

Assessed type

Context

#0 - c4-judge

2023-08-05T04:22:49Z

thereksfour marked the issue as primary issue

#1 - c4-judge

2023-08-06T16:39:32Z

thereksfour changed the severity to 2 (Med Risk)

#3 - julianmrodri

2023-08-28T13:57:51Z

After a thorough review we can confirm this is not an issue. This is the way it should work and that's the reason why there is a forceUpdate param. When forceUpdate == true, then you will always have the latest rewards to claim and the updated balance.

When is set to false, it will only distribute the rewards that were previously collected (the ones available in the contract). It is correct there might be additional rewards to be collected, but that can easily be done with another call to the same function using the forceUpdate == true.

There are no rewards "lost" in the process, no fix needs to be implemented. Even though unclaimedRewards is set to zero, then it will be populated with all the pending rewards again so the amount will be ok.

Moreover, the suggested fix would brick the function most of the time (as usually rewards are bigger than balance because it includes uncollected but pending rewards), and in that case it would attempt to transfer rewards not available in the contract. The check of just sending the balance in those cases is required.

#4 - c4-sponsor

2023-08-28T16:13:52Z

tbrent marked the issue as sponsor disputed

#5 - thereksfour

2023-08-31T05:08:11Z

@bin2chen66 please take a look. It seems that since _getPendingRewards has a false parameter, _unclaimedRewards does not accumulate unclaimed rewards in the controller, so the rewards are not lost.

    function _updateUser(address user) internal {
        uint256 balance = balanceOf(user);
        if (balance > 0) {
            uint256 pending = _getPendingRewards(user, balance, false);
            _unclaimedRewards[user] = _unclaimedRewards[user].add(pending);
        }
        _updateUserSnapshotRewardsPerToken(user);
    }

#6 - bin2chen66

2023-08-31T06:11:00Z

@thereksfour getPendingRewards(fresh = true) It doesn't matter if fresh istrue or false, because this can only be used to calculate the latest global accRewardsPerToken

Since _updateRewards() must be executed before _updateUser (user) is executed to ensure that accRewardsPerToken is up-to-date, it does not matter whether fresh is true

But the message above

Even though unclaimedRewards is set to zero,
 then it will be populated with all the pending rewards again so the amount will be ok.

It confuses me a bit, I might need to take another look I need to familiarize myself with this project again to see if I missed something

#7 - thereksfour

2023-08-31T06:22:19Z

_getClaimableRewards returns _unclaimedRewards + pendingRewards, that is, reward = _unclaimedRewards + pendingRewards, so just setting _unclaimedRewards to 0 will not decrease pendingRewards, which may be somewhat helpful

        uint256 reward = _getClaimableRewards(onBehalfOf, balance, false);
        uint256 totBal = REWARD_TOKEN.balanceOf(address(this));

        if (reward > totBal) {
            reward = totBal;
        }
        if (reward > 0) {
            _unclaimedRewards[onBehalfOf] = 0;
            _updateUserSnapshotRewardsPerToken(onBehalfOf);
            REWARD_TOKEN.safeTransfer(receiver, reward);
        }
...
    function _getClaimableRewards(
        address user,
        uint256 balance,
        bool fresh
    ) internal view returns (uint256) {
        uint256 reward = _unclaimedRewards[user].add(_getPendingRewards(user, balance, fresh));
        return reward.rayToWadNoRounding();
    }

#8 - bin2chen66

2023-08-31T06:39:11Z

@thereksfour pendingRewards is assumed to be 0. But _unclaimedRewards[user] has a value, the point is that the value in there is not in the current contract, it's in INCENTIVES_CONTROLLER. If it's cleared, it's gone. I thank I need to take another look.

#9 - bin2chen66

2023-08-31T08:25:45Z

@thereksfour I'll keep my original point Please help me see if I'm missing something. Thanks

The current implementation only moves rewards to the current contract if _collectAndUpdateRewards() is executed

_updateRewards() and _updateUser() are not triggered.

But _unclaimedRewards[user] is accumulated. _accRewardsPerToken and _userSnapshotRewardsPerToken[user] keeps getting bigger.

so that if no one has called _collectAndUpdateRewards() i.e. forceUpdate=false is not called.

This way the rewards balance in the contract will always be zero.

After _claimRewardsOnBehalf(forceUpdate=false). The user doesn't get any rewards, but _unclaimedRewards[user] is cleared to 0 and can't be refilled (note that it's not pendingRewards, assuming that pendingRewards is 0).

This way the rewards are lost

#10 - thereksfour

2023-08-31T10:29:50Z

Need review from sponsors. @julianmrodri

#11 - bin2chen66

2023-08-31T11:18:30Z

Here's a test case to look at Note:The balance of the current contract described above cannot be 0, it needs to be a little bit

add to StaticATokenLM.test.ts

    it('test_lost', async () => {
      const amountToDeposit = utils.parseEther('5')

      // Just preparation
      await waitForTx(await weth.deposit({ value: amountToDeposit.mul(2) }))
      await waitForTx(
        await weth.approve(staticAToken.address, amountToDeposit.mul(2), defaultTxParams)
      )

      // Depositing
      await waitForTx(
        await staticAToken.deposit(userSigner._address, amountToDeposit, 0, true, defaultTxParams)
      )
      await advanceTime(1);
      //***** need small reward balace
      await staticAToken.collectAndUpdateRewards()
      const staticATokenBalanceFirst = await stkAave.balanceOf(staticAToken.address);
      await advanceTime(60 * 60 * 24)

      // Depositing
      await waitForTx(
        await staticAToken.deposit(userSigner._address, amountToDeposit, 0, true, defaultTxParams)
      )

      const beforeRewardBalance = await stkAave.balanceOf(userSigner._address);
      const pendingRewardsBefore = await staticAToken.getClaimableRewards(userSigner._address)
      console.log("user Reward Balance(Before):",beforeRewardBalance);

      // user claim forceUpdate = false
      await waitForTx(await staticAToken.connect(userSigner).claimRewardsToSelf(false))

      const afterRewardBalance = await stkAave.balanceOf(userSigner._address);
      const pendingRewardsAfter = await staticAToken.getClaimableRewards(userSigner._address)
      console.log("user Reward Balance(After):",afterRewardBalance);


      const pendingRewardsDecline = pendingRewardsBefore.toNumber() - pendingRewardsAfter.toNumber() ;
      const getRewards= afterRewardBalance.toNumber() - beforeRewardBalance.toNumber() ;
      console.log("user pendingRewardsBefore:",pendingRewardsBefore);
      console.log("user pendingRewardsAfter:",pendingRewardsAfter);
      const staticATokenBalanceAfter = await stkAave.balanceOf(staticAToken.address);
      console.log("staticAToken Balance (before):",staticATokenBalanceFirst);
      console.log("staticAToken Balance (After):",staticATokenBalanceAfter);
      console.log("user lost:",pendingRewardsDecline - getRewards);



    })
$ yarn test:plugins:integration --grep "test_lost"


  StaticATokenLM: aToken wrapper with static balances and liquidity mining
Duplicate definition of RewardsClaimed (RewardsClaimed(address,address,uint256), RewardsClaimed(address,address,address,uint256))
    Rewards - Small checks
user Reward Balance(Before): BigNumber { value: "0" }
user Reward Balance(After): BigNumber { value: "34497547939" }
user pendingRewardsBefore: BigNumber { value: "1490345817336159" }
user pendingRewardsAfter: BigNumber { value: "34497293495" }
staticAToken Balance (before): BigNumber { value: "34497547939" }
staticAToken Balance (After): BigNumber { value: "0" }
user lost: 1490276822494725
      ✔ test_lost (947ms)


  1 passing (24s)

#12 - julianmrodri

2023-08-31T11:54:30Z

Thanks for the example. I'll review and let you know

#13 - julianmrodri

2023-08-31T13:09:52Z

Ok, the issue exists I can confirm now. This is a tricky one, nice catch. Found out that this was built this way on purpose. The idea is to allow the user to "sacrifice" some rewards for gas savings. You can see it in some of the tests, with comments like this one:

expect(pendingRewards5).to.be.eq(0) // User "sacrifice" excess rewards to save on gas-costs

We will discuss today what action we are taking with this issue.

in any case, the suggested mitigation does not address fully the issue, and causes the contract to fail under normal operations (simply try to run our test suite with that change). I believe we should probably address the main issue that the _unclaimed variable is set to 0, instead of to the rewards still pending to be collected.

What do you think about the function working this way? I ran a simple check and seems to address it at least for the example you provided. This mitigation was suggested on the other ticket linked here, it had some rounding issues but overall is the same.

function _claimRewardsOnBehalf( address onBehalfOf, address receiver, bool forceUpdate ) internal { if (forceUpdate) { _collectAndUpdateRewards(); } uint256 balance = balanceOf(onBehalfOf); uint256 reward = _getClaimableRewards(onBehalfOf, balance, false); uint256 totBal = REWARD_TOKEN.balanceOf(address(this)); if (reward == 0) { return; } if (reward > totBal) { reward = totBal; _unclaimedRewards[onBehalfOf] -= reward.wadToRay(); } else { _unclaimedRewards[onBehalfOf] = 0; } _updateUserSnapshotRewardsPerToken(onBehalfOf); REWARD_TOKEN.safeTransfer(receiver, reward); }

Thanks for taking a look!

#14 - julianmrodri

2023-08-31T13:50:13Z

@thereksfour @bin2chen66 updated response above. Tagging you to make sure you are notified.

#15 - bin2chen66

2023-08-31T14:30:00Z

@julianmrodri This one still has problems

  1. If there is a pendingReward may underflow For example: balance = 10 _unclaimedRewards[user]=9 pendingRewards = 2

_unclaimedRewards[onBehalfOf] -= reward.wadToRay(); will underflow

  1. finally executed _updateUserSnapshotRewardsPerToken(onBehalfOf); Then we need to accumulate pendingRewards to _unclaimedRewards[user].

Personally, I feel that if we don't want to revert, try this

  function _claimRewardsOnBehalf(
        address onBehalfOf,
        address receiver,
        bool forceUpdate
    ) internal {
        if (forceUpdate) {
            _collectAndUpdateRewards();
        }

        uint256 balance = balanceOf(onBehalfOf);
        uint256 reward = _getClaimableRewards(onBehalfOf, balance, false);
        uint256 totBal = REWARD_TOKEN.balanceOf(address(this));

        if (reward == 0) {
            return;
       }

        if (reward > totBal) {
+          // Insufficient balance resulting in no transfers out put into _unclaimedRewards[]
+           _unclaimedRewards[onBehalfOf] = (reward -totBal).wadToRay();
            reward = totBal;
-           _unclaimedRewards[onBehalfOf] -= reward.wadToRay();
        } else {
            _unclaimedRewards[onBehalfOf] = 0;
        }

        _updateUserSnapshotRewardsPerToken(onBehalfOf);
        REWARD_TOKEN.safeTransfer(receiver, reward);
    }

This may still have this prompt. expect(pendingRewards5).to.be.eq(0) // User "sacrifice" excess rewards to save on gas-costs

But I feel that this use case should be changed. It is okay to sacrifice a little. If it is a lot, it is still necessary to prevent the user from executing it.

#16 - c4-sponsor

2023-08-31T16:33:11Z

pmckelvy1 (sponsor) acknowledged

#17 - julianmrodri

2023-08-31T16:42:11Z

@thereksfour @bin2chen66 We had a group call and we decided to ACKNOWLEDGE the issue but we will not make code changes, just add a comment in the contract explaining this risk of losing rewards if you call it with the false parameter.

The reasons are:

  • We do not expect Aave V2 Rewards to come back in practice
  • Our protocol is not exposed to this issue. Because in our code we always claim with the parameter set to true, and nobody can claim on behalf of the protocol with the false parameter, the Protocol will always get the latest rewards when claiming
  • It is up to the user to call this with true or false. They might see value in calling it with false and sacrificing some rewards, which was the original reason it was built this way. But they always have the option to call it with the true parameter which will behave normally. So we consider it more like an option they have and not that much of a bug. We also do not expect people holding this wrapper token besides using it in our protocol.

However, we acknowledge and value the finding which was spot on and allowed us to understand the wrapper in more detail. Thanks for that!

#18 - c4-judge

2023-09-05T06:23:52Z

thereksfour marked the issue as satisfactory

#19 - c4-judge

2023-09-05T06:23:56Z

thereksfour marked the issue as selected for report

Findings Information

🌟 Selected for report: auditor0517

Also found by: 0xA5DF, RaymondFam, bin2chen, carlitox477, ronnyx2017

Labels

bug
grade-a
QA (Quality Assurance)
Q-06

Awards

Data not available

External Links

L-01: StaticATokenLM if out-of-gas may result in INCENTIVES_CONTROLLER == address(0)

In the constructor method of StaticATokenLM, a try/catch is executed

    constructor(
        ILendingPool pool,
        address aToken,
        string memory staticATokenName,
        string memory staticATokenSymbol
    ) public {
...
        try IAToken(aToken).getIncentivesController() returns (
            IAaveIncentivesController incentivesController
        ) {
            if (address(incentivesController) != address(0)) {
@>              INCENTIVES_CONTROLLER = incentivesController;
@>              REWARD_TOKEN = IERC20(INCENTIVES_CONTROLLER.REWARD_TOKEN());
            }
@>      } catch {}

        ASSET = IERC20(IAToken(aToken).UNDERLYING_ASSET_ADDRESS());
        ASSET.safeApprove(address(pool), type(uint256).max);
    }    

If try gets out-of-gas due to a gas estimation error, but the contract deploys normally due to the 1/64 reserved gas, then the contract will deploy normally.

This may result in the contract deploying and actually having INCENTIVES_CONTROLLER, but getting INCENTIVES_CONTROLLER==address(0).

Suggestion. If out-of-gas then revert

    constructor(
        ILendingPool pool,
        address aToken,
        string memory staticATokenName,
        string memory staticATokenSymbol
    ) public {
...
        try IAToken(aToken).getIncentivesController() returns (
            IAaveIncentivesController incentivesController
        ) {
            if (address(incentivesController) != address(0)) {
                INCENTIVES_CONTROLLER = incentivesController;
                REWARD_TOKEN = IERC20(INCENTIVES_CONTROLLER.REWARD_TOKEN());
            }
-       } catch {}
+       } catch (bytes memory errData) {
+           if (errData.length == 0) revert(); // out-of-gas
+       }

        ASSET = IERC20(IAToken(aToken).UNDERLYING_ASSET_ADDRESS());
        ASSET.safeApprove(address(pool), type(uint256).max);
    

L-02: MorphoFiatCollateral._underlyingRefPerTok() ERC4626 first user inflation attack

MorphoFiatCollateral._underlyingRefPerTok() using convertToAssets() as _underlyingRefPerTok()

First user executable, common ERC4626 First user inflation attack

  1. donate asset, raising totalAssets()
  2. perform MorphoFiatCollateral.refresh() to get high _underlyingRefPerTok() Formula: shares.mulDiv(totalAssets() + 1, totalSupply() + 10**_decimalsOffset(), rounding); _underlyingRefPerTok() = 1e18 * totalAssets() / 1
  3. Execute ERC4626.deposit() and ERC4626.draw() to retrieve all assets.
  4. run MorphoFiatCollateral.refresh()to get the normal_underlyingRefPerTok()`, but lower than the value in step 2
  5. MorphoFiatCollateral goes DISABLED.

Recommendation. Deploy with pre-stored shares

L-03: StargatePoolFiatCollateral missing revenueHiding mechanisms

Currently StargatePoolFiatCollateral does not have a revenueHiding mechanism similar to AppreciatingFiatCollateral to retaliate against minor revenue drops (e.g. caused by loss of precision).

It is recommended to inherit directly from AppreciatingFiatCollateral.sol, do not override refresh, and directly use AppreciatingFiatCollateral.sol's refersh() method.

L-04: MorphoTokenisedDeposit override _decimalsOffset() ==0 increase ERC4626 inflation attack risk

MorphoTokenisedDeposit._decimalsOffset override RewardableERC4626Vault._decimalsOffset() and return 0

    function _decimalsOffset() internal view virtual override returns (uint8) {
        return 0;
    }

It is recommended not to override, but to continue to use RewardableERC4626Vault._decimalsOffset() ==9

L-05: MorphoTokenisedDeposit._deposit() It is not recommended to use safeApprove, it is recommended to use the safeIncreaseAllowance()

SafeERC20.safeApprove() Determines if the current allowance is equal to 0, applies to the first approve, since _deposit() will be used repeatedly it is recommended to use safeIncreaseAllowance(). to avoid the risk of revert if the allowance is not used up.

    /**
     * @dev Deprecated. This function has issues similar to the ones found in
     * {IERC20-approve}, and its usage is discouraged.
     *
     * Whenever possible, use {safeIncreaseAllowance} and
     * {safeDecreaseAllowance} instead.
     */
    function safeApprove(
        IERC20 token,
        address spender,
        uint256 value
    ) internal {
        // safeApprove should only be called when setting an initial allowance,
        // or when resetting it to zero. To increase and decrease it, use
        // 'safeIncreaseAllowance' and 'safeDecreaseAllowance'
        require(
@>           (value == 0) || (token.allowance(address(this), spender) == 0),
            "SafeERC20: approve from non-zero to non-zero allowance"
        );
        _callOptionalReturn(token, abi.encodeWithSelector(token.approve.selector, spender, value));
    }

#0 - c4-judge

2023-08-06T10:17:49Z

thereksfour marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter