Zilliqa X-Bridge Exit Scam: One Key, No Quorum, $1M Gone (Oct 2026)
TL;DR — On 2026-10-08 at 03:29:23 UTC, a single Ethereum block (26,144,997) contained four
MessageBus.dispatchtransactions that moved 343,801.12 USDT + 79,370.99 USDC + 5.16344432 WBTC + 10,614.13 XCAD (≈ $1.0M) out of the Zilliqa TokenManager. Each message carried exactly one 65-byte signature — andecrecoverreturns0x29CD9c75…67a409572, the same EOA that submitted the transactions. That address is the bridge's entire validator set:getValidators()has returned exactly one address on every historical block since April 2025, making the "supermajority" threshold $\lceil 2N/3 \rceil = 1$. The Zilliqa side emitted zero exit events matching these amounts; the USDT message alone claims 103.39% of the entire zUSDT supply. Nothing here was hacked. The bridge worked exactly as Zilliqa built it — one key, one signature, one exit. That is the scam.
The evidence, verified on-chain
| Drain block | 26,144,997 — 2026-10-08 03:29:23 UTC, 4 tx in one block, status ✅ |
| Bridge (Ethereum) | 0x2EE8e8D7…53BEDe5 — TokenManager proxy, deployed 2025-01-11 |
| MessageBus | 0x49EA2082…5C89269C3 — all four exits routed through it |
| ValidatorManager | 0x9F71303e…53E82915 — deployed by the bridge owner EOA 0xb9366e40…55dAad8C on 2025-04-17 |
| Sole validator / sender / signer | 0x29CD9c75…67a409572 — EOA, tx nonces 77–80 |
| Recipient | 0x72852Ca9…Aa0aE26d82 → unverified swap contract 0x225a38bc…c4d53f2DC17 → WETH → burn → native ETH |
| Zilliqa-side bridge | 0x4fa6148c…c878d26c — implementation byte-identical at every historical block |
1. The quorum that never existed
The bridge's ValidatorManager gates every cross-chain message behind a supermajority of validators:
$$\text{signatures required} = \left\lceil \frac{2N}{3} \right\rceil$$
Querying getValidators() at the drain block — and at every sampled block back to April 2025 — returns N = 1:
| Block (Ethereum) | Date | The complete validator set |
|---|---|---|
| 22,100,000 | Apr 2025 | 0x250572Ed…dAF58944f |
| 22,800,000–24,500,000 | mid-2025 | 0x054973Fb…E84715cD16 |
| 25,500,000 → drain | May–Oct 2026 | 0x29CD9c75…67a409572 |
$$\left\lceil \frac{2 \cdot 1}{3} \right\rceil = \lceil 0.667 \rceil = 1 \text{ signature}$$
A "2/3 supermajority of validators," implemented over a set of one, is a single-key multisig wearing a governance costume. The three addresses above are not independent validators — they are the bridge's operational relayers, rotated in sequence by the same owner EOA (0xb9366e40…) that deployed the ValidatorManager, and funded in a chain: 0x2505… → 0x0549… → 0x29CD….
2. The key signed its own exit messages
Every drain transaction is a call to MessageBus.dispatch(uint256,address,bytes,uint256,uint256,bytes[]). The verified ChainDispatcherUpgradeable source signs:
$$h = \text{EIP-191}\big(\text{abi.encode}(\underbrace{32769}{\text{Zilliqa}}, \underbrace{1}{\text{Ethereum}}, \text{target}, \text{call}, \text{gasLimit}, \text{nonce})\big)$$
Decoding all four calldatas: one signature each (65 bytes, v=28), and ecrecover(h, r, s, v) = 0x29CD9c… — identical to the transaction sender:
| tx nonce | tx | token | raw amount | ecrecover |
|---|---|---|---|---|
| 77 | 0xeeba4507…aa4c3fab | WBTC | 516,344,432 (8 dp) = 5.16344432 | 0x29CD9c… ✅ |
| 78 | 0x875cfc71…f35d9f0a14 | USDT | 343,801,121,784 (6 dp) = 343,801.121784 | 0x29CD9c… ✅ |
| 79 | 0x3e56b446…774e2d097a | USDC | 79,370,996,209 (6 dp) = 79,370.996209 | 0x29CD9c… ✅ |
| 80 | 0xe80b3ffc…9966175d | XCAD | 10,614.127579529282 (18 dp) | 0x29CD9c… ✅ |
Positive control: a routine relay transaction from 2026-10-06 (0x9d22f6e7…a4d43d0, nonce 76, msgNonce=310) recovers the same key — so the decoding is correct, and the same key that legitimately relayed user traffic on Oct 6 minted itself an exit on Oct 8.
3. The messages were minted 32 seconds before execution
The message nonce field is a unix timestamp. All four:
| msgNonce | UTC | Δ to block (03:29:23) |
|---|---|---|
| 1,791,430,128 | 03:28:48 | 35 s |
| 1,791,430,129 | 03:28:49 | 34 s |
| 1,791,430,130 | 03:28:50 | 33 s |
| 1,791,430,131 | 03:28:51 | 32 s |
Four consecutive seconds. Whoever held 0x29CD… composed, signed, and executed the exit in under a minute — with no deposit preceding it on the source chain.
4. The Zilliqa side never said goodbye
A legitimate bridge-out must emit MessageSent (0x989eaa91…) on the Zilliqa contract 0x4fa6…d26c, accompanied by a lock/burn of the mirrored asset. A full eth_getLogs scan of the Zilliqa EVM for Oct 3–8 (epochs 37,305,000–37,750,000) finds exactly two bridge-out events in the final 48 hours: two 1-zUSDT test transfers from a day-old wallet (0xc6657cab…, 0x496078f8…, Oct 7, 15:20 and 15:38 UTC).
Against the drain's USDT message (zUSDT: 0x2274…dc37d75f9):
$$\frac{\text{claimed exit}}{\text{entire zUSDT supply}} = \frac{343{,}801.121784}{332{,}540.914636} = 1.0339 = \textbf{103.39%}$$
The single USDT message claims more than every zUSDT that has ever existed. No burn, no lock, no event — the claim is unsatisfiable by design, and the gateway on Ethereum had no mechanism to notice, because it never checks the source chain. It only checks that the one key signed.
5. The exit-scam case
Every classic exit-scam marker is present. Each one is checkable by hash:
| Pattern | On-chain evidence |
|---|---|
| Self-authorized withdrawal | The bridge's only "validator" (N=1, quorum=1) — 0x29CD9c…67a409572 — signed all four messages and submitted them itself: signer == sender == sole validator (§2) |
| Withdrawals backed by nothing | Zero lock/burn events on the Zilliqa bridge for these amounts; the USDT message alone claims 103.39% of every zUSDT ever minted (§4) |
| Kill the exits, keep the entrances | After the drain the bridge kept accepting deposits (0xAAF33a7e…Daf600c49D: 11,992 USDC + 10,053 USDT on Oct 22) while a user's Oct-18 withdrawal (0xd3e7f7da…3301e9) was never paid out — collecting funds it could no longer redeem |
| Rotate the drained key, stay silent | getValidators() at the drain block returns 0x29CD…; today the same call on 0x9F71303e…53E82915 returns 0x092A47C0…AB35A1A3A8. The owner silently swapped the drained key for a fresh one — no freeze, no disclosure |
| One ops cluster, end to end | Deployer 0xD89421Cd…Fe11a23E → owner 0xb9366e40…55dAad8C → relayer lineage 0x2505… → 0x0549… → 0x29CD… — a single cluster, gas-funded from Binance-14 0x21a31Ee1…28285549 and Binance-2 0x28C6c062…890d9593 since 2024-12-18; Binance-2 topped the deployer up with 0.5799 ETH 2 h 38 m after the drain |
| No theft report | As of publication: no post-mortem, no key-compromise disclosure, no incident timeline from Zilliqa for the bridge that operated under its own infrastructure |
The one alternative reading — an outsider stole the ops key (Zilliqa's infrastructure was compromised in July 2026) — is possible, but the chain offers it zero support over the simpler one: the operators used the one-key path they themselves configured in April 2025 and took what was left. And the burden of proof is not on depositors. If this was theft, Zilliqa can settle it in one page: who controlled 0x29CD… at 03:28 UTC on Oct 8, when the key was reported stolen, and why did the ops cluster keep drawing gas from Binance after the "theft"? Until that page exists, the on-chain record reads exactly like what it is: an exit executed by the bridge's own infrastructure, through a door the bridge's own owner built for one key.
6. The proof script
Recovers the validator signature on each drain transaction and checks it against the attacker. Runs against public archive RPCs (pip install pycryptodome):
#!/usr/bin/env python3
"""
POC — Zilliqa X-Bridge drain, 2026-10-08 (Ethereum block 26144997).
Single fact, proven three ways for each drain tx:
ecrecover(the one signature) == getValidators()[0] == tx sender (the attacker).
The bridge "2/3 supermajority" over a validator set of one == one key == the attacker.
Deps: pip install pycryptodome
"""
import json, urllib.request
from Crypto.Hash import keccak
# public archive-capable Ethereum RPCs (no keys, no rate for a handful of calls)
RPCS = ["https://eth.blockscout.com/api/eth-rpc", # Blockscout — archive
"https://eth.drpc.org"] # DRPC — archive
def jrpc(method, params):
last = None
for url in RPCS:
try:
req = urllib.request.Request(url,
data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method,
"params": params}).encode(),
headers={"Content-Type": "application/json",
"User-Agent": "xbridge-poc/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
out = json.load(r)
if "result" in out:
return out["result"]
last = out.get("error")
except Exception as e:
last = str(e)
raise RuntimeError(f"all RPCs failed: {last}")
VAL_MGR = "0x9F71303eE8E1aA74DEE0bC70a009420953E82915" # ValidatorManager proxy
DRAIN_BLOCK = 26144997 # 2026-10-08 03:29:23 UTC
DRAINS = [ # the four exits, all in block 26144997
"0xeeba45076aebad744ebd5d72a9655da5b2848f36f2760110f5101566aa4c3fab", # WBTC
"0x875cfc71bbeaef21ef49e57ffcf362811eaf0bb5aac3c8accc8855f35d9f0a14", # USDT
"0x3e56b4468627d68008a3ea71ccf8649f0694ccfdf520b0855db9de774e2d097a", # USDC
"0xe80b3ffc8db2ec777e10a5f6c9d6cd81536fb48c2c098ade028b67d16099675d", # XCAD
]
K = lambda b: keccak.new(digest_bits=256, data=b).digest()
sel = lambda s: K(s.encode())[:4].hex()
u = lambda x: x.to_bytes(32, "big")
# --- secp256k1 ecrecover, no external libs ------------------------------------
_P, _N = 2**256 - 2**32 - 977, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798,
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
def _add(P, Q):
if P is None: return Q
if Q is None: return P
if P[0] == Q[0] and (P[1] + Q[1]) % _P == 0: return None
l = (3*P[0]*P[0]) * pow(2*P[1], -1, _P) % _P if P == Q else \
(Q[1]-P[1]) * pow(Q[0]-P[0], -1, _P) % _P
x = (l*l - P[0] - Q[0]) % _P
return x, (l*(P[0]-x) - P[1]) % _P
def _mul(k, P):
R = None
while k:
if k & 1: R = _add(R, P)
P, k = _add(P, P), k >> 1
return R
def ecrecover(h, r, s, v):
y = pow((r*r*r + 7) % _P, (_P+1)//4, _P)
if y % 2 != v % 2: y = _P - y
z, ri = int.from_bytes(h, "big"), pow(r, -1, _N)
X, Y = _add(_mul((-z*ri) % _N, _G), _mul((s*ri) % _N, (r, y)))
return K(X.to_bytes(32, "big") + Y.to_bytes(32, "big"))[-20:].hex()
# --- the validator set AT THE DRAIN BLOCK (it was rotated afterwards) ----------
r = jrpc("eth_call", [{"to": VAL_MGR, "data": "0x" + sel("getValidators()")},
hex(DRAIN_BLOCK)])
b = bytes.fromhex(r[2:]); off = int.from_bytes(b[:32], "big")
n = int.from_bytes(b[off:off+32], "big")
validators = [b[off+32+i*32+12:off+32+(i+1)*32].hex() for i in range(n)]
validator = validators[0]
print(f"validator set @ block {DRAIN_BLOCK}: {validators} | "
f"quorum = ceil(2*{n}/3) = {-(-2*n//3)} signature(s)")
assert n == 1, "not the single-validator bridge"
# --- recover each drain signature ----------------------------------------------
ATTACKER = None
for tx in DRAINS:
t = jrpc("eth_getTransactionByHash", [tx])
body = bytes.fromhex(t["input"][2:])[4:]
W = lambda i: int.from_bytes(body[i*32:(i+1)*32], "big")
src, target, gas, nonce = W(0), body[32:64], W(3), W(4)
ln = int.from_bytes(body[W(2):W(2)+32], "big")
call = body[W(2)+32:W(2)+32+ln]
off_s = W(5)
cnt = int.from_bytes(body[off_s:off_s+32], "big")
eo = int.from_bytes(body[off_s+32:off_s+64], "big")
el = int.from_bytes(body[off_s+32+eo:off_s+32+eo+32], "big")
sig = body[off_s+32+eo+32:off_s+32+eo+32+el]
# signed: EIP-191 over abi.encode(srcChain, chainId, target, call, gasLimit, nonce)
m = (u(src) + u(1) + target + u(192) + u(gas) + u(nonce)
+ u(len(call)) + call + b"\x00" * (-len(call) % 32))
h = K(b"\x19Ethereum Signed Message:\n" + str(len(m)).encode() + m)
v = sig[64] - 27 if sig[64] >= 27 else sig[64]
signer = ecrecover(h, int.from_bytes(sig[:32], "big"),
int.from_bytes(sig[32:64], "big"), v)
sender = t["from"][2:].lower()
ATTACKER = ATTACKER or sender
ok = signer == sender == validator
print(f"{tx[:14]}… signatures={cnt} ecrecover=0x{signer} sender=0x{sender}"
f" validator==signer==sender: {ok}")
assert cnt == 1 and ok
print(f"\nVALIDATOR 0x{validator} == ATTACKER 0x{ATTACKER} "
"— the bridge's only validator signed its own drain.")