Platform: Code4rena
Start Date: 03/02/2022
Pot Size: $75,000 USDC
Total HM: 42
Participants: 52
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 21
Id: 83
League: ETH
Rank: 12/52
Findings: 5
Award: $1,672.15
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: WatchPug
Also found by: cmichel, harleythedog, hickuphh3, kirk-baird, leastwood
387.0982 USDC - $387.10
kirk-baird
Incorrect accounting in _calcRewardIntegral()
causes the rewards to be locked and will create an overflow that reverts for future calls.
The impact is a significant loss in each rewards token in terms of value and _calcRewardIntegral()
cannot be called again until we have transferred sufficient balance of each rewards token to ConvexStakingWrapper
to account for the rewards already claimed.
function _calcRewardIntegral( uint256 _pid, uint256 _index, address _account, uint256 _balance, uint256 _supply ) internal { RewardType memory reward = rewards[_pid][_index]; //get difference in balance and remaining rewards //getReward is unguarded so we use remaining to keep track of how much was actually claimed uint256 bal = IERC20(reward.token).balanceOf(address(this)); uint256 d_reward = bal - reward.remaining; // send 20 % of cvx / crv reward to treasury if (reward.token == cvx || reward.token == crv) { IERC20(reward.token).transfer(treasury, d_reward / 5); d_reward = (d_reward * 4) / 5; } IERC20(reward.token).transfer(address(claimContract), d_reward); if (_supply > 0 && d_reward > 0) { reward.integral = reward.integral + uint128((d_reward * 1e20) / _supply); } //update user integrals uint256 userI = userReward[_pid][_index][_account].integral; if (userI < reward.integral) { userReward[_pid][_index][_account].integral = reward.integral; claimContract.pushReward( _account, reward.token, (_balance * (reward.integral - userI)) / 1e20 ); } //update remaining reward here since balance could have changed if claiming if (bal != reward.remaining) { reward.remaining = uint128(bal); } rewards[_pid][_index] = reward; }
The core of this bug stems from setting the remaining rewards to the initial contract balance i.e.reward.remaining = uint128(bal);
even after we have transferred the rewards to the claimContract
.
Say we have reward.token = tokenA
, tokenA.balanceOf(ConvexStakingWrapper) = 100
and rewards[pid][tokena]remaining = 0
then we do an event that calls _calcRewardIntegral()
:
bal = 100
d_reward = 100 - 0 = 100
IERC20(reward.token).transfer(address(claimContract), d_reward);
occurs making the tokenA.balanceOf(ConvexStakingWrapper) = 0
reward.remaining = bal = 100
Now say the contract accrues 50 of tokenA
as rewards so tokenA.balanceOf(ConvexStakingWrapper) = 50
and an event happens so do _calcRewardIntegral()
:
bal = 50
uint256 d_reward = bal - reward.remaining;
is 50 - 100 = -50
this is an overflow and revertsThus, we are unable to make future calls to this contract with this pid
until the balance of tokenA
is greater than 100.
Consider, removing reward.remaining
entirely. This will prevent future calls from overflowing when there are not sufficient rewards. However, this will no longer track the individual reward amounts for each (pid
, index
) pair.
#0 - GalloDaSballo
2022-04-13T23:55:33Z
The warden has identified a way for reward tokens to consistently be stuck in the contract.
Anytime the balance of tokens in the contract is less than the balance of the previous _calcRewardIntegral
the operation uint256 d_reward = bal - reward.remaining;
will underflow causing a revert.
This consistently will prevent users from claiming rewards until the contracts balance is greater than reward.remaining
With the information that I have I believe this will consistently happen after the first claim as the new balance of the contract will always be lower than the reward.remaining
(the previous balance of the contract)
Because the issue is consistent, and effectively breaks the contract, I believe High Severity to be appropriate
#1 - GalloDaSballo
2022-05-04T00:08:05Z
Dup of #199
🌟 Selected for report: leastwood
Also found by: cmichel, kirk-baird
1061.9978 USDC - $1,062.00
kirk-baird
The reward calculations do not sufficiently account for different Curve LPs having the same rewards token.
As a result a you may take the rewards of one pid
by using another with the same rewards tokens addresses. Since all tokens have CVX
and CVR
tokens as rewards these rewards are essentially pool and can be claimed by pid
.
The function _calcRewardIntegral() simply uses the balance of the rewards token to determine how many rewards have been earnt by a pool. e.g.
uint256 bal = IERC20(reward.token).balanceOf(address(this));
Since reward.token
may be CRV
or CVX
or any other common token e.g. USDC
these rewards will be attributed to the pid
that has been passed by the user when calling withdraw()
ordeposit()
.
To fully exploit this vulnerability a user may do the following:
addRewards(pid)
where pid
is one that has not previously been addeddeposit(pid, 10)
thus the user will have 100% of the total supply of LP for this pidpid
pools which will transfer CVX
and CRV
to this contractwithdraw(10)
this will call _calcRewardIntegral()
and will transfer 4/5ths of the balance of the contract to the claim contract and attribute all of this balance to the attacker who may then withdraw the rewards.The token rewards need to be differentiated between which pool they came from. One option is to have a different contract for each pid
however this was the situation we were trying to improve upon.
#0 - GalloDaSballo
2022-04-14T00:03:03Z
The warden has shown how the contract ConvexStakingWrapper
will have troubles with it's accounting when the reward token for multiple pid
is the same.
From my experience with Convex the reward tokens will overlap very often (staking in a convex gauge gives you CVX and cvxCRV)
My immediate instinct would be to classify the finding as medium severity as ultimately this requires the sponsor to use multiple pools that overlap.
However, given that Convex boosters are used for depositing in Gauges, meaning that the ConvexStakingWrapper
will be used to deposit in multiple convex gauges.
For this reason the overlap is not just possible, but guaranteed as long as the contract allows staking of more than one pool.
While the contract could be written with just one pool in mind (USDM pool for example), it's design is clearly tailored to work with multiple pools. Given this consideration, the warden has found a way to break accounting of the contract.
For these reasons I believe the finding to be of High Severity
#1 - GalloDaSballo
2022-05-04T00:07:49Z
Dup of #146
🌟 Selected for report: leastwood
Also found by: CertoraInc, Czar102, WatchPug, csanuragjain, hickuphh3, kirk-baird
Incorrect accounting of rewards in updatePool()
when dealing with endBlock
will result in insufficient rewards being transferred to the users.
Rewards are generated per block between startBlock
and endBlock
. However if block.number >= endBlock
then no rewards will be accrued.
This does not account for the rewards between pool.lastRewardBlock
and endBlock
if pool.lastRewardBlock < endBlock
.
For example consider the case where pool.lastBlockReward = 10
and endBlock = 20
. If we call updatePool()
with block.number = 21
we will set pool.lastReward lock = 21
and not accrue any rewards.
Consider adding extra conditional statements to accrue rewards for the conditions:
block.number >= endBlock
pool.lastRewardBlock < endBlock
Here we would need to accrue endBlock - pool.lastRewardBlock
worth of block rewards.
#0 - r2moon
2022-02-18T08:45:59Z
🌟 Selected for report: ShadowyNoobDev
Also found by: 0xw4rd3n, CertoraInc, Czar102, GalloDaSballo, Heartless, IllIllI, Jujic, Randyyy, Rhynorater, Sleepy, SolidityScan, ckksec, kirk-baird, leastwood, pauliax, peritoflores, reassor
7.97 USDC - $7.97
kirk-baird
The function claimRewards()
is vulnerable to reentrancy. An attacker is able to drain the entire balance of any token for which they have rewards.
Note this attack requires the user to be able to gain control of execution as the to
address in the ERC20 transfer()
call, which is possible for many ERC20 implementations such as those that include any of the extensions ERC777, ERC1155 or ERC223.
function claimRewards(address[] calldata _tokens) external override { for (uint256 i = 0; i < _tokens.length; i++) { uint256 getting = reward[msg.sender][_tokens[i]]; IERC20(_tokens[i]).safeTransfer(msg.sender, getting); reward[msg.sender][_tokens[i]] = 0; } }
The function updates the user rewards after making an external call, which may allow an attacker to reenter the contract and still have their original balance.
Steps:
pushReward()
)claimRewards([token])
and gain control of the execution during IERC20(_tokens[i]).safeTransfer(msg.sender, getting);
reward[msg.sender][_tokens[i]];
We recommend using the check-effects-interactions pattern. This involved doing all state changes and checks before making external calls.
Consider updating the function to the following.
function claimRewards(address[] calldata _tokens) external override { for (uint256 i = 0; i < _tokens.length; i++) { uint256 getting = reward[msg.sender][_tokens[i]]; reward[msg.sender][_tokens[i]] = 0; IERC20(_tokens[i]).safeTransfer(msg.sender, getting); } }
🌟 Selected for report: hickuphh3
Also found by: 0x1f8b, 0xw4rd3n, BouSalman, CertoraInc, Czar102, Dravee, IllIllI, Randyyy, Rhynorater, Ruhum, ShadowyNoobDev, Sleepy, SolidityScan, WatchPug, bitbopper, cccz, cryptphi, csanuragjain, defsec, gzeon, harleythedog, hubble, hyh, kenta, kirk-baird, leastwood, mtz, pauliax, peritoflores, rfa, robee, samruna, throttle, wuwe1, ye0lde
125.4893 USDC - $125.49
kirk-baird
The function _calcRewardIntegral()
does not account for rounding issues when determining the balance to transfer to the treasury
.
tegral( uint256 _pid, uint256 _index, address _account, uint256 _balance, uint256 _supply ) internal { RewardType memory reward = rewards[_pid][_index]; //get difference in balance and remaining rewards //getReward is unguarded so we use remaining to keep track of how much was actually claimed uint256 bal = IERC20(reward.token).balanceOf(address(this)); uint256 d_reward = bal - reward.remaining; // send 20 % of cvx / crv reward to treasury if (reward.token == cvx || reward.token == crv) { IERC20(reward.token).transfer(treasury, d_reward / 5); d_reward = (d_reward * 4) / 5; } IERC20(reward.token).transfer(address(claimContract), d_reward); ...
Here the calculations IERC20(reward.token).transfer(treasury, d_reward / 5);
and d_reward = (d_reward * 4) / 5;
will result in a rounding error if d_reward
is not a multiple of 5.
Consider the example where d_reward = 1
d_reward / 5 = 0
(d_reward * 4) / 5 = 0
Thus, we will leak some token value.
Consider the updating the two lines of code to account for the rounding issues. The following code accounts for the rounding issues.
uint256 treasuryRewards = d_reward / 5; IERC20(reward.token).transfer(treasury, treasuryRewards); d_reward = d_reward - treasuryRewards;
#0 - GalloDaSballo
2022-04-12T00:21:05Z
I agree that potentially integer math can cause loss of precision.
I don't understand the logic that the warden is applying here.
Ultimately the solution they are recommending still performs the division
I believe that any value that is smaller than 5 will always be rounded down to 0 no matter what
For these reasons I think Low Severity to be more appropriate
#1 - JeeberC4
2022-04-21T02:05:39Z
Generating QA Report as warden had not submitted one and judge downgraded issue. Preserving original title: Rounding in _calcRewardIntegral() Leaks Tokens
#2 - GalloDaSballo
2022-04-27T14:05:52Z
Finding is valid
#3 - GalloDaSballo
2022-04-27T15:10:41Z
1