Platform: Code4rena
Start Date: 07/03/2024
Pot Size: $250,000 USDC
Total HM: 5
Participants: 24
Period: 21 days
Judge: 0xsomeone
Total Solo HM: 3
Id: 347
League: ETH
Rank: 2/24
Findings: 4
Award: $68,441.76
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: 0x11singh99
Also found by: Bauchibred, Dup1337, Topmark, XDZIBECX, bctester, bin2chen, erebus, forgebyola, oakcobalt, rvierdiiev, yashar, zhanmingjing
565.1582 USDC - $565.16
Judge has assessed an item in Issue #60 as 2 risk. The relevant finding follows:
Instances(1)
A state transition (ST)'s freezeDiamond()
or unfreezeDiamond
is intended to be called by either StateTransitionManger or the admin of the state transition. However, there is an error in unfreezeChain
on StateTransitionManager.sol that will prevent StateTransitionManger from unfreezing a chain when needed.
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function unfreezeChain(uint256 _chainId) external onlyOwner { //@audit this should be `unfreezeDiamond()`. This error prevents StateTransitionManager from freezing a chain(ST) when needed. |> IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); }
A state transition(ST) can be malicious. The hyperchain implementation is based on the assumption that StateTransitionManager(STM) is the main point of trust. This error prevents STM to exercise key control over ST to unfreeze an ST. If the ST admin is malicious and intends to freeze the chain for longer, STM won't be able to exercise intended control of the malicious ST.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol //@audit Due to error in StateTransitionManger, only ST admin can unfreeze the chain |> function unfreezeDiamond() external onlyAdminOrStateTransitionManager { Diamond.DiamondStorage storage diamondStorage = Diamond .getDiamondStorage(); require(diamondStorage.isFrozen, "a7"); // diamond proxy is not frozen diamondStorage.isFrozen = false; emit Unfreeze(); }
Recommendations:
In unfreezeChain
, change to call unfreezeDiamond()
.
#0 - c4-judge
2024-05-03T19:03:21Z
alex-ppg marked the issue as duplicate of #97
#1 - c4-judge
2024-05-03T19:03:25Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: oakcobalt
33817.8964 USDC - $33,817.90
https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol#L103 https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol#L123 https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol#L226
State transition manager(STM) will be unable to force upgrade a deployed ST against intended design for 'urgent high risk situation'.
This invalidates the designed safeguard mechanism of an STM force upgrade an ST.
According to doc, in case of urgent high risk situation, STM might force upgrade the contract
(ST).
However, the above safeguard of force upgrading ST is not possible due to StateTransitionManger.sol's incomplete implementation.
(1) A deployed chain(ST) can choose to perform a mandatory upgrade at its own convenience. It's also possible for an ST to postpone an upgrade set by the state transition manager. An ST will perform an upgrade through upgradeChainFromVersion()
on the Admin facet.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function upgradeChainFromVersion( uint256 _oldProtocolVersion, Diamond.DiamondCutData calldata _diamondCut |> ) external onlyAdminOrStateTransitionManager { ...
(https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol#L103)
Note that although upgradeChainFromVersion()
is access-controlled by both chain admin and StateTransitionManager contract. StateTransitionManager will not be able to call it, because no methods or flows on StateTransitionManger.sol will invoke the call.
(2) Another method on Admin facet for upgrade is executeUpgrade()
which is access-controlled by only StateTransitionManager contract. However, executeUpgrade()
can only be invoked inside _setChainIdUpgrade()
. And _setChainIdUpgrade()
can only be invoked inside createNewChain()
. This means, after a chain(ST)'s genesis, executeUpgrade()
cannot be invoked by stateTransitionManger again to perform further upgrades.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function executeUpgrade( Diamond.DiamondCutData calldata _diamondCut |> ) external onlyStateTransitionManager { Diamond.diamondCut(_diamondCut); emit ExecuteUpgrade(_diamondCut); }
function _setChainIdUpgrade( uint256 _chainId, address _chainContract ) internal { ... //@audit executeUpgrade of an ST will only be called once at chain deployment, because _setChainIdUpgrade() is only invoked when creating a new chain. |> IAdmin(_chainContract).executeUpgrade(cutData); ...
As a result of (1)&(2), StateChainManager cannot force upgrade an ST in case of urgent high risk situation
. This invalidates the safeguard of force upgrade as stated by doc.
Manual
In StateTransitionManager.sol, add a method that can call executeUpgrade()
or upgradeChainFromVersion()
on a local chain.
Other
#0 - saxenism
2024-04-04T11:31:23Z
Agree with the finding. Thank you :)
#1 - c4-sponsor
2024-04-04T11:31:27Z
saxenism (sponsor) confirmed
#2 - alex-ppg
2024-04-29T14:09:54Z
The Warden has demonstrated how, in an urgent high-risk situation, a forced upgrade cannot be performed in contradiction with the project's documentation.
Based on the fact that the vulnerability is correct and would surface in a low-likelihood scenario, a medium-risk assessment is appropriate.
#3 - c4-judge
2024-04-29T14:09:57Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2024-04-29T14:10:00Z
alex-ppg marked the issue as selected for report
🌟 Selected for report: oakcobalt
33817.8964 USDC - $33,817.90
https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol#L421-L427 https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol#L375
A user might be able to double withdraw during migration in some edge conditions: (1) if their withdrawal tx is included in a batch number the same or after eraFirstPostUpgradeBatch
; (2)And if the user finalizeWithdrawal
on the old L1ERC20Bridge.sol before L1ERC20Bridge.sol is upgraded.
The current migration process takes place in steps, which might allow edge conditions to occur. StepA: deploy new contracts(including L1SharedBridge); StepB:Era upgrade and L2 system contracts upgrade(at this point old L1ERC20Bridge still works); StepC: Upgrade L2 bridge and L1ERC20Brdige.sol. Then migrate funds to the new L1sharedBridge.
Since L1ERC20Bridge.sol is upgraded at the end. An edge condition can occur between StepA and StepB, where a user can still withdraw ERC20 tokens on the old L1ERC20Brdige.sol.
Scenario: If a user finalizeWithdrawal
ERC20 tokens on the old L1ERC20Bridge.sol first, they can withdraw again on L1SharedBridge.sol as long as the _l2batchNumber
of the withdraw tx equals or is greater than eraFirstPostUpgradeBatch
. In other words, _isEraLegacyWithdrawal
check can be invalidated.
eraFirstPostUpgradeBatch
;eraFirstPostUpgradeBatch
number;finializeWithdrawal
on the old L1ERC20Brdige.sol.UserA should receive funds because funds haven't been migrated yet;finializeWithdrawal
on L1SharedBridge.sol. This time, _isEraLegacyWithdrawal(_chainId, _l2BatchNumber)
check will be bypassed,because user's _l2BatchNumber
== eraFirstPostUpgradeBatch
; UserA can receive the withdrawal funds a second time;//code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol function finalizeWithdrawal( uint256 _chainId, uint256 _l2BatchNumber, uint256 _l2MessageIndex, uint16 _l2TxNumberInBatch, bytes calldata _message, bytes32[] calldata _merkleProof ) external override { //@audit-info when _l2BatchNumber>= eraFirstPostUpgradeBatch, `_isEraLegacyWithdrawal()` return false, checking on withdrawal status on legacyERC20bridge will be bypassed. |> if (_isEraLegacyWithdrawal(_chainId, _l2BatchNumber)) { require( !legacyBridge.isWithdrawalFinalized( _l2BatchNumber, _l2MessageIndex ), "ShB: legacy withdrawal" ); } _finalizeWithdrawal( _chainId, _l2BatchNumber, _l2MessageIndex, _l2TxNumberInBatch, _message, _merkleProof ); }
//code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol function _isEraLegacyWithdrawal( uint256 _chainId, uint256 _l2BatchNumber ) internal view returns (bool) { return (_chainId == ERA_CHAIN_ID) && |> (_l2BatchNumber < eraFirstPostUpgradeBatch); //@audit-info note:when _l2BatchNumber>= eraFirstPostUpgradeBatch, `_isEraLegacyWithdrawal()` return false. }
(https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol#L375)
As seen, checking on legacyERC20bridge withdrawal status will only be performed when _isEraLegacyWithdrawal
returns true. But due to _l2BatchNumber
of a withdrawal tx during the upgrade can equal or be greater than a predefined eraFirstPostUpgradeBatch
. _isEraLegacyWithdrawal
check can be based on false assumptions on _l2BatchNumber
and eraFirstPostUpgradeBatch
, allowing double withdrawal on edge cases.
Manual
Consider adding a grace period during and following an upgrade, during which time legacyWithdrawal status will always be checked.
Other
#0 - c4-sponsor
2024-04-18T15:29:55Z
razzorsec marked the issue as disagree with severity
#1 - c4-sponsor
2024-04-18T15:29:58Z
razzorsec (sponsor) confirmed
#2 - razzorsec
2024-04-18T15:30:23Z
Relistically invalid, since we will not finalize the upgrade batch. But an interesting thing to remember, so we would consider this as Low, as it is mostly about managing the server.
#3 - alex-ppg
2024-04-29T14:04:46Z
The Warden specifies that under certain circumstances, it is possible to perform a duplicate withdrawal when the system is in the midst of an upgrade.
The Sponsor specifies that this issue is realistically invalid, however, the Sponsor's statement relies on being aware of the flaw and acting actively against it (i.e. not finalizing the upgrade batch) which I do not consider adequate justification to lower the severity of this exhibit.
I believe a medium-risk grade is appropriate based on the fact that it illustrates a code flaw that will arise under operations permitted by the smart contracts which we cannot presume the Sponsor was aware of.
#4 - c4-judge
2024-04-29T14:04:54Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-04-29T14:04:57Z
alex-ppg marked the issue as selected for report
🌟 Selected for report: Bauchibred
Also found by: 0x11singh99, ChaseTheLight, Dup1337, hihen, lsaudit, oakcobalt
240.8022 USDC - $240.80
_amount
input argument is redundantInstances(1)
In L1ERC20Bridge::tranferTokenToSharedBridge
. _amount
input argument is unnecessary because the function is intended to always transfer the entire balance of a token.
The require statement require(amount == _amount, "Incorrect amount");
can be removed as well.
//code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol function tranferTokenToSharedBridge( address _token, |> uint256 _amount //@audit unnecessary code ) external { require(msg.sender == address(sharedBridge), "Not shared bridge"); uint256 amount = IERC20(_token).balanceOf(address(this)); require(amount == _amount, "Incorrect amount"); //@audit unnecessary code IERC20(_token).safeTransfer(address(sharedBridge), amount); }
Recommendations:
Consider remove _amount
argument and require amount == _amount
to simply transfer the balance of _token
if balance is non-zero.
eraFirstPostUpgradeBatch
Instances(1) There is an edge case where a user might not be able to claim a failed deposit.
Scenario: The user has deposited an ERC20 token through the old L1ERC20Bridge.sol right before or during the migration process.
The l2batchNumber of the user deposit might equal (or exceed) eraFirstPostUpgradeBatch
. Note eraFirstPostUpgradeBatch is a pre-determined batch number to be used to initialize L1ShareBridge.sol. Because (1) eraFirstPostUpgradeBatch
is pre-determined, (2)and the entire migration process might not finish within an atomic transaction (due to various contracts deployment, ERA-upgrade tx, and roughly 300 different erc20 funds migration process,etc), a deposit on the legacy L1ERC20Bridge.sol may be included in a batch number >= eraFirstPostUpgradeBatch
.
In the above case, if the user's deposit fails on L2 for any reason. They are not able to claim the deposit on L1 post-migration.
This is because the new L1ERC20Brdige.sol will pass the claimFailedDeposit control flow to L1SharedBridge::claimFailedDeposit
. In _claimFailedDeposit()
, _isEraLegacyWithdrawal
will evaluate to false (if _l2BatchNumber
>= eraFirstPostUpgradeBatch
), this will cause bool notCheckedInLegacyBridgeOrWeCanCheckDeposit
evaluates to true. In the if-body, depositHappened[_chainId][_l2TxHash]
will be checked. However, because the user made the deposit in the old L1ERC20Bridge.sol (before the last step of Migration), there is no record of this deposit in L1SharedBridge. User's claimFailedDeposit()
will revert. The user cannot claim.
//code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol function _claimFailedDeposit( bool _checkedInLegacyBridge, uint256 _chainId, //@audit-info note: this is ERA_CHAIN_ID address _depositSender, address _l1Token, uint256 _amount, bytes32 _l2TxHash, uint256 _l2BatchNumber, uint256 _l2MessageIndex, uint16 _l2TxNumberInBatch, bytes32[] calldata _merkleProof ) internal nonReentrant { ... { bool notCheckedInLegacyBridgeOrWeCanCheckDeposit; { //@audit if user 's made deposit through old L1ERC20Bridge, but the _l2BatchNumber >= eraFirstPostUpgradeBatch, notCheckedInLegacyBridgeOrWeCanCheckDeposit will return true |> bool weCanCheckDepositHere = !_isEraLegacyWithdrawal( _chainId, _l2BatchNumber ); //@audit even if the legacy bridge already checked the deposit, this might check the deposit in share bridge again. |> notCheckedInLegacyBridgeOrWeCanCheckDeposit = (!_checkedInLegacyBridge) || weCanCheckDepositHere; } if (notCheckedInLegacyBridgeOrWeCanCheckDeposit) { bytes32 dataHash = depositHappened[_chainId][_l2TxHash]; bytes32 txDataHash = keccak256( abi.encode(_depositSender, _l1Token, _amount) ); //@audit in the above scenario, this check will fail because user's deposit has no record in L1SharedBridge if deposited in the old ERC20Bridge |> require(dataHash == txDataHash, "ShB: d.it not hap"); delete depositHappened[_chainId][_l2TxHash]; } }
(https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol#L333) Edge case above will result in claiming failed deposit impossible on L1.
Recommendations:
In L1SharedBridge::_claimFaiedDeposit
, instead of bool notCheckedInLegacyBridgeOrWeCanCheckDeposit
, consider refactoring the if control flow based on bool _checkedInLegacyBridge
.
(1) if _checkedInLegacyBridge
is true, depositAmount
record is already checked and deleted in L1ERC20Bridge, which means deposit is made through L1ERC20Bridge (either oldERC20 bridge or newERC20 bridge). We only need to delete depositHappened[_chainId][_l2TxHash]
to prevent double claiming.
(2) if _checkedInLegacyBridge
is false. Either deposit is made through new L1ERC20Bridge or made directly through L1SharedBridge. We check depositHappened[_chainId][_l2TxHash]
first, a) if dataHash matches, we confirm and delete depositHappened[_chainId][_l2TxHash]
to prevent double claiming. b) if dataHash doesn't match or doesn't exist, we reject the claim. Either the deposit is made with old L1ERC20Bridge, which can be claimed from new L1ERC20Bridge (same as (1) flow), Or deposit already claimed, OR the deposit doesn't exist.
Instances(3) In some instances, resetting admin-controlled parameters lacks checks to ensure the new value will not be the old value. Consider adding a check to ensure the state is only written when the new value differs.
(1)
//code/contracts/ethereum/contracts/bridgehub/Bridgehub.sol function setPendingAdmin( address _newPendingAdmin ) external onlyOwnerOrAdmin { // Save previous value into the stack to put it into the event later address oldPendingAdmin = pendingAdmin; // Change pending admin //@audit Add check to ensure newPendingAdmin is different from oldPendingAdmin before writing to storage |> pendingAdmin = _newPendingAdmin; emit NewPendingAdmin(oldPendingAdmin, _newPendingAdmin); }
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function setPendingAdmin( address _newPendingAdmin ) external onlyOwnerOrAdmin { // Save previous value into the stack to put it into the event later address oldPendingAdmin = pendingAdmin; // Change pending admin //@audit Add check to ensure newPendingAdmin is different from oldPendingAdmin before writing to storage |> pendingAdmin = _newPendingAdmin; emit NewPendingAdmin(oldPendingAdmin, _newPendingAdmin); }
(3)
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function setNewVersionUpgrade( Diamond.DiamondCutData calldata _cutData, uint256 _oldProtocolVersion, uint256 _newProtocolVersion ) external onlyOwner { upgradeCutHash[_oldProtocolVersion] = keccak256(abi.encode(_cutData)); //@audit-info note: it didn't check the _newProtocolVersion is different from _oldProtocolVersion, only overwrite protocolVerision when _newProtocolVersion is different from current protocolVersion |> protocolVersion = _newProtocolVersion; }
Recommendations: Consider adding if-condition to only overwrite state parameter when the new value differs from the current value.
Instances(1) The code below is intended only for testing purposes. Consider removing code that is only intended for testing.
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function initialize( StateTransitionManagerInitializeData calldata _initializeData ) external reentrancyGuardInitializer { ... // While this does not provide a protection in the production, it is needed for local testing // Length of the L2Log encoding should not be equal to the length of other L2Logs' tree nodes preimages |> assert(L2_TO_L1_LOG_SERIALIZE_SIZE != 2 * 32); ...
Recommendations: Removing code intended only for testing.
Instances(1)
A state transition (ST)'s freezeDiamond()
or unfreezeDiamond
is intended to be called by either StateTransitionManger or the admin of the state transition. However, there is an error in unfreezeChain
on StateTransitionManager.sol that will prevent StateTransitionManger from unfreezing a chain when needed.
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function unfreezeChain(uint256 _chainId) external onlyOwner { //@audit this should be `unfreezeDiamond()`. This error prevents StateTransitionManager from freezing a chain(ST) when needed. |> IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); }
A state transition(ST) can be malicious. The hyperchain implementation is based on the assumption that StateTransitionManager(STM) is the main point of trust. This error prevents STM to exercise key control over ST to unfreeze an ST. If the ST admin is malicious and intends to freeze the chain for longer, STM won't be able to exercise intended control of the malicious ST.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol //@audit Due to error in StateTransitionManger, only ST admin can unfreeze the chain |> function unfreezeDiamond() external onlyAdminOrStateTransitionManager { Diamond.DiamondStorage storage diamondStorage = Diamond .getDiamondStorage(); require(diamondStorage.isFrozen, "a7"); // diamond proxy is not frozen diamondStorage.isFrozen = false; emit Unfreeze(); }
Recommendations:
In unfreezeChain
, change to call unfreezeDiamond()
.
Instances(1)
The the hyperchain setup baseToken is used instead of ETH
. However, in some cases, outdated comments are left.
(1)
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Mailbox.sol //@audit It should be L2 gas in baseToken, not ETH, because ST can have a non-eth baseToken, and this function to convert ETH price to baseToken denomination. |> /// @notice Derives the price for L2 gas in ETH to be paid. /// @param _l1GasPrice The gas price on L1 /// @param _gasPerPubdata The price for each pubdata byte in L2 gas /// @return The price of L2 gas in ETH function _deriveL2GasPrice( uint256 _l1GasPrice, uint256 _gasPerPubdata ) internal view returns (uint256) { ...
Recommendations: Change 'ETH' to baseToken.
_deriveL2GasPrice
might return incorrect value due to rounding, causing gas incorrectly paidInstances(1)
If an ST chain has a baseToken with a different decimal from ETH. The implementation in _deriveL2GasPrice
might cause rounding errors.
For example, suppose 1 ETH = 0.1 baseToken (8 decimals), and current _l1GasPrice
is 36 gwei/gas.
Current implementation: uint256 l1GasPriceConverted = (_l1GasPrice * s.baseTokenGasPriceMultiplierNominator) / s.baseTokenGasPriceMultiplierDenominator;
l1GasPriceConverted
= 36e9 * 0.1*1e6/1e18 = 36e-4 -> 0 baseToken/gas
Note: the resulting l1GasPriceConverted
will not be scaled and has to be in baseToken decimal because it is used directly to multiply gas for baseToken amount transfer.
In _deriveL2GasPrice
, when l1GasPriceConverted
round to 0. batchOverheadBaseToken
and pubdataPriceBaseToken
will return 0, L2 gas will be underestimated and underpaid.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Mailbox.sol function _deriveL2GasPrice(uint256 _l1GasPrice, uint256 _gasPerPubdata) internal view returns (uint256) { ... |> uint256 l1GasPriceConverted = (_l1GasPrice * s.baseTokenGasPriceMultiplierNominator) / s.baseTokenGasPriceMultiplierDenominator; uint256 pubdataPriceBaseToken; if (feeParams.pubdataPricingMode == PubdataPricingMode.Rollup) { pubdataPriceBaseToken = L1_GAS_PER_PUBDATA_BYTE * l1GasPriceConverted; } uint256 batchOverheadBaseToken = uint256(feeParams.batchOverheadL1Gas) * l1GasPriceConverted; ...
Recommendations: (1) Consider adding a scaling multiplier for a baseToken (2)Then, scaling up the converted gas price value and then scaling down before baseToken transfer.
Instances(3)
There are functions in Admin.sol with onlyAdminOrStateTransitionManager
modifier, which suggests they are intended to be called by both admin
and StateTransitionManager.
However, StateTransitionManager.sol doesn't have methods or flows to call them, making these functions only callable by chain admin. In case that StateTransitionManger needs to intervene a local chain(perhaps malicious) to change parameters through these functions. (1)
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function changeFeeParams( FeeParams calldata _newFeeParams ) external onlyAdminOrStateTransitionManager { ... FeeParams memory oldFeeParams = s.feeParams; s.feeParams = _newFeeParams; ...
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function setTokenMultiplier(uint128 _nominator, uint128 _denominator) external onlyAdminOrStateTransitionManager { ... s.baseTokenGasPriceMultiplierNominator = _nominator; s.baseTokenGasPriceMultiplierDenominator = _denominator; ...
Note that StateTransitionManager.sol doesn't have methods to directly invoke calls to these functions. Neither will StateTransitionManager calls these functions through an upgrade call(_setChainIdUpgrade()
).
(3)
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function setValidator( address _validator, bool _active ) external onlyStateTransitionManager { s.validators[_validator] = _active; ...
Note that unlike (1)&(2), s.validators
is set only once in (_setChainIdUpgrade()
) as part of the initialization of the new chain. But s.validators
cannot be updated again when needed and setValidator
cannot be called by StateTransitionManager.
Recommendations: Consider implementing direct call methods in StateTransitionManager.sol to callow STM controls when needed.
Instances(1)
In Admin.sol - setValidiumMode()
, a check on s.totalBatchesCommitted == 0
is enforced such that validium mode can only be set after genesis.
However, this check can be bypassed in changeFeeParams()
, because validiumMode is part of an element (s.feeParams.pubdataPricingMode
) of s.feeParams
. And the entire s.feeParams
can be re-set anytime with no restrictions on s.totalBatchesCommitted
.
As a result, a dangerous state change of validiumMode can be accidentally/maliciously reset in changeFeeParams
.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function setValidiumMode( PubdataPricingMode _validiumMode ) external onlyAdmin { //@audit this check can be bypassed in `changeFeeParams` require( |> s.totalBatchesCommitted == 0, "AdminFacet: set validium only after genesis" ); // Validium mode can be set only before the first batch is committed s.feeParams.pubdataPricingMode = _validiumMode; emit ValidiumModeStatusUpdate(_validiumMode); }
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function changeFeeParams(FeeParams calldata _newFeeParams) external onlyAdminOrStateTransitionManager { // Double checking that the new fee params are valid, i.e. // the maximal pubdata per batch is not less than the maximal pubdata per priority transaction. require(_newFeeParams.maxPubdataPerBatch >= _newFeeParams.priorityTxMaxPubdata, "n6"); FeeParams memory oldFeeParams = s.feeParams; //@audit this can reset validiumMode `s.feeParams.pubdataPricingMode` bypassing check in `setValidiumMode` |> s.feeParams = _newFeeParams; emit NewFeeParams(oldFeeParams, _newFeeParams); }
Recommendations:
Add check in changeFeeParams
to forbid changing s.feeParams.pubdataPricingMode
after the first batch is committed.
manual intervention
to proceed with upgrading to an older version.Instances(1)
Based on doc, if an ST skips an upgrade (i.e. it has version X, it did not upgrade to
X + 1and now the latest protocol version is
X + 2 there is no built-in way to upgrade). This team will require manual intervention from us to upgrade
.
The above documented manual intervention process from zksync can be bypassed. And the ST can proceed with upgrading to an older version (X+1
when current version is X+2
).
In upgradeChainFromVersion()
, there is no checking on ST's current version s.protocolVersion
against StateTranistionManger's protocolVersion. And it allows an ST to upgrade to an outdated version regardless of how old the ST's version is.
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function upgradeChainFromVersion( uint256 _oldProtocolVersion, Diamond.DiamondCutData calldata _diamondCut ) external onlyAdminOrStateTransitionManager { ... //@audit this only ensures that _oldProtoclVersion matches ST's current version. No check on ST's current version is exactly one version behind StateTransitionManger's version. |> require( s.protocolVersion == _oldProtocolVersion, "StateTransition: protocolVersion mismatch in STC when upgrading" ); Diamond.diamondCut(_diamondCut); emit ExecuteUpgrade(_diamondCut); require( s.protocolVersion > _oldProtocolVersion, "StateTransition: protocolVersion mismatch in STC after upgrading" );
Recommendations:
If the quote above from the doc should hold true, in upgradeChainFromVersion()
, add a check to ensure s.protocolVersion
== StateTransitionManger.protocolVersion() - 1, before allowing ST self-upgrade.
l2value
Instances(1) The new ERC20 l1-l2 deposit flow starts from Bridgehub::requestL2TransactionTwoBridges which allows ERC20 deposit to be done by a second bridge. The second bridge can be (1)either L1SharedBridge, (2) or third-party custom bridges.
The problem arises when L1SharedBridge is used as the second bridge.
L1SharedBridge::bridgehubDeposit
will be called to create ERC20 l1-l2 deposits. However, in bridgehubDeposit()
, l2Value
field is not checked to be zero. When user passes non-zero l2Value
to Bridgehub::requestL2TransactionTwoBridges
, l2Value
will be used as msg.value
for L2SharedBridge'sfinalizeDeposit()
. When msg.value
is non-zero, L2SharedBridge'sfinalizeDeposit()
will revert due to check require(msg.value == 0, "Value should be 0 for ERC20 bridge")
.
//code/contracts/ethereum/contracts/bridgehub/Bridgehub.sol function requestL2TransactionTwoBridges( L2TransactionRequestTwoBridgesOuter calldata _request ) external payable override nonReentrant returns (bytes32 canonicalTxHash) { ... L2TransactionRequestTwoBridgesInner memory outputRequest = IL1SharedBridge(_request.secondBridgeAddress) .bridgehubDeposit{value: _request.secondBridgeValue}( _request.chainId, msg.sender, |> _request.l2Value, //@audit When secondBridge is L1SharedBridge, user might input non-zero l2Value _request.secondBridgeCalldata );
//code/contracts/ethereum/contracts/bridge/L1SharedBridge.sol function bridgehubDeposit( uint256 _chainId, address _prevMsgSender, |> uint256, // l2Value, needed for Weth deposits in the future //@audit Although not used for Weth now, current ERC20 deposit flow require l2Value == 0, this is not checked bytes calldata _data ) external payable override onlyBridgehub returns (L2TransactionRequestTwoBridgesInner memory request) { ... // Request the finalization of the deposit on the L2 side //@audit This encode L2Bridge.finalizeDeposit() to be called on L2SharedBrdige, if L2Value is non-zero, L2SharedBridge will revert the tx, causing user ERC20 deposit to fail. |> bytes memory l2TxCalldata = _getDepositL2Calldata( _prevMsgSender, _l2Receiver, _l1Token, amount );
For comparison, L1ERC20Bridge.sol's ERC20 deposit flow will always ensure l2Value == 0.
Recommendations:
In L1SharedBridge::bridgehubDeposit, if _l1Token is not WETH (currently not supported), add a check to ensure _l2Value
== 0.
KECCAK256_SYSTEM_CONTRACT
(Not included in bot report)Instances(1)
KECCAK256_SYSTEM_CONTRACT
is imported in ContractDeployer.sol but never used.
//code/system-contracts/contracts/ContractDeployer.sol import {CREATE2_PREFIX, CREATE_PREFIX, NONCE_HOLDER_SYSTEM_CONTRACT, ACCOUNT_CODE_STORAGE_SYSTEM_CONTRACT, FORCE_DEPLOYER, MAX_SYSTEM_CONTRACT_ADDRESS, KNOWN_CODE_STORAGE_CONTRACT, BASE_TOKEN_SYSTEM_CONTRACT, IMMUTABLE_SIMULATOR_SYSTEM_CONTRACT, COMPLEX_UPGRADER_CONTRACT, KECCAK256_SYSTEM_CONTRACT} from "./Constants.sol"; ...
Recommendations: Remove the unused import.
Instances(1) The newly added system precompile codeOracle.yul and P256Verify.yul addresses are not added to Constants.sol.
Recommendations: Consider adding the new precompile address to Constant.sol
Instances(1) Based on doc, under some conditions, STM can freeze an ST to prevent unsafe usage. One of the conditions is an outdated chain.
- Freeze not upgraded chains - After a certain time the chains that are not upgraded are frozen. In addition, an ST can be malicious. When an ST hasn't upgraded to the latest protocol version intentionally for malicious gains, state transition manger contract(STM) can freeze the ST by calling
freezeChain()
.
//code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol function freezeChain(uint256 _chainId) external onlyOwner { IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); }
//code/contracts/ethereum/contracts/state-transition/chain-deps/facets/Admin.sol function unfreezeDiamond() external onlyAdminOrStateTransitionManager { ... diamondStorage.isFrozen = false;
However, current Admin.sol also allows an ST admin to unfreeze itself by ST calling unfreezeDiamond()
.
This might create a racing condition between a malicious ST and State transition manager.
Recommendations: Consider only allowing stateTransitionManger to unfreeze.
#0 - razzorsec
2024-04-18T09:55:25Z
Low-02 is a valid finding, but we consider the real impact to be very small!
#1 - c4-judge
2024-04-29T13:42:23Z
alex-ppg marked the issue as grade-b
#2 - flowercrimson
2024-04-30T13:25:35Z
Hey @alex-ppg , I think Low-05 StateTransitionManger cannot unfreeze a frozen chain to an error in StateTransitionManager.sol is a duplicate of #97
#3 - alex-ppg
2024-05-02T12:46:07Z
Thanks @flowercrimson for flagging! I will make sure this is corrected alongside #122 after PJQA.