Platform: Code4rena
Start Date: 27/05/2022
Pot Size: $75,000 USDC
Total HM: 20
Participants: 58
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 15
Id: 131
League: ETH
Rank: 1/58
Findings: 4
Award: $9,683.68
🌟 Selected for report: 3
🚀 Solo Findings: 2
4098.7998 USDC - $4,098.80
function startInflation() external override onlyGovernance { require(lastEvent == 0, "Inflation has already started."); lastEvent = block.timestamp; lastInflationDecay = block.timestamp; }
As lastEvent
and lastInflationDecay
are not initialized in the constructor()
, they will remain to the default value of 0
.
However, the permissionless executeInflationRateUpdate()
method does not check the value of lastEvent
and lastInflationDecay
and used them directly.
As a result, if executeInflationRateUpdate()
is called before startInflation()
:
_INFLATION_DECAY_PERIOD
has passed since lastInflationDecay
will be true
, and initialPeriodEnded
will be set to true
right away;lastEvent
in totalAvailableToNow += (currentTotalInflation * (block.timestamp - lastEvent));
is 0
, the totalAvailableToNow
will be set to totalAvailableToNow ≈ currentTotalInflation * 52 years
, which renders the constrains of totalAvailableToNow
incorrect and useless.function executeInflationRateUpdate() external override returns (bool) { return _executeInflationRateUpdate(); }
function _executeInflationRateUpdate() internal returns (bool) { totalAvailableToNow += (currentTotalInflation * (block.timestamp - lastEvent)); lastEvent = block.timestamp; if (block.timestamp >= lastInflationDecay + _INFLATION_DECAY_PERIOD) { currentInflationAmountLp = currentInflationAmountLp.scaledMul(annualInflationDecayLp); if (initialPeriodEnded) { currentInflationAmountKeeper = currentInflationAmountKeeper.scaledMul( annualInflationDecayKeeper ); currentInflationAmountAmm = currentInflationAmountAmm.scaledMul( annualInflationDecayAmm ); } else { currentInflationAmountKeeper = initialAnnualInflationRateKeeper / _INFLATION_DECAY_PERIOD; currentInflationAmountAmm = initialAnnualInflationRateAmm / _INFLATION_DECAY_PERIOD; initialPeriodEnded = true; } currentTotalInflation = currentInflationAmountLp + currentInflationAmountKeeper + currentInflationAmountAmm; controller.inflationManager().checkpointAllGauges(); lastInflationDecay = block.timestamp; } return true; }
// Used for final safety check to ensure inflation is not exceeded uint256 public totalAvailableToNow;
function _mint(address beneficiary, uint256 amount) internal returns (bool) { totalAvailableToNow += ((block.timestamp - lastEvent) * currentTotalInflation); uint256 newTotalMintedToNow = totalMintedToNow + amount; require(newTotalMintedToNow <= totalAvailableToNow, "Mintable amount exceeded"); totalMintedToNow = newTotalMintedToNow; lastEvent = block.timestamp; token.mint(beneficiary, amount); _executeInflationRateUpdate(); emit TokensMinted(beneficiary, amount); return true; }
Consider initializing lastEvent
, lastInflationDecay
in constructor()
.
or
Consider adding require(lastEvent != 0 && lastInflationDecay != 0, "...")
to executeInflationRateUpdate()
.
#0 - GalloDaSballo
2022-06-18T23:32:35Z
The warden has shown how startInflation
can be bypassed, breaking the Access Control as well as the Sponsor Goal for Emissions.
Because of this I believe High Severity to be appropriate.
The sponsor has mitigated in a subsequent PR
🌟 Selected for report: WatchPug
2732.5332 USDC - $2,732.53
Every time the BkdLocker#depositFees()
gets called, there will be a surge of rewards per locked token for the existing stakeholders.
This enables a well-known attack vector, in which the attacker will take a large portion of the shares before the surge, then claim the rewards and exit immediately.
While the _WITHDRAW_DELAY
can be set longer to mitigate this issue in the current implementation, it is possible for the admin to configure it to a very short period of time or even 0
.
In which case, the attack will be very practical and effectively steal the major part of the newly added rewards.
function depositFees(uint256 amount) external override { require(amount > 0, Error.INVALID_AMOUNT); require(totalLockedBoosted > 0, Error.NOT_ENOUGH_FUNDS); IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); RewardTokenData storage curRewardTokenData = rewardTokenData[rewardToken]; curRewardTokenData.feeIntegral += amount.scaledDiv(totalLockedBoosted); curRewardTokenData.feeBalance += amount; emit FeesDeposited(amount); }
Given:
totalLockedBoosted()
is 100,000 govToken
;1,000 rewardToken
;depositFees()
is called to add 1,000 rewardToken
;lock()
transaction to deposit 100,000 govToken
, taking 50% of the pool;claimFees()
and received 500 rewardToken
.As a result, the attacker has stolen half of the pending fees which belong to the old users.
Consider switching the reward to a rewardRate
-based gradual release model, such as Synthetix's StakingRewards contract.
See: https://github.com/Synthetixio/synthetix/blob/develop/contracts/StakingRewards.sol#L113-L132
#0 - chase-manning
2022-06-06T11:39:15Z
The withdrawal delay prevents this attack
#1 - GalloDaSballo
2022-06-19T15:45:50Z
Given the need for:
I believe High Severity to be out of the question.
That said, because the rewards are given out at the time of depositFees
, and they are not linearly vested, I do believe that a front-run MEV attack can be executed, being able to immediately claim the reward tokens, at the cost / risk of having to lock the tokens
The shorter the withdrawal delay, the lower the risk for the attack.
Because this is contingent on configuration, and at best it's a loss of yield for the lockers, I believe Medium Severity to be more appropriate
#2 - GalloDaSballo
2022-06-19T15:47:45Z
As an additional note, please consider the fact that if the potential value gained is high enough, an attacker could just hedge the risk of locking by shorting the tokens, effectively being delta neutral while using the rewards for profit.
This means that if your token becomes liquid enough (a goal for any protocol), you would expect the withdrawal delay to become ineffective as hedging options become available
Forcing the rewards to linearly vest will prevent the front-run from being effective and will reward long term lockers
🌟 Selected for report: WatchPug
2732.5332 USDC - $2,732.53
When Minter.sol#_executeInflationRateUpdate()
is called, if an _INFLATION_DECAY_PERIOD
has past since lastInflationDecay
, it will update the InflationRate for all of the gauges.
However, in the current implementation, the rates will be updated first, followed by the rewards being settled using the new rates on the gauges using inflationManager().checkpointAllGauges()
.
If the _INFLATION_DECAY_PERIOD
has passed for a long time before Minter.sol#executeInflationRateUpdate()
is called, the users may lose a significant amount of rewards.
On a side note, totalAvailableToNow
is updated correctly.
function _executeInflationRateUpdate() internal returns (bool) { totalAvailableToNow += (currentTotalInflation * (block.timestamp - lastEvent)); lastEvent = block.timestamp; if (block.timestamp >= lastInflationDecay + _INFLATION_DECAY_PERIOD) { currentInflationAmountLp = currentInflationAmountLp.scaledMul(annualInflationDecayLp); if (initialPeriodEnded) { currentInflationAmountKeeper = currentInflationAmountKeeper.scaledMul( annualInflationDecayKeeper ); currentInflationAmountAmm = currentInflationAmountAmm.scaledMul( annualInflationDecayAmm ); } else { currentInflationAmountKeeper = initialAnnualInflationRateKeeper / _INFLATION_DECAY_PERIOD; currentInflationAmountAmm = initialAnnualInflationRateAmm / _INFLATION_DECAY_PERIOD; initialPeriodEnded = true; } currentTotalInflation = currentInflationAmountLp + currentInflationAmountKeeper + currentInflationAmountAmm; controller.inflationManager().checkpointAllGauges(); lastInflationDecay = block.timestamp; } return true; }
function checkpointAllGauges() external override returns (bool) { uint256 length = _keeperGauges.length(); for (uint256 i; i < length; i = i.uncheckedInc()) { IKeeperGauge(_keeperGauges.valueAt(i)).poolCheckpoint(); } address[] memory stakerVaults = addressProvider.allStakerVaults(); for (uint256 i; i < stakerVaults.length; i = i.uncheckedInc()) { IStakerVault(stakerVaults[i]).poolCheckpoint(); } length = _ammGauges.length(); for (uint256 i; i < length; i = i.uncheckedInc()) { IAmmGauge(_ammGauges.valueAt(i)).poolCheckpoint(); } return true; }
function poolCheckpoint() public override returns (bool) { if (killed) return false; uint256 timeElapsed = block.timestamp - uint256(lastUpdated); uint256 currentRate = IController(controller).inflationManager().getKeeperRateForPool(pool); perPeriodTotalInflation[epoch] += currentRate * timeElapsed; lastUpdated = uint48(block.timestamp); return true; }
function getKeeperRateForPool(address pool) external view override returns (uint256) { if (minter == address(0)) { return 0; } uint256 keeperInflationRate = Minter(minter).getKeeperInflationRate(); // After deactivation of weight based dist, KeeperGauge handles the splitting if (weightBasedKeeperDistributionDeactivated) return keeperInflationRate; if (totalKeeperPoolWeight == 0) return 0; bytes32 key = _getKeeperGaugeKey(pool); uint256 poolInflationRate = (currentUInts256[key] * keeperInflationRate) / totalKeeperPoolWeight; return poolInflationRate; }
function getKeeperInflationRate() external view override returns (uint256) { if (lastEvent == 0) return 0; return currentInflationAmountKeeper; }
Given:
AmmGauge
pool;Minter.sol#_executeInflationRateUpdate()
is called;claimableRewards()
and received 500
Bkd tokens.Expected Results:
1000
Bkd tokens as rewards.Actual Results:
500
Bkd tokens as rewards.Consider moving the call to checkpointAllGauges()
to before the currentInflationAmountKeeper
is updated.
function _executeInflationRateUpdate() internal returns (bool) { totalAvailableToNow += (currentTotalInflation * (block.timestamp - lastEvent)); lastEvent = block.timestamp; if (block.timestamp >= lastInflationDecay + _INFLATION_DECAY_PERIOD) { controller.inflationManager().checkpointAllGauges(); currentInflationAmountLp = currentInflationAmountLp.scaledMul(annualInflationDecayLp); if (initialPeriodEnded) { currentInflationAmountKeeper = currentInflationAmountKeeper.scaledMul( annualInflationDecayKeeper ); currentInflationAmountAmm = currentInflationAmountAmm.scaledMul( annualInflationDecayAmm ); } else { currentInflationAmountKeeper = initialAnnualInflationRateKeeper / _INFLATION_DECAY_PERIOD; currentInflationAmountAmm = initialAnnualInflationRateAmm / _INFLATION_DECAY_PERIOD; initialPeriodEnded = true; } currentTotalInflation = currentInflationAmountLp + currentInflationAmountKeeper + currentInflationAmountAmm; lastInflationDecay = block.timestamp; } return true; }
#0 - chase-manning
2022-06-07T12:53:04Z
This should be medium risk, as should only have a minor impact on users getting less rewards and no over-minting can occur.
#1 - GalloDaSballo
2022-06-19T16:01:39Z
The warden has shown how, due to an incorrect order of operations, rates for new rewards can be enacted before the old rewards are distributed, causing a loss of rewards for end users.
There are 2 ways to judge this finding:
Ultimately the impact is a loss of value, further minimized by the fact that anyone can call poolCheckpoint
as well as executeInflationRateUpdate
meaning the losses can be minimized.
So from a coding standpoint I believe the bug should be fixed before deployment, but from a impact point of view the impact can be minimized to make "loss of yield" mostly rounding errors
Given the impact I believe the finding to be of Medium Severity
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, 0xNazgul, 0xf15ers, BowTiedWardens, Chom, Funen, Kaiziron, Kumpa, MiloTruck, Picodes, Ruhum, SecureZeroX, Sm4rty, SmartSek, StyxRave, WatchPug, Waze, asutorufos, bardamu, berndartmueller, c3phas, catchup, cccz, codexploder, cryptphi, defsec, delfin454000, dipp, fatherOfBlocks, gzeon, hake, hansfriese, hyh, masterchief, oyc_109, sach1r0, sashik_eth, shenwilly, simon135, unforgiven
119.8232 USDC - $119.82
Some ERC20 compatible token implementations, such as ERC777-like tokens, allow a callback call after the balances are updated in transfer()
. This callback could be used to re-enter the contract.
In AmmGauge.sol#unstakeFor()
, unstaked
is calculated by comparing the oldBal
with current balance after the external call (SafeTransferLib.safeTransferFrom()
). This allows the attacker to re-enter the contract and call stakeFor()
to increase balances[account]
.
uint256 oldBal = IERC20(ammToken).balanceOf(address(this)); IERC20(ammToken).safeTransferFrom(msg.sender, address(this), amount); uint256 newBal = IERC20(ammToken).balanceOf(address(this));
Given:
ammToken
is XYZ
;XYZ
is a ERC777-like token;XYZ.balanceOf(AmmGauge)
is 1,000,000e18
;XYZ.balanceOf(attacker)
is 0
;AmmGauge.balances[attacker]
is 10,000e18
.The attacker can increase AmmGauge.balances[attacker]
without paying for it.
unstakeFor(attacker, attacker, 10,000e18)
oldBal = IERC20(ammToken).balanceOf(address(this)) = 1,000,000e18
IERC20(ammToken).safeTransfer(attacker, 10,000e18);
XYZ.balanceOf(AmmGauge)
: 1,000,000e18
-> 990,000e18
XYZ.balanceOf(attacker)
: 0
-> 10,000e18
to
of the transfer()
(eg, the attacker's address), re-enter and call AmmGauge
's AmmGauge.stakeFor(attacker, 10,000e18)
oldBal = IERC20(ammToken).balanceOf(address(this)) = 990,000e18
IERC20(ammToken).safeTransferFrom(attacker, address(this), 10,000e18);
XYZ.balanceOf(AmmGauge)
: 990,000e18
-> 1,000,000e18
XYZ.balanceOf(attacker)
: 10,000e18
-> 0
staked = IERC20(ammToken).balanceOf(address(this)) - oldBal = 1,000,000e18 - 990,000e18 = 10,000e18
balances[attacker] += 10,000e18;
balances[attacker]
: 10,000e18
-> 20,000e18
IERC20(ammToken).safeTransfer(attacker, 10,000e18);
(end of the call)unstaked = oldBal - IERC20(ammToken).balanceOf(address(this)) = 1,000,000e18 - 1,000,000e18 = 0
balances[src] -= 0
: balances[attacker]
remain unchanged: 20,000e18
As a result:
XYZ.balanceOf(AmmGauge)
: 1,000,000e18
-> 1,000,000e18
XYZ.balanceOf(attacker)
: 0
-> 0
AmmGauge.balances[attacker]
: 10,000e18
-> 20,000e18
Now, the attacker can call unstakeFor(attacker, attacker, 20,000e18)
and withdraw 20,000e18
XYZ
The attacker can repeat the steps above to inflate AmmGauge.balances[attacker]
to XYZ.balanceOf(AmmGauge) = 1,000,000e18
, and call unstakeFor(attacker, attacker, 1,000,000e18)
to empty all the XYZ tokens in AmmGauge
.
Consider adding nonReentrant
modifier to unstakeFor()
and stakeFor()
.
#0 - chase-manning
2022-06-06T11:09:13Z
We do not support tokens that offer reentrancy opportunities
#1 - GalloDaSballo
2022-06-16T22:41:32Z
Per discussion in #19 finding is valid but of non-critical severity
#2 - GalloDaSballo
2022-06-16T22:48:17Z
Dup of #19