LSD Network - Stakehouse contest - cccz's results

A permissionless 3 pool liquid staking solution for Ethereum.

General Information

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

Stakehouse Protocol

Findings Distribution

Researcher Performance

Rank: 3/92

Findings: 7

Award: $5,122.24

🌟 Selected for report: 2

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: ronnyx2017

Also found by: cccz

Labels

bug
3 (High Risk)
satisfactory
duplicate-129

Awards

1012.6328 USDC - $1,012.63

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L52-L64

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

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

Findings Information

🌟 Selected for report: c7e7eff

Also found by: 0x4non, 9svR6w, HE1M, Jeiwan, Trust, aphak5010, arcoun, cccz, clems4ever, corerouter, koxuan, rotcivegaf, unforgiven

Labels

bug
3 (High Risk)
satisfactory
duplicate-147

Awards

40.8568 USDC - $40.86

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L51-L63

Vulnerability details

Impact

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.

Proof of Concept

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L51-L63

Tools Used

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

Findings Information

🌟 Selected for report: cccz

Labels

bug
3 (High Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
H-16

Awards

2925.3837 USDC - $2,925.38

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L52-L64

Vulnerability details

Impact

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.

Proof of Concept

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantPoolBase.sol#L52-L64

Tools Used

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

@dmvt https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L56-L79

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

Findings Information

🌟 Selected for report: Jeiwan

Also found by: 9svR6w, cccz, immeas, rbserver, unforgiven

Labels

bug
3 (High Risk)
satisfactory
duplicate-255

Awards

221.4628 USDC - $221.46

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L76-L95

Vulnerability details

Impact

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

Proof of Concept

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/SyndicateRewardsProcessor.sol#L76-L95

Tools Used

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

Findings Information

🌟 Selected for report: yixxas

Also found by: cccz, joestakey, pashov, sahar

Labels

bug
2 (Med Risk)
partial-50
duplicate-190

Awards

44.2926 USDC - $44.29

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/LiquidStakingManager.sol#L948-L954

Vulnerability details

Impact

Dao can set daoCommissionPercentage max to 100% in updateDAORevenueCommission which will make NodeRunner lose all rewards when calling claimRewardsAsNodeRunner

Proof of Concept

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

Tools Used

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

Findings Information

🌟 Selected for report: cccz

Labels

bug
2 (Med Risk)
disagree with severity
judge review requested
primary issue
satisfactory
selected for report
sponsor acknowledged
M-19

Awards

877.6151 USDC - $877.62

External Links

Lines of code

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L146-L148

Vulnerability details

Impact

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.

Proof of Concept

https://github.com/code-423n4/2022-11-stakehouse/blob/4b6828e9c807f2f7c569e6d721ca1289f7cf7112/contracts/liquid-staking/GiantMevAndFeesPool.sol#L146-L148

Tools Used

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

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