Redacted Cartel contest - HE1M's results

Boosted GMX assets from your favorite liquid token wrapper, Pirex - brought to you by Redacted Cartel.

General Information

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

Redacted Cartel

Findings Distribution

Researcher Performance

Rank: 4/101

Findings: 3

Award: $4,504.86

🌟 Selected for report: 1

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: KingNFT

Also found by: 0x52, HE1M, ladboy233, rvierdiiev, xiaoming90

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-113

Awards

902.6222 USDC - $902.62

External Links

Lines of code

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexGmx.sol#L657

Vulnerability details

Impact

There is a possibility to prevent any user from redeeming in PirexGmx.sol.

Please note that this attack uses two other contracts that are not in the scope, but the execution and logic of the attack depends on the in-scope contracts.

Proof of Concept

A user can deposit ETH or GMX-whitelisted token into PirexGmx.sol to get minted pxGlp.

function depositGlp( address token, uint256 tokenAmount, uint256 minUsdg, uint256 minGlp, address receiver ) external whenNotPaused nonReentrant returns ( uint256, uint256, uint256 ) { if (token == address(0)) revert ZeroAddress(); if (!gmxVault.whitelistedTokens(token)) revert InvalidToken(token); return _depositGlp(token, tokenAmount, minUsdg, minGlp, receiver); }
function depositGlpETH( uint256 minUsdg, uint256 minGlp, address receiver ) external payable whenNotPaused nonReentrant returns ( uint256, uint256, uint256 ) { return _depositGlp(address(0), msg.value, minUsdg, minGlp, receiver); }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexGmx.sol#L525

Suppose a user deposits some amount of a whitelisted token. Then, this amount of token will be approved to be consumed by the glpManager. https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexGmx.sol#L507

Moreover, the function mintAndStakeGlp will be called in gmxRewardRouterV2, in which it calls the glpManager to add liquidity for PirexGmx. https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/PirexGmx.sol#L510 https://snowtrace.io/address/0x82147c5a7e850ea4e28155df107f2590fd4ba327#code#F1#L132

When adding liquidity in the glpManager, a mapping lastAddedAt[PirexGmx address] = block.timestamp will be set. Please note that address of PirexGmx will be tracked in this mapping, not the address of user. https://snowtrace.io/address/0xe1ae4d4b06a5fe1fc288f6b4cd72f9f8323b107f#code#F1#L176 https://snowtrace.io/address/0x82147c5a7e850ea4e28155df107f2590fd4ba327#code#F1#L131

When calling the functions redeemPxGlp or redeemPxGlpETH, the same flow will be executed again: redeemPxGlp in PirexGmx.sol ==> unstakeAndRedeemGlp in gmxRewardRouterV2 ==> removeLiquidityForAccount in glpManager

In the glpManager, it is checked that the cooldown duration is passed or not through: require(lastAddedAt[PirexGmx address].add(cooldownDuration) <= block.timestamp, "GlpManager: cooldown duration not yet passed"); https://snowtrace.io/address/0xe1ae4d4b06a5fe1fc288f6b4cd72f9f8323b107f#code#F1#L185

In which, cooldownDuration is equal to 900 seconds (15 minutes).

This provides an attack surface that whenever a user would like to redeem in PirexGmx.sol, an attacker quickly before the user deposits very small amount in PirexGmx.sol so that lastAddedAt will be updated in the glpManager, and the user's transaction will be reverted. So, the user should wait for 15 more minutes to again call redeem function.

Please note that the mapping lastAddedAt[account] tracks the time the PirexGmx address deposits some amount, not the user's address, because the deposited amount is transferred from the user to PirexGmx and then from PirexGmx to glpManager.

Tools Used

There should be a limitation on the minimum amount that can be deposited, so that the attacker must pay higher to apply this attack.

#0 - c4-judge

2022-12-03T21:08:52Z

Picodes marked the issue as duplicate of #110

#1 - c4-judge

2022-12-05T10:46:12Z

Picodes marked the issue as duplicate of #113

#2 - c4-judge

2023-01-01T11:08:28Z

Picodes marked the issue as satisfactory

Awards

25.3241 USDC - $25.32

Labels

bug
3 (High Risk)
satisfactory
edited-by-warden
duplicate-275

External Links

Lines of code

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L397

Vulnerability details

Impact

An attacker can prevent any user from depositing into PirexGmx contract, as a result this contract will have no use.

Proof of Concept

Suppose the AutoPxGmx.sol has just deployed, so it will have the following initial conditions:

  • totalAssets() = 0: this shows the balance of pxGMX custodied by the AutoPxGmx contract
  • totalSupply = 0: this tracks the number of share tokens minted apxGMX

Then, Bob (malicious user) deposits 1 token GMX as the first user by calling depositGmx in AutoPxGmx.sol:

function depositGmx(uint256 amount, address receiver) external nonReentrant returns (uint256 shares) { if (amount == 0) revert ZeroAmount(); if (receiver == address(0)) revert ZeroAddress(); // Handle compounding of rewards before deposit (arguments are not used by `beforeDeposit` hook) if (totalAssets() != 0) beforeDeposit(address(0), 0, 0); // Intake sender GMX gmx.safeTransferFrom(msg.sender, address(this), amount); // Convert sender GMX into pxGMX and get the post-fee amount (i.e. assets) (, uint256 assets, ) = PirexGmx(platform).depositGmx( amount, address(this) ); // NOTE: Modified `convertToShares` logic to consider assets already being in the vault // and handle it by deducting the recently-deposited assets from the total uint256 supply = totalSupply; if ( (shares = supply == 0 ? assets : assets.mulDivDown(supply, totalAssets() - assets)) == 0 ) revert ZeroShares(); _mint(receiver, shares); emit Deposit(msg.sender, receiver, assets, shares); }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L370

As a result, this 1 token GMX will be converted to pxGMX, and 1 share token will be minted to Bob, so we will have:

  • totalAssets() = 1
  • totalSupply = 1

Now, suppose Alice (honest user) would like to deposit 100 token GMX in AutoPxGmx contract by calling depositGmx. Bob notices Alice's transaction, and front-runs her by transferring 100 token pxGMX directly to AutoPxGmx contract not through depositGmx function. Since Bob's transfer is not through the function depositGmx, no share token will be minted to Bob. So we will have:

  • totalAssets() = 1 + 100 = 101
  • totalSupply = 1

Now Alice's transaction will be executed. During the calculation for amount of share tokens to be minted to Alice, supply is equal to 1, so the second statement of if block will be executed: assets.mulDivDown(supply, totalAssets() - assets). This statement will be equal to 0, because 100 * 1 / (201 - 100) = 100 / 101 = 0. So, value of shares will be equal to zero, and Alice's transaction will be reverted ZeroShares().

if ( (shares = supply == 0 ? assets : assets.mulDivDown(supply, totalAssets() - assets)) == 0 ) revert ZeroShares();

So we will have still the same state as before:

  • totalAssets() = 101
  • totalSupply = 1

Now, Bob can call the function redeem(1, Bob, Bob):

function redeem( uint256 shares, address receiver, address owner ) public override returns (uint256 assets) { // Compound rewards and ensure they are properly accounted for prior to redemption calculation compound(poolFee, 1, 0, true); if (msg.sender != owner) { uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares; } // Check for rounding error since we round down in previewRedeem. require((assets = previewRedeem(shares)) != 0, "ZERO_ASSETS"); _burn(owner, shares); emit Withdraw(msg.sender, receiver, owner, assets, shares); asset.safeTransfer(receiver, assets); }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L339

This function will call the function previewRedeem(shares). In this function the amount of assets pxGMX which is 101 will be transferred to Bob, and 1 share token apxGMX will be burned. Moreover, Bob will not pay any withdrawal penalty, because _totalSupply - shares = 0.

function previewRedeem(uint256 shares) public view override returns (uint256) { // Calculate assets based on a user's % ownership of vault shares uint256 assets = convertToAssets(shares); uint256 _totalSupply = totalSupply; // Calculate a penalty - zero if user is the last to withdraw uint256 penalty = (_totalSupply == 0 || _totalSupply - shares == 0) ? 0 : assets.mulDivDown(withdrawalPenalty, FEE_DENOMINATOR); // Redeemable amount is the post-penalty amount return assets - penalty; }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L173

As a summary, Bob can prevent any user from depositing into AutoPxGmx contract by depositing just 1 token GMX to have nonzero totalSupply and then front-running any user by depositing the same amount of the user's deposit. As a result, the contract AutoPxGmx will be useless.

Please note that Bob can prepare token pxGMX by depositing GMX in the contract PirexGmx.sol by calling depositGmx.

The same vulnerability also exists is in the contract AutoPxGlp during depositing Glp/FsGlp/GlpETH, so maybe it is not reasonable to repeat the same scenario explanation for that contract as well, or make another report.

function _deposit(uint256 assets, address receiver) internal returns (uint256 shares) { // Check for rounding error since we round down in previewDeposit. uint256 supply = totalSupply; if ( (shares = supply == 0 ? assets : assets.mulDivDown(supply, totalAssets() - assets)) == 0 ) revert ZeroShares(); _mint(receiver, shares); emit Deposit(msg.sender, receiver, assets, shares); afterDeposit(receiver, assets, shares); }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGlp.sol#L314

Tools Used

The solution is to mint a large amount of apxGMX into AutoPxGmx at the time of deploy. For example, if 100000 apxGMX is minted to this contract, the totalSupply will be qual to 100000. So, Bob to apply the same attack in the previous scenario, he should front-runs Alice and transfers directly 100000 pxGMX, so makes the attack almost impossible for Bob.

#0 - c4-judge

2022-12-03T21:05:50Z

Picodes marked the issue as duplicate of #407

#1 - c4-judge

2023-01-01T11:08:55Z

Picodes marked the issue as satisfactory

#2 - C4-Staff

2023-01-10T21:54:30Z

JeeberC4 marked the issue as duplicate of #275

Findings Information

🌟 Selected for report: HE1M

Labels

bug
2 (Med Risk)
downgraded by judge
judge review requested
satisfactory
selected for report
M-02

Awards

3576.9207 USDC - $3,576.92

External Links

Lines of code

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L275

Vulnerability details

Impact

It is possible that an attacker can prevent any user from calling the functions withdraw, redeem, or depositGmx in contract AutoPxGmx by just manipulating the balance of token gmxBaseReward, so that during the function compound the swap will be reverted.

Proof of Concept

Whenever a user calls the functions withdraw, redeem, or depositGmx in contract AutoPxGmx, the function compound is called: https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L321 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L345 https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L379

The function compound claims token gmxBaseReward from rewardModule: https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L262

Then, if the balance of the token gmxBaseReward in custodian of the contract AutoPxGmx is not zero, the token gmxBaseReward will be swapped to token GMX thrrough uniswap V3 by callind the function exactInputSingle. Then the total amount of token GMX in custodian of the contract AutoPxGmx will be deposited in the contract PirexGmx to receive token pxGMX:

if (gmxBaseRewardAmountIn != 0) { gmxAmountOut = SWAP_ROUTER.exactInputSingle( IV3SwapRouter.ExactInputSingleParams({ tokenIn: address(gmxBaseReward), tokenOut: address(gmx), fee: fee, recipient: address(this), amountIn: gmxBaseRewardAmountIn, amountOutMinimum: amountOutMinimum, sqrtPriceLimitX96: sqrtPriceLimitX96 }) ); // Deposit entire GMX balance for pxGMX, increasing the asset/share amount (, pxGmxMintAmount, ) = PirexGmx(platform).depositGmx( gmx.balanceOf(address(this)), address(this) ); }

Whenever the function compound is called inside the mentioned functions, the parameters are: compound(poolFee, 1, 0, true);

  • fee = poolFee
  • amountOutMinimum = 1
  • sqrtPriceLimitX96 = 0
  • optOutIncentive = true

The vulnerability is the parameter amountOutMinimum which is equal to 1. This provides an attack surface so that if the balance of token gmxBaseReward in AutoPxGmx is nonzero and small enough that does not worth 1 token GMX, the swap procedure will be reverted.

For example, if the balance of gmxBaseReward is equal to 1, then since the value of gmxBaseReward is lower than token GMX, the output amount of GMX after swapping gmxBaseReward will be zero, and as parameter amountOutMinimum is equal to 1, the swap will be reverted, and as a result compound function will be reverted.

Attack Scenario:

Suppose, recently the function compound was called, so the balance of token gmxBaseReward in contract AutoPxGmx is equal to zero. Later, Alice (honest user) would like to withdraw. So, she calls the function withdraw(...).

function withdraw( uint256 assets, address receiver, address owner ) public override returns (uint256 shares) { // Compound rewards and ensure they are properly accounted for prior to withdrawal calculation compound(poolFee, 1, 0, true); shares = previewWithdraw(assets); // No need to check for rounding error, previewWithdraw rounds up. if (msg.sender != owner) { uint256 allowed = allowance[owner][msg.sender]; // Saves gas for limited approvals. if (allowed != type(uint256).max) allowance[owner][msg.sender] = allowed - shares; } _burn(owner, shares); emit Withdraw(msg.sender, receiver, owner, assets, shares); asset.safeTransfer(receiver, assets); }

https://github.com/code-423n4/2022-11-redactedcartel/blob/03b71a8d395c02324cb9fdaf92401357da5b19d1/src/vaults/AutoPxGmx.sol#L315

In a normal situation, the function compound will be called, and since the balance of gmxBaseReward is zero, no swap will be executed in uniswap V3, and the rest of the code logic will be executed.

But in this scenario, before the Alice's transaction, Bob transfers 1 token gmxBaseReward directly to contract AutoPxGmx . So, when Alice's transaction is going to be executed, inside the function compound the swap function will be called (because the balance gmxBaseReward is equal to 1 now). In the function exactInputSingle in uniswap V3, there is a check: require(amountOut >= params.amountOutMinimum, 'Too little received'); https://etherscan.io/address/0xe592427a0aece92de3edee1f18e0157c05861564#code#F1#L128

This check will be reverted, because 1 token of gmxBaseReward is worth less than 1 token of GMX, so the amount out will be zero which is smaller than amountOutMinimum.

In summary, an attacker before user's deposit, checks the balance of token gmxBaseReward in AutoPxGmx. If this balance is equal to zero, the attacker transfers 1 token gmxBaseReward to contract AutoPxGmx, so the user's transaction will be reverted, and user should wait until this balance reaches to the amount that worth more than or equal to 1 toke GMX.

Tools Used

The parameter amountOutMinimum should be equal to zero when the function compound is called. compound(poolFee, 0, 0, true);

#0 - Picodes

2022-12-03T20:59:53Z

The attack is unlikely: there is no real reason to do it as the attacker would have to pay gas, you need conditions on the price, and conditions on the reward amounts, as if rewards are accruing you won't be able to do it, so downgrading to Medium severity

#1 - Picodes

2022-12-03T21:00:28Z

However it's true that it may be good to set a minimum amount for the swaps that depends on the amountIn, or remove entirely the minAmountOut requirement

#2 - c4-judge

2022-12-03T21:00:32Z

Picodes changed the severity to 2 (Med Risk)

#3 - c4-sponsor

2022-12-07T16:09:22Z

kphed requested judge review

#4 - kphed

2022-12-07T16:16:59Z

Hi @Picodes, this is going to be resolved as a side effect of issue #321 being fixed (i.e. compound will be updated to consider token balances w/o dependence on external factors to prevent DoS'ing of users). Wanted to flag it for your attention but ultimately up to you re: awarding. Thanks for your help ser.

#5 - c4-judge

2022-12-30T21:16:19Z

Picodes marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter