Summary
A newly created chain that has been migrated to the gateway will be lost if tries to migrate back to L1
Vulnerability Details
When a new chain is created, the priorityTree
is initialized:
contract DiamondInit is ZKChainBase, IDiamondInit {
using PriorityQueue for PriorityQueue.Queue;
using PriorityTree for PriorityTree.Tree;
constructor() reentrancyGuardInitializer {}
function initialize(InitializeData calldata _initializeData) external reentrancyGuardInitializer returns (bytes32) {
...
s.priorityTree.setup(s.priorityQueue.getTotalPriorityTxs());
...
return Diamond.DIAMOND_INIT_SUCCESS_RETURN_VALUE;
}
}
library PriorityTree {
using PriorityTree for Tree;
using DynamicIncrementalMerkle for DynamicIncrementalMerkle.Bytes32PushTree;
...
function setup(Tree storage _tree, uint256 _startIndex) internal {
_tree.tree.setup(ZERO_LEAF_HASH);
_tree.startIndex = _startIndex;
}
...
}
library DynamicIncrementalMerkle {
function setup(Bytes32PushTree storage self, bytes32 zero) internal returns (bytes32 initialRoot) {
self._nextLeafIndex = 0;
self._zeros.push(zero);
self._sides.push(bytes32(0));
return bytes32(0);
}
}
The setup simply creates the first leaf as a bytes32(0) and initializes the startIndex
to the priority queue transactions.
At this point, no hashes are registered in historicalRoots
mapping.
Now let's follow the flow to migrate the chain from L1 to the GateWay:
function forwardedBridgeBurn(
address _settlementLayer,
address _originalCaller,
bytes calldata _data
) external payable override onlyBridgehub returns (bytes memory chainBridgeMintData) {
...
s.settlementLayer = _settlementLayer;
chainBridgeMintData = abi.encode(prepareChainCommitment());
}
function prepareChainCommitment() public view returns (ZKChainCommitment memory commitment) {
...
commitment.priorityTree = s.priorityTree.getCommitment();
...
}
The chainBridgeMintData
will contain all the data from the priority tree.
function getCommitment(Tree storage _tree) internal view returns (PriorityTreeCommitment memory commitment) {
commitment.nextLeafIndex = _tree.tree._nextLeafIndex;
commitment.startIndex = _tree.startIndex;
commitment.unprocessedIndex = _tree.unprocessedIndex;
commitment.sides = _tree.tree._sides;
}
The sides
will simply contain the bytes32(0) leaf from the previous setup.
function forwardedBridgeMint(
bytes calldata _data,
bool _contractAlreadyDeployed
) external payable override onlyBridgehub {
...
if (block.chainid == L1_CHAIN_ID) {
if (
!s.priorityTree.isHistoricalRoot(
_commitment.priorityTree.sides[_commitment.priorityTree.sides.length - 1]
)
) {
revert NotHistoricalRoot();
}
if (!_contractAlreadyDeployed) {
revert ContractNotDeployed();
}
if (s.settlementLayer == address(0)) {
revert NotMigrated();
}
s.priorityTree.l1Reinit(_commitment.priorityTree);
} else if (_contractAlreadyDeployed) {
if (s.settlementLayer == address(0)) {
revert NotMigrated();
}
s.priorityTree.checkGWReinit(_commitment.priorityTree);
s.priorityTree.initFromCommitment(_commitment.priorityTree);
} else {
s.priorityTree.initFromCommitment(_commitment.priorityTree);
}
...
}
In this if branch will enter the last one because the contract has been newly deployed. Hence, will call priorityTree::initFromCommitment
:
function initFromCommitment(Tree storage _tree, PriorityTreeCommitment memory _commitment) internal {
uint256 height = _commitment.sides.length;
if (height == 0) {
revert InvalidCommitment();
}
_tree.startIndex = _commitment.startIndex;
_tree.unprocessedIndex = _commitment.unprocessedIndex;
_tree.tree._nextLeafIndex = _commitment.nextLeafIndex;
_tree.tree._sides = _commitment.sides;
bytes32 zero = ZERO_LEAF_HASH;
_tree.tree._zeros = new bytes32[](height);
for (uint256 i; i < height; ++i) {
_tree.tree._zeros[i] = zero;
zero = Merkle.efficientHash(zero, zero);
}
_tree.historicalRoots[_tree.tree.root()] = true;
}
This will just set the only side to the bytes32(0)
leaf.
At this point, if the chain decides to migrate back to L1 it will be lost. Let's see why:
function forwardedBridgeBurn(
address _settlementLayer,
address _originalCaller,
bytes calldata _data
) external payable override onlyBridgehub returns (bytes memory chainBridgeMintData) {
...
s.settlementLayer = _settlementLayer;
chainBridgeMintData = abi.encode(prepareChainCommitment());
}
function prepareChainCommitment() public view returns (ZKChainCommitment memory commitment) {
...
commitment.priorityTree = s.priorityTree.getCommitment();
...
}
The chainBridgeMintData
will contain all the data from the priority tree.
function getCommitment(Tree storage _tree) internal view returns (PriorityTreeCommitment memory commitment) {
commitment.nextLeafIndex = _tree.tree._nextLeafIndex;
commitment.startIndex = _tree.startIndex;
commitment.unprocessedIndex = _tree.unprocessedIndex;
commitment.sides = _tree.tree._sides;
}
The commitment data will be exactly the same as the first migration. However, when this will arrive in L1 this will happen:
function forwardedBridgeMint(
bytes calldata _data,
bool _contractAlreadyDeployed
) external payable override onlyBridgehub {
...
if (block.chainid == L1_CHAIN_ID) {
if (
!s.priorityTree.isHistoricalRoot(
_commitment.priorityTree.sides[_commitment.priorityTree.sides.length - 1]
)
) {
revert NotHistoricalRoot();
}
if (!_contractAlreadyDeployed) {
revert ContractNotDeployed();
}
if (s.settlementLayer == address(0)) {
revert NotMigrated();
}
s.priorityTree.l1Reinit(_commitment.priorityTree);
} else if (_contractAlreadyDeployed) {
if (s.settlementLayer == address(0)) {
revert NotMigrated();
}
s.priorityTree.checkGWReinit(_commitment.priorityTree);
s.priorityTree.initFromCommitment(_commitment.priorityTree);
} else {
s.priorityTree.initFromCommitment(_commitment.priorityTree);
}
...
}
Now, it will enter the first if branch because it will be executed on L1. The first check is if the only side
from the commitment (bytes32(0)) is an historical root from the priority tree. As we can see, no historical root is registered on the setup, hence this check will fail.
Impact
High, the chain will be lost as stated in the docs:
Migrations from GW to L1 do not have any chain recovery mechanism, i.e. if the step (3) from the above fails for some reason (e.g. a new protocol version id is available on the CTM), then the chain is basically lost.
Tools Used
Manual review
Recommendations
Register the initial bytes32(0) leaf as historical root upon setup:
function setup(Tree storage _tree, uint256 _startIndex) internal {
_tree.tree.setup(ZERO_LEAF_HASH);
_tree.startIndex = _startIndex;
++ _tree.historicalRoots[bytes32(0)] = true;
}