sherlock-audit / 2024-02-optimism-2024-judging

4 stars 3 forks source link

Trust - Anyone can freeze future withdrawals and any L2->L1 messaging due to mismatch between the VM-viewed block number and the user supplied block number. #206

Closed sherlock-admin2 closed 5 months ago

sherlock-admin2 commented 5 months ago

Trust

high

Anyone can freeze future withdrawals and any L2->L1 messaging due to mismatch between the VM-viewed block number and the user supplied block number.

Summary

Anyone can freeze future withdrawals and any L2->L1 messaging due to mismatch between the VM-viewed block number and the user supplied block number.

Vulnerability Detail

When a game is created, the proposer passed the L2 block number. It is stored as the third Clone parameter:

function l2BlockNumber() public pure returns (uint256 l2BlockNumber_) {
    l2BlockNumber_ = _getArgUint256(0x40);
}

If a game is defended correctly, the anchor will use this function to commit a trusted OutputRoot:

anchors[gameType] = OutputRoot({ l2BlockNumber: game.l2BlockNumber(), root: Hash.wrap(game.rootClaim().raw()) });

This root can at that point be used by proofs. Note that if the l2BlockNumber() is not fresh, meaning we have a root for a later block number, the anchor state is not updated:

// No need to update anything if the anchor state is already newer.
if (game.l2BlockNumber() <= anchors[gameType].l2BlockNumber) {
    return;
}
// Must be a game that resolved in favor of the state.
if (game.status() != GameStatus.DEFENDER_WINS) {
    return;
}
// Actually update the anchor state.
anchors[gameType] = OutputRoot({ l2BlockNumber: game.l2BlockNumber(), root: Hash.wrap(game.rootClaim().raw()) });

Thus, if it was possible to prove an honest root for a dishonest l2BlockNumber, an attacker could choose a block number far in the future and make it the anchor. From that point, all true proofs will suffer from the early return and won't update the rootClaim. Thus, any new withdrawals will not be accepted (they are not part of the trusted root).

In fact, this is exactly the case, there is no limit to how large the passed blockNumber is. On Game creation, it just checks it is not smaller than the already trusted root:

// Do not allow the game to be initialized if the root claim corresponds to a block at or before the
// configured starting block number.
if (l2BlockNumber() <= rootBlockNumber) revert UnexpectedRootClaim(rootClaim());

Furthermore, this parameter is invisible in the eyes of the Fault Proof VM. In addLocalData(), we can see which inputs are sent to the VM for processing.

IPreimageOracle oracle = VM.oracle();
if (_ident == LocalPreimageKey.L1_HEAD_HASH) {
    // Load the L1 head hash
    oracle.loadLocalData(_ident, uuid.raw(), l1Head().raw(), 32, _partOffset);
} else if (_ident == LocalPreimageKey.STARTING_OUTPUT_ROOT) {
    // Load the starting proposal's output root.
    oracle.loadLocalData(_ident, uuid.raw(), starting.raw(), 32, _partOffset);
} else if (_ident == LocalPreimageKey.DISPUTED_OUTPUT_ROOT) {
    // Load the disputed proposal's output root
    oracle.loadLocalData(_ident, uuid.raw(), disputed.raw(), 32, _partOffset);
} else if (_ident == LocalPreimageKey.DISPUTED_L2_BLOCK_NUMBER) {
    // Load the disputed proposal's L2 block number as a big-endian uint64 in the
    // high order 8 bytes of the word.
    // We add the index at depth + 1 to the starting block number to get the disputed L2
    // block number.
    uint256 l2Number = startingOutputRoot.l2BlockNumber + disputedPos.traceIndex(SPLIT_DEPTH) + 1;
    oracle.loadLocalData(_ident, uuid.raw(), bytes32(l2Number << 0xC0), 8, _partOffset);
} else if (_ident == LocalPreimageKey.CHAIN_ID) {
    // Load the chain ID as a big-endian uint64 in the high order 8 bytes of the word.
    oracle.loadLocalData(_ident, uuid.raw(), bytes32(L2_CHAIN_ID << 0xC0), 8, _partOffset);
} else {
    revert InvalidLocalIdent();
}

The global block number is never passed. The VM is only concerned with the disputed block number, which it calculates from the anchor block number and the trace index of the disputed position. That's insufficient, because the traceIndex() calculates on an assumed block number according to the game depth. This has been also separately verified with the sponsor, to ensure that no extra details were missed.

The attack path is therefore:

Impact

Anyone can freeze future withdrawals and any L2->L1 messaging.

Code Snippet

Tool used

Manual Review

Recommendation

The l2BlockNumber passed should be santized to line with the block number calculated by the VM.

Duplicate of #90