Q1 2025 Web3 Security ReportAccess control failures led to $1.63 billion in losses
Discover report insights
  • Hacken
  • Blog
  • Discover
  • Real-World OP Fault-Proof Vulnerabilities & Fixes

Real-World OP Fault-Proof Vulnerabilities & Fixes

10 minutes

The Fault-Proof System in the OP Stack is under active development and subject to frequent modifications. It has also undergone multiple security audits, during which several vulnerabilities were identified.

In this blog post, we’ll discuss five real-world bugs that auditors and researchers uncovered in early 2024-25. While all identified issues were promptly addressed, and none were exploited in practice, they remain instructive examples of design pitfalls to avoid in future iterations.

FaultDisputeGame.sol

“Chess Clock” Logic Issue

The FaultDisputeGame contract implements a dual-timer “chess clock” mechanism to enforce timeouts in turn-based fault dispute games.

The protocol defines a fixed total duration for each game, denoted as GAME_DURATION. Each side—defenders and attackers—starts with half of this time allocated to their respective clocks. Every claim in the game is associated with a Clock object that tracks the remaining time available for a response. When a new claim is made, it inherits the clock of its grandparent claim in the DAG. Making a move resumes the clock for the disputed claim and pauses the clock for the new one.

However, this mechanism opened the door to a timing-based attack that could compromise the protocol’s safety and liveness by preventing honest participants from responding within their allotted time.

A malicious actor could exploit this mechanism as follows:

  1. The attacker submits a fraudulent root claim during game initialization (Node 0).
  2. An honest participant challenges the root claim by submitting a counter-claim (Node 1).
  3. The attacker waits until the final second of the challenge window and submits a new move attacking Node 1 (Node 2).
  4. Immediately after, the challenge window expires. Although Node 2 could still be countered, the attack on Node 0 would revert with ClockTimeExceeded.

As a result, Node 0 is resolved as UNCOUNTERED, since it has no uncountered children. The attacker wins the game, and the fraudulent root claim is accepted.

A similar strategy can be used in reverse – to defeat an honest root claim and block its acceptance. Both directions have serious consequences, but the acceptance of a malicious root claim is particularly severe and qualifies as a critical vulnerability.

The “chess clock” logic has since been revised to close this attack vector. Instead of relying solely on GAME_DURATION, two new parameters were introduced:

  • MAX_CLOCK_DURATION:
    It is an immutable parameter that sets the maximum amount of time that may accumulate on a team’s chess clock during a dispute game. This limit ensures that no team can indefinitely delay the game by accumulating excessive time on their clock.​

However, it’s important to note that the actual wall-clock time a team takes to make a move might exceed MAX_CLOCK_DURATION due to the application of CLOCK_EXTENSION. These extensions provide additional time credits under certain conditions, as explained below.

  • CLOCK_EXTENSION:
    It is an immutable parameter that adjusts a team’s clock when their remaining time is insufficient for the next move. The exact application depends on the context and type of move being made.​

Whenever a move() is made, the system calculates how much time should be granted to the grandchild claim. The logic follows these rules:

  1. Time Required for the Grandchild:
    • If the new claim is the execution trace bisection root, it is granted CLOCK_EXTENSION × 2 seconds to allow adequate time for trace generation and inspection.
    • If the next step involves a step() execution, the required time is CLOCK_EXTENSION plus challenge period, since step execution may require additional validation.
    • In all other cases, the time needed is simply CLOCK_EXTENSION.
  2. Clock Adjustment:
    If the remaining time on the opposing team’s clock is less than the required time for the grandchild claim, the clock is “rolled back” to ensure exactly the required amount of time is available. This prevents denial-of-response by always ensuring the defending team receives the minimum viable time window for a response.

The clock extension logic is implemented in the move function as follows:

// Clock extension is a mechanism that automatically extends the clock for a potential
// grandchild claim when there would be less than the clock extension time left if a player
// is forced to inherit another team's clock when countering a freeloader claim. Exact
// amount of clock extension time depends exactly where we are within the game.
uint64 actualExtension;
if (nextPositionDepth == MAX_GAME_DEPTH - 1) {
    // If the next position is `MAX_GAME_DEPTH - 1` then we're about to execute a step. Our
    // clock extension must therefore account for the LPP challenge period in addition to
    // the standard clock extension.
    actualExtension = CLOCK_EXTENSION.raw() + uint64(VM.oracle().challengePeriod());
} else if (nextPositionDepth == SPLIT_DEPTH - 1) {
    // If the next position is `SPLIT_DEPTH - 1` then we're about to begin an execution
    // trace bisection and we need to give extra time for the off-chain challenge agent to
    // be able to generate the initial instruction trace on the native FPVM.
    actualExtension = CLOCK_EXTENSION.raw() * 2;
} else {
    // Otherwise, we just use the standard clock extension.
    actualExtension = CLOCK_EXTENSION.raw();
}

// Check if we need to apply the clock extension.
if (nextDuration.raw() > MAX_CLOCK_DURATION.raw() - actualExtension) {
    nextDuration = Duration.wrap(MAX_CLOCK_DURATION.raw() - actualExtension);
}

This mechanism ensures that the opposing party is always granted enough time to respond, preventing denial-of-response attacks and censorship strategies.

It’s important to note that while these extensions can cause the actual time used by a team to exceed MAX_CLOCK_DURATION, the total extension time is inherently limited. This limitation is proportional to the depth of the game tree, which is bounded by the protocol. Specifically, the depth correlates logarithmically with the length of the execution trace, ensuring it remains reasonably small. This design guarantees that the dispute game will eventually reach finality.​

L1 Reorganization-Induced Inconsistencies

This issue stemmed from a lack of consideration for potential L1 reorgs. The logic of the dispute game relies on several crucial concepts, including claims and moves. Claims are submitted as an attack or defense move to assert a position on a parent claim. Whenever a new claim is made, it is pushed to the claimData vector. When another move—attack or defense—is made on this claim, the index of the claim in the claimData vector is used to identify the parent:

    function move(uint256 _challengeIndex, Claim _claim, bool _isAttack) public payable virtual {
        // INVARIANT: Moves cannot be made unless the game is currently in progress.
        if (status != GameStatus.IN_PROGRESS) revert GameNotInProgress();

        // Get the parent. If it does not exist, the call will revert with OOB.
        ClaimData memory parent = claimData[_challengeIndex];

The problem arises if a reorganization on L1 occurs between the time the transaction is sent and when it is executed. In that case, the challenge corresponding to _challengeIndex might have changed, meaning the disputed claim is not the one originally intended. This creates the following scenario:

  1. An invalid claim hash is submitted.
  2. Honest challengers submit an attack move to prove the claim wrong.
  3. A block reorg occurs.
  4. The invalid claim is replaced with a valid one.
  5. The challengers’ transactions are applied to the new (valid) claim.
  6. The challengers lose their bond.

This outcome results in the loss of collateral by honest challengers. It is particularly problematic due to the frequency of L1 reorgs and the game’s incentive model, where only the first challenger receives the bond reward. As a result, challengers are incentivized to act before block finalization. The financial impact of this issue depends on the claim’s depth in the game tree and can be significant.

This issue was resolved by requiring the disputed claim to be passed explicitly to move() and adding a validation check:

    function move(Claim _disputed, uint256 _challengeIndex, Claim _claim, bool _isAttack) public payable virtual {
        // INVARIANT: Moves cannot be made unless the game is currently in progress.
        if (status != GameStatus.IN_PROGRESS) revert GameNotInProgress();

        // Get the parent. If it does not exist, the call will revert with OOB.
        ClaimData memory parent = claimData[_challengeIndex];

        // INVARIANT: The claim at the _challengeIndex must be the disputed claim.
        if (Claim.unwrap(parent.claim) != Claim.unwrap(_disputed)) revert InvalidDisputedClaimIndex();

Loss of Bonds Due to Incorrect tx.origin Usage

In FaultDisputeGame.initialize(), the claimant’s address was originally assigned using tx.origin instead of msg.sender:

        claimData.push(
            ClaimData({
                parentIndex: type(uint32).max,
                counteredBy: address(0),
                claimant: tx.origin,
                bond: uint128(msg.value),
                claim: rootClaim(),
                position: ROOT_POSITION,
                clock: LibClock.wrap(Duration.wrap(0), Timestamp.wrap(uint64(block.timestamp)))
            })
        );

Since tx.origin returns the original external account that initiated the transaction (not the immediate caller), contract calls routed through intermediaries could lead to incorrect attribution. In the context of dispute resolution, this could result in bond collateral being forfeited by uninvolved parties, violating the intended incentive structure.

OptimismPortal2.sol

Unsafe Cast Could Lead to Incorrect Game Type Selection

Several issues stemmed from unsafe casting between integer types during dispute game creation. Specifically, the issue lies in converting a GameType value (which is a uint32) into a uint8 via inline assembly without enforcing proper bounds checks. This is implemented in the LibGameType.raw function:

/// @title LibGameType
/// @notice This library contains helper functions for working with the `GameType` type.
library LibGameType {
    /// @notice Get the value of a `GameType` type in the form of the underlying uint8.
    /// @param _gametype The `GameType` type to get the value of.
    /// @return gametype_ The value of the `GameType` type as a uint8 type.
    function raw(GameType _gametype) internal pure returns (uint8 gametype_) {
        assembly {  
            gametype_ := _gametype
        }
    }
}

Because the GameType enum is backed by a uint32, directly casting it to uint8 without bounds checking may truncate the higher-order bits and produce an incorrect value. This introduces a risk where an untrusted input with a high numeric value could be misinterpreted as a valid uint8-sized GameType, bypassing validation logic.

This function is used in OptimismPortal.sol to verify that a given dispute game matches the expected type. If a malformed GameType passes through unchecked, the system might incorrectly assume the dispute is of a valid type, potentially triggering incorrect dispute resolution logic or bypassing checks. 

op-challenger and op-program

Incorrect Blob Preimage Handling

This issue concerned EIP-4844 blob data handling in the off-chain components of the Optimism fault proof system. Specifically, op-challenger mishandled transactions involving blob data, leading to divergence between on-chain and off-chain execution in Cannon.

EIP-4844 introduces blobs—large data structures that are not directly readable by the EVM, but are committed to on-chain using the KZG polynomial commitment scheme. Each blob contains 4096 field elements representing evaluations of a polynomial over a finite field. While the blob data is stored off-chain, its KZG commitment is included in the transaction and can be verified using evaluation proofs at specific field points.

To correctly validate such transactions, op-challenger must:

  1. Retrieve the full blob data.
  2. Compute the value of the blob polynomial at a given evaluation point z.
  3. Construct the corresponding preimage key as a concatenation of the blob commitment and the evaluation point z.
  4. Push this (key, value) pair to the PreimageOracle, so the on-chain op-program can read and verify it using the same logic.

The issue was that op-challenger used an incorrect value for z—specifically, it used the integer index of a field element within the blob (e.g., 0, 1, 2…) instead of the correct evaluation point, which must be a field element in the finite field​ (the scalar field of the BLS12-381 curve). These evaluation points are typically drawn from a fixed domain used in KZG polynomial commitments, such as powers of a primitive root of unity. Instead of using one of these valid evaluation points for z, the index was mistakenly used when constructing the preimage key, leading to incorrect data being stored in the PreimageOracle.

As a result, the value stored in the PreimageOracle did not correspond to the correct evaluation point, causing the op-program to retrieve invalid data. This broke the consistency between off-chain and on-chain execution and would result in invalid fault proofs, even if the transaction was otherwise valid.

The issue was addressed by reworking the blob handling logic in op-challenger and op-program to ensure correctness of preimage keys involving blob data. Specifically, the fix replaced the use of a blob index with the correct evaluation point z, derived from a root of unity in the field​, when constructing preimage keys. Additional validations were added to catch incorrect evaluations.

Conclusion

Every vulnerability we dissected points to a larger truth: the OP fault-proof stack isn’t fragile, it’s evolving. Your job is to evolve with it.

Want to ship faster and sleep better? Hacken’s protocol-security team is one call away.

Subscribe
to our newsletter

Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.

Speaker Img

Table of contents

  • FaultDisputeGame.sol
  • OptimismPortal2.sol
  • op-challenger and op-program
  • Conclusion

Tell us about your project

Follow Us

Read next:

More related