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: 29/111
Findings: 5
Award: $762.68
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: __141345__
Also found by: 0xbepresent, Deivitto, HollaDieWaldfee, Nyx, PaludoX0, RaymondFam, ak1, cccz, ck, cryptostellar5, csanuragjain, ladboy233, rvierdiiev
88.523 USDC - $88.52
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L328-L332 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimNodeOp.sol#L89-L114 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/ClaimProtocolDAO.sol#L20-L35
The whenNotPaused
modifier is used to pause minipool creation and staking/withdrawing GGP. However, there are several cases this modifier could be bypassed, which breaks the intended admin control function and special mode.
stake()
In paused mode, no more stakeGGP()
is allowed,
File: contract/Staking.sol 319: function stakeGGP(uint256 amount) external whenNotPaused { 320: // Transfer GGP tokens from staker to this contract 321: ggp.safeTransferFrom(msg.sender, address(this), amount); 322: _stakeGGP(msg.sender, amount); 323: }
However, restakeGGP()
is still available, which potentially violate the purpose of pause mode.
File: contract/Staking.sol 328: function restakeGGP(address stakerAddr, uint256 amount) public onlySpecificRegisteredContract("ClaimNodeOp", msg.sender) { 329: // Transfer GGP tokens from the ClaimNodeOp contract to this contract 330: ggp.safeTransferFrom(msg.sender, address(this), amount); 331: _stakeGGP(stakerAddr, amount); 332: }
withdraw()
In paused mode, no more withdrawGGP()
is allowed,
File: contract/Staking.sol 358: function withdrawGGP(uint256 amount) external whenNotPaused { 373: vault.withdrawToken(msg.sender, ggp, amount);
However, claimAndRestake()
is still available, which can withdraw from the vault.
File: contract/ClaimNodeOp.sol 089: function claimAndRestake(uint256 claimAmt) external { 103: if (restakeAmt > 0) { 104: vault.withdrawToken(address(this), ggp, restakeAmt); 105: ggp.approve(address(staking), restakeAmt); 106: staking.restakeGGP(msg.sender, restakeAmt); 107: } 108: 109: if (claimAmt > 0) { 110: vault.withdrawToken(msg.sender, ggp, claimAmt); 111: }
THe function spend()
can also ignore the pause mode to withdraw from the vault. But this is a guardian function. It could be intended behavior.
File: contract/ClaimProtocolDAO.sol 20: function spend( 21: string memory invoiceID, 22: address recipientAddress, 23: uint256 amount 24: ) external onlyGuardian { 32: vault.withdrawToken(recipientAddress, ggpToken, amount);
Manual analysis.
whenNotPaused
modifier to restakeGGP()
and claimAndRestake()
spend()
.#0 - c4-judge
2023-01-09T12:28:15Z
GalloDaSballo marked the issue as primary issue
#1 - GalloDaSballo
2023-01-29T18:16:20Z
The warden has shown an inconsistency within similar functions regarding how they behave during a pause. Because the finding pertains to an inconsistent functionality, without a loss of principal, I agree with Medium Severity
#2 - c4-judge
2023-01-29T18:16:26Z
GalloDaSballo marked the issue as selected for report
🌟 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#L54-L61 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L82-L98 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L66-L77
The interval number rounding error due to round down could accumulate, in the long term, the arithmetic error can make the reward inflation amount deviate from expected growth rate. Some reward could be "lost", but deserve to be generated otherwise.
The inflationIntervalElapsedSeconds
is calculated based on the timestamp difference between the spot timestamp and InflationIntervalStartTime
. To get the number of Inflation Intervals, inflationIntervalElapsedSeconds
is divided by InflationIntervalSeconds
(which is set to be 1 days, or 86400 seconds). And round down to get the interval number.
File: contract/ProtocolDAO.sol 40: setUint(keccak256("ProtocolDAO.InflationIntervalSeconds"), 1 days); File: contract/RewardsPool.sol 54: function getInflationIntervalsElapsed() public view returns (uint256) { 56: uint256 startTime = getInflationIntervalStartTime(); 60: return (block.timestamp - startTime) / dao.getInflationIntervalSeconds();
Lastly, the way InflationIntervalStartTime
updated is simply add the inflationIntervalElapsedSeconds
to last InflationIntervalStartTime
on record.
File: contract/RewardsPool.sol 82: function inflate() internal { 84: uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()); 98: addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds);
The issue is, the rounding down for the interval number could accumulate, over the long term making the reward calculation inaccurate. The inflation growth rate will not work as expected.
Imagine the following
InflationIntervalStartTime
is on timestamp 1,000,000.startRewardsCycle()
is called, and InflationIntervalStartTime
is updated to 3,419,480 (1,000,000 + 2,419,200 + 280), the 280 seconds is some delay of the function call, where the accumulated error comes from.
During this reward cycle, the reward will be inflated by 28 inflation cycle.InflationIntervalStartTime
as 3,419,480.
Each cycle, the inflation interval number will be 28 (inflated 28 times).
Assume in each cycle, the delay is 280 seconds. This delay will not be counted in the inflation calculation due to the rounding down.InflationIntervalStartTime
will be increased to 27,784,120 (1,000,000 + 309 * 86,680). Noted that here 86,680 is used instead of 86,400, because the 280 seconds delay is also included.
However, the calendar days passed is 310 days (309 * 86,680 / 86,400 = 310), over the time, the 280 seconds delay accumulate to 1 whole day.Also assuming the last startRewardsCycle()
call is at the end of 310 days, not just pass the reward cycle timestamp.
According to the function getInflationAmt()
, the inflation amount is calculated based on the interval numbers 309, but the actual calendar day passed is 310. The inflation amount will has a difference here for 1 inflation interval (1 day). The difference is 1.000133680617113500 (5% annual).
Effectively, the 0.0134% reward is lost due to the arithmetic error accumulation.
File: contract/RewardsPool.sol 66: function getInflationAmt() public view returns (uint256 currentTotalSupply, uint256 newTotalSupply) { 69: uint256 inflationIntervalsElapsed = getInflationIntervalsElapsed(); 73: // Compute inflation for total inflation intervals elapsed 74: for (uint256 i = 0; i < inflationIntervalsElapsed; i++) { 75: newTotalSupply = newTotalSupply.mulWadDown(inflationRate); 76: } 77: return (currentTotalSupply, newTotalSupply);
Manual analysis.
In function inflate()
, when update InflationIntervalStartTime
, instead of simply add the inflationIntervalElapsedSeconds
, also round down the elapsed seconds, just like the interval elapsed calculation.
File: contract/RewardsPool.sol 82: function inflate() internal { 84: uint256 inflationIntervalElapsedSeconds = (block.timestamp - getInflationIntervalStartTime()); -98: addUint(keccak256("RewardsPool.InflationIntervalStartTime"), inflationIntervalElapsedSeconds); +98: uint256 adjustElapsedSeconds = inflationIntervalElapsedSeconds * dao.getInflationIntervalSeconds() / dao.getInflationIntervalSeconds(); +99: addUint(keccak256("RewardsPool.InflationIntervalStartTime"), adjustElapsedSeconds);
#0 - GalloDaSballo
2023-01-10T18:30:45Z
Keeping it separate for now
#1 - 0xju1ie
2023-01-18T15:54:19Z
Dupe of #648
#2 - c4-judge
2023-01-29T18:50:21Z
GalloDaSballo marked the issue as duplicate of #648
#3 - c4-judge
2023-02-08T10:02:25Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: caventa
Also found by: 0xdeadbeef0x, Allarious, Franfran, __141345__, betweenETHlines, hansfriese, mert_eren, stealthyz, unforgiven, wagmi
118.8832 USDC - $118.88
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MinipoolManager.sol#L278-L281 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Staking.sol#L196-L199
Currently, the Moratorium waiting period to cancel minipool is set to be 5 days. However, the wait period checks only the msg.sender, the requirement could be bypassed if the node operator has multiple nodes, and some other nodeID has already passed the waiting period.
The getRewardsStartTime(msg.sender)
only tracks the msg.sender, not the nodeID address.
File: contracts/contract/ProtocolDAO.sol 52: setUint(keccak256("ProtocolDAO.MinipoolCancelMoratoriumSeconds"), 5 days); File: contracts/contract/MinipoolManager.sol 278: // make sure they meet the wait period requirement 279: if (block.timestamp - staking.getRewardsStartTime(msg.sender) < dao.getMinipoolCancelMoratoriumSeconds()) { 280: revert CancellationTooEarly(); 281: } File: contracts/contract/Staking.sol 196: function getRewardsStartTime(address stakerAddr) public view returns (uint256) { 197: int256 stakerIndex = getIndexOf(stakerAddr); 198: return getUint(keccak256(abi.encodePacked("staker.item", stakerIndex, ".rewardsStartTime"))); 199: }
The RewardsStartTime
is only initialized if it is not set yet. So new created minipool for other nodeID will not be recorded.
File: contracts/contract/MinipoolManager.sol 225: if (staking.getRewardsStartTime(msg.sender) == 0) { 226: staking.setRewardsStartTime(msg.sender, block.timestamp); 227: }
Imagine, node operator addr(Alice) has 2 nodeID, addr(0x01) and addr(0x02). Alice creates minipool for nodeID addr(0x01). after 6 days, Alice creates another minipool for nodeID addr(0x02). According to the wait period, nodeID addr(0x02) should not be cancelled since it is still within the 5 day Moratorium range. But getRewardsStartTime(addr(Alice))
is set to 6 days ago by addr(0x01), which qualifies for cancellation. Then nodeID addr(0x02) can bypass the wait period and be cancelled.
Manual analysis.
Record the RewardsStartTime
per nodeID, not just operator address.
#0 - c4-judge
2023-01-09T12:40:09Z
GalloDaSballo marked the issue as duplicate of #519
#1 - c4-judge
2023-02-08T10:03:55Z
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
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L88-L107 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/tokens/TokenggAVAX.sol#L113-L130
In the current rewards accounting, vault shares in deposit()
and redeem()
can not correctly record the spot yields generated by the staked asset. Yields are released over the next rewards cycle. As a result, malicious users can steal yields from innocent users by picking special timing to deposit()
and redeem()
.
The key in this issue is that the rewards release is not aligned with the actual staking period. Hence, inevitably there will be some mismatch between the rewards received and the true effective staking.
Although in the known issue section of the doc, the comment claims that some actions could be done for abuse case, there is some underlying potential unfairness of the mismatched mechanism. And sometimes it might not cause big steal of funds, however, overtime, some users could lose some portion of their rewards, some users might just be lucky to receive some extra rewards they do not deserve. And the difference could be small or medium depending on specific situation, the inaccurate release model unavoidably introduce some degree of unfairness.
In syncRewards()
, the current asset balance is break into 2 parts: totalReleasedAssets_
and lastRewardsAmt_/nextRewardsAmt
. The lastRewardsAmt_
is the surplus balance of the asset, or the most recent yields.
File: contract/tokens/TokenggAVAX.sol 88: function syncRewards() public { 099: uint256 nextRewardsAmt = (asset.balanceOf(address(this)) + stakingTotalAssets_) - totalReleasedAssets_ - lastRewardsAmt_; 102: uint32 nextRewardsCycleEnd = ((timestamp + rewardsCycleLength) / rewardsCycleLength) * rewardsCycleLength; 104: lastRewardsAmt = nextRewardsAmt.safeCastTo192(); 106: rewardsCycleEnd = nextRewardsCycleEnd; 107: totalReleasedAssets = totalReleasedAssets_ + lastRewardsAmt_;
And in the next rewards cycle, lastRewardsAmt_
will be linearly added to totalReleasedAssets_
, their sum is the return value of totalAssets()
:
113: function totalAssets() public view override returns (uint256) { 120: if (block.timestamp >= rewardsCycleEnd_) { 121: // no rewards or rewards are fully unlocked 122: // entire reward amount is available 123: return totalReleasedAssets_ + lastRewardsAmt_; 124: } 125: 126: // rewards are not fully unlocked 127: // return unlocked rewards and stored total 128: uint256 unlockedRewards = (lastRewardsAmt_ * (block.timestamp - lastSync_)) / (rewardsCycleEnd_ - lastSync_); 129: return totalReleasedAssets_ + unlockedRewards; 130: }
totalAssets()
will be referred when deposit()
and redeem()
.
File: contract/tokens/upgradeable/ERC4626Upgradeable.sol 42: function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) { 44: require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); 091: function redeem( 092: uint256 shares, 093: address receiver, 094: address owner 095: ) public virtual returns (uint256 assets) { 103: require((assets = previewRedeem(shares)) != 0, "ZERO_ASSETS"); 104: 105: beforeWithdraw(assets, shares); 106: 107: _burn(owner, shares); 132: function previewDeposit(uint256 assets) public view virtual returns (uint256) { 133: return convertToShares(assets); 134: } 148: function previewRedeem(uint256 shares) public view virtual returns (uint256) { 149: return convertToAssets(shares); 150: } 120: function convertToShares(uint256 assets) public view virtual returns (uint256) { 121: uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. 122: 123: return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); 124: } 126: function convertToAssets(uint256 shares) public view virtual returns (uint256) { 127: uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. 128: 129: return supply == 0 ? shares : shares.mulDivDown(totalAssets(), supply); 130: }
Based on the above rules, there are 2 potential abuse cases:
rewardsCycleEnd
timestamp, a user can not get the yields from last rewards cycle. Since the totalAssets()
only contain totalReleasedAssets_
but not the yields part. It takes 1 rewards cycle to linearly add to the totalReleasedAssets_
.Assume per 10,000 asset staking generate yields of 280 for 28 days, and the reward cycle is 14 day. A malicious user Alice can do the following:
withdraw(10,000)
from account Bob, front run it with syncRewards()
, so that the most recent yields of amount 140 from Bob will stay in the vault.redeem()
to take the yields of 280.Effectively steal the yields from Bob. The profit for Alice is not 280, because after 14 day, her own deposit also generates some yield, in this example this portion is 140. At the end, Alice steal yield of amount 140.
syncRewards()
is called. It is possible that yields from multiple rewards cycles accumulates, and being released in the next cycle.Knowing that the yields has been accumulated for 3 rewards cycles, a malicious user can deposit()
and call syncRewards()
to trigger the release of the rewards. redeem()
after 1 cycle.
Here the malicious user gets yields of 3 cycles, lose 1 in the waiting cycle. The net profit is 2 cycle yields, and the gained yields should belong to the other users in the vault.
Manual analysis.
lastRewardsAmt_
not released, allow the users to redeem as it is linearly released later.#0 - c4-judge
2023-01-10T17:47:38Z
GalloDaSballo marked the issue as duplicate of #478
#1 - c4-judge
2023-02-08T20:11:46Z
GalloDaSballo marked the issue as satisfactory
🌟 Selected for report: ladboy233
Also found by: __141345__, pauliax, peakbolt, rvierdiiev, unforgiven
369.1045 USDC - $369.10
https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/MultisigManager.sol#L68-L76 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/Ocyticus.sol#L55-L67 https://github.com/code-423n4/2022-12-gogopool/blob/aec9928d8bdce8a5a4efe45f54c39d4fc7313731/contracts/contract/RewardsPool.sol#L210-L229
The multisig could be disabled, if all the multisigs were disabled, the reward cycle will have Denial of Service, and become inoperable for some period. Although it is an edge case that all multisigs were disabled, some treatment for special cases will make the protocol more robust in operations.
Each multisig could be disabled, or even all multisigs can be disabled totally.
File: contract/MultisigManager.sol 68: function disableMultisig(address addr) external guardianOrSpecificRegisteredContract("Ocyticus", msg.sender) { 69: int256 multisigIndex = getIndexOf(addr); 70: if (multisigIndex == -1) { 71: revert MultisigNotFound(); 72: } 73: 74: setBool(keccak256(abi.encodePacked("multisig.item", multisigIndex, ".enabled")), false); 75: emit DisabledMultisig(addr, msg.sender); 76: } File: contract/Ocyticus.sol 55: function disableAllMultisigs() public onlyDefender { 56: MultisigManager mm = MultisigManager(getContractAddress("MultisigManager")); 57: uint256 count = mm.getCount(); 58: 59: address addr; 60: bool enabled; 61: for (uint256 i = 0; i < count; i++) { 62: (addr, enabled) = mm.getMultisig(i); 63: if (enabled) { 64: mm.disableMultisig(addr); 65: } 66: } 67: }
If enabledCount
is 0, function distributeMultisigAllotment()
will revert in line 229. startRewardsCycle()
will also revert and cause DoS. Because the multisigClaimContractAllotment
is not dependent on the number of enabled multisigs.
File: contract/RewardsPool.sol 203: function distributeMultisigAllotment( 204: uint256 allotment, 205: Vault vault, 206: TokenGGP ggp 207: ) internal { 210: uint256 enabledCount; 211: uint256 count = mm.getCount(); 212: address[] memory enabledMultisigs = new address[](count); 215: for (uint256 i = 0; i < count; i++) { 216: (address addr, bool enabled) = mm.getMultisig(i); 217: if (enabled) { 218: enabledMultisigs[enabledCount] = addr; 219: enabledCount++; 220: } 221: } 229: uint256 tokensPerMultisig = allotment / enabledCount; 156: function startRewardsCycle() external { 171: uint256 multisigClaimContractAllotment = getClaimingContractDistribution("ClaimMultisig"); 186: if (multisigClaimContractAllotment > 0) { 187: emit MultisigRewardsTransfered(multisigClaimContractAllotment); 188: distributeMultisigAllotment(multisigClaimContractAllotment, vault, ggp); 189: }
Manual analysis.
Add checks for the enabledCount
if (enabledCount > 0) { // ... } else { // ... }
If the enabledCount
is 0, the corresponding rewards can be saved to somewhere else, instead of lost or locked in the contract.
#0 - c4-judge
2023-01-08T16:08:13Z
GalloDaSballo marked the issue as duplicate of #143
#1 - c4-judge
2023-02-08T19:59:59Z
GalloDaSballo marked the issue as satisfactory