Note
Go to the end to download the full example code or to run this example in your browser via JupyterLite.
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
but the keys were not independent: four of Ronin’s five sat with one party.
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()

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)