Platform: Code4rena
Start Date: 11/11/2022
Pot Size: $90,500 USDC
Total HM: 52
Participants: 92
Period: 7 days
Judge: LSDan
Total Solo HM: 20
Id: 182
League: ETH
Rank: 3/92
Findings: 7
Award: $5,122.24
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: ronnyx2017
Also found by: cccz
1012.6328 USDC - $1,012.63
GiantMevAndFeesPool.withdrawETH will reduce the value of idleETH before burning GaintLP. GaintLP.burn will call GiantMevAndFeesPool.beforeTokenTransfer and call updateAccumulatedETHPerLP to calculate accumulatedETHPerLPShare. Decreasing the value of idleETH before burning GaintLP will cause totalRewardsReceived to increase and accumulatedETHPerLPShare to increase, i.e. treating the ETH deposited by the user as rewards, resulting in distributing too many rewards to the user.
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L52-L64 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L176-L178
None
Change to
function withdrawETH(uint256 _amount) external nonReentrant { require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount"); require(lpTokenETH.balanceOf(msg.sender) >= _amount, "Invalid balance"); require(idleETH >= _amount, "Come back later or withdraw less ETH"); - idleETH -= _amount; lpTokenETH.burn(msg.sender, _amount); + idleETH -= _amount; (bool success,) = msg.sender.call{value: _amount}(""); require(success, "Failed to transfer ETH"); emit LPBurnedForETH(msg.sender, _amount); }
#0 - c4-judge
2022-11-21T15:35:02Z
dmvt marked the issue as duplicate of #140
#1 - c4-judge
2022-11-21T15:35:08Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T15:35:54Z
dmvt marked the issue as duplicate of #146
#3 - c4-judge
2022-11-21T15:36:09Z
dmvt marked the issue as not a duplicate
#4 - c4-judge
2022-11-21T15:36:22Z
dmvt marked the issue as duplicate of #129
#5 - c4-judge
2022-11-21T17:09:27Z
dmvt marked the issue as not a duplicate
#6 - c4-judge
2022-11-21T17:09:36Z
dmvt marked the issue as duplicate of #129
#7 - c4-judge
2022-11-30T13:56:09Z
dmvt marked the issue as partial-25
#8 - c4-judge
2022-11-30T13:56:14Z
dmvt marked the issue as satisfactory
#9 - dmvt
2022-12-03T11:26:25Z
There are three other channels involved in this conversation. Individual issues are not the correct place to debate. I am engaging you in those channels and having a conversation with the organization regarding your concerns. Please stop repeating yourself on individual issues in this repo.
#10 - thereksfour
2022-12-03T12:59:42Z
Let's say there are 10 lpTokenETH in the contract (idleETH = 10) and 3 ETH in rewards. User A has 5 lpTokenETH and User B has 1 lpTokenETH
User A calls the withdrawETH function to withdraw the staked ETH and the reward, he should have received 5 staked ETH and (13-10) / 10 * 5 =1.5 ETH of the reward. But due to the vulnerability, User A will receive (13-(10-5))/10*5 = 4 ETH reward and 5 ETH when exiting the pool.
And User B will revert when exiting the pool due to the overflow in the _updateAccumulatedETHPerLP function
function totalRewardsReceived() public view override returns (uint256) { return address(this).balance + totalClaimed - idleETH; // @auidt: (13-5-4) + 4 - (5-1) = 4 } ... function _updateAccumulatedETHPerLP(uint256 _numOfShares) internal { if (_numOfShares > 0) { uint256 received = totalRewardsReceived(); uint256 unprocessed = received - totalETHSeen; // @auidt: 4 - 8 revert
This results in more rewards for user A and losses for later users
#11 - c4-judge
2022-12-03T13:39:07Z
dmvt marked the issue as full credit
🌟 Selected for report: c7e7eff
Also found by: 0x4non, 9svR6w, HE1M, Jeiwan, Trust, aphak5010, arcoun, cccz, clems4ever, corerouter, koxuan, rotcivegaf, unforgiven
40.8568 USDC - $40.86
SyndicateRewardsProcessor._distributeETHRewardsToUserForToken is used in GiantMevAndFeesPool and StakingFundsVault to calculate the rewards that can be claimed by users. However, SyndicateRewardsProcessor._distributeETHRewardsToUserForToken assigns an incorrect value to claimed, resulting in the recorded rewards claimed by the user are too small, thus allowing the user to claim more rewards
uint256 due = ((accumulatedETHPerLPShare * balance) / PRECISION) - claimed[_user][_token]; if (due > 0) { claimed[_user][_token] = due; // @ audit: should be "claimed[_user][_token] += due;"
Consider that now accumulatedETHPerLPShare == 100, balance == 1e24, due == 100, claimed[_user][_token] = 100, and the user receives 100 rewards.
Then accumulatedETHPerLPShare increases to 200, due = 200 - 100, claimed[_user][_token] should be 200, whereas in the current implementation it is 100, and the user is rewarded 100.
After that, accumulatedETHPerLPShare increases to 300, and due = 300 - 100 = 200, the user should get 100, but gets 200.
None
Change to
function _distributeETHRewardsToUserForToken( address _user, address _token, uint256 _balance, address _recipient ) internal { require(_recipient != address(0), "Zero address"); uint256 balance = _balance; if (balance > 0) { // Calculate how much ETH rewards the address is owed / due uint256 due = ((accumulatedETHPerLPShare * balance) / PRECISION) - claimed[_user][_token]; if (due > 0) { - claimed[_user][_token] = due; + claimed[_user][_token] += due;
#0 - c4-judge
2022-11-21T14:01:22Z
dmvt marked the issue as duplicate of #60
#1 - c4-judge
2022-11-30T09:58:04Z
dmvt marked the issue as partial-25
#2 - c4-judge
2022-11-30T09:58:09Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2022-11-30T11:29:40Z
dmvt marked the issue as not a duplicate
#4 - c4-judge
2022-11-30T11:29:49Z
dmvt marked the issue as duplicate of #59
#5 - C4-Staff
2022-12-21T05:47:22Z
JeeberC4 marked the issue as duplicate of #147
🌟 Selected for report: cccz
2925.3837 USDC - $2,925.38
GiantMevAndFeesPool.withdrawETH calls lpTokenETH.burn, then GiantMevAndFeesPool.beforeTokenTransfer, followed by a call to _distributeETHRewardsToUserForToken sends ETH to the user, which allows the user to call any function in the fallback. While GiantMevAndFeesPool.withdrawETH has the nonReentrant modifier, GiantMevAndFeesPool.claimRewards does not have the nonReentrant modifier. When GiantMevAndFeesPool.claimRewards is called in GiantMevAndFeesPool.withdrawETH, the idleETH is reduced but the ETH is not yet sent to the user, which increases totalRewardsReceived and accumulatedETHPerLPShare, thus making the user receive more rewards when calling GiantMevAndFeesPool.claimRewards.
None
Change to
function withdrawETH(uint256 _amount) external nonReentrant { require(_amount >= MIN_STAKING_AMOUNT, "Invalid amount"); require(lpTokenETH.balanceOf(msg.sender) >= _amount, "Invalid balance"); require(idleETH >= _amount, "Come back later or withdraw less ETH"); - idleETH -= _amount; lpTokenETH.burn(msg.sender, _amount); + idleETH -= _amount; (bool success,) = msg.sender.call{value: _amount}(""); require(success, "Failed to transfer ETH"); emit LPBurnedForETH(msg.sender, _amount); }
#0 - dmvt
2022-11-21T15:41:41Z
Function has the nonReentrant guard.
#1 - c4-judge
2022-11-21T15:41:45Z
dmvt marked the issue as unsatisfactory: Invalid
#2 - thereksfour
2022-11-22T02:23:57Z
The nonReentrant modifier does not prevent the reentrancy of other functions without the nonReentrant modifier in the withdrawETH function, my similar finding in xdefi
https://github.com/code-423n4/2022-01-xdefi-findings/issues/25
#3 - dmvt
2022-11-22T11:45:08Z
Ok, fair enough. I still think this is functionally equivalent to your finding in #239, but I'll reopen it and see what the sponsor thinks.
#4 - c4-judge
2022-11-22T11:45:19Z
dmvt marked the issue as nullified
#5 - c4-judge
2022-11-22T11:45:23Z
dmvt marked the issue as not nullified
#6 - c4-judge
2022-11-22T11:45:27Z
dmvt marked the issue as primary issue
#7 - thereksfour
2022-11-22T14:11:41Z
Ok, fair enough. I still think this is functionally equivalent to your finding in #239, but I'll reopen it and see what the sponsor thinks.
The functionality is similar, but the risks are different.
In #239 users are only distributed more rewards for themselves when they exit the pool.
But this issue is much higher risk, as the user can call claimRewards for any account in the withdrawETH callback to claim more rewards.
Let's say there are 10 lpTokenETH in the contract (idleETH = 10) and 3 ETH in rewards. User A has two accounts, account a has 5 lpTokenETH and account b has 1 lpTokenETH
Using the vulnerability in #239, account a will receive (13-(10-5))/10*5 = 4 ETH reward and 5 ETH when exiting the pool.
But account b will revert when exiting the pool due to the overflow in the _updateAccumulatedETHPerLP function
function totalRewardsReceived() public view override returns (uint256) { return address(this).balance + totalClaimed - idleETH; // @auidt: (13-5-4) + 4 - (5-1) = 4 } ... function _updateAccumulatedETHPerLP(uint256 _numOfShares) internal { if (_numOfShares > 0) { uint256 received = totalRewardsReceived(); uint256 unprocessed = received - totalETHSeen; // @auidt: 4 - 8 revert
And using this vulnerability, account b claims the reward in the callback for account a. Since account a's 5 ETH has not been sent at this point, when account b claims the reward, the following calculation is shown
function totalRewardsReceived() public view override returns (uint256) { return address(this).balance + totalClaimed - idleETH; // @auidt: 9 + 4 - 5 = 8 } ... function _updateAccumulatedETHPerLP(uint256 _numOfShares) internal { if (_numOfShares > 0) { uint256 received = totalRewardsReceived(); uint256 unprocessed = received - totalETHSeen; // @auidt: 8 - 8 == 0 no revert
At this point accumulatedETHPerLPShare is still (13 - 5)/ 10 = 0.8 Account b receives 0.8 * 1 = 0.8 eth. The reentrant vulnerability creates more attack surfaces for attackers, which is why I reported the two issues separately
#8 - c4-sponsor
2022-11-28T16:54:00Z
vince0656 marked the issue as sponsor confirmed
#9 - c4-judge
2022-12-01T23:53:41Z
dmvt marked the issue as satisfactory
#10 - c4-judge
2022-12-01T23:53:44Z
dmvt marked the issue as selected for report
221.4628 USDC - $221.46
StakingFundsVault inherits from SyndicateRewardsProcessor() and uses SyndicateRewardsProcessor.totalRewardsReceived() directly as the total amount of rewards.
function totalRewardsReceived() public virtual view returns (uint256) { return address(this).balance + totalClaimed; }
totalRewardsReceived() uses the contract's ETH balance plus the claimed rewards as the total rewards, but in StakingFundsVault, the contract's ETH balance contains ETH deposited by the user besides the rewards from Syndicate. That is, StakingFundsVault takes the ETH deposited by the user as a reward. This results in the user receiving too many rewards and not enough ETH in the contract for the user to withdraw
None
Consider using a state variable to record the ETH deposited by the user, and implementing the totalRewardsReceived function to subtract the amount of ETH deposited by the user from the balance
#0 - c4-judge
2022-11-21T14:24:21Z
dmvt marked the issue as duplicate of #115
#1 - c4-judge
2022-11-21T14:24:28Z
dmvt marked the issue as not a duplicate
#2 - c4-judge
2022-11-21T14:25:11Z
dmvt marked the issue as duplicate of #114
#3 - c4-judge
2022-11-21T17:03:23Z
dmvt marked the issue as not a duplicate
#4 - c4-judge
2022-11-21T17:03:28Z
dmvt marked the issue as duplicate of #114
#5 - c4-judge
2022-11-30T13:06:56Z
dmvt marked the issue as nullified
#6 - dmvt
2022-11-30T13:07:08Z
doesn't meet the burden of proof
#7 - thereksfour
2022-12-02T15:42:16Z
#8 - thereksfour
2022-12-03T13:24:09Z
Let's say there are 10 lpToken in the StakingFundsVault and 3 ETH in rewards. User A has 5 lpToken
User A calls claimRewards to claim his reward, and he should have received half of the 3 ETH rewards. But since StakingFundsVault treats the user's deposit as a reward, the total reward is 13 ETH instead of 3 ETH, and User A will get 13/2 = 6.5 ETH, so User A will withdraw the deposits of other users as reward.
function totalRewardsReceived() public virtual view returns (uint256) { return address(this).balance + totalClaimed; }
#9 - c4-judge
2022-12-03T13:35:06Z
dmvt marked the issue as not nullified
#10 - c4-judge
2022-12-03T13:35:10Z
dmvt marked the issue as satisfactory
#11 - C4-Staff
2022-12-21T05:37:42Z
JeeberC4 marked the issue as duplicate of #255
44.2926 USDC - $44.29
Dao can set daoCommissionPercentage max to 100% in updateDAORevenueCommission which will make NodeRunner lose all rewards when calling claimRewardsAsNodeRunner
https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L948-L954 https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L921-L931
None
Consider setting a more reasonable cap on daoCommissionPercentage, like 30%
#0 - c4-judge
2022-11-21T15:42:04Z
dmvt marked the issue as duplicate of #190
#1 - c4-judge
2022-12-02T17:18:39Z
dmvt marked the issue as partial-50
🌟 Selected for report: cccz
877.6151 USDC - $877.62
GiantMevAndFeesPool.beforeTokenTransfer will try to distribute the user's current rewards to the user when transferring GaintLP, but since beforeTokenTransfer will not call StakingFundsVault.claimRewards to claim the latest rewards, thus making the calculated accumulatedETHPerLPShare smaller and causing the user to lose some rewards.
None
Consider claiming the latest rewards from StakingFundsVault before the GiantMevAndFeesPool.beforeTokenTransfer calls updateAccumulatedETHPerLP()
#0 - dmvt
2022-11-21T15:33:02Z
The warden failed to describe a vulnerability, impact, negative result, or otherwise prove that the issue reported exists.
#1 - c4-judge
2022-11-21T15:33:07Z
dmvt marked the issue as unsatisfactory: Insufficient proof
#2 - thereksfour
2022-11-22T02:31:23Z
@dmvt https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L56-L71 Before calling updateAccumulatedETHPerLP in the claimRewards function, the latest rewards are claimed from the StakingFundsVault, but this is not done before calling updateAccumulatedETHPerLP in beforeTokenTransfer, which leaves the latest rewards undistributed, resulting in users losing their rewards.
#3 - dmvt
2022-11-22T11:55:34Z
I'll reopen this for the sponsor to evaluate, given your additional description. However, I strongly encourage you to include a walkthrough of the specific steps, a test, or a Alice/Bob example in your proof of concept. A link to 3 lines of code is insufficient proof of your report. I'm not 100% clear on it, but I think this may be a duplicate or subset of issue #178.
#4 - c4-judge
2022-11-22T11:55:44Z
dmvt marked the issue as nullified
#5 - c4-judge
2022-11-22T11:55:50Z
dmvt marked the issue as not nullified
#6 - thereksfour
2022-11-22T14:38:45Z
GiantMevAndFeesPool.beforeTokenTransfer will try to distribute the user's current rewards to the user when transferring GaintLP, but since beforeTokenTransfer will not call StakingFundsVault.claimRewards to claim the latest rewards, thus making the calculated accumulatedETHPerLPShare smaller and causing the user to lose some rewards.
In fact this issue is different from #178, which I also reported, and it is caused by claimed[from] not being updated.
The cause of this issue is due to not fetching rewards from the StakingFundsVault in time.
GiantMevAndFeesPool's rewards need to be fetched by calling StakingFundsVault.claimRewards, as done in GiantMevAndFeesPool.claimRewards.
function claimRewards( address _recipient, address[] calldata _stakingFundsVaults, bytes[][] calldata _blsPublicKeysForKnots ) external { uint256 numOfVaults = _stakingFundsVaults.length; require(numOfVaults > 0, "Empty array"); require(numOfVaults == _blsPublicKeysForKnots.length, "Inconsistent array lengths"); for (uint256 i; i < numOfVaults; ++i) { StakingFundsVault(payable(_stakingFundsVaults[i])).claimRewards( address(this), _blsPublicKeysForKnots[i] ); } updateAccumulatedETHPerLP();
But beforeTokenTransfer doesn't do that, which causes the rewards to remain in the StakingFundsVault, and the GiantMevAndFeesPool to have fewer rewards available for distribution, causing the user to lose some rewards
function beforeTokenTransfer(address _from, address _to, uint256) external { require(msg.sender == address(lpTokenETH), "Caller is not giant LP"); updateAccumulatedETHPerLP();
#7 - c4-sponsor
2022-11-28T17:04:38Z
vince0656 marked the issue as disagree with severity
#8 - c4-sponsor
2022-11-28T17:04:46Z
vince0656 requested judge review
#9 - c4-sponsor
2022-11-28T17:05:17Z
vince0656 marked the issue as sponsor acknowledged
#10 - vince0656
2022-11-28T17:06:40Z
So the nuance here is that due to contract limitations, users should be encouraged for this specific case to claim rewards before transferring tokens due to the requirement of claim params that the contract wouldn't readily have when executing a transfer. We can document this limitation in detail and encourage users to always claim before transferring the tokens
#11 - c4-judge
2022-12-01T23:48:38Z
dmvt marked the issue as satisfactory
#12 - dmvt
2022-12-01T23:50:37Z
I think medium is appropriate for this issue given that we have a loss of funds if the user performs actions out of order.
#13 - c4-judge
2022-12-01T23:50:43Z
dmvt marked the issue as selected for report