DeFiFoundry
50,000 USDC
View results
Submission Details
Severity: low
Invalid

Execution Fee Entrapment via Unrestricted Payment Mechanism

Summary

The PerpetualVault contract contains a critical issue where execution fees can become permanently trapped due to the unlimited payment acceptance and withdrawal mechanism in GmxProxy being inadequate which results in overpaid execution fees being unrecoverable, even by the contract owner.

Vulnerability Details

// In PerpetualVault
function _payExecutionFee(uint256 depositId, bool isDeposit) internal {
uint256 minExecutionFee = getExecutionGasLimit(isDeposit) * tx.gasprice;
if (msg.value < minExecutionFee) {
revert Error.InsufficientAmount();
}
if (msg.value > 0) {
payable(address(gmxProxy)).transfer(msg.value); // Transfer entire msg.value
depositInfo[depositId].executionFee = msg.value;
}
}

The _payExecutionFee function stores the entire msg.value as an execution fee which should only store the minimum amount required (minExecutionFee). Any excess ETH sent by the user is not returned.

// In GmxProxy
function withdrawEth() external onlyOwner returns (uint256) {
uint256 balance = address(this).balance;
payable(msg.sender).transfer(balance);
return balance;
}

The withdrawEth function only handles ETH that was accidentally sent but does not handle where the excess ETH is for fees.

Users who send more ETH than necessary cannot get the excess back, the funds are stuck in the GMX proxy contract.

Impact

Excess fees will be trapped and the contract owner will not be able to withdraw them.

POC

Add this to PerpetualVault.t.sol and run it forge test --match-test test_ExecutionFeeOverpayment --rpc-url arbitrum -vvvv.

function test_ExecutionFeeOverpayment() external {
// Setup initial state
address alice = makeAddr("alice");
uint256 initialBalance = 2 ether;
deal(alice, initialBalance);
// Get minimum required execution fee
uint256 executionFee = PerpetualVault(vault).getExecutionGasLimit(true) * tx.gasprice;
uint256 depositAmount = 1e10;
// Setup deposit parameters
IERC20 collateralToken = PerpetualVault(vault).collateralToken();
vm.startPrank(alice);
deal(address(collateralToken), alice, depositAmount);
collateralToken.approve(vault, depositAmount);
// Send double the required execution fee
uint256 overpayment = executionFee * 2;
uint256 aliceBalanceBefore = alice.balance;
// Perform deposit with overpayment
PerpetualVault(vault).deposit{value: overpayment}(depositAmount);
vm.stopPrank();
// Verify the full overpayment was stored instead of just required fee
uint256[] memory depositIds = PerpetualVault(vault).getUserDeposits(alice);
(, , , uint256 storedFee, , ) = PerpetualVault(vault).depositInfo(depositIds[0]);
assertEq(storedFee, overpayment, "Full overpayment was stored as execution fee");
assertEq(
aliceBalanceBefore - alice.balance,
overpayment,
"User lost full overpayment amount"
);
// Get GmxProxy address and verify ETH is trapped there
address payable gmxProxyAddr = payable(address(PerpetualVault(vault).gmxProxy()));
uint256 trappedEth = gmxProxyAddr.balance;
assertTrue(trappedEth > 0, "ETH should be trapped in GmxProxy");
// Try to withdraw trapped ETH as owner
address owner = GmxProxy(gmxProxyAddr).owner();
uint256 ownerBalanceBefore = owner.balance;
// Attempt withdrawal as owner
vm.prank(owner);
vm.deal(gmxProxyAddr, trappedEth); // Ensure proxy has ETH balance
// Try to withdraw ETH
bool success;
bytes memory result;
(success, result) = address(gmxProxyAddr).call(
abi.encodeWithSignature("withdrawEth()")
);
// Verify ETH remains trapped even after owner withdrawal attempt
assertEq(gmxProxyAddr.balance, trappedEth, "ETH should remain trapped after withdrawal attempt");
assertEq(
owner.balance,
ownerBalanceBefore,
"Owner balance should not change"
);
// Verify no one can access the trapped ETH
vm.expectRevert();
(success, ) = address(gmxProxyAddr).call{value: 0}("");
}

Result:

├─ [7986] TransparentUpgradeableProxy::fallback()
│ ├─ [7363] GmxProxy::withdrawEth() [delegatecall]
│ │ ├─ [43] PerpetualVaultTest::fallback{value: 1000000000000000000}()
│ │ │ └─ ← [Revert] EvmError: Revert
│ │ └─ ← [Revert] EvmError: Revert
│ └─ ← [Revert] EvmError: Revert

ETH is trapped in GmxProxy and cannot be retrieved.

├─ [0] VM::assertEq(1000000000000000000 [1e18], 1000000000000000000 [1e18], "ETH should remain trapped after withdrawal attempt")

Even the owner cannot withdraw the trapped ETH.

├─ [0] VM::expectRevert(custom error 0xf4844814)
├─ [684] TransparentUpgradeableProxy::receive()
│ ├─ [55] GmxProxy::receive() [delegatecall]
│ │ └─ ← [Stop]

Attempts to access trapped ETH always fail.

Tools Used

  • Manual review

  • Foundry

Recommendations

Implement strict execution fee limits in PerpetualVault.

function _payExecutionFee(uint256 depositId, bool isDeposit) internal {
uint256 requiredFee = getExecutionGasLimit(isDeposit) * tx.gasprice;
if (msg.value < requiredFee) {
revert Error.InsufficientAmount();
}
if (msg.value > requiredFee) {
revert Error.ExcessiveExecutionFee();
}
payable(address(gmxProxy)).transfer(requiredFee);
depositInfo[depositId].executionFee = requiredFee;
}
Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
Assigned finding tags:

Users mistake, only impacting themselves.

Please read the CodeHawks documentation to know which submissions are valid. If you disagree, provide a coded PoC and explain the real likelihood and the detailed impact on the mainnet without any supposition (if, it could, etc) to prove your point.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.