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: 27/123
Findings: 3
Award: $203.71
🌟 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
Due to unchecked math in the _withdrawLP()
function, a user can trigger an underflow in their points
and infinitely increase their rewards.
The problem exists in several places.
Problem 1. The configureTimelockOptions()
function allows setting timelockMultiplier
to zero.
There are two points to note:
This is a realistic scenario, as an admin may want to discourage users from extending their positions by setting a zero timelockMultiplier
. Thus, passing zero timelockMultiplier
maybe considered as a valid behaviour (not a misconfiguration), especially since it is not forbidden in the configureTimelockOptions()
function.
The timelockMultiplier
is passed to the contract in a complex way, packed together with another variable (timelockPeriod
) into a single uint256
, thus increasing the possibility of mistakes when passing new data.
Taking these two points into account, as well as the fact that the key problem lies in the unchecked math in the _withdrawLP()
function, this finding falls within the scope.
Problem 2. The _stakeLP()
function contains code that changes the value of multiplier
in the user's position:
if (stakerLPPosition[msg.sender].multiplier == 0) { stakerLPPosition[msg.sender].multiplier = timelockMultiplier;
Thus, if the user created a position with a zero multiplier
, they can then call _stakeLP()
a second time with a different timelockMultiplier
and overwrite their multiplier
to a new, positive value.
Problem 3. The _withdrawLP()
function contains unchecked math that does not check for underflow when calculating points:
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; }
As a result, a hacker can set points
to be greater than what they had in lpPosition.points
, but less than pool.totalPoints
.
This will cause the value of pool.totalPoints
to change only slightly, but lpPosition.points
will become a huge value (close to 2 ** 256
). The hacker will then be able to claim this huge reward.
The PoC is written as a hardhat test and can be downloaded via secret gist: https://gist.github.com/knek-little-projects/7dbb2a5dc8315c4c5c3fe114b10b11a1
Put the test in test/PointsUnderflow.test.js
file and run with npx hardhat test test/PointsUnderflow.test.js
The test consists of the following steps:
The output of the test shows that a normal user (Alice) stakes 100 LP and gets 10000
points with a small amount of reward, while the hacker (Bob) stakes only 2 LP and gets 3037375438411862678027
points and an immense amount of reward:
ALICE STAKES 100 LP BOB STAKES 2 LP BOB STAKES 0 LP SKIP TIME BOB HAS POINTS 0 BOB HAS REWARD 0 BOB WITHDRAWS 1 LP SKIP TIME ALICE HAS POINTS 10000 ALICE HAS REWARD 3037375438411862678027 BOB HAS POINTS 115792089237316195423570985008687907853269984665640564039457584007913129639836 BOB HAS REWARD 31570186802875909244984703739959627676612910076697005458645310405
The power of mind
To prevent the issues described, it is recommended to avoid using unchecked math excessively and to forbid a zero multiplier
.
#0 - c4-judge
2023-03-16T04:35:17Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2023-03-16T04:36:26Z
hansfriese marked the issue as duplicate of #450
#2 - c4-judge
2023-03-17T01:09:16Z
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
NeoTokyo uses custom-written PermitControl
:
https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/access/PermitControl.sol#L21
It is recommended to use OpenZeppelin AccessControl
instead of custom-written PermitControl
. This has several benefits, making it a preferable choice:
NeoTokyo uses a single general interface IGenericGetter
to call different contracts even though their ABI is different:
Using a single IGenericGetter
interface for all contracts instead of separate interfaces for each contract can be problematic for catching type-related errors.
There is type-unsafe comparison in withdraw()
:
uint8(_assetType) == 2
which should be replaced with
_assetType == AssetType.BYTES
There is loss of precision in some formulas in the NeoTokyo contract. Generally it is better to put the division operation in the and of the expression.
The line:
uint256 points = amount * 100 / 1e18 * lpPosition.multiplier / _DIVISOR;
can be replaced with
uint256 points = (amount * 100 * lpPosition.multiplier) / (1e18 * _DIVISOR);
The line:
uint256 points = amount * 100 / 1e18 * timelockMultiplier / _DIVISOR;
can be replaced with
uint256 points = amount * 100 * timelockMultiplier / (1e18 * _DIVISOR);
#0 - c4-judge
2023-03-17T02:37:18Z
hansfriese marked the issue as grade-b
#1 - TimTinkers
2023-03-21T01:44:02Z
We're not ditching PermitControl
. Acknowledged some of the other suggestions.
#2 - c4-sponsor
2023-03-21T01:44:08Z
TimTinkers marked the issue as sponsor acknowledged
🌟 Selected for report: JCN
Also found by: 0x1f8b, 0xSmartContract, 0xSolus, 0xhacksmithh, 0xnev, Angry_Mustache_Man, Aymen0909, Diana, Flora, Inspex, Madalad, MatricksDeCoder, MiniGlome, R-Nemes, RaymondFam, ReyAdmirado, Rolezn, SAAJ, Sathish9098, Shubham, Udsen, Viktor_Cortess, arialblack14, atharvasama, ayden, c3phas, carlitox477, descharre, dharma09, durianSausage, fatherOfBlocks, ginlee, glcanvas, hunter_w3b, leopoldjoy, matrix_0wl, mrpathfindr, nadin, oyc_109, pipoca, schrodinger, slvDev, ulqiorra, volodya
19.3029 USDC - $19.30
Constraints checks for enum
are added automatically by the solidity compiler, so the following code is redundant and may be removed to save gas:
if (uint8(_assetType) > 4) { revert InvalidAssetType(uint256(_assetType)); }
Similarly, the following code may be simplified from:
if (uint8(_assetType) == 2 || uint8(_assetType) > 4) { revert InvalidAssetType(uint256(_assetType)); }
To:
if (_assetType == AssetType.BYTES) { revert InvalidAssetType(uint256(_assetType)); }
The code which removes a position in _withdrawLP()
...
if (lpPosition.amount == 0) { lpPosition.multiplier = 0; }
...misses the removal of lpPosition.timelockEndTime
:
lpPosition.timelockEndTime = 0;
Adding it will add an additional gas compensation for the caller.
Using bytes32
instead of string
for mappings can save gas, as bytes32
is cheaper to store and compare. You can change the identityCreditYield
and vaultCreditMultiplier
mappings to use bytes32
keys:
mapping(uint256 => mapping(bytes32 => string)) private identityCreditYield; mapping(bytes32 => uint256) private vaultCreditMultiplier;
https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L326 https://github.com/code-423n4/2023-03-neotokyo/blob/dfa5887062e47e2d0c801ef33062d44c09f6f36e/contracts/staking/NeoTokyoStaker.sol#L332
Make sure to update the contract code accordingly to work with bytes32
keys instead of string
.
Remove unnecessary else
in getStakerPosition()
:
Replace the following code...
function getStakerPosition ( address _staker, AssetType _assetType ) external view returns (uint256[] memory) { if (_assetType == AssetType.S1_CITIZEN) { return _stakerS1Position[_staker]; } else if (_assetType == AssetType.S2_CITIZEN) { return _stakerS2Position[_staker]; } else { revert UnknowablePosition(uint256(_assetType)); } }
by this:
function getStakerPosition ( address _staker, AssetType _assetType ) external view returns (uint256[] memory) { if (_assetType == AssetType.S1_CITIZEN) { return _stakerS1Position[_staker]; } else if (_assetType == AssetType.S2_CITIZEN) { return _stakerS2Position[_staker]; } revert UnknowablePosition(uint256(_assetType)); }
#0 - c4-judge
2023-03-17T03:46:16Z
hansfriese marked the issue as grade-b
#1 - c4-sponsor
2023-03-21T01:47:53Z
TimTinkers marked the issue as sponsor confirmed