Closed c4-submissions closed 11 months ago
141345 marked the issue as sufficient quality report
OpenCoreCH (sponsor) disputed
curve.ambientSeeds_
is the overall ambient liquidity, you cannot just make this arbitrarily small as an attacker (unless there is no other liquidity in the pool, in which case you are eligible to all rewards anyways). There are also other things wrong with this issue:
So to exploit this bug, we only need to making sure accrue global method called when curve.ambientSeeds is small value. Then deposit a bunch of token to inflate pos.seeds value on the same block. Then call claim/mint to update accrue reward. Because global weight or accrueAmbientGlobalTimeWeightedLiquidity() never update global weight on same block, global weight still using old value which is smaller than new pos.seeds_ value.
This describes a hypothetical attack scenario, but not how it actually could be achieved. The accrue function is called whenever pos.seeds_
is modified such that the accrual always happens with the old value first. Everything that happens within a block is completely irrelevant (after the first accrual) because everything is time-weighted and dt = 0 if the accrual function is called multiple times in the same block.
curve.ambientSeeds_
and pos.seeds_
indeed change along user operations (mint/withdraw) but that is not the core of this issue. (total of all pos.seeds_
should roughly equal curve.ambientSeeds
)
@OpenCoreCH You miss 2 important points:
curve.ambientSeeds_
is used to calculate current global rewards based on deltaTime. Which is timeWeightedWeeklyGlobalAmbLiquidity_
inside accrueAmbientGlobalTimeWeightedLiquidity()
function.timeWeightedWeeklyGlobalAmbLiquidity_
does not reupdated when curve.ambientSeeds_
change. As pointed out above, it does not re-update second time on same block.By exploiting this temporary lag, it become a slippage issue. User can increase their own reward position weight by how much they deposit (using different address) and global reward weight not getting updated. weeklyRewards = userWeight /globalWeight * totalWeeklyReward
At this point, it just a question of how much exploiter should deposit to make userWeight >globalWeight
happen
And by how much, I could roughly guess. If you deposit 100 times of total pool liquidity. You get 100 seconds of weekly rewards. Because this is repeated exploits. You can expand this into another 6048 transactions. Giving you total 604800 seconds of weekly rewards in one block.
Also because there is no cap on how much token user can claim weekly.
You can make timeWeightedWeeklyPositionAmbLiquidity_
> overallTimeWeightedLiquidity
happen here..
Basically allowing steal all token from pool if you have enough money to deposit.
This describes a hypothetical attack scenario, but not how it actually could be achieved. The accrue function is called whenever
pos.seeds_
is modified such that the accrual always happens with the old value first. Everything that happens within a block is completely irrelevant (after the first accrual) because everything is time-weighted and dt = 0 if the accrual function is called multiple times in the same block.
I have time to do review. Looking back, it is not possible to increase one position liquidity pos.seeds_
without calling accrued first. So accrue always use old value instead of inflated value. I was wrong.
This still worth looking into as the issue still valid if somehow exploiter can increase their liquidity/deposit without going through accrue.
This is possible if combined with another issue: typo on accrue method (or call wrong curve function) here TradeMatcher.harvestRange()
#144.
It update global ambient curve ambientSeeds_
but accrue update concentrated reward curve.concLiq_
.
Even then the slippage is reversed and its impact is insignificant and not dramatic.
So yeah, this issue is invalid.
dmvt marked the issue as unsatisfactory: Invalid
Lines of code
https://github.com/code-423n4/2023-10-canto/blob/37a1d64cf3a10bf37cbc287a22e8991f04298fa0/canto_ambient/contracts/mixins/LiquidityMining.sol#L276-L282
Vulnerability details
Impact
Exploiter can abuse slippage to claim more weekly reward.
The amount of slippage damage is unclear due to lack of deployment context and testing. Worst case scenario is the exploiter own 100% deposit of single pool allowing extreme slippage to steal entire contract token. Owning 100% of single pool rarely happen on live network. But it is possible to flashloan to own majority of the pool token.
Tools Used
Manual
Summary
New sidecar
LiquidityMiningPath.sol
provide function to claim new CANTO reward token based on time spend deposit on UniswapV2 (AmbientPosition) or V3(Concentrated) pool position. The new rewards fomular can be simplified as:reward = userTimeWeight * weeklyRewardRate / totalTimeWeighted
weeklyRewardRate
: fixed value set by governanceuserTimeWeight
: user time spend in the pool weighted. Update everytime user mint/burn/claim throughTradeMatcher.sol
operationtotalTimeWeighted
: total time weight. Update along userTimeWeight.Here is how acrrued reward calculated in code:
userTimeWeight = userDeltaTimeWeekly * pos.seeds_
pos.seeds_
is user provided liquidity/token in poolpos.seeds_
change everytime user mint/burn/claim throughTradeMatcher.sol
operationtotalTimeWeighted = globalDeltaTime * curve.ambientSeeds_
curve.ambientSeeds_
is a very convoluted/complex value updated along side withpos.seed_
curve.ambientSeeds_
>pos.seeds_
most of the time.totalTimeWeighted
does not update to new value if accrued on same block.pos.seeds_
>curve.ambientSeeds_
become trueUnder assumption that:
pos.seeds_
to very high valuecurve.ambientSeeds_
can be really small value if exploiter can own majority of the pool token.curve.ambientSeeds_
value is frozen with small value and not updating to bigger value along withpos.seeds_
reward
can be inflated to really high value by making this condition become truepos.seeds_ >= curve.ambientSeeds_
oruserTimeWeight > totalTimeWeighted
Proof of concept
As an exploiter, all I need to do is the following:
claimAmbientRewards()
it can update accrued reward for previous week and next week.curve.ambientSeeds_
need to be really small value as much as possible for higher slippage payoutcurve.ambientSeeds_
and userpos.seeds_
.accrueAmbientGlobalTimeWeightedLiquidity()
will be called once. This update global weight to new value:deltaTime * curve.ambientSeeds_
pos.seeds_
value by mint/deposit token to the pool. This will update user weight to new value:deltaTime * pos.seeds_
.curve.ambientSeeds_
also updated but not global weight when calling on same block.claimAmbientRewards()
for previous week, the current week deltaTime is 1 second, hopefully newuserTimeWeight
> non-updatedtotalTimeWeighted
Vulneribility Details
Look at how rewards is calcualted in
LiquidityMining.sol
:As above, this can simplified as:
reward = userTimeWeight * weeklyRewardRate / totalTimeWeighted
The value
timeWeightedWeeklyGlobalAmbLiquidity_
is updated in functionLiquidityMining.accrueAmbientGlobalTimeWeightedLiquidity()
. Which is called everytime user mint/burn/claim position.Look at how global weight and user weight is calculated
There are several things to look at here:
curve.ambientSeeds_
pos.seeds_
(time == block.timestamp)
Now we only need to figure out how to manipulate
curve.ambientSeeds_
andpos.seeds_
. Back-tracking this project is a nightmarish process. To replicate this bug, it is much simpler to add a bunch of console.log onLiquidityCurve.liquidityPayable()
andLiquidityCurve.liquidityReceivable()
to see howcurve.ambientSeeds_
change. Also,PositionRegistar.mintPosLiq
andPositionRegistar.burnPosLiq
to see howpos.seeds_
change.Running test file, it is easy to found out another several things:
curve.ambientSeeds_
always >=pos.seeds_
curve.ambientSeeds_
,pos.seeds_
change with user mint/burn pool LP token.curve.ambientSeeds_
as 1e1 andpos.seeds_
as 1e9.So to exploit this bug, we only need to making sure accrue global method called when
curve.ambientSeeds_
is small value. Then deposit a bunch of token to inflatepos.seeds_
value on the same block. Then call claim/mint to update accrue reward. Because global weight oraccrueAmbientGlobalTimeWeightedLiquidity()
never update global weight on same block, global weight still using old value which is smaller than newpos.seeds_
value.Recommended Mitigation Steps
None
Assessed type
Math