The Zero Root Everyone Trusted — JavaScript Bug Hunt
Modelled on the Nomad bridge exploit of 1 August 2022: a routine upgrade initialised the bridge's trusted root to 0x00.
- Language: JavaScript
- Layer: Backend
- Difficulty: Hard
- Concepts: Security, State
- Modelled on: Nomad · 2022
- Visible tests: a proven message under a confirmed root is processed; after a zero-root upgrade, unproven messages are rejected
- Reward: 50 XP for a complete fix
Briefing
Modelled on the Nomad bridge exploit of 1 August 2022: a routine upgrade initialised the bridge's trusted root to 0x00. A message that has never been proven also maps to the default root 0x00, so the check "is this message's root confirmed?" passed for any message. Once the first exploit transaction appeared, hundreds of copycats replayed it with their own addresses, and roughly $190 million was drained.
This project is a reconstruction. replica.js keeps confirmed roots, proven messages and processed messages; process looks up a message's root — defaulting to the zero root — and asks whether that root is acceptable.
Fix the replica so the zero root is never acceptable, however the contract was initialised.
Bug report
BUG-NOMAD · Priority: Critical · Reported by: incident response
- acceptableRoot(state, root, now) is true only when root is NOT the zero root, root has a confirmation time, and that time is <= now
- initialize(state, root) may be called with the zero root (the upgrade did exactly that) — it must not make any unproven message acceptable
- process(state, message, now) throws when the message was already processed, or when its proven root is missing or not acceptable; otherwise marks it processed and returns true
Observed: after the upgrade, process() accepted messages that were never proven.
Logs
[replica] initialize committedRoot=0x00
[replica] process msg=h:withdraw-100-to-0xbeef root=0x00 -> accepted
[replica] 300+ processed messages with no proof in the next hoursThe code as shipped
src/bridge/replica.js (editable)
var c = require("./constants");
exports.create = function () {
return { confirmAt: {}, messages: {}, processed: {} };
};
exports.initialize = function (state, committedRoot) {
state.confirmAt[committedRoot] = 1;
};
exports.prove = function (state, message, root) {
state.messages[c.hashMessage(message)] = root;
};
exports.acceptableRoot = function (state, root, now) {
var t = state.confirmAt[root];
if (!t) return false;
return t <= now;
};
exports.process = function (state, message, now) {
var h = c.hashMessage(message);
if (state.processed[h]) throw new Error("already processed");
var root = state.messages[h] || c.ZERO_ROOT;
if (!exports.acceptableRoot(state, root, now)) throw new Error("not proven");
state.processed[h] = true;
return true;
};
Read-only context: src/bridge/constants.js.
Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More JavaScript bug hunts.