Era

ZKsync
FoundryLayer 2
500,000 USDC
View results
Submission Details
Severity: medium
Valid

A newly created chain that has been migrated to the gateway will be lost if tries to migrate back to L1

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;
/// @dev Initialize the implementation to prevent any possibility of a Parity hack.
constructor() reentrancyGuardInitializer {}
/// @notice ZK chain diamond contract initialization
/// @return Magic 32 bytes, which indicates that the contract logic is expected to be used as a diamond proxy
/// initializer
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;
...
/// @notice Set up the tree
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:

  • The forwardedBridgeBurn function will be called on the Diamond of the newly created chain on L1:

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.

  • On the gateway it will first deploy the diamond contract for the new chain and the then call the forwardedBridgeMint function on it:

function forwardedBridgeMint(
bytes calldata _data,
bool _contractAlreadyDeployed
) external payable override onlyBridgehub {
...
if (block.chainid == L1_CHAIN_ID) {
// L1 PTree contains all L1->L2 transactions.
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; // Height, including the root node.
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:

  • The forwardedBridgeBurn function will be called on the Diamond chain on GW:

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:

  • On L1 it will call the forwardedBridgeMint function on the diamond contract from the chain:

function forwardedBridgeMint(
bytes calldata _data,
bool _contractAlreadyDeployed
) external payable override onlyBridgehub {
...
if (block.chainid == L1_CHAIN_ID) {
// L1 PTree contains all L1->L2 transactions.
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;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

A newly created chain that has been migrated to the gateway will be lost if tries to migrate back to L1

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.