DEV Community

ohmygod
ohmygod

Posted on

The UXLINK DelegateCall Exploit: How a Single Function Turned a $44M Multisig Into a One-Click ATM

Today — March 20, 2026 — the UXLINK exploiter converted 5,496 ETH (~$11M) into DAI, six months after the original September 2025 breach. The stolen funds are still moving. Here's exactly how the exploit worked, why multisig wallets aren't the security silver bullet teams think they are, and the delegateCall pattern that keeps destroying protocols.


The Attack: From DelegateCall to Total Control in One Transaction

On September 22, 2025, an attacker discovered that UXLINK's multisig wallet smart contract exposed an unguarded delegateCall function. In a single transaction, they:

  1. Hijacked admin privileges — called delegateCall to execute arbitrary code in the multisig's context
  2. Replaced all owners — invoked addOwnerWithThreshold to insert their address and strip legitimate admins
  3. Drained reserves — withdrew $4M USDT, $500K USDC, 3.7 WBTC, and 25 ETH
  4. Minted 1 billion tokens — doubled the total supply from 1B to 2B UXLINK
  5. Dumped everything — cashed out ~6,732 ETH ($28.1M) across six wallets, using both DEXs and CEXs

Total damage: $44M+ stolen, token price crashed 70%+ (from $0.30 to $0.09), and $70M in market cap evaporated in hours.

The Vulnerable Pattern: Unguarded DelegateCall

The core vulnerability is deceptively simple. delegateCall executes external code in the context of the calling contract — meaning the external code can read and write the caller's storage, including ownership mappings.

// ❌ VULNERABLE — The pattern that killed UXLINK
contract VulnerableMultisig {
    mapping(address => bool) public owners;
    uint256 public threshold;

    // Anyone who can reach this function controls everything
    function executeDelegateCall(
        address target,
        bytes calldata data
    ) external returns (bytes memory) {
        // Missing: ownership check
        // Missing: target whitelist
        // Missing: function selector restriction
        (bool success, bytes memory result) = target.delegatecall(data);
        require(success, "delegatecall failed");
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

The attacker deployed a malicious contract that, when called via delegateCall, modified the multisig's storage directly:

// Attacker's payload contract
contract AdminTakeover {
    // Storage layout must match the multisig exactly
    mapping(address => bool) public owners;
    uint256 public threshold;

    function attack(address attacker) external {
        // This executes in the multisig's storage context
        owners[attacker] = true;
        threshold = 1; // Now only 1 signature needed
        // Optionally remove all legitimate owners
    }
}
Enter fullscreen mode Exit fullscreen mode

Why This Keeps Happening: The DelegateCall Hall of Shame

UXLINK isn't alone. Unguarded delegateCall is one of the most lethal patterns in DeFi:

Incident Date Loss Root Cause
Parity Multisig Nov 2017 $150M frozen delegateCall to self-destructed library
Poly Network Aug 2021 $611M Cross-chain delegateCall bypassed access control
Ronin Bridge Mar 2022 $625M Compromised keys + insufficient multisig threshold
UXLINK Sep 2025 $44M Unguarded delegateCall enabled admin takeover

The pattern is always the same: delegateCall executes in the caller's context, and if you don't restrict who can call it and what it can target, the attacker owns your storage.

The Twist: Hacker Gets Hacked

In a plot twist worthy of a heist movie, the UXLINK attacker fell victim to a phishing scam hours after their own exploit. Approximately 542 million UXLINK tokens (~$48M at pre-hack prices) were siphoned by an Inferno Drainer phishing contract.

This created a forensic nightmare: stolen funds split between the original attacker and a secondary phishing group, with money trails crossing Ethereum, Arbitrum, and multiple mixers. Six months later, the original attacker is still laundering — today's 5,496 ETH → DAI conversion proves the funds remain active.

The Laundering Playbook

The attacker's post-exploit fund flow reveals a methodical laundering strategy:

September 2025: Exploit → Drain reserves + mint tokens
    ├── Swap USDT/USDC → DAI on Ethereum
    ├── Bridge Arbitrum USDT → ETH → Ethereum
    ├── Dump UXLINK tokens → ETH across 6 wallets
    └── Disperse across 20+ addresses

February 2026: Buy 5,493 ETH with 10.87M DAI

March 20, 2026: Convert 5,496 ETH → 11M DAI
    └── Cycling between ETH and DAI to obscure trail
Enter fullscreen mode Exit fullscreen mode

The ETH↔DAI cycling suggests the attacker is using DEX liquidity pools as an informal mixer — converting between assets at different times to break chain analysis heuristics.

Defense Pattern 1: Secure DelegateCall Guard

If your protocol must use delegateCall, lock it down with multiple layers:

// ✅ SECURE — Multi-layer delegateCall protection
contract SecureMultisig {
    mapping(address => bool) public owners;
    uint256 public threshold;
    mapping(address => bool) public approvedTargets;
    mapping(bytes4 => bool) public blockedSelectors;
    bool private _locked;

    modifier onlyMultisig() {
        require(msg.sender == address(this), "must go through proposal");
        _;
    }

    modifier noReentrancy() {
        require(!_locked, "reentrant");
        _locked = true;
        _;
        _locked = false;
    }

    // Layer 1: Only callable through governance proposal
    // Layer 2: Target must be pre-approved
    // Layer 3: Dangerous selectors are blocked
    // Layer 4: Reentrancy guard
    function executeDelegateCall(
        address target,
        bytes calldata data
    ) external onlyMultisig noReentrancy returns (bytes memory) {
        require(approvedTargets[target], "target not approved");

        bytes4 selector = bytes4(data[:4]);
        require(!blockedSelectors[selector], "selector blocked");

        // Block storage-modifying patterns
        require(
            selector != bytes4(keccak256("addOwnerWithThreshold(address,uint256)")),
            "cannot modify owners via delegatecall"
        );

        (bool success, bytes memory result) = target.delegatecall(data);
        require(success, "delegatecall failed");

        // Post-execution invariant check
        _verifyOwnershipIntact();

        return result;
    }

    function _verifyOwnershipIntact() internal view {
        // Verify at least threshold owners still exist
        // This catches storage corruption from delegatecall
        require(threshold > 0, "threshold corrupted");
        require(threshold <= _ownerCount(), "threshold > owners");
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense Pattern 2: Timelock + Announcement for Admin Changes

The UXLINK attacker replaced owners instantly. A timelock would have given the team hours or days to react:

// ✅ SECURE — Timelocked ownership changes
contract TimelockMultisig {
    uint256 public constant OWNER_CHANGE_DELAY = 48 hours;

    struct PendingChange {
        address newOwner;
        uint256 executeAfter;
        bool executed;
    }

    mapping(uint256 => PendingChange) public pendingChanges;
    uint256 public changeNonce;

    event OwnerChangeQueued(
        uint256 indexed nonce,
        address newOwner,
        uint256 executeAfter
    );

    function queueOwnerChange(address newOwner) 
        external 
        onlyMultisig 
    {
        uint256 nonce = changeNonce++;
        pendingChanges[nonce] = PendingChange({
            newOwner: newOwner,
            executeAfter: block.timestamp + OWNER_CHANGE_DELAY,
            executed: false
        });

        emit OwnerChangeQueued(nonce, newOwner, block.timestamp + OWNER_CHANGE_DELAY);
    }

    function executeOwnerChange(uint256 nonce) 
        external 
        onlyMultisig 
    {
        PendingChange storage change = pendingChanges[nonce];
        require(!change.executed, "already executed");
        require(block.timestamp >= change.executeAfter, "timelock active");

        change.executed = true;
        _addOwner(change.newOwner);
    }

    // Emergency: any single owner can cancel a pending change
    function cancelOwnerChange(uint256 nonce) external {
        require(owners[msg.sender], "not owner");
        delete pendingChanges[nonce];
    }
}
Enter fullscreen mode Exit fullscreen mode

Defense Pattern 3: Supply Cap Enforcement

The attacker minted 1 billion tokens because the contract had no hardcoded supply cap:

// ✅ SECURE — Immutable supply cap
contract SecureToken is ERC20 {
    uint256 public immutable MAX_SUPPLY;
    address public minter;
    bool public mintingDisabled;

    constructor(uint256 maxSupply) {
        MAX_SUPPLY = maxSupply; // Set once, cannot change
    }

    function mint(address to, uint256 amount) external {
        require(msg.sender == minter, "not minter");
        require(!mintingDisabled, "minting disabled");
        require(
            totalSupply() + amount <= MAX_SUPPLY, 
            "exceeds max supply"
        );
        _mint(to, amount);
    }

    // One-way: once disabled, minting can never be re-enabled
    function disableMinting() external {
        require(msg.sender == minter, "not minter");
        mintingDisabled = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Solana Perspective: CPI Context Confusion

Solana doesn't have delegateCall, but Cross-Program Invocations (CPI) create similar risks when programs trust the calling context:

// ✅ SECURE — Anchor program with explicit authority validation
use anchor_lang::prelude::*;

#[program]
pub mod secure_multisig {
    use super::*;

    pub fn execute_transaction(
        ctx: Context<ExecuteTransaction>,
        tx_index: u64,
    ) -> Result<()> {
        let multisig = &ctx.accounts.multisig;
        let transaction = &mut ctx.accounts.transaction;

        // Verify threshold signatures BEFORE execution
        require!(
            transaction.signers.iter()
                .filter(|s| multisig.owners.contains(s))
                .count() >= multisig.threshold as usize,
            ErrorCode::NotEnoughSigners
        );

        // Verify transaction hasn't been executed
        require!(!transaction.executed, ErrorCode::AlreadyExecuted);

        // Verify the transaction target is in the approved list
        require!(
            multisig.approved_programs.contains(&transaction.program_id),
            ErrorCode::ProgramNotApproved
        );

        transaction.executed = true;

        // Execute via CPI with PDA signer
        let seeds = &[
            b"multisig",
            multisig.key().as_ref(),
            &[multisig.bump],
        ];

        anchor_lang::solana_program::program::invoke_signed(
            &transaction.to_instruction(),
            &ctx.remaining_accounts.to_vec(),
            &[seeds],
        )?;

        Ok(())
    }
}

#[error_code]
pub enum ErrorCode {
    #[msg("Not enough signers")]
    NotEnoughSigners,
    #[msg("Transaction already executed")]
    AlreadyExecuted,
    #[msg("Target program not in approved list")]
    ProgramNotApproved,
}
Enter fullscreen mode Exit fullscreen mode

The UXLINK Multisig Security Audit Checklist

Use this before deploying any multisig or governance wallet:

Access Control

  • [ ] delegateCall is either removed entirely or restricted to governance-approved targets
  • [ ] Owner changes require timelock (48h+ recommended)
  • [ ] Any single owner can cancel pending admin changes
  • [ ] Threshold cannot be reduced below original deployment value without timelock

Token Security

  • [ ] MAX_SUPPLY is immutable (set in constructor or as constant)
  • [ ] Minting can be permanently disabled (one-way kill switch)
  • [ ] Token contract ownership is separate from treasury multisig

Monitoring & Response

  • [ ] Real-time alerts on ownership change transactions
  • [ ] Automatic exchange notifications on large token movements
  • [ ] Emergency pause function that any single owner can trigger
  • [ ] Incident response plan with exchange contacts pre-established

Key Management

  • [ ] Keys distributed across different devices and geographic locations
  • [ ] No single person holds enough keys to meet threshold
  • [ ] Hardware wallets required for all multisig signers
  • [ ] Key rotation schedule (quarterly minimum)

The $44M Lesson

UXLINK's disaster distills to three failures:

  1. Unguarded delegateCall — the technical root cause that enabled everything
  2. No supply cap — allowed the attacker to mint tokens, doubling the damage
  3. No timelock on admin changes — ownership transfer was instant and irreversible

Six months later, with the attacker still cycling ETH↔DAI today, the funds remain largely unrecovered. The lesson isn't just "use multisig" — it's that multisig is only as secure as its implementation. A delegateCall function without access control turns your entire security model into a single point of failure.

Every protocol with a multisig treasury should ask: If someone calls delegateCall on our wallet contract right now, what can they do? If the answer is anything other than "nothing," you have the same vulnerability that cost UXLINK $44 million.


This analysis is part of the DeFi Security Research series. Follow for weekly deep-dives into real exploits, audit techniques, and defense patterns.

Top comments (0)