Platform: Code4rena
Start Date: 21/11/2022
Pot Size: $90,500 USDC
Total HM: 18
Participants: 101
Period: 7 days
Judge: Picodes
Total Solo HM: 4
Id: 183
League: ETH
Rank: 5/101
Findings: 4
Award: $4,323.79
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: xiaoming90
Also found by: __141345__
4127.2162 USDC - $4,127.22
https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexRewards.sol#L289-L291 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexRewards.sol#L315-L317 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexRewards.sol#L402-L403 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PxGmxReward.sol#L53-L55 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PxGmxReward.sol#L75-L77 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PxGmxReward.sol#L121-L122
The current time based px rewards calculation system is not accurate, and not fair for users. Due to GMX protocol reward rate fluctuation, px users stake and claim at different time could get less or more rewards they deserve.
Some users could abuse the rule to time the high and low GMX rewards periods, effectively steal rewards from other users. Evermore, anyone can call claim()
for other users. Innocent users could lose px rewards unconsciously.
Take PirexRewards.sol as example, PxGmxReward.sol is analogous.
Users rewards and globalRewards
/globalState.rewards
are purely time based weights:
File: src/PirexRewards.sol function userAccrue(ERC20 producerToken, address user) public { 289: uint256 rewards = u.rewards + 290: u.lastBalance * 291: (block.timestamp - u.lastUpdate); function _globalAccrue(GlobalState storage globalState, ERC20 producerToken) 315: uint256 rewards = globalState.rewards + 316: (block.timestamp - lastUpdate) * 317: lastSupply; File: src\vaults\PxGmxReward.sol function _userAccrue(address user) internal { 75: uint256 rewards = u.rewards + 76: u.lastBalance * 77: (block.timestamp - u.lastUpdate); function _globalAccrue() internal { 53: uint256 rewards = globalState.rewards + 54: (block.timestamp - globalState.lastUpdate) * 55: globalState.lastSupply;
The rewards users get is based on the above weights in the real claimed rewards from GMX.
File: src\PirexRewards.sol function claim(ERC20 producerToken, address user) external { 402: uint256 rewardState = p.rewardStates[rewardToken]; 403: uint256 amount = (rewardState * userRewards) / globalRewards; File: src\vaults\PxGmxReward.sol 105: function claim(address receiver) external { 121: uint256 _rewardState = rewardState; 122: uint256 amount = (_rewardState * userRewards) / globalRewards;
Assuming: Here use GLP as example.There are only 2 users Alice and Bob. Will look at day 0, day 100, and day 200, 3 time points.
harvest()
is called. Alice's staked GLP has reward of 1,000 EsGmx, but Alice did not claim the rewards. If call userAccrue()
, producerTokens[pxGlp].userStates[Alice]
should be 100 pxGlp * 100 days = 10,000, producerTokens[pxGlp][EsGmx]
should be 1,000.producerTokens[pxGlp].userStates[Alice]
is 10,000 + 100 pxGlp * 100 days = 20,000, producerTokens[pxGlp][EsGmx]
is 1,500, and are reset to 0.harvest()
is called. Alice's staked GLP has reward of 1,000 EsGmx. Alice did not claim the rewards. If call userAccrue()
, producerTokens[pxGlp].userStates[Alice]
will be 10,000, producerTokens[pxGlp][EsGmx]
1,000.producerTokens[pxGlp].userStates[Alice]
is 20,000, producerTokens[pxGlp].userStates[Bob]
is 100 pxGlp * 100 days = 10,000.
producerTokens[pxGlp][EsGmx]
is 2,000. globalRewards
is the sum of them 30,000.
The rewards Bob will get is 2,000 * 10,000 / 30,000 = 667 pxGmx.Compared to case 1, Alice's rewards is 167 pxGmx less. In fact, for the 2nd 100 days, total rewards is 1,000 EsGmx, Alice and Bob each deserves 500 pxGmx.
The reason is, GMX protocol rewards are not uniform for different periods. But the current pxGmx rewards rule is time based, variation of GMX protocol rewards rate is not accounted for, which creates inaccuracy for px rewards.
Malicious users could monitor GMX protocol rewards rate of high and low periods, stake and claim accordingly, effectively dilute or steal other px users's rewards. Even worse, claim()
can be called for other users. Malicious users could abuse this to dilute other users rewards. on the other hand, innocent users could lose some portion of their rewards.
Manual analysis.
claim()
in PirexRewards.sol#0 - c4-judge
2022-12-03T18:36:57Z
Picodes marked the issue as duplicate of #177
#1 - c4-judge
2023-01-01T10:40:53Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Jeiwan
Also found by: 0xLad, 0xSmartContract, 8olidity, HE1M, JohnSmith, KingNFT, Koolex, Lambda, R2, __141345__, carrotsmuggler, cccz, gogo, hl_, joestakey, koxuan, ladboy233, pashov, peanuts, rbserver, rvierdiiev, seyni, unforgiven, xiaoming90, yongskiws
25.3241 USDC - $25.32
https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PirexERC4626.sol#L68 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGlp.sol#L311-L315 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L394-L398 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGlp.sol#L142-L144 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L164-L166
Although the PirexERC4626
and AutoPxGlp
contract check for 0 shares, the rounding down error can still be used to steal new user deposit. Part of the new deposit could be stolen. The attacker may monitor the pool activities to catch the steal opportunities.
Part of the deposit fund from the 2nd user will be stolen by the attacker for new vault. Potentially the attacker can intentionally monitor the pool activities to find steal conditions.
The checks for 0 share are not enough to prevent the exchange rate manipulation, just slightly better.
File: src/vaults/PirexERC4626.sol 68: require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); File: src/vaults/AutoPxGlp.sol 311: if ( 312: (shares = supply == 0 313: ? assets 314: : assets.mulDivDown(supply, totalAssets() - assets)) == 0 315: ) revert ZeroShares(); File: src/vaults/AutoPxGmx.sol function depositGmx(uint256 amount, address receiver) { 385: (, uint256 assets, ) = PirexGmx(platform).depositGmx( 386: amount, 387: address(this) 388: ); 392: uint256 supply = totalSupply; 393: 394: if ( 395: (shares = supply == 0 396: ? assets 397: : assets.mulDivDown(supply, totalAssets() - assets)) == 0 398: ) revert ZeroShares(); File: src/PirexGmx.sol function depositGmx(uint256 amount, address receiver) { 388: if (amount == 0) revert ZeroAmount(); 389: if (receiver == address(0)) revert ZeroAddress();
Take depositGlp()
in AutoPxGlp.sol for example, assuming no fee. Others are analogous.
An attacker will do the following:
depositGlp()
in the pool.depositGlp()
, the attacker will front run it, transfer some PxGlp into the AutoPxGlp vault, not through depositGlp()
function through AutoPxGlp.sol, but direct transfer with ERC20(GLP).transfer(address(vault), amount)
, in this way the totalAssets
/ shares
ratio will be inflated.withdraw()/redeem()
, steal part of the user's deposit fund.Take the asset Glp, the numbers for each steps would be:
depositGlp(Glp, 1e21, 1, 1, user)
. The attacker will front run to transfer (500 * 99) PxGlp into the AutoPxGlp vault directly. Now the vault has (495e20 + 99) PxGlp as totalAssets
, 99 share, 1 share inflated to (5e20 + 1) Glp.AutoPxGlp#depositGlp(Glp, 1e21, 1, 1, user)
-> PirexGmx(platform).depositGlp(Glp, 1e21, 1, 1, address(this))
will return assets
of 1e21.
Then in line 403 call _deposit(1e21, user)
, assets.mulDivDown(supply, totalAssets() - assets)
, 1e21 * 99 / (495e20 + 99) is just less than 2, so will round to 1 wei. As a results, the new user gets 1 share, the vault has (505e20 + 99) PxGlp, almost 50,500 PxGlp.If the attacker does not have 49,500 PxGlp upfront, the steal still works, just the profit a little lower.
Although the condition of empty vault for this pattern seems not easy meet, if do it on purpose, the malicious user can still abuse the system in certain circumstances, in which the vector conditions are not so hard to meet. For instance in the following ways:
File: src/vaults/PirexERC4626.sol 68: require((shares = previewDeposit(assets)) != 0, "ZERO_SHARES"); 178: function previewDeposit(uint256 assets) { 184: return convertToShares(assets); 185: } 156: function convertToShares(uint256 assets) { 162: uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. 163: 164: return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); 165: } File: src\vaults\AutoPxGlp.sol 304: function _deposit(uint256 assets, address receiver) { 309: uint256 supply = totalSupply; 310: 311: if ( 312: (shares = supply == 0 313: ? assets 314: : assets.mulDivDown(supply, totalAssets() - assets)) == 0 315: ) revert ZeroShares(); 316: 317: _mint(receiver, shares); 322: } 142: function totalAssets() public view override returns (uint256) { 143: return asset.balanceOf(address(this)); 144: } File: src\vaults\AutoPxGmx.sol 164: function totalAssets() public view override returns (uint256) { 165: return asset.balanceOf(address(this)); 166: }
Manual analysis.
#0 - c4-judge
2023-01-01T10:41:21Z
Picodes marked the issue as satisfactory
#1 - c4-judge
2023-01-01T11:39:30Z
Picodes changed the severity to 3 (High Risk)
#2 - C4-Staff
2023-01-10T21:54:30Z
JeeberC4 marked the issue as duplicate of #275
🌟 Selected for report: Jeiwan
Also found by: 0xbepresent, Koolex, __141345__, cryptoDave, cryptonue, datapunk, pashov, unforgiven
131.6023 USDC - $131.60
https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexRewards.sol#L179-L197 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexRewards.sol#L386-L415
The admin could call removeRewardToken()
to change the rewardToken[]
array. If before the reward token is added back, some user claims the rewards, the user could lose the rewards for the removed token.
And claim()
function could be called by anyone, some users could lose rewards due to other users' operation. Malicious users could monitor admin's call to removeRewardToken()
, and call claim()
for other users right after. As a result, the malicious users effectively steal portion of the removed token from other users. Which could be claimed after added back.
Consider the following:
rewardTokens[]
has 2 token, WETH and esGMX.removeRewardToken(pxGlp, 1)
, then rewardTokens[]
only has 1 token, WETH.claim(pxGlp, Alice)
. But now the rewardTokens[]
only has WETH.userRewards
will be reset to 0 afterwards. The esGMX rewards will no longer be available even added back.In this case, no esGMX will be received although px contract will get esGMX in harvest()
. Those rewards will be left for the other users.
Even worse, since anyone can call claim()
, Alice could lose the rewards if someone else called claim(pxGlp, Alice)
right after the admin removed the token. Malicious user could abuse this to claim for other users to make them lose rewards. The lost rewards will accumulate until the reward token is added back. Effectively the malicious user can steal other users rewards portion.
rewardTokens[]
array could remove token.
File: src/PirexRewards.sol 179: function removeRewardToken(ERC20 producerToken, uint256 removalIndex) 185: ERC20[] storage rewardTokens = producerTokens[producerToken] 186: .rewardTokens; 187: uint256 lastIndex = rewardTokens.length - 1; 188: 189: if (removalIndex != lastIndex) { 190: // Set the element at removalIndex to the last element 191: rewardTokens[removalIndex] = rewardTokens[lastIndex]; 192: } 193: 194: rewardTokens.pop(); 195: 196: emit RemoveRewardToken(producerToken, removalIndex); 197: }
The for loop won't do anything to the removed token, even the px contract received the reward. And user's rewards
will be cleared.
373: function claim(ERC20 producerToken, address user) external { 387: uint256 rLen = rewardTokens.length; 390: p.globalState.rewards = globalRewards - userRewards; 391: p.userStates[user].rewards = 0; 396: for (uint256 i; i < rLen; ++i) { 397: ERC20 rewardToken = rewardTokens[i]; 409: producer.claimUserReward( 410: address(rewardToken), 411: amount, 412: recipient 413: ); 415: }
Manual analysis.
claim()
in PirexRewards.solremoveRewardToken()
, force to distribute the related rewards before the token is deleted#0 - Picodes
2022-12-03T17:57:49Z
Downgrading to Med as it relies on the admin calling RemoveRewardToken
although the reward token is valuable and there is still pending or accruing rewards
#1 - c4-judge
2022-12-03T17:57:55Z
Picodes changed the severity to 2 (Med Risk)
#2 - c4-judge
2022-12-03T20:43:25Z
Picodes marked the issue as primary issue
#3 - c4-judge
2022-12-05T10:38:58Z
Picodes marked the issue as duplicate of #255
#4 - c4-judge
2022-12-30T21:28:06Z
Picodes marked the issue as not a duplicate
#5 - JeffCX
2022-12-31T20:18:09Z
#6 - c4-judge
2023-01-01T10:39:24Z
Picodes marked the issue as primary issue
#7 - c4-judge
2023-01-01T10:39:59Z
Picodes marked the issue as duplicate of #271
#8 - c4-judge
2023-01-01T10:40:15Z
Picodes marked the issue as satisfactory
🌟 Selected for report: gzeon
Also found by: 0xPanda, 0xSmartContract, B2, Deivitto, Diana, JohnSmith, PaludoX0, Rahoz, RaymondFam, ReyAdmirado, Rolezn, Schlagatron, Secureverse, Tomio, __141345__, adriro, ajtra, aphak5010, c3phas, chaduke, codeislight, cryptonue, datapunk, dharma09, halden, karanctf, keccak123, oyc_109, pavankv, sakshamguruji, saneryee, unforgiven
39.6537 USDC - $39.65
In AutoPxGmx.sol, uint24 public poolFee
can be placed next to address public platform
, as a result, 1 slot storage can be saved. According to the currently layout, they both occupy 1 slot, but after re-arrangement, they can be packed into 1 slot.
// src/vaults/AutoPxGmx.sol 26 uint24 public poolFee = 3000; 31 address public platform;
Reference: Layout of State Variables in Storage.
In totalAssets()
, asset.balanceOf(address(this))
is already called, the result of totalAssets() - assetsBeforeClaim
can be saved into local memory. A function call and storage sload can be saved. opcode sload
which costs 100 gas, and function call has some extra overhead.
File: src/vaults/AutoPxGmx.sol 288: if ((totalAssets() - assetsBeforeClaim) != 0) { 289: totalFee = 290: ((asset.balanceOf(address(this)) - assetsBeforeClaim) * 291: platformFee) / 292: FEE_DENOMINATOR; 164: function totalAssets() public view override returns (uint256) { 165: return asset.balanceOf(address(this)); 166: }
producerTokens[]
and rewardTokens[]
pxGmx
and pxGlp
are all immutable, hence in claimRewards()
, the returned producerTokens[]
and rewardTokens[]
will always be the same.
File: src/PirexGmx.sol 50: ERC20 public immutable gmxBaseReward; // e.g. WETH (Ethereum) 55: PxERC20 public immutable pxGmx; 56: PxERC20 public immutable pxGlp; 739: function claimRewards() { 749: producerTokens = new ERC20[](4); 750: rewardTokens = new ERC20[](4); 752: producerTokens[0] = pxGmx; 753: producerTokens[1] = pxGlp; 754: producerTokens[2] = pxGmx; 755: producerTokens[3] = pxGlp; 756: rewardTokens[0] = gmxBaseReward; 757: rewardTokens[1] = gmxBaseReward; 758: rewardTokens[2] = ERC20(pxGmx); // esGMX rewards distributed as pxGMX 759: rewardTokens[3] = ERC20(pxGmx);
Suggestion:
Save producerTokens[]
and rewardTokens[]
to constants or immutable, and avoid re-assign value every time claimRewards()
is called.
rewardTokens[]
can be combined in harvest()
_producerTokens[]
has 4 elements, but only 2 tokens, pxGmx
and pxGlp
, there are duplicates.
rewardTokens[]
is the same, has 4 elements, but only 2 tokens, gmxBaseReward
and pxGmx.
The same for rewardAmounts[]
. The same token reward amounts can be combined. As a result, 2 storage sload for storage producerState
can be saved, each for 100 gas. And in extra 2 calls for _globalAccrue(producerState.globalState, p)
, there are several storage reads and writes, each for 100 gas.
File: src/PirexRewards.sol 338: function harvest() { 346: (_producerTokens, rewardTokens, rewardAmounts) = producer 347: .claimRewards(); 348: uint256 pLen = _producerTokens.length; 349: 350: // Iterate over the producer tokens and update reward state 351: for (uint256 i; i < pLen; ++i) { 352: ERC20 p = _producerTokens[i]; 353: uint256 r = rewardAmounts[i]; 355: // Update global reward accrual state and associate with the update of reward state 356: ProducerToken storage producerState = producerTokens[p]; 357: 358: _globalAccrue(producerState.globalState, p); 359: 360: if (r != 0) { 361: producerState.rewardStates[rewardTokens[i]] += r; 362: } 363: }
#0 - c4-judge
2022-12-05T11:26:08Z
Picodes marked the issue as grade-b
#1 - drahrealm
2022-12-09T06:59:35Z
One of the finding previously found in earlier issues, while the other one is by design to support future updates where emitted reward tokens can differ between producers
#2 - c4-sponsor
2022-12-09T06:59:43Z
drahrealm marked the issue as sponsor disputed