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
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:
Propose a valid root for the blockNumber which the VM sees (anchor number + 1 + 2^SPLIT_DEPTH)
Attach a max value for l2BlockNumber
After the game is won, resolve it
All future games are guaranteed to not update the anchor root.
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.
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:
If a game is defended correctly, the anchor will use this function to commit a trusted OutputRoot:
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: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: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.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:
l2BlockNumber
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