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: 9/111
Findings: 4
Award: $2,286.97
π Selected for report: 1
π 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
owner will be deprived from picking the node id which they already used.
when natspec says // If nodeID exists, only allow overwriting if node is finished or canceled
The pool that are marked as Withdrawable
and Error
are also used to overwrite the node id
finished
will be set in withdrawMinipoolFunds
where the staked amount is reduced but avaxassigned
is not reset. This would be drawback to owner of nodeID
function withdrawMinipoolFunds(address nodeID) external nonReentrant { int256 minipoolIndex = requireValidMinipool(nodeID); address owner = onlyOwner(minipoolIndex); requireValidStateTransition(minipoolIndex, MinipoolStatus.Finished); setUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".status")), uint256(MinipoolStatus.Finished)); uint256 avaxNodeOpAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpAmt"))); uint256 avaxNodeOpRewardAmt = getUint(keccak256(abi.encodePacked("minipool.item", minipoolIndex, ".avaxNodeOpRewardAmt"))); uint256 totalAvaxAmt = avaxNodeOpAmt + avaxNodeOpRewardAmt; Staking staking = Staking(getContractAddress("Staking")); staking.decreaseAVAXStake(owner, avaxNodeOpAmt); Vault vault = Vault(getContractAddress("Vault")); vault.withdrawAVAX(totalAvaxAmt); owner.safeTransferETH(totalAvaxAmt); }
createMinipool
function is public and anyone can call it.
Here, the overwrite can happen which does not check for owner of the 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);
Also, when we look at this line requireValidStateTransition(minipoolIndex, MinipoolStatus.Prelaunch)
, Prelaunch state can be allowed for Withdrawable,Error
pools also. The comment from dev says that only finished or canceled pools would be allowed for overwrte but that is not the case here.
function requireValidStateTransition
} else if (currentStatus == MinipoolStatus.Withdrawable || currentStatus == MinipoolStatus.Error) { isValid = (to == MinipoolStatus.Finished || to == MinipoolStatus.Prelaunch); } else if (currentStatus == MinipoolStatus.Finished || currentStatus == MinipoolStatus.Canceled) { // Once a node is finished/canceled, if they re-validate they go back to beginning state isValid = (to == MinipoolStatus.Prelaunch); } else {
Manual review
check for owner of the nodeid using the function onlyOwner
before overwrite
#0 - 0xminty
2023-01-04T00:56:45Z
dupe of #805
#1 - c4-judge
2023-01-09T12:37:48Z
GalloDaSballo marked the issue as duplicate of #213
#2 - c4-judge
2023-02-03T19:26:10Z
GalloDaSballo changed the severity to 3 (High Risk)
#3 - c4-judge
2023-02-08T08:26:45Z
GalloDaSballo changed the severity to 2 (Med Risk)
#4 - c4-judge
2023-02-08T08:50:12Z
GalloDaSballo changed the severity to 3 (High Risk)
#5 - c4-judge
2023-02-08T20:22:58Z
GalloDaSballo marked the issue as satisfactory
#6 - c4-judge
2023-02-09T08:53:06Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#7 - Simon-Busch
2023-02-09T12:50:12Z
Changed severity back from QA to H as requested by @GalloDaSballo
π Selected for report: 0xdeadbeef0x
Also found by: 0xLad, 0xNazgul, 0xSmartContract, 0xbepresent, Arbor-Finance, Breeje, HE1M, IllIllI, Qeew, Rolezn, SEVEN, SamGMK, SmartSek, TomJ, WatchDogs, ak1, btk, ck, datapunk, dic0de, eierina, fs0c, hansfriese, koxuan, ladboy233, peanuts, rvierdiiev, sces60107, tonisives, unforgiven, yongskiws
14.9051 USDC - $14.91
A well known attack vector for almost all shares based liquidity pool contracts, where an early user can manipulate the price per share and profit from late users' deposits because of the precision loss caused by the rather large value of price per share.
The attack vector and impact is the same as TOB-YEARN-003, where users may not receive shares in exchange for their deposits if the total asset amount has been manipulated through a large βdonationβ.
A malicious early user can deposit() with 1 wei of asset token as the first depositor of the LToken, and get 1 wei of shares.
Then the attacker can send 10000e18 - 1 of asset tokens and inflate the price per share from 1.0000 to an extreme value of 1.0000e22 ( from (1 + 10000e18 - 1) / 1) .
As a result, the future user who deposits 19999e18 will only receive 1 wei (from 19999e18 * 1 / 10000e18) of shares token.
They will immediately lose 9999e18 or half of their deposits if they redeem() right after the deposit().
The attacker can profit from future users' deposits. While the late users will lose part of their funds to the attacker.
The depositAVAX is,
function depositAVAX() public payable returns (uint256 shares) { uint256 assets = msg.value; // Check for rounding error since we round down in previewDeposit. if ((shares = previewDeposit(assets)) == 0) { revert ZeroShares(); } emit Deposit(msg.sender, msg.sender, assets, shares); IWAVAX(address(asset)).deposit{value: assets}(); _mint(msg.sender, shares); afterDeposit(assets, shares); }.
previewDeposit calles the convertToShares
function previewDeposit(uint256 assets) public view virtual returns (uint256) { return convertToShares(assets); }
The convertToShares function is
function convertToShares(uint256 assets) public view virtual returns (uint256) { uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); }
Manual review
onsider requiring a minimal amount of share tokens to be minted for the first minter, and send a port of the initial mints as a reserve to the DAO so that the pricePerShare can be more resistant to manipulation.
#0 - c4-judge
2023-01-08T13:11:33Z
GalloDaSballo marked the issue as duplicate of #209
#1 - c4-judge
2023-02-08T08:23:37Z
GalloDaSballo marked the issue as satisfactory
2194.0366 USDC - $2,194.04
when the contract is paused , allowing startRewardsCycle would inflate the token value which might not be safe.
Rewards should not be claimed by anyone when all other operations are paused.
I saw that the witdrawGGP
has this WhenNotPaused
modifier.
Inflate should not consider the paused duration.
lets say, when the contract is paused for theduration of 2 months, then the dao, protocol, and node validator would enjoy the rewards. This is not good for a health protocol
startRewardsCycle does not have the WhenNotPaused modifier.
function startRewardsCycle() external { if (!canStartRewardsCycle()) { revert UnableToStartRewardsCycle(); } emit NewRewardsCycleStarted(getRewardsCycleTotalAmt()); // Set start of new rewards cycle setUint(keccak256("RewardsPool.RewardsCycleStartTime"), block.timestamp); increaseRewardsCycleCount(); // Mint any new tokens from GGP inflation // This will always 'mint' (release) new tokens if the rewards cycle length requirement is met // since inflation is on a 1 day interval and it needs at least one cycle since last calculation inflate(); uint256 multisigClaimContractAllotment = getClaimingContractDistribution("ClaimMultisig"); uint256 nopClaimContractAllotment = getClaimingContractDistribution("ClaimNodeOp"); uint256 daoClaimContractAllotment = getClaimingContractDistribution("ClaimProtocolDAO"); if (daoClaimContractAllotment + nopClaimContractAllotment + multisigClaimContractAllotment > getRewardsCycleTotalAmt()) { revert IncorrectRewardsDistribution(); } TokenGGP ggp = TokenGGP(getContractAddress("TokenGGP")); Vault vault = Vault(getContractAddress("Vault")); if (daoClaimContractAllotment > 0) { emit ProtocolDAORewardsTransfered(daoClaimContractAllotment); vault.transferToken("ClaimProtocolDAO", ggp, daoClaimContractAllotment); } if (multisigClaimContractAllotment > 0) { emit MultisigRewardsTransfered(multisigClaimContractAllotment); distributeMultisigAllotment(multisigClaimContractAllotment, vault, ggp); } if (nopClaimContractAllotment > 0) { emit ClaimNodeOpRewardsTransfered(nopClaimContractAllotment); ClaimNodeOp nopClaim = ClaimNodeOp(getContractAddress("ClaimNodeOp")); nopClaim.setRewardsCycleTotal(nopClaimContractAllotment); vault.transferToken("ClaimNodeOp", ggp, nopClaimContractAllotment); } }
Manual review
We suggest to use WhenNotPaused
modifier.
#0 - emersoncloud
2023-01-24T13:05:27Z
#1 - c4-judge
2023-01-27T19:35:27Z
GalloDaSballo marked the issue as primary issue
#2 - GalloDaSballo
2023-02-03T10:52:31Z
The Warden has shown an inconsistency as to how Pausing is used.
While other aspects of the code are pausable and under the control of the guardian
, a call to startRewardsCycle
can be performed by anyone, and in the case of a system-wide pause may create unfair gains or lost rewards
#3 - GalloDaSballo
2023-02-03T10:52:39Z
For this reason I agree with Medium Severity
#4 - c4-judge
2023-02-08T08:31:11Z
GalloDaSballo marked the issue as selected for report
π Selected for report: __141345__
Also found by: 0xbepresent, Deivitto, HollaDieWaldfee, Nyx, PaludoX0, RaymondFam, ak1, cccz, ck, cryptostellar5, csanuragjain, ladboy233, rvierdiiev
68.0946 USDC - $68.09
There is whenNotPaused
in function stakeGGP(uint256 amount) external whenNotPaused {.
This can be added by team to prevent the staking for a particular duration.
The scenario could be where the paused is desired. but this can be bypassed. 1. For maintenance. 2. there could be some issue due to staking. whether protocol or user could suffer if they stake so the whenNotPaused can be used to prevent this.
already staked user can use the restakeGGP
route to stake even when the stakeGGP is paused.
Below is the stakeGGP
where the whenNotPaused
is used.
function stakeGGP(uint256 amount) external whenNotPaused { // Transfer GGP tokens from staker to this contract ggp.safeTransferFrom(msg.sender, address(this), amount); _stakeGGP(msg.sender, amount); }.
Below is the restakeGGP
which does not have the whenNotPaused
function restakeGGP(address stakerAddr, uint256 amount) public onlySpecificRegisteredContract("ClaimNodeOp", msg.sender) { // Transfer GGP tokens from the ClaimNodeOp contract to this contract ggp.safeTransferFrom(msg.sender, address(this), amount); _stakeGGP(stakerAddr, amount); }
Manual review
Use the whenNotPaused
for restakeGGP
too.
#0 - c4-judge
2023-01-08T13:28:09Z
GalloDaSballo marked the issue as duplicate of #351
#1 - c4-judge
2023-01-29T18:15:30Z
GalloDaSballo marked the issue as duplicate of #673
#2 - c4-judge
2023-02-08T08:56:57Z
GalloDaSballo marked the issue as satisfactory