GoGoPool contest - caventa's results

Liquid staking for Avalanche.

General Information

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

GoGoPool

Findings Distribution

Researcher Performance

Rank: 24/111

Findings: 4

Award: $1,015.59

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: bin2chen

Also found by: AkshaySrivastav, RaymondFam, caventa, cozzetti, csanuragjain, hansfriese, rvierdiiev, shark

Labels

bug
3 (High Risk)
satisfactory
sponsor acknowledged
upgraded by judge
edited-by-warden
duplicate-532
fix later (sponsor)

Awards

597.9492 USDC - $597.95

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L670-L683 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/Staking.sol#L376-L383 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/tokens/TokenggAVAX.sol#L180-L203

Vulnerability details

Impact

We need to compensate the liquid staker with the slashed GGP. Right now, the slashed GGP is transferred and left in ProtocolDAO.

Proof of Concept

Multisig wallet may slash the GGP of the minipool.

/// @notice Slashes the GPP of the minipool with the given index /// @dev Extracted this because of "stack too deep" errors. /// @param index Index of the minipool 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")); staking.slashGGP(owner, slashGGPAmt); }

and eventually, the slashed amount will be transferred to ProtocolDAO contract.

function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); vault.transferToken("ProtocolDAO", ggp, ggpAmt); }

This is not correct because according to the documents,

See https://code4rena.com/contests/2022-12-gogopool-contest,

Node Operators join the protocol by creating Minipools where they deposit AVAX, request some amount of Liquid Staker AVAX and put up 10% of the requested amount in GGP. GGP, our protocol token, is how we ensure rewards for Liquid Stakers if the Node Operator does not maintain sufficient uptime for Avalanche rewards.

See https://multisiglabs.notion.site/Architecture-Protocol-Overview-4b79e351133f4d959a65a15478ec0121,

When someone creates a new Minipool on our network, they have to put up 10% of their requested AVAX as a collateral in GGP. For example, if they are borrowing 1000 AVAX from liquid stakers, they will have to put up 1000 AVAX * 0.1 GGP in order to stake on our network. If the validator is failing at their duties, their GGP will be slashed and used to compensate the loss to our Liquid Stakers.

Therefore, the slashed GGP needs to be transferred to the liquid staker holder

Tools Used

Manual

  1. Instead of transferring the GGP to protocolDAO contract, transfer the GGP to TokenggAVAX.
function slashGGP(address stakerAddr, uint256 ggpAmt) public onlySpecificRegisteredContract("MinipoolManager", msg.sender) { Vault vault = Vault(getContractAddress("Vault")); decreaseGGPStake(stakerAddr, ggpAmt); --- vault.transferToken("ProtocolDAO", ggp, ggpAmt); +++ vault.transferToken("TokenggAVAX", ggp, ggpAmt); }
  1. Whenever the user withdraws or redeems their AVAX from tokenggAVAX.sol, transfer the GGP based on the share to the user.
function withdrawAVAX(uint256 assets) public returns (uint256 shares) { shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up. beforeWithdraw(assets, shares); _burn(msg.sender, shares); emit Withdraw(msg.sender, msg.sender, msg.sender, assets, shares); IWAVAX(address(asset)).withdraw(assets); msg.sender.safeTransferETH(assets); // add the logic here to transfer the 10% GGP based on shares to the msg.sender }
function redeemAVAX(uint256 shares) public returns (uint256 assets) { // Check for rounding error since we round down in previewRedeem. if ((assets = previewRedeem(shares)) == 0) { revert ZeroAssets(); } beforeWithdraw(assets, shares); _burn(msg.sender, shares); emit Withdraw(msg.sender, msg.sender, msg.sender, assets, shares); IWAVAX(address(asset)).withdraw(assets); msg.sender.safeTransferETH(assets); // add the logic here to transfer the 10% GGP based on shares to the msg.sender }

#0 - c4-judge

2023-01-10T18:24:20Z

GalloDaSballo marked the issue as primary issue

#1 - GalloDaSballo

2023-01-10T18:24:28Z

Primary because of remediation

#2 - 0xju1ie

2023-01-20T14:03:17Z

We transfer it to the Protocol DAO right now because it will have to be sold off and then the avax deposited back to the TokenggAvax rewards. Slashing is somewhat rare so we planned this to be a manual process at first. I think the proper solution would look something like what was suggested in #532

#3 - c4-judge

2023-01-29T19:39:38Z

GalloDaSballo marked the issue as duplicate of #532

#4 - c4-judge

2023-02-02T21:00:29Z

GalloDaSballo changed the severity to 3 (High Risk)

#5 - c4-judge

2023-02-09T07:51:42Z

GalloDaSballo marked the issue as satisfactory

Awards

9.9345 USDC - $9.93

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
edited-by-warden
duplicate-213

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L191-L283

Vulnerability details

Impact

Every minipool has an owner and it is recorded in this line

setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender);

of the createMinipool function. However, there is no checking to disallow non owner to recreate the same mini pool which is wrong.

Proof of Concept

A user creates a minipool

/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

And cancel the minipool

/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }

Then, another user can recreate the minipool again with the same createMinipool function with the same nodeID. This should not be allowed!!

Added a test unit.

function testMinipoolManager() public { address nodeID1 = randAddress(); address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); vm.stopPrank(); vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); vm.stopPrank(); { vm.startPrank(nodeOp); MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); // Create a minipool skip(5 seconds); minipoolMgr.cancelMinipool(nodeID1); // Cancel the minipool after 5 seconds vm.stopPrank(); } { vm.startPrank(nodeOp2); MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); // Recreate the same minipool with the SAME NODEID by different user vm.stopPrank(); } }

Tools Used

Manual and added a test unit.

Change the createMiniPool function. Add owner checking if minipool is updated.

/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { +++ if(getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner"))) != msg.sender) { +++ // revert +++ } requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

#0 - c4-judge

2023-01-10T17:02:05Z

GalloDaSballo marked the issue as duplicate of #213

#1 - c4-judge

2023-02-03T19:26:10Z

GalloDaSballo changed the severity to 3 (High Risk)

#2 - c4-judge

2023-02-08T08:26:45Z

GalloDaSballo changed the severity to 2 (Med Risk)

#3 - c4-judge

2023-02-08T08:50:12Z

GalloDaSballo changed the severity to 3 (High Risk)

#4 - c4-judge

2023-02-08T20:25:41Z

GalloDaSballo marked the issue as satisfactory

#5 - c4-judge

2023-02-09T08:53:06Z

GalloDaSballo changed the severity to QA (Quality Assurance)

#6 - Simon-Busch

2023-02-09T12:46:57Z

Changed severity back from QA to H as requested by @GalloDaSballo

Findings Information

🌟 Selected for report: unforgiven

Also found by: caventa, rvierdiiev

Labels

bug
2 (Med Risk)
partial-25
duplicate-555

Awards

253.1581 USDC - $253.16

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/ClaimNodeOp.sol#L44-L52 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L191-L283

Vulnerability details

Impact

Rewards calculation may include CANCELLED minpool period which is wrong.

Proof of Concept

rewardsStartTime is important because it determines if a staker is eligible for the upcoming rewards cycle.

/// @notice Determines if a staker is eligible for the upcoming rewards cycle /// @dev Eligiblity: time in protocol (secs) > RewardsEligibilityMinSeconds. Rialto will call this. function isEligible(address stakerAddr) external view returns (bool) { Staking staking = Staking(getContractAddress("Staking")); uint256 rewardsStartTime = staking.getRewardsStartTime(stakerAddr); uint256 elapsedSecs = (block.timestamp - rewardsStartTime); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); return (rewardsStartTime != 0 && elapsedSecs >= dao.getRewardsEligibilityMinSeconds()); }

If a user create a minipool

function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

And cancel the minipool after 5 days

/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }

And recreate the minipool again by calling the same createMinipool function, the same isEligible function abovementioned will include the CANCELLATION PERIOD which is wrong.

See this line in the isEligible function

uint256 elapsedSecs = (block.timestamp - rewardsStartTime);

This is because the rewardsStartTime uses the recorded value from the first createMinipool() call and not the recorded value from the second createManipool() call

Tools Used

Manual

Change the createMinipool function

/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); --- if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); --- } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

#0 - c4-judge

2023-01-10T21:04:26Z

GalloDaSballo marked the issue as duplicate of #251

#1 - c4-judge

2023-02-09T07:55:24Z

GalloDaSballo marked the issue as not a duplicate

#2 - GalloDaSballo

2023-02-09T07:55:38Z

Looks less precise, awarding 25%

#3 - c4-judge

2023-02-09T07:55:40Z

GalloDaSballo marked the issue as duplicate of #555

#4 - c4-judge

2023-02-09T07:55:53Z

GalloDaSballo marked the issue as partial-25

Findings Information

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor confirmed
edited-by-warden
fix security (sponsor)
M-12

Awards

154.5481 USDC - $154.55

External Links

Lines of code

https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L225-L227 https://github.com/code-423n4/2022-12-gogopool/blob/main/contracts/contract/MinipoolManager.sol#L279-L281

Vulnerability details

Impact

When canceling a minipool that was canceled before, it may skip MinipoolCancelMoratoriumSeconds checking and allow the user to cancel the minipool immediately.

Proof of Concept

A user may create a minipool.

/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { onlyOwner(minipoolIndex); requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

and after 5 days, the user cancels the minipool

/// @notice Owner of a minipool can cancel the (prelaunch) minipool /// @param nodeID 20-byte Avalanche node ID the Owner registered with function cancelMinipool(address nodeID) external nonReentrant { Staking staking = Staking(getContractAddress("Staking")); ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); int256 index = requireValidMinipool(nodeID); onlyOwner(index); // make sure they meet the wait period requirement if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { revert CancellationTooEarly(); } _cancelMinipoolAndReturnFunds(nodeID, index); }

Then, the user recreates the minipool again by calling the same createMinipool function. Then, the user cancels the minipool immediately. The user should not be allowed to cancel the minpool immediately and he should wait for 5 more days.

Added a test unit to MinipoolManager.t.sol

function testMinipoolManager() public { address nodeID1 = randAddress(); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); { MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); skip(5 days); minipoolMgr.cancelMinipool(mp.nodeID); // Must skip 5 days to be executed } { MinipoolManager.Minipool memory mp = createMyMinipool(nodeID1, 1000 ether, 1000 ether, 2 weeks); minipoolMgr.cancelMinipool(mp.nodeID); // Do not need 5 days more to be executed which is wrong } vm.stopPrank(); }

Tools Used

Manual and added a test unit

Change the createMinipool function. Always setRewardsStartTime everytime the minippol is recreated.

/// @notice Accept AVAX deposit from node operator to create a Minipool. Node Operator must be staking GGP. Open to public. /// @param nodeID 20-byte Avalanche node ID /// @param duration Requested validation period in seconds /// @param delegationFee Percentage delegation fee in units of ether (2% is 0.2 ether) /// @param avaxAssignmentRequest Amount of requested AVAX to be matched for this Minipool function createMinipool( address nodeID, uint256 duration, uint256 delegationFee, uint256 avaxAssignmentRequest ) external payable whenNotPaused { if (nodeID == address(0)) { revert InvalidNodeID(); } ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); if ( // Current rule is matched funds must be 1:1 nodeOp:LiqStaker msg.value != avaxAssignmentRequest || avaxAssignmentRequest > dao.getMinipoolMaxAVAXAssignment() || avaxAssignmentRequest < dao.getMinipoolMinAVAXAssignment() ) { revert InvalidAVAXAssignmentRequest(); } if (msg.value + avaxAssignmentRequest < dao.getMinipoolMinAVAXStakingAmt()) { revert InsufficientAVAXForMinipoolCreation(); } Staking staking = Staking(getContractAddress("Staking")); staking.increaseMinipoolCount(msg.sender); staking.increaseAVAXStake(msg.sender, msg.value); staking.increaseAVAXAssigned(msg.sender, avaxAssignmentRequest); --- if (staking.getRewardsStartTime(msg.sender) == 0) { staking.setRewardsStartTime(msg.sender, block.timestamp); --- } uint256 ratio = staking.getCollateralizationRatio(msg.sender); if (ratio < dao.getMinCollateralizationRatio()) { revert InsufficientGGPCollateralization(); } // Get a Rialto multisig to assign for this minipool MultisigManager multisigManager = MultisigManager(getContractAddress("MultisigManager")); address multisig = multisigManager.requireNextActiveMultisig(); // Create or update a minipool record for nodeID // If nodeID exists, only allow overwriting if node is finished or canceled // (completed its validation period and all rewards paid and processing is complete) int256 minipoolIndex = getIndexOf(nodeID); if (minipoolIndex != -1) { requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); resetMinipoolData(minipoolIndex); // Also reset initialStartTime as we are starting a whole new validation setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".initialStartTime")), 0); } else { minipoolIndex = int256(getUint(keccak256("minipool.count"))); // The minipoolIndex is stored 1 greater than actual value. The 1 is subtracted in getIndexOf() setUint(keccak256(abi.encodePacked("minipool.index", nodeID)), uint256(minipoolIndex + 1)); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".nodeID")), nodeID); addUint(keccak256("minipool.count"), 1); } // Save the attrs individually in the k/v store setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".duration")), duration); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".delegationFee")), delegationFee); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".owner")), msg.sender); setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpInitialAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), msg.value); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), avaxAssignmentRequest); emit MinipoolStatusChanged(nodeID, MinipoolStatus.Prelaunch); Vault vault = Vault(getContractAddress("Vault")); vault.depositAVAX{value: msg.value}(); }

#0 - GalloDaSballo

2023-01-09T12:39:52Z

Code + POC -> Primary

#1 - c4-judge

2023-01-09T12:39:55Z

GalloDaSballo marked the issue as primary issue

#2 - 0xju1ie

2023-01-20T12:16:04Z

This solution would mess up other aspects of the protocol. In cancel minipool we should really just check the minipoolStartTime against the cancelMoratoriumSeconds.

#3 - GalloDaSballo

2023-01-29T19:32:21Z

The warden has shown a logic flaw in the Finite State Machine, as shown in the POC, cancelling a second miniPool can be done before MinipoolCancelMoratoriumSeconds

Because the exploit doesn't demonstrate a reliable way to extra value or funds from the protocol, I agree with Medium Severity

#4 - c4-judge

2023-02-08T08:30:52Z

GalloDaSballo 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