Skip to main content

Description

On September 22, 2025, UxLink’s multisig was compromised through private key theft, resulting in $11.3M direct theft plus unauthorized minting of 10 trillion tokens worth ~$28M. Attackers manipulated the multisig configuration to drain funds and gain token minting privileges.

Core Vulnerability Mechanism

The attack exploited two weaknesses:
  1. Compromised Private Keys: Attackers gained access to UxLink’s multisig owner private keys
  2. Unrestricted Multisig Configuration: Safe contract allowed threshold reduction and owner changes without security checks
  3. Token Minting Access: Compromised multisig gained Manager privileges over the UXLINK token contract
Attack Vector: Compromised private keys enabled execution of multisig transactions without validation or rate limiting.

Attack Analysis

Attack Sequence

Phase 1: Multisig Compromise Attackers used compromised private keys to revoke admin privileges, add themselves as owners, and reduce the multisig threshold to 1, gaining full control. Phase 2: Treasury Drainage Drained $11.3M in assets (USDT, USDC, WBTC, ETH) and converted them across chains. Phase 3: Token Exploitation Used multisig control to mint unlimited UXLINK tokens, causing a price crash and significant market cap loss.

Root Causes

  1. Private Key Compromise: Compromised private keys due to poor operational security
  2. No Multisig Protection: No safeguards against rapid configuration changes
  3. No Rate Limiting: Multisig changes executed immediately without cooling periods
  4. Missing Access Controls: No whitelisting for owner changes

Proposed Solution

Multisig protection assertions could have prevented this attack by enforcing governance invariants:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Assertion} from "../../lib/credible-std/src/Assertion.sol";
import {PhEvm} from "../../lib/credible-std/src/PhEvm.sol";
import {ISafe} from "@safe-global/safe-contracts/contracts/interfaces/ISafe.sol";

contract UxLinkMultisigProtectionAssertion is Assertion {
    // Minimum threshold that must be maintained
    uint256 constant MIN_THRESHOLD = 2;

    // Whitelisted addresses that can become owners (set at deployment)
    address constant ALLOWED_OWNER_1 = 0x1111111111111111111111111111111111111111;
    address constant ALLOWED_OWNER_2 = 0x2222222222222222222222222222222222222222;
    address constant ALLOWED_OWNER_3 = 0x3333333333333333333333333333333333333333;

    function triggers() external view override {
        registerCallTrigger(
            this.assertThresholdProtection.selector,
            ISafe.changeThreshold.selector
        );

        registerCallTrigger(
            this.assertOwnerAddition.selector,
            ISafe.addOwnerWithThreshold.selector
        );

        registerCallTrigger(
            this.assertOwnerRemoval.selector,
            ISafe.removeOwner.selector
        );
    }

    /// @notice Prevents threshold reduction below minimum
    function assertThresholdProtection() external view {
        ISafe safe = ISafe(ph.getAssertionAdopter());

        PhEvm.CallInputs[] memory calls = ph.getCallInputs(
            address(safe),
            ISafe.changeThreshold.selector
        );

        for (uint256 i = 0; i < calls.length; i++) {
            uint256 newThreshold = abi.decode(calls[i].input, (uint256));

            // Never allow threshold below minimum
            require(newThreshold >= MIN_THRESHOLD, "Threshold cannot go below minimum");
        }
    }

    /// @notice Validates new owner additions against whitelist
    function assertOwnerAddition() external view {
        ISafe safe = ISafe(ph.getAssertionAdopter());

        PhEvm.CallInputs[] memory calls = ph.getCallInputs(
            address(safe),
            ISafe.addOwnerWithThreshold.selector
        );

        for (uint256 i = 0; i < calls.length; i++) {
            (address newOwner, uint256 threshold) = abi.decode(calls[i].input, (address, uint256));

            // Basic validation
            require(newOwner != address(0), "Invalid owner address");

            // Check whitelist
            require(
                newOwner == ALLOWED_OWNER_1 ||
                newOwner == ALLOWED_OWNER_2 ||
                newOwner == ALLOWED_OWNER_3,
                "New owner not whitelisted"
            );

            // Never allow threshold below minimum
            require(threshold >= MIN_THRESHOLD, "Threshold cannot go below minimum");
        }
    }

    /// @notice Validates owner removals don't reduce threshold below minimum
    function assertOwnerRemoval() external view {
        ISafe safe = ISafe(ph.getAssertionAdopter());

        PhEvm.CallInputs[] memory calls = ph.getCallInputs(
            address(safe),
            ISafe.removeOwner.selector
        );

        for (uint256 i = 0; i < calls.length; i++) {
            (, , uint256 threshold) = abi.decode(calls[i].input, (address, address, uint256));

            // Never allow threshold below minimum
            require(threshold >= MIN_THRESHOLD, "Threshold cannot go below minimum");
        }
    }
}
Assertions cannot track state across transactions (e.g., cooling periods). The original attack used rapid sequential transactions, which would require off-chain monitoring or timelocked governance contracts to fully prevent. However, the assertions above still provide strong protection by enforcing minimum thresholds and owner whitelisting.

How These Assertions Prevent the Attack

What they do:
  1. Threshold Protection: Prevents threshold from dropping below a safe minimum (e.g., 2)
  2. Owner Whitelisting: Only allows pre-approved addresses to become owners
  3. Removal Validation: Ensures owner removals don’t reduce threshold below minimum
How they prevent the attack:
  • Step 1: assertThresholdProtection() blocks any attempt to reduce threshold to 1
  • Step 2: assertOwnerAddition() blocks attacker addresses that aren’t whitelisted
  • Step 3: assertOwnerRemoval() ensures threshold stays above minimum even when removing owners
  • Result: Attacker cannot gain solo control of the multisig
Key insight: While these assertions can’t prevent private key compromise or enforce time delays between transactions, they establish hard boundaries that attackers cannot cross regardless of how many keys they control.

Key Takeaway

The UxLink attack shows that multisig security requires both operational security (private key protection) and protocol-level protections (configuration validation). The $39.3M total impact ($11.3M direct + $28M token minting) demonstrates how compromised private keys can lead to complete protocol takeover when governance controls are missing. Credible Layer assertions can enforce governance invariants: maintaining reasonable thresholds, validating owner changes, and preventing rapid reconfiguration. This approach ensures operational security failures don’t lead to complete protocol compromise.