Platform: Code4rena
Start Date: 15/12/2022
Pot Size: $128,000 USDC
Total HM: 28
Participants: 111
Period: 19 days
Judge: GalloDaSballo
Total Solo HM: 1
Id: 194
League: ETH
Rank: 2/111
Findings: 14
Award: $5,254.39
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: hansfriese
Also found by: Allarious, HollaDieWaldfee, betweenETHlines, chaduke, unforgiven, wagmi
949.1258 USDC - $949.13
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L279-L281 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56-L85 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L163-L167 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L223
This report deals with how a staker can manipulate his avaxAssignedHighWater
in order to earn higher GGP rewards.
In the "known issues" of this contest (https://multisiglabs.notion.site/Known-Issues-42e2f733daf24893a93ad31100f4cd98) there is a section that deals with GGP rewards manipulation, stating that:
Just before rewards are distributed, a NodeOp can increase their GGP stake to claim a bigger share of rewards, even if they have no minipools currently running.
The issue I am explaining here is a different one however. The known issue deals with increasing the GGP stake just before rewards are distributed up to the maximum 150% collateralization ratio.
I am describing how the avaxAssignedHighWater
can be manipulated, thereby increasing the amount of GGP needed to reach the 150% collateralization ratio.
Therefore if avaxAssignedHighWater
is manipulated to be higher than it should be, a malicious staker can stake even more GGP and earn even higher rewards.
The issue exists because if a staker creates a first Minipool and waits MinipoolCancelMoratoriumSeconds
, he can cancel new Minipools instantly (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L279-L281). RewardsStartTime
is set when the first Minipool is created and will not be set again when a second Minipool is created.
The issue is that ClaimNodeOp.calculateAndDistributeRewards
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56-L85) calls staking.resetAVAXAssignedHighWater
which sets avaxAssignedHighWater
to the amount of AVAX currently assigned (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L163-L167).
ClaimNodeOp.calculateAndDistributeRewards
which calls staking.resetAVAXAssignedHighWater
which resets avaxAssignedHighWater
to the amount of currently assigned AVAXavaxAssignedHighWater
is set to the very high value and the staker has a very high ceiling of rewards he can earn the next time the claimNodeOp.calculateAndDistributeRewards
function is calledVSCode
There is no quick fix for this because obviously avaxAssignedHighWater
should be adapted in ClaimNodeOp.calculateAndDistributeRewards
. The only value that it can be set to is the currently assigned AVAX.
I propose to change how avaxAssigned
is increased. Currently avaxAssigned
is increased when the Minipool is created (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L223).
I think it is better to increase it in the MinipoolManager.claimAndInitiateStaking
function. Because at this point, the staker can no longer cancel the Minipool.
#0 - c4-judge
2023-01-09T20:38:40Z
GalloDaSballo marked the issue as duplicate of #401
#1 - c4-judge
2023-01-29T18:48:17Z
GalloDaSballo marked the issue as duplicate of #566
#2 - c4-judge
2023-02-08T09:48:32Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-08T09:48:46Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: immeas
Also found by: 0xdeadbeef0x, Allarious, Franfran, HollaDieWaldfee, Josiah, RaymondFam, SmartSek, ast3ros, unforgiven
484.3389 USDC - $484.34
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444-L478 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L424-L426 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L430 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L424-L426 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L673 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L674
According to a message by the sponsor in the Discord channel (https://discord.com/channels/810916927919620096/1052967326502363146/1058480285169238147), AVAX is restaked every 14 days.
This means that even when a node operator provides a duration of say 28 days, the staking will be ended after 14 days using the MinipoolManager.recordStakingEnd
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440) and the Minipool will be recreated using the MinipoolManager.recreateMinipool
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444-L478).
In the Minipool.recreateMinipool
function, the rewards of the previous staking period are compounded. The duration
is not updated. It stays at 28 days for the whole lifetime of the Minipool.
The way slashing is implemented introduces an issue.
Say the duration
of a Minipool is 140 days (i.e. 10 staking periods of 14 days each). Let's assume that staking fails in the tenth staking period i.e. no rewards are sent to MinipoolManager.recordStakingEnd
. This will trigger the slashing mechanism (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L424-L426).
Slashing occurs with the compounded amount which is considerably higher than the initially staked AVAX amount. And the duration for which to slash GGP will be the whole 140 days duration.
Obviously this is wrong. The previous 9 staking periods were successful and rewards were paid to TokenggAVAX
holders. So the slashing should only occur for the tenth staking period.
TokenggAVAX
holders (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L430)slash
function is executed (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L424-L426). It calculates the amount to slash for the whole 140 days duration (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L673) and based on the compounded amount of AVAX staked (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L674)In this example the amount that was actually slashed was ten times as much as it should have been.
VSCode
I cannot think of an easy fix for how to implement slashing correctly because the implementation of the Rialto Multisig that manages the Minipools is not known to me. So I am not able to say how a proposed fix is / is not compatible with the Rialto Multisig.
I think though that a possible solution can be to hard-code the 14 day slashing period. Such that when slashing is executed, it always slashes 14 days of GGP rewards.
Alternatively, the Rialto Multisig might provide the optional slashing duration to the MinipoolManager.recordStakingEnd
function just like it provides totalEligibleGGPStaked
to the ClaimNodeOp.calculateAndDistributeRewards
.
#0 - c4-judge
2023-01-10T17:35:50Z
GalloDaSballo marked the issue as duplicate of #493
#1 - c4-judge
2023-02-08T08:52:39Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - c4-judge
2023-02-08T09:46:37Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xc0ffEE, 0xmint, AkshaySrivastav, Allarious, Ch_301, Franfran, HollaDieWaldfee, IllIllI, Jeiwan, Lirios, Manboy, adriro, ak1, bin2chen, caventa, chaduke, clems4ever, cozzetti, datapunk, imare, immeas, kaliberpoziomka8552, ladboy233, pauliax, peritoflores, rvierdiiev, sces60107, sk8erboy, stealthyz, unforgiven, wagmi, wallstreetvilkas, yixxas
9.9345 USDC - $9.93
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L196-L269 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L152-L175 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L279-L281
The MinipoolManager.createMinipool
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L196-L269) is used to create minipools. It can also be used to overwrite existing nodeID
s.
The only requirement in order to overwrite an existing nodeID
is (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243):
requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch);
You can see in the MinipoolManager.requireValidStateTransition
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L152-L175) that the state can change into the Prelaunch
state from 4 states:
Withdrawable
, Error
, Finished
and Canceled
.
There are two checks missing for overwriting an existing nodeID
:
Firstly, the state of the Minipool must not be Withdrawable
. Overwriting a Withdrawable
Minipool causes the rewards that are yet to be withdrawn to be lost.
Secondly I think the minipool should only be overwritten by the owner. Because if anyone can do it, then the owner that wants to overwrite the nodeID
may not get the chance to because the nodeID
is already overwritten.
Another important thing that is important to explain in order to describe a practical exploitation scenario is that if a staker creates one Minipool, then waits MinipoolCancelMoratoriumSeconds
, he can create a new Minipool and cancel that new Minipool instantly (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L279-L281).
The exploitation scenario I will detail in the "Proof of Concept" section allows a malicious staker to reset the rewards of other stakers' minipools that are in the Withdrawable
state.
Furthermore because the malicious staker has created a valid Minipool and waited MinipoolCancelMoratoriumSeconds
, he can cancel new Minipools at any time, which allows him to perform the attack without any risk of freezing his funds by getting them staked.
I created the following test file that shows how a malicious staker A can overwrite the Minipool of another staker B that is in the Withdrawable
state, thereby resetting the rewards which leads to a loss for staker B.
Put the test into the test/unit
folder and run it with forge test --match testCreateMinipoolOtherStakerMalicious -vv
.
// SPDX-License-Identifier: GPL-3.0-only pragma solidity 0.8.17; import "./utils/BaseTest.sol"; import {FixedPointMathLib} from "@rari-capital/solmate/src/utils/FixedPointMathLib.sol"; contract MinipoolManagerTest is BaseTest { using FixedPointMathLib for uint256; int256 private index; address private nodeOp; uint256 private status; uint256 private ggpBondAmt; function setUp() public override { super.setUp(); nodeOp = getActorWithTokens("nodeOp", MAX_AMT, MAX_AMT); } function testCreateMinipoolOtherStakerMalicious() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; uint256 delegationFee = 0.02 ether; address nodeID = address(0x69); address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT); // nodeOp stakes vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); minipoolMgr.createMinipool{value: depositAmt}(nodeID, duration, delegationFee, avaxAssignmentRequest); int256 mPindex = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp1 = minipoolMgr.getMinipool(mPindex); vm.stopPrank(); // start staking address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(duration); vm.startPrank(address(rialto)); uint256 rewards = 10 ether; uint256 halfRewards = rewards / 2; deal(address(rialto), address(rialto).balance + rewards); // rialto multisig reports staking end minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp1.nodeID, block.timestamp, rewards); vm.stopPrank(); int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); console.log(mp1Updated.avaxTotalRewardAmt); console.log(mp1Updated.owner); // nodeOp2 stakes vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); minipoolMgr.createMinipool{value: 1000 ether}(nodeID, 300 days, delegationFee, 1000 ether); MinipoolManager.Minipool memory mp1Updated2 = minipoolMgr.getMinipool(minipoolIndex); console.log(mp1Updated2.avaxTotalRewardAmt); console.log(mp1Updated2.owner); } }
The practical exploitation scenario which resets the rewards for the stakers looks like this:
Withdrawable
createMinipool
with the Withdrawable
Minipool's nodeID
to reset the rewardStaker A can do this for any Minipool that becomes Withdrawable
.
VSCode
As outlined above, the Minipool that is overwritten should only be overwritten by the owner and must not be in the Withdrawable
state.
Make the following changes to the MinipoolManager.createMinipool
function:
if (minipoolIndex != -1) { + onlyOwner(minipoolIndex); + bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); + MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); + require(currentStatus != MinipoolStatus.Withdrawable, "Rewards not withdrawn"); requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex);
#0 - 0xminty
2023-01-04T00:06:58Z
dupe of #805
#1 - c4-judge
2023-01-09T12:37:31Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T12:33:01Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T08:26:46Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:28:58Z
GalloDaSballo marked the issue as satisfactory
#7 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#8 - Simon-Busch
2023-02-09T12:53:29Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xdeadbeef0x, bin2chen, cozzetti, danyams, enckrish, imare, ladboy233
971.6675 USDC - $971.67
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670-L683 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L94-L97
When staking is done, a Rialto multisig calls MinipoolManager.recordStakingEnd
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440).
If the avaxTotalRewardAmt
has the value zero, the MinipoolManager
will slash the node operator's GGP.
The issue is that the amount to slash can be greater than the GGP balance the node operator has staked.
This will cause the call to MinipoolManager.recordStakingEnd
to revert because an underflow is detected.
This means a node operator can create a minipool that cannot be slashed.
A node operator must provide at least 10% of avaxAssigned
as collateral by staking GGP.
It is assumed that a node operator earns AVAX at a rate of 10% per year.
So if a Minipool is created with a duration of > 365 days
, the 10% collateral is not sufficient to pay the expected rewards.
This causes the function call to revert.
Another cause of the revert can be that the GGP price in AVAX changes. Specifically if the GGP price falls, there needs to be slashed more GGP.
Therefore if the GGP price drops enough it can cause the call to slash to revert.
I think it is important to say that with any collateralization ratio this can happen. The price of GGP must just drop enough or one must use a long enough duration.
The exact impact of this also depends on how the Rialto multisig handles failed calls to MinipoolManager.recordStakingEnd
.
It looks like if this happens, MinipoolManager.recordStakingError
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515) is called.
This allows the node operator to withdraw his GGP stake.
So in summary a node operator can create a Minipool that cannot be slashed and probably remove his GGP stake when it should have been slashed.
When calling MinipoolManager.recordStakingEnd
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440) and the avaxTotalRewardAmt
parameter is zero, the node operator is slashed:
// No rewards means validation period failed, must slash node ops GGP. if (avaxTotalRewardAmt == 0) { slash(minipoolIndex); }
The MinipoolManager.slash
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670-L683) then calculates expectedAVAXRewardsAmt
and from this slashGGPAmt
:
uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt);
Downstraem there is then a revert due to underflow because of the following line in Staking.decreaseGGPStake
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L94-L97):
subUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".ggpStaked")), amount);
You can add the following foundry test to MinipoolManager.t.sol
:
function testRecordStakingEndWithSlashFail() public { uint256 duration = 366 days; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); vm.startPrank(address(rialto)); skip(duration); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); }
See that it runs successfully with duration = 365 days
and fails with duration = 366 days
.
The similar issue occurs when the GGP price drops. I chose to implement the test with duration
as the cause for the underflow because your tests use a fixed AVAX/GGP price.
VSCode, Foundry
You should check if the amount to be slashed is greater than the node operator's GGP balance. If this is the case, the amount to be slashed should be set to the node operator's GGP balance.
I believe this check can be implemented within the MinipoolManager.slash
function without breaking any of the existing accounting logic.
function slash(int256 index) private { address nodeID = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".nodeID"))); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt); emit GGPSlashed(nodeID, slashGGPAmt); Staking staking = Staking(getContractAddress("Staking")); if (slashGGPAmt > staking.getGGPStake(owner)) { slashGGPAmt = staking.getGGPStake(owner); } staking.slashGGP(owner, slashGGPAmt); }
#0 - GalloDaSballo
2023-01-09T09:46:27Z
Good Description + Best POC
#1 - c4-judge
2023-01-09T09:46:31Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-12T19:58:52Z
This is a combination of two other issues from other wardens
#3 - GalloDaSballo
2023-02-03T11:58:06Z
This finding combines 2 issues:
Am going go to dedoup this and the rest, but ultimately I think these are different findings, that should have been filed separately
#4 - GalloDaSballo
2023-02-03T12:01:10Z
The Warden has shown how a malicious staker could bypass slashing, by inputting a duration that is beyond the intended amount.
Other reports have shown how to sidestep the slash or reduce it, however, this report shows how the bypass can be enacted maliciously to break the protocol functionality, to the attacker's potential gain.
Because slashing is sidestepped in it's entirety, I believe this finding to be of High Severity
#5 - c4-judge
2023-02-03T21:30:24Z
GalloDaSballo marked the issue as selected for report
#6 - 0xju1ie
2023-02-05T14:03:17Z
If price drops Slash can revert -> Medium
dupe of #494
Is this the primary for Attacker can set Duration to too high to cause a revert -> High
?
🌟 Selected for report: gz627
Also found by: AkshaySrivastav, Allarious, Czar102, HE1M, HollaDieWaldfee, KmanOfficial, adriro, ast3ros, betweenETHlines, bin2chen, brgltd, cccz, chaduke, hihen, imare, mookimgo, neumo, nogo, peanuts, unforgiven
21.713 USDC - $21.71
The ProtocolDAO.upgradeExistingContract
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ProtocolDAO.sol#L209-L216) allows to upgrade an existing contract in the protocol. E.g. it can be used to upgrade the Staking
contract.
The function takes newAddr
, newName
and existingAddr
as arguments.
It only really makes sense to upgrade an existing contract to a new contract that has the same name as the old contract. E.g. the staking contract must be called Staking
before and after the upgrade. This is because other contracts look up the contract address by the contract's name:
Staking(getContractAddress("Staking"))
However the ProtocolDAO.upgradeExistingContract
function calls registerContract
first and then unregisterContract
. Because both the new and the old contract have the same name, the contract.address
entry of the new contract gets deleted again when unregisterContract
is called.
ProtocolDAO.upgradeExistingContract
is called to upgrade the Staking
contract with these arguments: newAddr=0x02
, newName="Staking"
, existingAddr=0x01
.registerContract
works fineunregisterContract(0x01)
is called which executes this line: deleteAddress(keccak256(abi.encodePacked("contract.address", name)));
. This deletes the contract.address
entry for the Staking
contract. So other contracts in the protocol cannot find the Staking
contract which makes the protocol inoperableVSCode
Call unregisterContract
first and then registerContract
such that the ProtocolDAO.upgradeExistingContract
looks like this:
function upgradeExistingContract( address newAddr, string memory newName, address existingAddr ) external onlyGuardian { unregisterContract(existingAddr); registerContract(newAddr, newName); }
#0 - c4-judge
2023-01-09T10:05:23Z
GalloDaSballo marked the issue as duplicate of #742
#1 - c4-judge
2023-02-08T20:09:57Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: imare
Also found by: 0Kage, 0xbepresent, AkshaySrivastav, Faith, HollaDieWaldfee, Jeiwan, Saintcode_, betweenETHlines, btk, dic0de, enckrish, gz627, imare, jadezti, kaliberpoziomka8552, nogo, simon135, sk8erboy
17.3743 USDC - $17.37
Judge has assessed an item in Issue #338 as 2 risk. The relevant finding follows:
L-07 It should be possible to assign Minipool to a new Multisig MinipoolManager.sol 1
#0 - c4-judge
2023-02-03T17:07:20Z
GalloDaSballo marked the issue as duplicate of #702
#1 - c4-judge
2023-02-03T17:07:26Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: __141345__
Also found by: 0xbepresent, Deivitto, HollaDieWaldfee, Nyx, PaludoX0, RaymondFam, ak1, cccz, ck, cryptostellar5, csanuragjain, ladboy233, rvierdiiev
68.0946 USDC - $68.09
Judge has assessed an item in Issue #338 as 2 risk. The relevant finding follows:
L-04 Staking.restakeGGP function should have whenNotPaused modifier Staking.sol 1
#0 - c4-judge
2023-02-03T17:07:06Z
GalloDaSballo marked the issue as duplicate of #673
#1 - c4-judge
2023-02-08T08:11:27Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: wagmi
Also found by: HollaDieWaldfee, __141345__, chaduke, hansfriese, peritoflores, rvierdiiev, sces60107, slowmoses, supernova
145.3017 USDC - $145.30
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L82-L100 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L84 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L98
The RewardsPool.inflate
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L82-L100) is used to inflate the circulating GGP supply by a fixed rate (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ProtocolDAO.sol#L41) every InflationIntervalSeconds
.
The RewardsPool.getInflationIntervalsElapsed
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L54-L61) is used to calculate how many intervals have passed since inflation was last applied.
The issue is that the InflationIntervalStartTime
variable is incremented by inflationIntervalElapsedSeconds
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L98).
However inflation is not applied to all of these passed seconds because the amount of inflation intervals is rounded down.
In effect this causes the actual inflation rate to be lower than what is specified by the inflation rate variable.
So the GGP rewards are lower than they should be.
Let's assume the following values:
InflationIntervalStartTime = 10000
block.timestamp = 10199
InflationInvervalSeconds = 100
Now inflation is applied to (10199 - 10000) / 100 = 1
interval.
The InflationIntervalStartTime
is then incremented by 10199 - 10000 = 199
.
However inflation is only applied to 100
seconds. So the InflationIntervalStartTime
should be incremented by only 100
.
VSCode
The calculation of inflationIntervalElapsedSeconds
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L84) in the RewardsPool.inflate
function should be changed like this:
function inflate() internal { ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); - uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()); + uint256 inflationIntervalElapsedSeconds = getInflationIntervalsElapsed() * dao.getInflationIntervalSeconds(); (uint256 currentTotalSupply, uint256 newTotalSupply) = getInflationAmt(); TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP"));
#0 - c4-judge
2023-01-10T10:25:28Z
GalloDaSballo marked the issue as duplicate of #648
#1 - c4-judge
2023-02-08T10:02:41Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: hansfriese
Also found by: 0Kage, 0xdeadbeef0x, Allarious, Aymen0909, Ch_301, Franfran, HollaDieWaldfee, Jeiwan, Nyx, RaymondFam, SmartSek, adriro, betweenETHlines, bin2chen, cccz, datapunk, immeas, kaliberpoziomka8552, peritoflores, wagmi
21.713 USDC - $21.71
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444-L478 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L152-L175 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L287-L303 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L298 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L457
The MinipoolManager.recreateMinipool
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L444-L478) moves the Minipool into the Prelaunch
state.
The Prelaunch
state can be entered from the Withdrawable
as well as the Finished
state (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L152-L175).
The MinipoolManager.recreateMinipool
function is intended to be called from the Withdrawable
state, i.e. before withdrawal has taken place.
However since the Prelaunch
state can be entered from the Finished
state, MinipoolManager.withdrawMinipoolFunds
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L287-L303), which puts the Minipool into the Finished
state, can be called before MinipoolManager.recreateMinipool
.
So a malicious staker can front-run the Rialto multisig's transaction to recreate the minipool and withdraw his funds before the Minipool is recreated.
This is an issue because the funds can then be withdrawn a second time when the recreated Minipool is in the Withdrawable
state again.
The actual exploitation of this issue is not as easy as it might seem.
Consider this:
MinipoolManager.withdrawMinipoolFunds
his AVAX stake is set to 0
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L298)MinipoolManager.recreateMinipool
only increases the AVAX stake by 10 AVAX (i.e. the reward amount) (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L457)Withdrawable
state again, the withdrawal fails because reducing the AVAX stake reverts due to underflow.In conclusion this exploit allows the staker to withdraw staking rewards multiple times. The staker must keep track of his staked AVAX amount such that all operations succeed though.
Withdrawing rewards multiple times is possible since MinipoolManager.recreateMinipool
increases the AVAX stake by the rewards amount even though the rewards were withdrawn already.
VSCode
The MinipoolManager.recreateMinpool
function should check that the Minipool status is not Finished
.
The status can only become Finished
when either withdrawal has taken place or MinpoolManager.finishFailedMinipoolByMultisig
was called.
In both cases recreating the Minipool should not be possible.
So change the MinipoolManager.recreateMinipool
function like this:
function recreateMinipool(address nodeID) external whenNotPaused { int256 minipoolIndex = onlyValidMultisig(nodeID); + bytes32 statusKey = keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")); + MinipoolStatus currentStatus = MinipoolStatus(getUint(statusKey)); + require(currentStatus != MinipoolStatus.Finished, "State canot be Finished"); requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); Minipool memory mp = getMinipool(minipoolIndex);
#0 - c4-judge
2023-01-09T12:56:29Z
GalloDaSballo marked the issue as duplicate of #484
#1 - c4-judge
2023-02-03T12:41:08Z
GalloDaSballo marked the issue as duplicate of #569
#2 - c4-judge
2023-02-08T08:27:15Z
GalloDaSballo changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-02-08T20:15:50Z
GalloDaSballo marked the issue as satisfactory
#4 - c4-judge
2023-02-09T08:45:54Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - Simon-Busch
2023-02-09T12:35:57Z
Changed the severity back to M as requested by @GalloDaSballo
118.8832 USDC - $118.88
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670-L683 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L94-L97
When staking is done, a Rialto multisig calls MinipoolManager.recordStakingEnd
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440).
If the avaxTotalRewardAmt
has the value zero, the MinipoolManager
will slash the node operator's GGP.
The issue is that the amount to slash can be greater than the GGP balance the node operator has staked.
This will cause the call to MinipoolManager.recordStakingEnd
to revert because an underflow is detected.
This means a node operator can create a minipool that cannot be slashed.
A node operator must provide at least 10% of avaxAssigned
as collateral by staking GGP.
It is assumed that a node operator earns AVAX at a rate of 10% per year.
So if a Minipool is created with a duration of > 365 days
, the 10% collateral is not sufficient to pay the expected rewards.
This causes the function call to revert.
Another cause of the revert can be that the GGP price in AVAX changes. Specifically if the GGP price falls, there needs to be slashed more GGP.
Therefore if the GGP price drops enough it can cause the call to slash to revert.
I think it is important to say that with any collateralization ratio this can happen. The price of GGP must just drop enough or one must use a long enough duration.
The exact impact of this also depends on how the Rialto multisig handles failed calls to MinipoolManager.recordStakingEnd
.
It looks like if this happens, MinipoolManager.recordStakingError
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515) is called.
This allows the node operator to withdraw his GGP stake.
So in summary a node operator can create a Minipool that cannot be slashed and probably remove his GGP stake when it should have been slashed.
When calling MinipoolManager.recordStakingEnd
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L385-L440) and the avaxTotalRewardAmt
parameter is zero, the node operator is slashed:
// No rewards means validation period failed, must slash node ops GGP. if (avaxTotalRewardAmt == 0) { slash(minipoolIndex); }
The MinipoolManager.slash
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L670-L683) then calculates expectedAVAXRewardsAmt
and from this slashGGPAmt
:
uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt);
Downstraem there is then a revert due to underflow because of the following line in Staking.decreaseGGPStake
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L94-L97):
subUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".ggpStaked")), amount);
You can add the following foundry test to MinipoolManager.t.sol
:
function testRecordStakingEndWithSlashFail() public { uint256 duration = 366 days; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 100 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); vm.startPrank(address(rialto)); skip(duration); minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); }
See that it runs successfully with duration = 365 days
and fails with duration = 366 days
.
The similar issue occurs when the GGP price drops. I chose to implement the test with duration
as the cause for the underflow because your tests use a fixed AVAX/GGP price.
VSCode, Foundry
You should check if the amount to be slashed is greater than the node operator's GGP balance. If this is the case, the amount to be slashed should be set to the node operator's GGP balance.
I believe this check can be implemented within the MinipoolManager.slash
function without breaking any of the existing accounting logic.
function slash(int256 index) private { address nodeID = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".nodeID"))); address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); uint256 duration = getUint(keccak256(abi.encodePacked("minipool.item", index, ".duration"))); uint256 avaxLiquidStakerAmt = getUint(keccak256(abi.encodePacked("minipool.item", index, ".avaxLiquidStakerAmt"))); uint256 expectedAVAXRewardsAmt = getExpectedAVAXRewardsAmt(duration, avaxLiquidStakerAmt); uint256 slashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt); emit GGPSlashed(nodeID, slashGGPAmt); Staking staking = Staking(getContractAddress("Staking")); if (slashGGPAmt > staking.getGGPStake(owner)) { slashGGPAmt = staking.getGGPStake(owner); } staking.slashGGP(owner, slashGGPAmt); }
#0 - Simon-Busch
2023-02-03T14:35:32Z
Created this report as duplicate of report https://github.com/code-423n4/2022-12-gogopool-findings/issues/136 as requested by @GalloDaSballo
#1 - c4-judge
2023-02-03T19:33:36Z
GalloDaSballo changed the severity to 2 (Med Risk)
#2 - c4-judge
2023-02-03T21:34:46Z
GalloDaSballo marked the issue as duplicate of #494
#3 - GalloDaSballo
2023-02-03T21:34:50Z
Considering the slash part (due to price) as Med
#4 - c4-judge
2023-02-08T08:13:19Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: unforgiven
Also found by: 0x73696d616f, 0xNazgul, Bnke0x0, Breeje, HollaDieWaldfee, IllIllI, Rolezn, __141345__, btk, ck, csanuragjain, fs0c, joestakey, kiki_dev, koxuan, rvierdiiev
40.8808 USDC - $40.88
The TokenggAVAX
contract is supposed to stream rewards in AVAX
to ggAVAX
holders over a period of 14 days.
The 14 days period however is only an upper limit for the reward streaming period.
The way the reward streaming period is calculated can make is as small as 1 second (due to rounding).
This is clearly not intended and you should calculate nextRewardsCycleEnd
differently such that the 14 day period is ensured.
Rewards are streamed during a period of length rewardsCycleEnd - lastSync
in the totalAssets
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L113-L130).
rewardsCycleEnd
and lastSync
are calculated in the syncRewards
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L88-L109).
I will show with a simple example that rewardsCycleEnd - lastSync
can be as small as 1 second meaning that all rewards that are supposed to be streamed over a 14 days period are streamed within 1 second:
syncRewards()
is called with:
block.timestamp = 10099
,
rewardsCycleLength = 100
nextRewardsCycleEnd
is calculated:
uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength;
timestamp + rewardsCycleLength
is equal to 10199
. Dividing this by rewardsCycleLength
results in 101
due to rounding.
101
* 100
is equal to 10100
.
So nextRewardsCycleEnd = 10100
and lastSync = 10099
.
This means that after 1 second all rewards are streamed.
VSCode
In the code it is stated that you calculate uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength;
because nextRewardsCycleEnd
should be divisible by rewardsCycleLength
.
I see no reason for this requirement and instead propose to calculate nextRewardsCycleEnd
like this:
uint32 nextRewardsCycleEnd = (timestamp + rewardsCycleLength);
#0 - c4-judge
2023-01-10T17:50:19Z
GalloDaSballo marked the issue as duplicate of #478
#1 - c4-judge
2023-02-08T20:12:19Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: HollaDieWaldfee
Also found by: Allarious, Aymen0909, Saintcode_, SmartSek, adriro, bin2chen, cccz, kaliberpoziomka8552, minhtrng, rvierdiiev, sces60107, sk8erboy, wagmi
88.523 USDC - $88.52
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L81-L84 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L51
The MinipoolManager.recordStakingError
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L484-L515) does not decrease the minipoolCount
of the staker.
This means that if a staker has a minipool that encounters an error, his minipoolCount
can never go to zero again.
This is bad because the minipoolCount
is used in ClaimNodeOp.calculateAndDistributeRewards
to determine if the rewardsStartTime
of the staker should be reset (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L81-L84).
Since the minipoolCount
cannot go to zero, the rewardsStartTime
will never be reset.
This means that the staker is immediately eligible for rewards when he creates a minipool again whereas he should have to wait rewardsEligibilityMinSeconds
before he is eligible (which is 14 days at the moment) (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L51).
To conclude, failing to decrease the minipoolCount
allows the staker to earn higher rewards because he is eligible for staking right after he creates a new minipool and does not have to wait again.
I have created the following test that you can add to the MinipoolManager.t.sol
file that logs the minipoolCount
in the Staking
, Error
and Finished
state.
The minipoolCount
is always 1
although it should decrease to 0
when recordStakingError
is called.
function testRecordStakingErrorWrongMinipoolCount() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; uint128 ggpStakeAmt = 200 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); bytes32 errorCode = "INVALID_NODEID"; int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); vm.prank(nodeOp); // minipool count when in "Staking" state: 1 console.log(staking.getMinipoolCount(nodeOp)); vm.prank(address(rialto)); minipoolMgr.recordStakingError{value: validationAmt}(mp1.nodeID, errorCode); vm.prank(nodeOp); // minipool count when in "Error" state: 1 console.log(staking.getMinipoolCount(nodeOp)); vm.prank(address(rialto)); assertEq(vault.balanceOf("MinipoolManager"), depositAmt); MinipoolManager.Minipool memory mp1Updated = minipoolMgr.getMinipool(minipoolIndex); vm.prank(address(rialto)); minipoolMgr.finishFailedMinipoolByMultisig(mp1Updated.nodeID); MinipoolManager.Minipool memory mp1finished = minipoolMgr.getMinipool(minipoolIndex); vm.prank(nodeOp); // minipool count when in "Finished" state: 1 console.log(staking.getMinipoolCount(nodeOp)); }
VSCode
You need to simply add the line staking.decreaseMinipoolCount(owner);
to the MinipoolManager.recordStakingError
function.
#0 - c4-judge
2023-01-09T09:41:58Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-09T09:42:01Z
Better POC -> Primary
#2 - 0xju1ie
2023-01-16T20:58:19Z
duplicate of https://github.com/code-423n4/2022-12-gogopool-findings/issues/590 Actually, #590 is missing this scenario and details of the extra steps, where the exploit would be if they created another minipool.
#3 - GalloDaSballo
2023-01-31T15:10:32Z
The Warden has shown how, calling recordStakingError
will not decrease the minipoolCount
This will not only impact view functions but also impact Yield calculations
For this reason, I agree with Medium Severity
#4 - c4-judge
2023-01-31T15:10:40Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: HollaDieWaldfee
Also found by: aviggiano
2194.0366 USDC - $2,194.04
The TokenggAVAX
contract (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L24) can be paused.
The whenTokenNotPaused
modifier is applied to the following functions (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L225-L239):
previewDeposit
, previewMint
, previewWithdraw
and previewRedeem
Thereby any calls to functions that deposit or withdraw funds revert.
There are two functions (maxWithdraw
and maxRedeem
) that calculate the max amount that can be withdrawn or redeemed respectively.
Both functions return 0
if the TokenggAVAX
contract is paused.
The issue is that TokenggAVAX
does not override the maxDeposit
and maxMint
functions (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L156-L162) in the ERC4626Upgradable
contract like it does for maxWithdraw
and maxRedeem
.
Thereby these two functions return a value that cannot actually be deposited or minted.
This can cause any components that rely on any of these functions to return a correct value to malfunction.
So maxDeposit
and maxMint
should return the value 0
when TokenggAVAX
is paused.
TokenggAVAX
contract is paused by calling Ocyticus.pauseEverything
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L37-L43)TokenggAVAX.maxDeposit
returns type(uint256).max
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L157)deposit
cannot be called with this value because it is paused (previewDeposit
reverts because of the whenTokenNotPaused
modifier) (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/upgradeable/ERC4626Upgradeable.sol#L44)VSCode
The maxDeposit
and maxMint
functions should be overridden by TokenggAVAX
just like maxWithdraw
and maxRedeem
are overridden and return 0
when the contract is paused (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L206-L223).
So add these two functions to the TokenggAVAX
contract:
function maxDeposit(address) public view override returns (uint256) { if (getBool(keccak256(abi.encodePacked("contract.paused", "TokenggAVAX")))) { return 0; } return return type(uint256).max; } function maxMint(address) public view override returns (uint256) { if (getBool(keccak256(abi.encodePacked("contract.paused", "TokenggAVAX")))) { return 0; } return return type(uint256).max; }
#0 - GalloDaSballo
2023-01-05T19:10:10Z
Looks off, the modifiers will revert on pause, not return 0
#1 - 0xju1ie
2023-01-19T09:59:08Z
Id say Low: (e.g. assets are not at risk: state handling, function incorrect as to spec, issues with comments). Excludes Gas optimizations, which are submitted and judged separately.
#2 - emersoncloud
2023-01-19T09:59:29Z
Good catch, I think we should override those for consistency at least but there's no way to exploit to lose assets. Agreed that QA makes sense.
#3 - GalloDaSballo
2023-01-31T14:08:37Z
By definition, the finding is Informational in Nature.
Because of the relevancy, I'm awarding it QA - Low
L
#4 - c4-judge
2023-01-31T14:08:47Z
#5 - GalloDaSballo
2023-01-31T20:57:56Z
I had a change of heart on this issue, because this pertains to a standard that is being implemented
For that reason am going to award Medium Severity, because the function breaks the standard, and historically we have awarded similar findings (e..g broken ERC20, broken ERC721 standard), with Medium
The Warden has shown an inconsistency between the ERC-4626 Spec and the implementation done by the sponsor, while technically this is an informational finding, the fact that a standard was broken warrants a higher severity, leading me to believe that Medium is a more appropriate Severity
Am making this decision because the Sponsor is following the standard, and the implementation of these functions is not consistent with it
#6 - c4-judge
2023-02-01T17:16:24Z
GalloDaSballo marked the issue as primary issue
#7 - c4-judge
2023-02-01T17:16:30Z
GalloDaSballo marked the issue as selected for report
🌟 Selected for report: IllIllI
Also found by: 0xSmartContract, AkshaySrivastav, HollaDieWaldfee, RaymondFam, Rolezn, ast3ros, betweenETHlines, brgltd, chaduke, codeislight, cozzetti, lukris02, pauliax, sces60107
122.8177 USDC - $122.82
Risk | Title | File | Instances |
---|---|---|---|
L-00 | Contracts inherit from Base but do not set version variable | - | 2 |
L-01 | Function should emit event | - | 8 |
L-02 | It should be possible to pause / unpause contracts individually | Ocyticus.sol | 1 |
L-03 | NewRewardsCycleStarted event emits wrong amount | RewardsPool.sol | 1 |
L-04 | Staking.restakeGGP function should have whenNotPaused modifier | Staking.sol | 1 |
L-05 | Staking.getStaker function does not set avaxAssignedHighWater value | Staking.sol | 1 |
L-06 | Function access modifier can be more restrictive | Staking.sol | 2 |
L-07 | It should be possible to assign Minipool to a new Multisig | MinipoolManager.sol | 1 |
N-00 | amountAvailableForStaking should return 0 instead of revert due to underflow | TokenggAVAX.sol | 1 |
N-01 | Remove TODO comments | - | 3 |
N-02 | Remove unnecessary imports | - | 2 |
N-03 | "Data Storage Schema" comment is missing entries | Staking.sol | 1 |
N-04 | Use require instead of assert | TokenggAVAX.sol | 1 |
N-05 | Use requireValidStaker instead of getIndexOf | Staking.sol | 6 |
BaseAbstract
but do not set version
variableThe BaseAbstract
contract has a version
variable that should be set in the child contracts. Some child contracts set version
to 1
. Others fail to set the version
variable which means it is set to 0
.
This can cause issues in any components integrating with the GoGoPool protocol that check the version of the contracts.
These are the contracts that do not but should set the version
variable:
Functions that make important changes to the protocol should emit events such that other components can listen for them on the blockchain.
This allows for more robust monitoring of the application.
There are 5 functions that should emit an event.
removeDefender
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L32-L34
pauseEverything
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L37-L43
resumeEverything
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L47-L52
disableAllMultisigs
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L55-L67
The withdrawMinipoolFunds
function changes the status of a minipool.
So it should emit the MinipoolStatusChangedEvent
like the other functions that change the minipool status.
There is 1 function that should emit an event.
calculateAndDistributeRewards
:
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L56-L85
The current functionality to pause contracts is located in Ocyticus.sol
.
There you can see that it is only possible to pause all pausable contracts at once using the pauseEverything
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L37-L43).
It should be possible to pause contracts individually.
Specifically the TokenggAVAX
contract can in some cases still be unpaused while MinipoolManager
and Staking
are paused such that users can still redeem their shares.
Similarly it should be possible to unpause contracts individually.
NewRewardsCycleStarted
event emits wrong amountThe RewardsPool.startRewardsCycle
function emits a NewRewardsCycleStarted
event (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L161).
However the event uses the RewardsCycleTotalAmount
before it is updated in the inflate
function.
The updated amount is the amount that is actually distributed as reward so it should be the amount that is emitted as well.
So emitting the event should take place after inflate
is called.
Staking.restakeGGP
function should have whenNotPaused
modifierWhen the Staking
contract is paused, Staking.stakeGGP
and Staking.withdrawGGP
cannot be called because they have the whenNotPaused
modifier:
The issue is that the Staking.restakeGGP
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L328) does not have this modifier which allows stakers to restake their GGP rewards using the ClaimNodeOp.claimAndRestake
function when the Staking
contract is paused.
This behavior is inconsistent because pausing the Staking
contract does not actually ensure that the staked GGP amounts do not change.
So you should add the whenNotPaused
modifier to the Staking.restakeGGP
function.
This ensures that by calling ClaimNodeOp.claimAndRestake
, stakers can only withdraw their rewards but not restake them.
Staking.getStaker
function does not set avaxAssignedHighWater
valueThe Staking.getStaker
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L405-L414) returns all information about a staker.
However it does not set the avaxAssignedHighWater
variable of the Staker
struct.
This means that staker.avaxAssignedHighWater
will always equal zero which can be the wrong value, leading to errors in any components that rely on this value.
You should add the following line to the Staking.getStaker
function:
staker.avaxAssignedHighWater = getUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".avaxAssignedHighWater")));
The Staking.increaseAVAXAssignedHighWater
and Staking.resetAVAXAssignedHighWater
functions currently make use of the onlyRegisteredNetworkContract
modifier:
Both functions are only called in one other contract so they can use a more restrictive access modifier.
Staking.increaseAVAXAssignedHighWater
should use onlySpecificRegisteredContract("MinipoolManager", msg.sender)
.
Staking.resetAVAXAssignedHighWater
should use onlySpecificRegisteredContract("ClaimNodeOp", msg.sender)
.
When a Minipool is created, a Multisig is assigned to it (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L236).
However there is no possibility to assign a different Multisig to an existing Minipool.
There can be scenarios that make it necessary to assign a new Multisig to a Minipool. E.g. if something changes about Avalanche Staking such that the old Multisig is not compatible anymore.
Therefore it should be possible to change Multisigs. E.g. allow the guardian to change Multisigs.
amountAvailableForStaking
should return 0 instead of revert due to underflowThe amountAvailableForStaking
function (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L132-L140) in TokenggAVAX.sol
calculates:
return totalAssets_ - reservedAssets - stakingTotalAssets;
This calculation can revert due to underflow because users can withdraw so much funds that stakingTotalAssets
is equal to totalAssets_
. So if reservedAssets
is subtracted as well this causes an underflow.
You should add this check above the line that can underflow:
if (totalAssets_ < reservedAssets + stakingTotalAssets) { return 0; }
It looks like your existing code does not expect the amountAvailableForStaking
function to revert and actually expects it to return 0
instead.
There is no security risk when the code reverts due to underflow but making it return 0
makes the code cleaner.
TODO
commentsTODO
comments left over from development should not stay in production code.
Imports that are not actually used should be removed to make the code cleaner and accurately show the dependencies.
In the Staking.sol
file there is a "Data Storage Schema" comment that explains the storage schema of a "staker" (https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L15-L27).
However there are missing entries (minipoolCount
, rewardsStartTime
, ggpRewards
, lastRewardsCycleCompleted
).
Add these entries to complete the storage schema.
require
instead of assert
The receive
function in TokenggAVAX
(https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L82-L84) uses assert
to make sure that the msg.sender
is the WAVAX
token.
The reason you should use require
instead of assert
is that assert
consumes all remaining Gas if the condition is not met, whereas require
returns the remaining Gas to the caller.
By using require
, if someone sends AVAX mistakenly, his remaining Gas will be returned.
requireValidStaker
instead of getIndexOf
There are 6 instances where getIndexOf
is used and should be replaced.
Instead you should use requireValidStaker
which reverts if the staker address is invalid.
#0 - GalloDaSballo
2023-01-24T16:30:31Z
L-00 Contracts inherit from Base but do not set version variable - 2 L
L-01 Function should emit event - 8 NC
L-02 It should be possible to pause / unpause contracts individually Ocyticus.sol 1 R
L-03 NewRewardsCycleStarted event emits wrong amount RewardsPool.sol 1 R
L-04 Staking.restakeGGP function should have whenNotPaused modifier Staking.sol 1 Dup of 673
L-05 Staking.getStaker function does not set avaxAssignedHighWater value Staking.sol 1 L
L-06 Function access modifier can be more restrictive Staking.sol 2 R
L-07 It should be possible to assign Minipool to a new Multisig MinipoolManager.sol 1 Dup of 702 - 50%
N-00 amountAvailableForStaking should return 0 instead of revert due to underflow TokenggAVAX.sol 1 R
N-01 Remove TODO comments - 3 NC
N-02 Remove unnecessary imports - 2 NC
N-03 "Data Storage Schema" comment is missing entries Staking.sol 1 NC
N-04 Use require instead of assert TokenggAVAX.sol 1 R
N-05 Use requireValidStaker instead of getIndexOf R
#1 - GalloDaSballo
2023-02-03T17:06:33Z
1L from dups
#2 - GalloDaSballo
2023-02-03T17:07:58Z
2L 6R 4NC
3L 6R 4NC including Dups
#3 - c4-judge
2023-02-03T22:09:48Z
GalloDaSballo marked the issue as grade-b