On 16 October 2026, between 04:39 and 04:40 UTC, an automated bot finalized a batch of old, unclaimed Sui Bridge withdrawals on Ethereum. Most of them belonged to ordinary users. Eight of them did not. They had been sent from the Sui address of the Cetus exploiter, and finalizing them delivered 86 ETH, 109,000 USDT and 0.9 WBTC to the attacker’s Ethereum wallet, around $392K by the bot operator’s own estimate.
No contract was broken. Nobody forged a signature. The bridge did exactly what it was built to do. That is what makes this incident worth studying: a perfectly correct, permissionless code path turned dormant attacker funds into spendable ones, because the off-chain automation around it never asked whose money it was moving.
transferBridgedTokensWithSignatures() lets anyone submit a committee-signed message, and the bot submitted every valid message it found without screening the sender or recipient.Cetus, the largest concentrated-liquidity DEX on Sui, was drained of roughly $220 million on 22 May 2025 through an arithmetic overflow in its liquidity math. Sui validators froze about $162 million on the Sui side within the first hour, while part of the haul had already left for Ethereum. Public analyses from several research teams list the attacker’s Sui address as 0xe28b50…8ff06 and the Ethereum address that received bridged funds as 0x89012a55…24919b.
The 16 October TokensClaimed events, which we decoded ourselves, show that a set of Sui-to-Ethereum bridge messages from that same Sui address had been signed but never claimed on Ethereum. On a lock-and-claim bridge, that leaves the funds sitting in the Ethereum-side vault indefinitely. We could not find a public explanation of why they were left unclaimed, and we won’t guess at one. What matters is that they were still claimable, and that claiming them pays out to the recipient fixed in the message, which here was the attacker.
The Ethereum side of Sui Bridge is open source. A transfer from Sui is finalized on Ethereum by calling transferBridgedTokensWithSignatures() with the message and enough committee signatures. The function checks the signatures, checks that the nonce hasn’t been processed, enforces a 24-hour rate limit, and then pays the recipient encoded in the message. It does not care who msg.sender is.
The simplified sketch below is our illustration of that flow, condensed from the public SuiBridge.sol, not a verbatim copy:
// Simplified illustration of a permissionless bridge claim (not verbatim SuiBridge code)
function transferBridgedTokensWithSignatures(
bytes[] memory signatures,
Message memory message
) external nonReentrant verifyMessageAndSignatures(message, signatures) {
require(!isTransferProcessed[message.nonce], "already processed");
TokenTransferPayload memory p = decodeTokenTransferPayload(message.payload);
require(p.targetChain == chainID, "wrong chain");
// Funds always go to the recipient chosen on the source chain.
// The caller cannot redirect them, and the caller is never checked.
_transferTokensFromVault(message.chainID, p.tokenID, p.recipientAddress, p.amount);
isTransferProcessed[message.nonce] = true;
emit TokensClaimed(message.chainID, message.nonce, chainID, p.tokenID,
p.amount, p.senderAddress, p.recipientAddress);
}
This is a sound, common design. Because the caller cannot change where the money goes, letting anyone relay a message is safe for the user, and it is good for liveness: a user who lost interest, or a relayer that went offline, does not leave funds stranded forever. Many bridges work the same way.
The catch is that “safe for the user” assumes the recipient should receive the funds. When the recipient is an exploiter, a permissionless claim is a one-way valve that anyone can open.
The three claim transactions came from the same externally owned account, 0x7f2e…6671, each one a batched Multicall3 call that finalized many withdrawals at once. We decoded every TokensClaimed event emitted by the Sui Bridge contract in those transactions. The withdrawals sent to the attacker were:
| Tx | Sui nonce | Asset | Amount |
|---|---|---|---|
0x89ca33…dde89 | 7025, 7027, 7030 | ETH | 28 ETH each (84 ETH) |
0x89ca33…dde89 | 7028 | WBTC | 0.9 WBTC |
0x89ca33…dde89 | 7031 | USDT | 93,000 USDT |
0xce494e…36ac | 7020, 7022 | USDT | 8,000 USDT each |
0xe63cb0…1fc8 | 7021 | ETH | 2 ETH |
Every one of these events carries the same 32-byte sender, 0xe28b50cef1d633ea43d3296a3f6b67ff0312a5f1a99f0af753c85b8b5de8ff06, the Cetus attacker’s Sui address, and the same recipient, 0x89012a55cd6b88e407c9d4ae9b3425f55924919b. The two signals that could have stopped the claim were sitting in the event data the whole time.
The same batches also finalized legitimate withdrawals for other users, including one WBTC and two ETH withdrawals on Sui Bridge, plus pending withdrawals on other protocols’ contracts. In other words, this was a general-purpose “finish what others left unfinished” bot, and the attacker’s messages simply looked like more stale funds.
About half an hour later, a user signing as tanuki42 sent an on-chain message to the bot’s address pointing out that roughly $390K had gone to “an address publicly associated with the Cetus exploit” and suggesting it screen recipients before “rescuing” funds. Defimon Alerts relayed the message on X.
The operator, signing as lostandfound.eth, replied on-chain at 05:38 UTC: “you’re right, our mistake. We didn’t know 0x89012a…919b was the Cetus attacker when our automated run completed those Sui Bridge withdrawals.” The reply lists the three claim transactions, estimates the total at about $392K, says the 109,000 USDT had not moved, asks for the case to be routed to Tether and Cetus for a freeze, and says the bot now screens every recipient against known exploit and sanctioned addresses before claiming.
When we checked the wallet after these messages, it still held 109,000 USDT and 0.9 WBTC, and USDT’s getBlackListStatus() for the address returned false. The ETH, of course, cannot be frozen by anyone.
It is tempting to file this under “operator error” and move on. We think there are three broader lessons.
After an exploit, incident response focuses on funds that are moving. Messages that were signed but never executed get far less attention, yet they are live liabilities. Anyone, at any time, can turn them into spendable assets. The same applies to queued withdrawals, unexecuted governance actions, pending limit orders and approvals that never expire. We made a similar point about stale state in our NEAR Intents exploit analysis, where balances that looked inert turned out to be withdrawable.
When a protocol lets anyone finalize anything, the protocol itself does no screening, so whatever screening exists lives in the relayers. A careful relayer can be undone by a careless one: it takes one bot, one person running a script, or one MEV searcher looking for gas refunds to complete the transfer. Protocol teams should not assume “nobody will bother” is a control.
Tools that find stuck bridge funds have drawn a lot of attention lately, and the well-known checkers are read-only: they tell users what is claimable and leave the claim to them. A bot that executes claims on others’ behalf is a different thing: it is a payment system with no compliance checks. It deserves the same threat model as any contract that moves value.
Screen both ends of the transfer, cap what can be claimed without human review, and fail closed when a screening source is unavailable. An off-chain check is enough, but a wrapper contract makes the rule enforceable. The following is a simplified illustration:
// Simplified illustration: a relayer wrapper that refuses flagged transfers
contract ScreenedClaimRelayer {
ISuiBridge public immutable bridge;
IScreeningList public immutable screening; // maintained off-chain, updated by a multisig
uint256 public immutable reviewThresholdUsd;
function claim(bytes[] calldata sigs, Message calldata m, uint256 quotedUsd) external {
TokenTransferPayload memory p = decodeTokenTransferPayload(m.payload);
require(!screening.isFlaggedSui(p.senderAddress), "flagged sender");
require(!screening.isFlaggedEvm(p.recipientAddress), "flagged recipient");
require(quotedUsd < reviewThresholdUsd, "needs manual review");
bridge.transferBridgedTokensWithSignatures(sigs, m);
}
}
0x89ca33e27e8a8b409a202b2c5710d6e1bb0accd505b6c34e95c24f35a14dde890xce494e04a3fe01292c7edb8b1be228c943926e920bdb3737786e6062670d36ac0xe63cb0903e6784fd2343c1052ba190fe37c85d8b1d9442b9b54c1155b2e41fc80x7f2ee458d58e61345ad61a70e8072511bf9f66710x89012a55cd6b88e407c9d4ae9b3425f55924919b0xe28b50cef1d633ea43d3296a3f6b67ff0312a5f1a99f0af753c85b8b5de8ff060xda3bd1fe1973470312db04551b65f401bc8a92fdTokensClaimed events and token transfers)The Sui Bridge contract behaved correctly, and the bot operator acknowledged the mistake within about half an hour of being flagged and changed its process. Still, a well-meaning script handed an exploiter close to $400K that had been sitting unclaimed in the bridge vault. If your protocol has any function that anyone can call to finalize something, your security model has to include the question of who that something belongs to.
Running a bridge, a keeper network or a recovery tool that moves other people’s funds? Contact Panda Security for a design and code review, or see our audit pricing.
How a permissionless accept() and a buyback with no slippage limit let an attacker drain 2.31 ETH from the KolanHook Uniswap v4 hook on Ethereum.
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.
Autheo’s THEO token lost 92% after attackers used a Hyperlane TrustedRelayerIsm misconfiguration to mint 19 million unbacked tokens on Base and extract roughly $1.76 million. Here’s how the attack worked and why single-key security models remain dangerous for cross-chain assets.
Uniswap v4 hooks explained: the PoolManager singleton, flash accounting, and what the Cork and Bunni exploits teach us about hook security. A PandaSecurity deep dive.