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: 41/111
Findings: 1
Award: $373.72
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: HollaDieWaldfee
Also found by: 0xdeadbeef0x, bin2chen, cozzetti, danyams, enckrish, imare, ladboy233
373.7183 USDC - $373.72
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L425 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L681-L682
The MinipoolManager will not be able to slash a staker's GGP depending on the minipool's duration and the GGP price in AVAX. This specifically occurs when:
staker.item<i>.ggpStaked < (expectedAVAXRewardsRate * minipool.item<i>.duration) / (365 days * ggpPriceInAVAX).
Assuming this is true, recordStakingEnd( ) will throw an arithmetic underflow error.
This vulnerability occurs due to the private function, slash( ), which is called in recordStakingEnd( ). slash( ) calls slashGGP( ) in the Staking contract, which then decreases a user's GGP stake by a given amount. However, staking.slashGGP( ) can be called in MinipoolManager with a value greater than a user's stake, causing an underflow error.
This vulnerability occurs over a wide range of scenarios since a staker's duration is not validated in createMinipool( ), ggpPriceInAVAX is variable, and a minipool owner can stake a wide range of values. For example, assuming ProtocolDAO.ExpectedAVAXRewardsRate remains at 10%, this bug will occur if the owner's stake is 10% of their assigned AVAX, the minipool's duration is 365 days, and ggpPriceInAVAX decreases by any amount.
Due to MinipoolManager's state transition logic, the only way to reset a vulnerable pool and distribute the owner's and stakers' AVAX is by calling recordStakingError( ), which does not slash the owner's staked amount.
Here is the proof of concept from the given test suite:
--- a/test/unit/MinipoolManager.t.sol +++ b/test/unit/MinipoolManager.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.17; import "./utils/BaseTest.sol"; +import {stdError} from "forge-std/Test.sol"; import {FixedPointMathLib} from "@rari-capital/solmate/src/utils/FixedPointMathLib.sol"; @@ -502,6 +503,46 @@ contract MinipoolManagerTest is BaseTest { + function testRecordStakingEndWithSlashBug() public { + uint256 duration = 365 days; + uint256 depositAmt = 1000 ether; + uint256 avaxAssignmentRequest = 1000 ether; + uint256 validationAmt = depositAmt + avaxAssignmentRequest; + uint128 ggpStakeAmt = 100 ether; + + // Stake GGP -- 1 GGP = 1 AVAX + vm.startPrank(nodeOp); + ggp.approve(address(staking), MAX_AMT); + staking.stakeGGP(ggpStakeAmt); + + // Create a minipool with a duration of 365 days + 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.startPrank(address(rialto)); + minipoolMgr.claimAndInitiateStaking(mp1.nodeID); + + bytes32 txID = keccak256("txid"); + minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); + + // Wait 14 days + skip(14 days); + + // Decrease AVAX price by 1/10**18 + (uint256 _currentGGPPriceInAvax, ) = oracle.getGGPPriceInAVAX(); + rialto.setGGPPriceInAVAX(_currentGGPPriceInAvax - 1, block.timestamp); + + // Have Rialto call recordStakingEnd with the param avaxTotalRewardAmt set to 0 + vm.expectRevert(stdError.arithmeticError); + minipoolMgr.recordStakingEnd{value: validationAmt}(mp1.nodeID, block.timestamp, 0 ether); + + vm.stopPrank(); + }
Foundry & VSCode
Call staking.slashGGP( ) in slash( ) only after verifying that a minipool owner's stake is greater than or equal to the expected amount of GGP to be slashed. If it is not, call slash( ) with the owner's staked GGP rather than the expected amount of slashed GGP.
@@ -673,12 +673,15 @@ contract MinipoolManager is Base, ReentrancyGuard, IWithdrawer { 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")); + + uint256 expectedSlashGGPAmt = calculateGGPSlashAmt(expectedAVAXRewardsAmt); + uint256 GGPStaked = staking.getGGPStake(owner); + uint256 slashGGPAmt = (expectedSlashGGPAmt > GGPStaked) ? GGPStaked : expectedSlashGGPAmt; + + setUint(keccak256(abi.encodePacked("minipool.item", index, ".ggpSlashAmt")), slashGGPAmt); + emit GGPSlashed(nodeID, slashGGPAmt); staking.slashGGP(owner, slashGGPAmt); }
#0 - c4-judge
2023-01-10T15:10:50Z
GalloDaSballo marked the issue as duplicate of #136
#1 - c4-judge
2023-02-03T19:40:44Z
GalloDaSballo changed the severity to 3 (High Risk)
#2 - GalloDaSballo
2023-02-03T19:52:45Z
Duration not validated - Price Drops - 50% because they got the problems but not the attack (set above 365 duration)
#3 - c4-judge
2023-02-03T19:52:51Z
GalloDaSballo marked the issue as partial-50