Platform: Code4rena
Start Date: 02/10/2023
Pot Size: $1,100,000 USDC
Total HM: 28
Participants: 64
Period: 21 days
Judge: GalloDaSballo
Total Solo HM: 13
Id: 292
League: ETH
Rank: 23/64
Findings: 2
Award: $3,483.40
🌟 Selected for report: 0
🚀 Solo Findings: 0
3388.1817 USDC - $3,388.18
https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/system-contracts/contracts/ComplexUpgrader.sol#L21-L31 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/upgrades/DefaultUpgrade.sol#L25-L46 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol#L214-L230 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L398-L414 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L189-L197
In case of providing a faulty implementation (buggy)
for an upgrade, the upgrade must be carried out and cannot be changed
even if we revert the batches.
Let's imagine a scenario where the zkSync team
wants to carry out an upgrade for some of the critical functionalities
in the system.
This is done by the following steps:
FORCE_DEPLOYER
calls the upgrade function on the ComplexUpgrader
function upgrade(address _delegateTo, bytes calldata _calldata) external payable { require(msg.sender == FORCE_DEPLOYER, "Can only be called by FORCE_DEPLOYER"); require(_delegateTo.code.length > 0, "Delegatee is an EOA"); (bool success, bytes memory returnData) = _delegateTo.delegatecall(_calldata); assembly { if iszero(success) { revert(add(returnData, 0x20), mload(returnData)) } } }
upgrade
in DefaultUpgrade.sol
function upgrade(ProposedUpgrade calldata _proposedUpgrade) public override returns (bytes32) { super.upgrade(_proposedUpgrade); _setNewProtocolVersion(_proposedUpgrade.newProtocolVersion); _upgradeL1Contract(_proposedUpgrade.l1ContractsUpgradeCalldata); _upgradeVerifier(_proposedUpgrade.verifier, _proposedUpgrade.verifierParams); _setBaseSystemContracts(_proposedUpgrade.bootloaderHash, _proposedUpgrade.defaultAccountHash); bytes32 txHash; txHash = _setL2SystemContractUpgrade( _proposedUpgrade.l2ProtocolUpgradeTx, _proposedUpgrade.factoryDeps, _proposedUpgrade.newProtocolVersion ); _setAllowList(IAllowList(_proposedUpgrade.newAllowList)); _postUpgrade(_proposedUpgrade.postUpgradeCalldata); emit UpgradeComplete(_proposedUpgrade.newProtocolVersion, txHash, _proposedUpgrade); return Diamond.DIAMOND_INIT_SUCCESS_RETURN_VALUE; }
s.l2SystemContractsUpgradeTxHash
function _setL2SystemContractUpgrade( IMailbox.L2CanonicalTransaction calldata _l2ProtocolUpgradeTx, bytes[] calldata _factoryDeps, uint256 _newProtocolVersion ) internal returns (bytes32) { ... bytes memory encodedTransaction = abi.encode(_l2ProtocolUpgradeTx); ... bytes32 l2ProtocolUpgradeTxHash = keccak256(encodedTransaction); s.l2SystemContractsUpgradeTxHash = l2ProtocolUpgradeTxHash; return l2ProtocolUpgradeTxHash; }
committed batch
this upgrade tx will be included and eventually proven
and executed
.before the batch
that includes the upgrade is executed
the team realizes that the upgraded system would have a critical vulnerability
s.l2SystemContractsUpgradeBatchNumber
which means that the faulty implementation still has to be carried out in the next batch
. This gives an attacker time to exploit
the whole system in case of such an error.function revertBatches(uint256 _newLastBatch) external nonReentrant onlyValidator { .... if (s.l2SystemContractsUpgradeBatchNumber > _newLastBatch) { delete s.l2SystemContractsUpgradeBatchNumber; } ... }
s.l2SystemContractsUpgradeTxHash
it won't succeed because if trying to invoke the upgrade
process again to set the new tx hash will fail.DefaultUpgrade
tries to set the new protocol version which requires that the s.l2SystemContractsUpgradeBatchNumber == bytes32(0)
so the process will fail and there is 100% certainty that the faulty implementation will be carried out even if the batch is reverted.function _setNewProtocolVersion(uint256 _newProtocolVersion) internal { uint256 previousProtocolVersion = s.protocolVersion; require( _newProtocolVersion > previousProtocolVersion, "New protocol version is not greater than the current one" ); // If the previous upgrade had an L2 system upgrade transaction, we require that it is finalized. require(s.l2SystemContractsUpgradeTxHash == bytes32(0), "Previous upgrade has not been finalized"); require( s.l2SystemContractsUpgradeBatchNumber == 0, "The batch number of the previous upgrade has not been cleaned" ); s.protocolVersion = _newProtocolVersion; emit NewProtocolVersion(previousProtocolVersion, _newProtocolVersion); }
VS Code
It would make more sense that in case of reverting an upgrade tx the s.l2SystemContractsUpgradeTxHash
should be provided again to prevent such an error to increase the robustness of the system
. In cases like this, the team has time to prevent the upgrade from happening, fix the error, and make an upgrade tx again rather than leaving the vulnerable upgrade in the wild.
function revertBatches(uint256 _newLastBatch) external nonReentrant onlyValidator { require(s.totalBatchesCommitted > _newLastBatch, "v1"); // The last committed batch is less than new last batch require(_newLastBatch >= s.totalBatchesExecuted, "v2"); // Already executed batches cannot be reverted if (_newLastBatch < s.totalBatchesVerified) { s.totalBatchesVerified = _newLastBatch; } s.totalBatchesCommitted = _newLastBatch; if (s.l2SystemContractsUpgradeBatchNumber > _newLastBatch) { delete s.l2SystemContractsUpgradeBatchNumber; + delete s.l2SystemContractsUpgradeTxHash; } emit BlocksRevert(s.totalBatchesCommitted, s.totalBatchesVerified, s.totalBatchesExecuted); }
Upgradable
#0 - c4-pre-sort
2023-10-31T14:40:26Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-10-31T14:40:35Z
bytes032 marked the issue as duplicate of #168
#2 - c4-pre-sort
2023-11-01T05:48:38Z
bytes032 marked the issue as not a duplicate
#3 - c4-pre-sort
2023-11-02T15:40:10Z
141345 marked the issue as duplicate of #117
#4 - miladpiri
2023-11-09T12:20:40Z
The scenario requires that we upgrade a faulty version, and then we can not cancel it. But we can it, it is possible by shceduling an instant L1 upgrade and overrid the s.l2SystemContractsUpgradeTxHash
. It is at most Low.
Not duplicate of 117.
#5 - c4-sponsor
2023-11-09T12:20:45Z
miladpiri marked the issue as disagree with severity
#6 - c4-judge
2023-11-25T19:03:35Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-11-25T19:05:27Z
GalloDaSballo marked the issue as not a duplicate
#8 - evmboi32
2023-11-29T22:42:34Z
From what I understand the only way to change the s.l2SystemContractsUpgradeTxHash
is by invoking the upgrade
on the DefaultUpgrade
. So yes you could schedule an instant L1 upgrade but it will revert. Let's follow the execution flow.
When the upgrade
is called on the DefaultUpgrade
it does a few things. It sets a new protocol version, upgrades L1 contract, sets the L2 system contract upgrade, etc. All of these calls have to succeed in order to override the s.l2SystemContractsUpgradeTxHash
which is set inside the _setL2SystemContractUpgrade
function upgrade(ProposedUpgrade calldata _proposedUpgrade) public override returns (bytes32) { super.upgrade(_proposedUpgrade); _setNewProtocolVersion(_proposedUpgrade.newProtocolVersion); _upgradeL1Contract(_proposedUpgrade.l1ContractsUpgradeCalldata); _upgradeVerifier(_proposedUpgrade.verifier, _proposedUpgrade.verifierParams); _setBaseSystemContracts(_proposedUpgrade.bootloaderHash, _proposedUpgrade.defaultAccountHash); bytes32 txHash; txHash = _setL2SystemContractUpgrade( _proposedUpgrade.l2ProtocolUpgradeTx, _proposedUpgrade.factoryDeps, _proposedUpgrade.newProtocolVersion ); _setAllowList(IAllowList(_proposedUpgrade.newAllowList)); _postUpgrade(_proposedUpgrade.postUpgradeCalldata); emit UpgradeComplete(_proposedUpgrade.newProtocolVersion, txHash, _proposedUpgrade); return Diamond.DIAMOND_INIT_SUCCESS_RETURN_VALUE; }
The function _setL2SystemContractUpgrade
is ok as it seems to override the desired variable
function _setL2SystemContractUpgrade( IMailbox.L2CanonicalTransaction calldata _l2ProtocolUpgradeTx, bytes[] calldata _factoryDeps, uint256 _newProtocolVersion ) internal returns (bytes32) { ... // We want the hashes of l2 system upgrade transactions to be unique. // This is why we require that the `nonce` field is unique to each upgrade. require( _l2ProtocolUpgradeTx.nonce == _newProtocolVersion, "The new protocol version should be included in the L2 system upgrade tx" ); ... bytes32 l2ProtocolUpgradeTxHash = keccak256(encodedTransaction); s.l2SystemContractsUpgradeTxHash = l2ProtocolUpgradeTxHash; return l2ProtocolUpgradeTxHash; }
But before the _setL2SystemContractUpgrade
is executed, the call to _setNewProtocolVersion
is executed inside the upgrade function as seen above. Let's take a look at that function (this has to pass for the execution flow to continue and reach the _setL2SystemContractUpgrade
call)
function _setNewProtocolVersion(uint256 _newProtocolVersion) internal { uint256 previousProtocolVersion = s.protocolVersion; require( _newProtocolVersion > previousProtocolVersion, "New protocol version is not greater than the current one" ); // If the previous upgrade had an L2 system upgrade transaction, we require that it is finalized. require(s.l2SystemContractsUpgradeTxHash == bytes32(0), "Previous upgrade has not been finalized"); require( s.l2SystemContractsUpgradeBatchNumber == 0, "The batch number of the previous upgrade has not been cleaned" ); s.protocolVersion = _newProtocolVersion; emit NewProtocolVersion(previousProtocolVersion, _newProtocolVersion); }
Here we see that upgrading to a new version requires the s.l2SystemContractsUpgradeTxHash
to be equal to bytes32(0)
which is clearly not as the update is pending. So as I see it there is no way of overriding the s.l2SystemContractsUpgradeTxHash
as the upgrade
function reverts everytime when the s.l2SystemContractsUpgradeTxHash
is not 0
(which is not as i have shown in the original report because the value is not reset when reverting batches)
#9 - miladpiri
2023-12-01T11:06:23Z
In this scenario that a buggy upgrade is already scheduled and now we want to cancel it, it is possible by overriding s.l2SystemContractsUpgradeTxHash
. Note that it is not mandatory that DefaultUpgrade
be used for overriding for such special case.
So, the solution is that deploy another custom upgrade contract that only overrides s.l2SystemContractsUpgradeTxHash
(not touching protocol version, etc), and instead of delegate calling to DefaultUpgrade
, we delegate call to this custom upgrade contract. So, the limitation of new protocol version you mentioned is not an issue anymore.
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Admin.sol#L98
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/libraries/Diamond.sol#L288
#10 - evmboi32
2023-12-01T11:50:56Z
@miladpiri Yes, but the problem is that the upgrade tx has to be carried out in the next batch
. So it would give you next to zero available time to code and deploy the new upgrader. And if the hacker is aware of any bugs in the implementation he can exploit the system.
After an upgrade has been initiated, it will be required that the next commit batches operation already contains the system upgrade transaction. It is [checked](https://github.com/code-423n4/2023-10-zksync/blob/ef99273a8fdb19f5912ca38ba46d6bd02071363d/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L157) by verifying the corresponding L2→L1 log.
#11 - c4-judge
2023-12-07T19:21:51Z
This previously downgraded issue has been upgraded by GalloDaSballo
#12 - c4-judge
2023-12-07T19:21:51Z
This previously downgraded issue has been upgraded by GalloDaSballo
#13 - c4-judge
2023-12-07T19:22:00Z
GalloDaSballo marked the issue as duplicate of #214
#14 - c4-judge
2023-12-07T19:23:21Z
GalloDaSballo marked the issue as partial-50
#15 - GalloDaSballo
2023-12-07T19:23:38Z
I don't believe we would have awarded this report without 214, so am marking as Dup, 50%
🌟 Selected for report: erebus
Also found by: 0xTheC0der, Bauchibred, HE1M, Jorgect, Udsen, alexfilippov314, chaduke, evmboi32, hash, ladboy233, lsaudit, oakcobalt, rvierdiiev, ustas, wangxx2026, xuwinnie, zero-idea, zkrunner
95.2225 USDC - $95.22
function _execute(Call[] calldata _calls) internal { + uint256 totalValue; + for (uint256 i = 0; i < _calls.length; ++i) { + totalValue += calls[i].value; + } + require(address(this).balance >= totalValue); for (uint256 i = 0; i < _calls.length; ++i) { (bool success, bytes memory returnData) = _calls[i].target.call{value: _calls[i].value}(_calls[i].data); if (!success) { // Propage an error if the call fails. assembly { revert(add(returnData, 0x20), mload(returnData)) } } } }
In the provided documentation https://github.com/code-423n4/2023-10-zksync/blob/main/docs/Smart%20contract%20Section/L1%20smart%20contracts.md#gettersfacet it states that the Getters
facet must never be frozen
. But when doing a diamondCut there is no check if the facet we are updating the selectors for is the Getters
facet. When the first selector is added to a new facet the facet itself is pushed into the facet array and _isFacetFreezable
is set. Make sure to check if the facet in question is the Getters
facet and require that the _isFacetFreezable
is set to false
.
function diamondCut(DiamondCutData memory _diamondCut) internal { FacetCut[] memory facetCuts = _diamondCut.facetCuts; address initAddress = _diamondCut.initAddress; bytes memory initCalldata = _diamondCut.initCalldata; uint256 facetCutsLength = facetCuts.length; for (uint256 i = 0; i < facetCutsLength; i = i.uncheckedInc()) { Action action = facetCuts[i].action; address facet = facetCuts[i].facet; bool isFacetFreezable = facetCuts[i].isFreezable; bytes4[] memory selectors = facetCuts[i].selectors; require(selectors.length > 0, "B"); // no functions for diamond cut if (action == Action.Add) { _addFunctions(facet, selectors, isFacetFreezable); } else if (action == Action.Replace) { _replaceFunctions(facet, selectors, isFacetFreezable); } else if (action == Action.Remove) { _removeFunctions(facet, selectors); } else { revert("C"); // undefined diamond cut action } } _initializeDiamondCut(initAddress, initCalldata); emit DiamondCut(facetCuts, initAddress, initCalldata); }
L1Messenger#publishPubdataAndClearState
Inside the header, it says that there are 2 bytes for the total len of compressed
as opposed to the documentation https://github.com/code-423n4/2023-10-zksync/blob/main/docs/Smart%20contract%20Section/Handling%20pubdata%20in%20Boojum/State%20diff%20compression%20v1%20spec.md where it says that the total_logs_len
is 3 bytes.
function publishPubdataAndClearState( bytes calldata _totalL2ToL1PubdataAndStateDiffs ) external onlyCallFromBootloader { ... /// Check State Diffs /// encoding is as follows: /// header (1 byte version, 2 bytes total len of compressed, 1 byte enumeration index size, 2 bytes number of initial writes) /// body (N bytes of initial writes [32 byte derived key || compressed value], M bytes repeated writes [enumeration index || compressed value]) /// encoded state diffs: [20bytes address][32bytes key][32bytes derived key][8bytes enum index][32bytes initial value][32bytes final value] ... }
We also use 3 bytes in the code
uint24 compressedStateDiffSize = uint24(bytes3(_totalL2ToL1PubdataAndStateDiffs[calldataPtr:calldataPtr + 3])); ```audi # Unused variable in ```SystemContext#publishTimestampDataToL1``` ```solidity function publishTimestampDataToL1() external onlyCallFromBootloader { (uint128 currentBatchNumber, uint128 currentBatchTimestamp) = getBatchNumberAndTimestamp(); (, uint128 currentL2BlockTimestamp) = getL2BlockNumberAndTimestamp(); // The structure of the "setNewBatch" implies that currentBatchNumber > 0, but we still double check it require(currentBatchNumber > 0, "The current batch number must be greater than 0"); bytes32 prevBatchHash = batchHash[currentBatchNumber - 1]; // In order to spend less pubdata, the packed version is published uint256 packedTimestamps = (uint256(currentBatchTimestamp) << 128) | currentL2BlockTimestamp; SystemContractHelper.toL1( false, bytes32(uint256(SystemLogKey.PACKED_BATCH_AND_L2_BLOCK_TIMESTAMP_KEY)), bytes32(packedTimestamps) ); }
The variable bytes32 prevBatchHash = batchHash[currentBatchNumber - 1];
is unused.
L1Messenger#publishPubdataAndClearState
The number of logs is compared to itself which is always true. Instead, it should be compared to L2_TO_L1_LOGS_MERKLE_TREE_LEAVES
.
function publishPubdataAndClearState( bytes calldata _totalL2ToL1PubdataAndStateDiffs ) external onlyCallFromBootloader { uint256 calldataPtr = 0; /// Check logs uint32 numberOfL2ToL1Logs = uint32(bytes4(_totalL2ToL1PubdataAndStateDiffs[calldataPtr:calldataPtr + 4])); require(numberOfL2ToL1Logs <= numberOfL2ToL1Logs, "Too many L2->L1 logs"); calldataPtr += 4; ... }
#0 - bytes032
2023-10-30T09:14:19Z
TODO
TODO TODO
L1Messenger#publishPubdataAndClearState
TODO
SystemContext#publishTimestampDataToL1
TODO
L1Messenger#publishPubdataAndClearState
TODO
#1 - c4-judge
2023-12-10T20:21:38Z
GalloDaSballo marked the issue as grade-c
#2 - GalloDaSballo
2023-12-10T20:22:03Z
Manually awarding B because of the Governance finding
#3 - c4-judge
2023-12-10T20:22:08Z
GalloDaSballo marked the issue as grade-b