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:
-
Hijacked admin privileges — called
delegateCallto execute arbitrary code in the multisig's context -
Replaced all owners — invoked
addOwnerWithThresholdto insert their address and strip legitimate admins - Drained reserves — withdrew $4M USDT, $500K USDC, 3.7 WBTC, and 25 ETH
- Minted 1 billion tokens — doubled the total supply from 1B to 2B UXLINK
- 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;
}
}
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
}
}
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
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");
}
}
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];
}
}
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;
}
}
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,
}
The UXLINK Multisig Security Audit Checklist
Use this before deploying any multisig or governance wallet:
Access Control
- [ ]
delegateCallis 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_SUPPLYis 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:
-
Unguarded
delegateCall— the technical root cause that enabled everything - No supply cap — allowed the attacker to mint tokens, doubling the damage
- 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)