Neo Tokyo contest - minhquanym's results

A staking contract for the crypto gaming illuminati.

General Information

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

Neo Tokyo

Findings Distribution

Researcher Performance

Rank: 4/123

Findings: 3

Award: $3,004.10

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: adriro

Also found by: ABA, Dug, Haipls, J4de, Madalad, ast3ros, auditor0517, joestakey, kutugu, minhquanym, rbserver, sinarette

Labels

bug
3 (High Risk)
satisfactory
duplicate-423

Awards

2819.6915 USDC - $2,819.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1388

Vulnerability details

Impact

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.

Proof of Concept

Consider a scenario:

  1. 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

  2. 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.

  3. 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
  1. Bob withdraw at the same time, but his TX is executed after Alice, he will receive
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.

Tools Used

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

Findings Information

🌟 Selected for report: adriro

Also found by: ABA, Dug, Haipls, J4de, Madalad, ast3ros, auditor0517, joestakey, kutugu, minhquanym, rbserver, sinarette

Labels

bug
3 (High Risk)
judge review requested
satisfactory
upgraded by judge
duplicate-423

Awards

2819.6915 USDC - $2,819.69

External Links

Lines of code

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1044

Vulnerability details

Impact

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.

Proof of Concept

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

  1. Attacker add huge amount of BYTES to his position, effectively increase totalPoints to large value
  2. Alice TX is executed. Since totalPoints is manipulate to be larger, Alice's share gets smaller and Alice receives less reward
  3. Attacker remove BYTES that he staked. Note that becasue, timelock duration is not extended when attacker stake BYTES, he can remove it in the same block. So the cost for the attack is just gas cost. The amount of BYTES could be his own funds or flash loan since all the attack is happen in 1 block.

Tools Used

Manual 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

Awards

154.74 USDC - $154.74

Labels

bug
3 (High Risk)
satisfactory
sponsor confirmed
duplicate-261

External Links

Lines of code

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1623

Vulnerability details

Impact

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.

Proof of Concept

Consider the scenario

  1. Alice (attacker) deposits 1e18 LP token to NeoTokyoStaker. Let's just assume multiplier = 10000. Alice will receive the amount of
points = amount * 100 / 1e18 * multiplier / _DIVISOR;
  = 1e18 * 100 / 1e18 * 10000 / 100
  = 10000
  1. Alice withdraws 1e16 - 1 wei LP token. The amount of points will be deducted is
points = amount * 100 / 1e18 * multiplier / _DIVISOR;
  = (1e16 - 1) * 100 / 1e18 * 10000 / 100
  = 0
  1. As we can see, Alice successfully withdraws 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.
  2. In this example, she can repeat 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.

Tools Used

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

Summary

IdTitle
1Unnecessary check for AssetType
2AssetType has value from 0-3, check for uint8(_assetType) > 4 still allow _assetType = 4
3Division before multiplication
4Formula to calculate point in LP pool can be simplified

1. Unnecessary check for AssetType

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1205

Detail

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));
}

Recommendation

Remove the AssetType checks.

2. AssetType has value from 0-3, check for uint8(_assetType) > 4 still allow _assetType = 4

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1205

Detail

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));
}

Recommendation

Consider updating the check or removing it since it is unnecessary anyway.

3. Division before multiplication

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1388

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1623

Detail

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);
}

Recommendation

Consider doing all multiplication before division to avoid truncation.

4. Formula to calculate point in LP pool can be simplified

https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L1623

Detail

Formula to calculate points in functions stake() and withdraw() can be simplified since _DIVISOR = 100.

unchecked {
  uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR;

Recommendation

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter