Platform: Code4rena
Start Date: 08/03/2023
Pot Size: $60,500 USDC
Total HM: 2
Participants: 123
Period: 7 days
Judge: hansfriese
Id: 220
League: ETH
Rank: 4/123
Findings: 3
Award: $3,004.10
🌟 Selected for report: 0
🚀 Solo Findings: 0
2819.6915 USDC - $2,819.69
In NeoTokyoStaker, staker is a competitive system where stakers compete for a fixed emission rate in each of the S1 Citizen, S2 Citizen, and LP token staking pools. For each staking pool, there are some reward windows. Each reward window has different emission rates configurable by admin. So when calculating the final reward amount for a user, it does a for loop through every reward window and sum them up.
However, it used the final points
and totalPoints
when staker claimed reward for every previous reward window, which is not correct since totalPoints
is different every time other stakers deposit/withdraw.
The result is the reward is incorrectly calculated, it can be higher or lower than expected, and both cases are high risk issues.
Consider a scenario:
Citizen S2 pools have 2 reward windows.
The first starts at t1 = 100 and rate1 = 200
, the second starts at t2 = 200 and rate2 = 300
Alice and Bob both stakes at t0 = 100
, Citizen S2 tokens have the same weight (100
) so Alice and Bob should receive equal amounts of rewards at any moment.
Alice withdraw first at t3 = 300
, her share is equal to 100
and totalPoints = 200
, she will receive
totalReward = ((t2 - t1) * rate1 + (t3 - t2) * rate2) = 100 * 200 + 100 * 300 = 50000 share = points * totalReward / totalPoints = 100 * 50000 / 200 = 25000 totalPoints = 100
totalReward = ((t2 - t1) * rate1 + (t3 - t2) * rate2) = 100 * 200 + 100 * 300 = 50000 share = points * totalReward / totalPoints = 100 * 50000 / 100 = 50000
As we can see, Alice and Bob both deposit and withdraw at the same time, but Alice only gets 25000
while Bob gets 50000
token reward.
Totally, they receive 75000
while totalReward
is only 50000
, which is more than expected amount of BYTES should be minted.
Manual Review
Consider changing the formula to calculate rewards for users. For example, in Master Chef, we need to calculate the accumulated reward rate per share of the pool every time a user interacts with the pool and only need to update the position of each user when they interact.
#0 - c4-judge
2023-03-16T03:03:36Z
hansfriese marked the issue as duplicate of #423
#1 - c4-judge
2023-03-16T03:03:46Z
hansfriese marked the issue as satisfactory
2819.6915 USDC - $2,819.69
In NeoTokyoStaker, BYTES token can be staked into a Citizen. First, the Citizen must be staked, it will be locked for a timelock duration in Staking contract. Staker want to stake BYTES can specify this Citizen ID and stake into it.
However, when users stake into a Citizen, it did not extend the timelock. As the result, attacker can abuse this to manipulate totalPoints
, making other stakers receive less rewards.
Attacker will stake any Citizen and wait after timelockEndTime
to execute the attack. Now consider the scenario when Alice (a normal user) claims her reward
totalPoints
to large valuetotalPoints
is manipulate to be larger, Alice's share gets smaller and Alice receives less rewardManual Review
Consider extending timelock duration when stakers stake BYTES into Citizen.
#0 - hansfriese
2023-03-16T03:36:34Z
The impact wouldn't be high as there is a cap for stakedBytes
#1 - c4-judge
2023-03-16T03:36:41Z
hansfriese marked the issue as satisfactory
#2 - c4-judge
2023-03-16T03:36:49Z
hansfriese changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-03-16T13:12:49Z
hansfriese marked the issue as primary issue
#4 - TimTinkers
2023-03-21T05:49:04Z
@hansfriese I consider this a duplicate of #423 given that the real underlying issue is the mutability of Alice's previously earned rewards when a pool's totalPoints
changes. With the fix of 423 implemented this becomes a non-issue for Alice.
#5 - c4-sponsor
2023-03-21T05:50:00Z
TimTinkers requested judge review
#6 - c4-judge
2023-03-21T09:23:07Z
hansfriese marked the issue as duplicate of #261
#7 - c4-judge
2023-03-21T09:24:27Z
hansfriese marked the issue as not a duplicate
#8 - c4-judge
2023-03-21T09:36:29Z
hansfriese marked the issue as duplicate of #423
#9 - hansfriese
2023-03-21T09:37:48Z
I agree the flashloan attack is possible because the current reward logic is wrong. It's appropriate to mark it as a duplicate of #423.
#10 - c4-judge
2023-03-29T00:21:39Z
hansfriese marked the issue as not a duplicate
#11 - c4-judge
2023-03-29T00:21:49Z
hansfriese changed the severity to 3 (High Risk)
#12 - c4-judge
2023-03-29T00:22:14Z
hansfriese marked the issue as duplicate of #423
🌟 Selected for report: rokso
Also found by: 0x52, 0xnev, ABA, BPZ, DadeKuma, Haipls, J4de, Jeiwan, Josiah, Krace, LegendFenGuin, Lirios, MadWookie, RaymondFam, Ruhum, Toshii, UdarTeam, aashar, ak1, anodaram, auditor0517, carlitox477, cccz, jekapi, juancito, kaden, kenzo, minhquanym, nobody2018, rbserver, rokso, ulqiorra
154.74 USDC - $154.74
In function _withdrawLP()
, it calculates the amount of points
from the amount
input parameter.
unchecked { uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; // Update the caller's LP token stake. lpPosition.amount -= amount; lpPosition.points -= points; // Update the pool point weights for rewards. pool.totalPoints -= points; }
However, because of rounding down in calculation, the attacker can withdraw all amount
without removing any points. As a result, an attacker's LP position can have points > 0
even though amount = 0
, which means attackers still receive rewards without depositing anything.
Consider the scenario
1e18
LP token to NeoTokyoStaker. Let's just assume multiplier = 10000
. Alice will receive the amount ofpoints = amount * 100 / 1e18 * multiplier / _DIVISOR; = 1e18 * 100 / 1e18 * 10000 / 100 = 10000
1e16 - 1
wei LP token. The amount of points
will be deducted ispoints = amount * 100 / 1e18 * multiplier / _DIVISOR; = (1e16 - 1) * 100 / 1e18 * 10000 / 100 = 0
1e16 - 1
wei LP token without losing any points. Alice can repeat step 2 as many times as she wants and then create a position with large points
without any LP tokens.100
times (can be done by deploying a contract to do the for loop) to withdraw all and then repeat from step 1. For each time, she will get 10000
points.Manual Review
Consider adding PRECISION (e.g: 1e18
) when calculating points
from amount
in LP pool. Also consider doing all multiplication before division to avoid precision loss.
#0 - c4-judge
2023-03-16T03:11:40Z
hansfriese marked the issue as primary issue
#1 - c4-judge
2023-03-16T03:11:47Z
hansfriese marked the issue as satisfactory
#2 - TimTinkers
2023-03-21T05:51:09Z
Great find.
#3 - c4-sponsor
2023-03-21T05:51:13Z
TimTinkers marked the issue as sponsor confirmed
#4 - c4-judge
2023-03-21T09:20:43Z
hansfriese marked the issue as duplicate of #261
🌟 Selected for report: 0xSmartContract
Also found by: 0x1f8b, 0x6980, 0xAgro, 0xSolus, 0xhacksmithh, 0xkazim, ABA, BPZ, BowTiedOriole, ChainReview, DadeKuma, DeFiHackLabs, Deathstore, DevABDee, Diana, Dravee, Dug, Englave, Go-Langer, Haipls, IceBear, Inspex, Jeiwan, Kek, Kresh, Madalad, MatricksDeCoder, MyFDsYours, RaymondFam, Rolezn, SAAJ, Sathish9098, Taloner, Udsen, Viktor_Cortess, atharvasama, ayden, brgltd, btk, carlitox477, catellatech, chaduke, codeislight, deadrxsezzz, descharre, erictee, fatherOfBlocks, favelanky, glcanvas, handsomegiraffe, jasonxiale, jekapi, joestakey, lemonr, luxartvinsec, martin, matrix_0wl, minhquanym, mrpathfindr, nadin, oyc_109, parsely, peanuts, pfedprog, rbserver, rokso, saian, santipu_, scokaf, slvDev, tsvetanovv, ubl4nk, ulqiorra, yamapyblack, zaskoh
29.6697 USDC - $29.67
Id | Title |
---|---|
1 | Unnecessary check for AssetType |
2 | AssetType has value from 0-3, check for uint8(_assetType) > 4 still allow _assetType = 4 |
3 | Division before multiplication |
4 | Formula to calculate point in LP pool can be simplified |
ENUM in Solidity will revert automatically if it is assigned a value that is not predefined. So the check for AssetType in stake()
and withdraw()
functions are unnecessary.
if (uint8(_assetType) > 4) { // @audit unnecessary check revert InvalidAssetType(uint256(_assetType)); }
Remove the AssetType checks.
uint8(_assetType) > 4
still allow _assetType = 4
AssetType ENUM only has 4 predefined values, so its value is from 0-3. However the check in function stake()
and withdraw()
are still allow _assetType = 4
.
if (uint8(_assetType) > 4) { // @audit value 4 is still allowed revert InvalidAssetType(uint256(_assetType)); }
Consider updating the check or removing it since it is unnecessary anyway.
Performing multiplication before division is generally better to avoid loss of precision because Solidity integer division might truncate. There are multiple places in the codebase this issue exists. For example
unchecked { // @audit division before multiplication uint256 share = points * _PRECISION / pool.totalPoints * totalReward; uint256 daoShare = share * pool.daoTax / (100 * _DIVISOR); share /= _PRECISION; daoShare /= _PRECISION; return ((share - daoShare), daoShare); }
Consider doing all multiplication before division to avoid truncation.
Formula to calculate points
in functions stake()
and withdraw()
can be simplified since _DIVISOR = 100
.
unchecked { uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR;
So the formula can be simplified to
uint256 points = amount * lpPosition.multiplier / 1e18;
#0 - c4-judge
2023-03-17T02:50:12Z
hansfriese marked the issue as grade-b