Platform: Code4rena
Start Date: 26/05/2022
Pot Size: $75,000 USDT
Total HM: 31
Participants: 71
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 18
Id: 126
League: ETH
Rank: 7/71
Findings: 3
Award: $3,120.92
π Selected for report: 2
π Solo Findings: 1
937.187 USDT - $937.19
shutdownPool() marks shutdown successful even if it's not (i.e. when withdrawAll() call wasn't successful). As withdrawing logic expect that the pool in shutdown has already provided the funds, and makes no additional attempts to retrieve them, user funds will be frozen permanently as there are no mechanics in place to turn shutdown off for a pool.
Setting severity to medium as that's user principal funds freeze scenario conditional on any issues in withdrawAll().
In the case of unsuccessful withdrawAll() call the pool nevertheless will be marked as shutdown:
//withdraw from gauge try IStaker(staker).withdrawAll(pool.lptoken, pool.gauge) {} catch {} pool.shutdown = true; gaugeMap[pool.gauge] = false; emit PoolShuttedDown(_pid); return true; }
It will block the withdrawals as there will be not enough funds to fulfil the claim:
//pull from gauge if not shutdown // if shutdown tokens will be in this contract if (!pool.shutdown) { IStaker(staker).withdraw(lptoken, gauge, _amount); } //some gauges claim rewards when withdrawing, stash them in a seperate contract until next claim //do not call if shutdown since stashes wont have access address stash = pool.stash; if (stash != address(0) && !isShutdown && !pool.shutdown) { IStash(stash).stashRewards(); } //return lp tokens IERC20(lptoken).safeTransfer(_to, _amount);
This way user funds will be frozen as the system will not attempt to withdraw from the pool, while there will be no funds to transfer to the user and _withdraw() will be reverting on L422 safeTransfer call.
The shutdownPool logic can to be updated:
Consider unifying the logic with shutdownSystem() and marking the pool shutdown only if withdraw was successful, for example:
//shutdown pool function shutdownPool(uint256 _pid, bool forced) external returns (bool){ require(msg.sende == poolManager, "!auth"); PoolInfo storage pool = poolInfo[_pid]; bool withdrawSuccess = false; //withdraw from gauge try IStaker(staker).withdrawAll(pool.lptoken,pool.gauge){ withdrawSuccess = true; } catch {} if (withdrawSuccess || forced) { pool.shutdown = true; gaugeMap[pool.gauge] = false; emit PoolShuttedDown(_pid); return true; } return false; }
#0 - jetbrain10
2022-06-15T15:42:39Z
Convex has the same, and we may need force shutdown
#1 - GalloDaSballo
2022-07-25T00:48:59Z
My issue with the submission is that it makes the following statement:
Setting severity to medium as that's user principal funds freeze scenario conditional on any issues in withdrawAll().
Without mentioning any possible scenario in which withdrawAll
could fail
Given the information that I have we could have reverts if:
Will think about severity
#2 - GalloDaSballo
2022-07-27T00:51:33Z
After reviewing the rest of the contest, I remember marking a finding about Paused tokens with Medium Severity, so while I think the submission could have been made stronger by providing real-world examples of how the tokens could be stuck, to maintain consistency I'll mark this finding as valid as well.
As while it's unspecified how a revert could happen, the try catch irreversible system does mean that a failed rescue bricks the tokens, perhaps a governance only secondary rescue system may be helpful for emergency scenarios
π Selected for report: hyh
2082.6377 USDT - $2,082.64
There is no check for the reward token amount to be transferred out in stashRewards(). As reward token list is external (controlled with IGauge(gauge).reward_tokens
), and an arbitrary token can end up there, in the case when such token doesn't allow for zero amount transfers, the stashRewards() managed extra rewards retrieval can become unavailable.
I.e. stashRewards() can be blocked for even an extended period of time, so all other extra rewards gathering will not be possible. This cannot be controlled by the system as pool reward token list is external.
Setting the severity to medium as reward gathering is a base functionality of the system and its availability is affected.
stashRewards() attempts to send the amount
to rewardArbitrator() without checking:
if (activeCount > 1) { //take difference of before/after(only send new tokens) uint256 amount = IERC20(token).balanceOf(address(this)); amount = amount.sub(before); //send to arbitrator address arb = IDeposit(operator).rewardArbitrator(); if (arb != address(0)) { IERC20(token).safeTransfer(arb, amount); } }
If IStaker(staker).withdraw()
produced no new tokens for any reason, the amount = amount.sub(before)
above can be zero:
uint256 before = IERC20(token).balanceOf(address(this)); IStaker(staker).withdraw(token);
As reward token
can be arbitrary, it can also be reverting on an attempt to transfer zero amounts:
https://github.com/d-xo/weird-erc20#revert-on-zero-value-transfers
If this be the case then the whole stashRewards() call will be failing until IStaker(staker).withdraw()
manage to withdraw some tokens
or such token
be removed from gauge's reward token list. Both events arenβt directly controllable by the system.
Consider running the transfer only when amount is positive:
- if (activeCount > 1) { + if (amount > 0 && activeCount > 1) { //take difference of before/after(only send new tokens) uint256 amount = IERC20(token).balanceOf(address(this)); amount = amount.sub(before); //send to arbitrator address arb = IDeposit(operator).rewardArbitrator(); if (arb != address(0)) { IERC20(token).safeTransfer(arb, amount); } }
#0 - GalloDaSballo
2022-07-27T01:00:03Z
The warden has shown how, due to a lack of check for zero-transfer, for specific tokens, the stashRewads
function can be made to revert, causing it not to process any reward token
Because this is contingent on the token having zero-balance and reverting, I think Medium Severity to be appropriate
π Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, 0xDjango, 0xNazgul, 0xf15ers, BouSalman, Chom, Deivitto, Dravee, ElKu, FSchmoede, Funen, GimelSec, Hawkeye, MiloTruck, Picodes, SecureZeroX, SmartSek, TerrierLover, WatchPug, _Adam, asutorufos, berndartmueller, c3phas, catchup, cccz, cogitoergosumsw, cryptphi, csanuragjain, delfin454000, dipp, ellahi, gzeon, hansfriese, horsefacts, hyh, kirk-baird, minhquanym, oyc_109, pauliax, reassor, robee, sashik_eth, shenwilly, simon135, sorrynotsorry, sseefried, unforgiven, xiaoming90, z3s
101.0887 USDT - $101.09
CreateStash can create a misconfigured stashes that have zero gauge as a result of operational mistake. This is possible as calls with zero gauge will pass, all isV*() calls will be returning true, i.e. it's possible to create a stash of any type with zero gauge.
//Create a stash contract for the given gauge. //function calls are different depending on the version of curve gauges so determine which stash type is needed function CreateStash(uint256 _pid, address _gauge, address _staker, uint256 _stashVersion) external returns(address){
x0.call will return true:
function IsV1(address _gauge) private returns(bool){ bytes memory data = abi.encode(rewarded_token); (bool success,) = _gauge.call(data); return success; } function IsV2(address _gauge) private returns(bool){ bytes memory data = abi.encodeWithSelector(reward_tokens,uint256(0)); (bool success,) = _gauge.call(data); return success; } function IsV3(address _gauge) private returns(bool){ bytes memory data = abi.encodeWithSelector(rewards_receiver,address(0)); (bool success,) = _gauge.call(data); return success; }
Consider checking _gauge and _staker addresses to be non-zero.
separate
in both cases:
//some gauges claim rewards when depositing, stash them in a seperate contract until next claim
* distribute a child reward token (i.e. a secondary one from Curve, or a seperate one).
One step process offers no protection for the cases when ownership transfer is performed mistakenly or with any malicious intent.
Adding a modest complexity of an additional step and a delay is a low price to pay for having time to evaluate the ownership change.
Booster.setOwner:
function setOwner(address _owner) external { require(msg.sender == owner, "!auth"); owner = _owner; emit OwnerUpdated(_owner); }
VoterProxy.setOwner:
function setOwner(address _owner) external { require(msg.sender == owner, "!auth"); owner = _owner; }
Consider utilizing two-step ownership transferring process (proposition and acceptance in the separate actions) with a noticeable delay between the steps to enforce the transparency and stability of the system.
Similar situation in the most contracts, for instance:
BaseRewardPool:
constructor( uint256 pid_, address stakingToken_, address rewardToken_, address operator_, address rewardManager_ ) { pid = pid_; stakingToken = IERC20(stakingToken_); rewardToken = IERC20(rewardToken_); operator = operator_; rewardManager = rewardManager_; }
Booster:
constructor( address _staker, address _minter, address _veAsset, address _feeDistro ) { isShutdown = false; staker = _staker; owner = msg.sender; voteDelegate = msg.sender; feeManager = msg.sender; poolManager = msg.sender; minter = _minter; veAsset = _veAsset; feeDistro = _feeDistro; }
ExtraRewardStashV1:
constructor( uint256 _pid, address _operator, address _staker, address _gauge, address _rFactory ) { pid = _pid; operator = _operator; staker = _staker; gauge = _gauge; rewardFactory = _rFactory; }
Consider using zero checks for key addresses to protect against operational mistakes
#0 - GalloDaSballo
2022-07-07T23:14:15Z
I'm going to mark this as valid and Low and consider it equivalent to "Lack of address(0) check"
NC
NC
Short and sweet, 1L, 2NC