Bridge failures: the Ronin and Wormhole exploits (2022)#

Most bridges do not verify the other chain; they trust a committee to sign withdrawals, and are exactly as safe as its keys and the code that checks its signatures. In 2022 both failed. Ronin released funds on 5 of 9 validator signatures; one company ran four validators and still held permission to sign for a fifth, so a single breach yielded 173,600 ether and 25.5 million USDC. Wormhole’s Solana program trusted an account, supplied by the caller, saying that the guardians’ signatures had been checked; a forged account minted 120,000 wrapped ether from nothing.

For a threshold of t of n keys held by independent parties, each compromised with probability p, the bridge falls with probability

\[\sum_{k=t}^{n} \binom{n}{k} p^k (1-p)^{n-k},\]

but the keys were not independent: four of Ronin’s five sat with one party.

from math import comb

import matplotlib.pyplot as plt

import blockchainkit as bk
from blockchainkit.contracts import World
from blockchainkit.crypto import public_key

Ronin: five keys of nine#

world = World()
world.fund("users", 1_000)
validators = [public_key(k) for k in range(1, 10)]
ronin = world.deploy("sky mavis", bk.channels.ValidatorBridge, validators, 5, name="Ronin")
world.transact("users", ronin, "deposit", value=1_000)

stolen_with = {}
for keys in range(1, 10):
    approvals = [
        bk.channels.sign_withdrawal(k, ronin, "attacker", 1_000, keys) for k in range(1, keys + 1)
    ]
    receipt = world.transact("attacker", ronin, "withdraw", "attacker", 1_000, keys, approvals)
    stolen_with[keys] = receipt.success
    if receipt.success:
        break
print("first successful theft with", max(stolen_with), "keys")
assert max(stolen_with) == 5 and world.balance("attacker") == 1_000
first successful theft with 5 keys

Wormhole: a verifier chosen by the caller#

drained = {}
for fixed in (False, True):
    world = World()
    world.fund("users", 1_000)
    guardians = [public_key(k) for k in range(1, 20)]
    verifier = world.deploy("wormhole", bk.channels.GuardianVerifier, guardians, 13)
    bridge = world.deploy("wormhole", bk.channels.WormholeBridge, verifier, fixed)
    world.transact("users", bridge, "deposit", value=1_000)
    forged = world.deploy("attacker", bk.channels.ForgedVerifier)
    world.transact("attacker", bridge, "complete_transfer", "attacker", 1_000, 0, forged)
    world.transact("attacker", bridge, "redeem", 1_000)
    drained[fixed] = world.balance("attacker")
print("drained before the fix:", drained[False], " after:", drained[True])
assert drained == {False: 1_000, True: 0}

fig, (left, right) = plt.subplots(1, 2, figsize=(11, 4))
p = [i / 100 for i in range(0, 101, 2)]
independent = [sum(comb(9, k) * q**k * (1 - q) ** (9 - k) for k in range(5, 10)) for q in p]
one_party = p  # Five keys behind one company's systems fall together.
left.plot(p, independent, color="#16a34a", label="9 independent validators")
left.plot(p, one_party, color="#dc2626", label="5 keys at one company")
left.set(xlabel="chance a party is compromised", ylabel="chance the bridge falls")
left.set_title("Ronin: a 5-of-9 threshold that was really 1-of-1")
left.legend()
right.bar(["vulnerable", "fixed"], [drained[False], drained[True]], color=["#dc2626", "#16a34a"])
right.set(ylabel="ether drained of 1,000 locked", title="Wormhole: forged verification")
fig.tight_layout()

plt.show()
Ronin: a 5-of-9 threshold that was really 1-of-1, Wormhole: forged verification
drained before the fix: 1000  after: 0

Exercise#

Change the Ronin bridge so that each organization’s keys count once, whatever number of validators it runs. How many organizations must the attacker now compromise, and what does that suggest about counting signatures rather than signers?

Total running time of the script: (0 minutes 0.275 seconds)

Gallery generated by Sphinx-Gallery