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: 9/24
Findings: 2
Award: $805.96
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0x11singh99
Also found by: Bauchibred, Dup1337, Topmark, XDZIBECX, bctester, bin2chen, erebus, forgebyola, oakcobalt, rvierdiiev, yashar, zhanmingjing
565.1582 USDC - $565.16
The StateTransitionManager
contract in the zkSync system contains two functions, freezeChain
and unfreezeChain
, intended to manage the operational state of a specified blockchain by freezing and unfreezing it, respectively. Both functions are designed to either halt or resume operations, particularly in response to detected vulnerabilities or the need for maintenance. However, unfreezeChain()
function incorrectly calls freezeDiamond()
instead of an expected counterpart method unfreezeDiamond()
that would revert the chain's state. This means that chain that is once frozen, is blocked forever.
Permament DoS of a chain after first freeze.
Please add following test to FreezeChain.t.sol
and execute it:
function test_FreezingUnfreezingChain() public { createNewChain(getDiamondCutData(diamondInit)); address newChainAddress = chainContractAddress.stateTransition(chainId); GettersFacet gettersFacet = GettersFacet(newChainAddress); bool isChainFrozen = gettersFacet.isDiamondStorageFrozen(); assertEq(isChainFrozen, false); vm.stopPrank(); vm.startPrank(governor); chainContractAddress.freezeChain(block.chainid); vm.expectRevert(bytes.concat("q1")); // storage frozen, cannot unfreeze chainContractAddress.unfreezeChain(block.chainid); }
The tests runs successfully, meaning that it reverts with q1
error - storage frozen:
Running 1 test for test/foundry/unit/concrete/state-transition/StateTransitionManager/FreezeChain.t.sol:freezeChainTest [PASS] test_FreezingUnfreezingChain() (gas: 3397331) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.46ms Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual Review
Change code to unfreeze diamond:
function unfreezeChain(uint256 _chainId) external onlyOwner { - IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); + IZkSyncStateTransition(stateTransition[_chainId]).unfreezeDiamond(); }
DoS
#0 - c4-judge
2024-04-02T17:02:14Z
alex-ppg marked the issue as duplicate of #97
#1 - c4-judge
2024-04-29T13:51:52Z
alex-ppg changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-04-29T13:53:49Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: Bauchibred
Also found by: 0x11singh99, ChaseTheLight, Dup1337, hihen, lsaudit, oakcobalt
240.8022 USDC - $240.80
Issues | |
---|---|
[L-01] | Funds lost when hyperbridging is enabled after adding chains |
[L-02] | Unable to claim failed deposit when requestL2TransactionDirect() is used directly |
[L-03] | No possibility to know if an immutable exists or not |
[L-04] | NonceHolder can´t set a value for the first nonce & can overwrite a used nonce |
[L-05] | onlySelf modifier causes bypassing access control |
[L-06] | Governance execute and executeInstant functions to follow CEI pattern |
[L-07] | The operator will not be paid any fees when refundGas == gasLimit |
[L-08] | Tests fail silently |
[NC-01] | Withdrawals don´t have a cool down period |
[NC-02] | Shadow Upgrade calldata will be on-chain during executeInstant and subject to frontruns |
[NC-03] | MsgValueSimulator may be forced to revert |
hyperbridgingEnabled
is defined and used in code, however, it's not set. The sponsor confirmed that it's a subject of introduction in the next update. There is one possible issue that might arise, when the hyperbridging is enabled sometime after a chain is running, concerning chainBalance
increasing on deposits and decreasing on withdrawals/failed deposit claims. By default hyperbridgingEnabled
is set to false and chainBalance
won't be increased on deposits, when it's set to false later, it might DoS withdrawal:
function _claimFailedDeposit( bool _checkedInLegacyBridge, uint256 _chainId, address _depositSender, address _l1Token, uint256 _amount, bytes32 _l2TxHash, uint256 _l2BatchNumber, uint256 _l2MessageIndex, uint16 _l2TxNumberInBatch, bytes32[] calldata _merkleProof ) internal nonReentrant { // [...] if (!hyperbridgingEnabled[_chainId]) { // @audit possible DoS when it will be set after user deposits are made // check that the chain has sufficient balance require(chainBalance[_chainId][_l1Token] >= _amount, "ShB n funds"); chainBalance[_chainId][_l1Token] -= _amount; }
requestL2TransactionDirect()
is used directlyThere are 2 possibilities to deposit funds into Hyperbrige: via L1SharedBridge
, or legacy L1ERC20Bridge
. In both cases L1SharedBridge.depositHappened
struct is updated to allow the user to claim failed deposit. the legacy bridge uses requestL2TransactionDirect()
and doesn't call confirmL2Transaction
- it's meant to be called from the legacy bridge, which keeps deposit data, that's also why there's this check in L1SharedBridge
:
notCheckedInLegacyBridgeOrWeCanCheckDeposit = (!_checkedInLegacyBridge) || weCanCheckDepositHere;
However, L1SharedBridge.requestL2TransactionDirect()
can also be called by anyone directly, without registering information about deposit, hence not allowing to claim failed deposit in case of issues.
Please consider either limiting requestL2TransactionDirect()
to L1ERC20Bridge
caller or at least mark info about possible funds loss when using this function in docs.
In zkSync, there is no notion of constructor bootstrapping contract, there is no possibility to override smart contract code during deployment, which is used in immutable variables.
Because zkSync still wants to support them, the implementation comes up with a smart contract that simulates immutables. However, when calling getImmutable
, it returns a mapping value of an index. When the value there is not initialized, it will return 0, just as the value initialized with 0, so there is no way of knowing if a certain immutable was set for a given contract.
Contract: ImmutableSimulator.sol 18: contract ImmutableSimulator is IImmutableSimulator { 19: /// @dev mapping (contract address) => (index of immutable variable) => value 20: /// @notice that address uses `uint256` type to leave the option to introduce 32-byte address space in future. 21: mapping(uint256 contractAddress => mapping(uint256 index => bytes32 value)) internal immutableDataStorage; 22: 23: /// @notice Method that returns the immutable with a certain index for a user. 24: /// @param _dest The address which the immutable belongs to. 25: /// @param _index The index of the immutable. 26: /// @return The value of the immutables. /// @audit Because there is no check if the index is set, it will return 0 on both occasions that the immutable exists and is 0, and the immutable index not existing 27: function getImmutable(address _dest, uint256 _index) external view override returns (bytes32) { 28: return immutableDataStorage[uint256(uint160(_dest))][_index]; 29: } 30: 31: /// @notice Method used by the contract deployer to store the immutables for an account 32: /// @param _dest The address which to store the immutables for. 33: /// @param _immutables The list of the immutables. 34: function setImmutables(address _dest, ImmutableData[] calldata _immutables) external override { 35: require(msg.sender == address(DEPLOYER_SYSTEM_CONTRACT), "Callable only by the deployer system contract"); 36: unchecked { 37: uint256 immutablesLength = _immutables.length; 38: for (uint256 i = 0; i < immutablesLength; ++i) { 39: uint256 index = _immutables[i].index; 40: bytes32 value = _immutables[i].value; 41: immutableDataStorage[uint256(uint160(_dest))][index] = value; 42: } 43: } 44: } 45: } 46:
NonceHolder
can´t set a value for the first noncesetValueUnderNonce
sets the nonce value key
for the msg.sender
as used;
Contract: NonceHolder.sol 82: function setValueUnderNonce(uint256 _key, uint256 _value) public onlySystemCall { 83: IContractDeployer.AccountInfo memory accountInfo = DEPLOYER_SYSTEM_CONTRACT.getAccountInfo(msg.sender); 84: 85: require(_value != 0, "Nonce value cannot be set to 0"); 86: // If an account has sequential nonce ordering, we enforce that the previous 87: // nonce has already been used. 88: if (accountInfo.nonceOrdering == IContractDeployer.AccountNonceOrdering.Sequential && _key != 0) { 89: require(isNonceUsed(msg.sender, _key - 1), "Previous nonce has not been used"); 90: } 91: 92: uint256 addressAsKey = uint256(uint160(msg.sender)); 93: 94: nonceValues[addressAsKey][_key] = _value; 95: 96: emit ValueSetUnderNonce(msg.sender, _key, _value); 97: } 98:
The call checks the previous key (nonce) is being used if the account nonce mode set in sequential by isNonceUsed(msg.sender, _key - 1)
and isNonceUsed
function is below:
Contract: NonceHolder.sol 154: function isNonceUsed(address _address, uint256 _nonce) public view returns (bool) { 155: uint256 addressAsKey = uint256(uint160(_address)); 156: return (_nonce < getMinNonce(_address) || nonceValues[addressAsKey][_nonce] > 0); 157: }
Accordingly, either the previous nonce should be written with a value : nonceValues[addressAsKey][_nonce] > 0
OR
getMinNonce(_address)
should be greater than the nonce:
Contract: NonceHolder.sol 46: function getMinNonce(address _address) public view returns (uint256) { 47: uint256 addressAsKey = uint256(uint160(_address)); 48: (, uint256 minNonce) = _splitRawNonce(rawNonces[addressAsKey]); 49: 50: return minNonce; 51: }
Whereas _splitRawNonce
is:
Contract: NonceHolder.sol 179: function _splitRawNonce(uint256 _rawMinNonce) internal pure returns (uint256 deploymentNonce, uint256 minNonce) { 180: deploymentNonce = _rawMinNonce / DEPLOY_NONCE_MULTIPLIER; 181: minNonce = _rawMinNonce % DEPLOY_NONCE_MULTIPLIER; 182: }
So, if line 181 returns 0 - the first nonce after deployment, it wont suffice the requirement of _nonce < getMinNonce(_address)
(L:156 of NonceHolder.sol)
Moreover, in this line:
Contract: NonceHolder.sol 88: if (accountInfo.nonceOrdering == IContractDeployer.AccountNonceOrdering.Sequential && _key != 0) { 89: require(isNonceUsed(msg.sender, _key - 1), "Previous nonce has not been used"); 90: }
if the previous nonce is used - as desired - it continues to write the proposed nonce,
BUT, It´s never checked whether the proposed nonce is USED either, which is recommended to be validated before writing it.
onlySelf
modifier causes bypassing access controlGovernance
contract´s onlySelf
modifier is used in updateDelay
and updateSecurityCouncil
functions.
Accordingly, a scheduled upgrade can be executed either in execute
or executeInstant
functions to change the minDelay
and securityCouncil
variables.
These execution functions will set the caller context as msg.sender == address(this)
.
Here´s the onlySelf
modifier:
Contract: Governance.sol 58: modifier onlySelf() { 59: require(msg.sender == address(this), "Only governance contract itself is allowed to call this function"); 60: _; 61: }
However, by this, the Owner can change the SecurityCouncil
arbitrarily due to:
a. The owner can call scheduleTransparent
and scheduleShadow
b. The owner can execute the scheduled operation by execute
So, the functions with onlySecurityCouncil
can be bypassed especially when the Owner calls scheduleShadow
with the calldata of updating the Security Council to their address and execute
the operation on their own.
After the change of Security Council, any operation can be proposed and executed at the same TX instantly due to there is no vetoer body.
We recommend implementing a flag for scheduleShadow
. Accordingly, don´t let the owner execute
if the Operation being executed is a shadow schedule and the caller is the owner:
Contract: Governance.sol 167: function execute(Operation calldata _operation) external payable onlyOwnerOrSecurityCouncil { 168: bytes32 id = hashOperation(_operation); 169: // Check if the predecessor operation is completed. 170: _checkPredecessorDone(_operation.predecessor); 171: // Ensure that the operation is ready to proceed. 172: require(isOperationReady(id), "Operation must be ready before execution");//@audit-ok this can be executed earlier! + if (msg.sender == owner) { + require(!isShadowSchedule(id)); + } 173: _execute(_operation.calls); 174: // Reconfirming that the operation is still ready after execution. 175: // This is needed to avoid unexpected reentrancy attacks of re-executing the same operation. 176: require(isOperationReady(id), "Operation must be ready after execution"); // @audit-ok this will pass anyway, not sure about the logic 177: // Set operation to be done 178: timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 179: emit OperationExecuted(id); 180: }
execute
and executeInstant
functions to follow CEI patternBoth execute
and executeInstant
functions
a. execute the calls
b. checks for re-entrancy
c. Marks the id
with EXECUTED_PROPOSAL_TIMESTAMP
However, the below changes are more convenient:
Contract: Governance.sol 167: function execute(Operation calldata _operation) external payable onlyOwnerOrSecurityCouncil { 168: bytes32 id = hashOperation(_operation); 169: // Check if the predecessor operation is completed. 170: _checkPredecessorDone(_operation.predecessor); 171: // Ensure that the operation is ready to proceed. 172: require(isOperationReady(id), "Operation must be ready before execution"); + // Set operation to be done + timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 173: _execute(_operation.calls); - // Reconfirming that the operation is still ready after execution. - // This is needed to avoid unexpected reentrancy attacks of re-executing the same operation. - require(isOperationReady(id), "Operation must be ready after execution"); - // Set operation to be done - timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 179: emit OperationExecuted(id); 180: }
Contract: Governance.sol 185: function executeInstant(Operation calldata _operation) external payable onlySecurityCouncil { 186: bytes32 id = hashOperation(_operation); 187: // Check if the predecessor operation is completed. 188: _checkPredecessorDone(_operation.predecessor); 189: // Ensure that the operation is in a pending state before proceeding. 190: require(isOperationPending(id), "Operation must be pending before execution"); + // Set operation to be done + timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 191: // Execute operation. 192: _execute(_operation.calls); - // Reconfirming that the operation is still pending before execution. - // This is needed to avoid unexpected reentrancy attacks of re-executing the same operation. - require(isOperationPending(id), "Operation must be pending after execution"); - // Set operation to be done - timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 198: emit OperationExecuted(id); 199: }
refundGas
== gasLimit
During processing L1 TX in Bootloader
the surplus fees can be refunded to the user depending on the gasLimit and the gas price.
This is calculated by the operator and the Bootloader and accordingly, the user is refunded.
The snippet shows the brief logic of the mechanism:
Contract: bootloader.yul 985: let potentialRefund := saturatingSub( 986: safeAdd(reservedGas, gasForExecution, "safeadd: potentialRefund1"), 987: safeAdd(gasSpentOnExecution, ergsSpentOnPubdata, "safeadd: potentialRefund2") 988: ) 989: 990: // Asking the operator for refund 991: askOperatorForRefund(potentialRefund, ergsSpentOnPubdata, gasPerPubdata) 992: 993: // In case the operator provided smaller refund than the one calculated 994: // by the bootloader, we return the refund calculated by the bootloader. 995: refundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund) 996: } 997: 998: if gt(refundGas, gasLimit) { 999: assertionError("L1: refundGas > gasLimit") 1000: } 1001: 1002: let payToOperator := safeMul(gasPrice, safeSub(gasLimit, refundGas, "lpah"), "mnk") 1003: 1004: notifyAboutRefund(refundGas) 1005: 1006: // Paying the fee to the operator 1007: mintEther(BOOTLOADER_FORMAL_ADDR(), payToOperator, false) 1008:
L: 991 - 995 calculates the refund if any.
L: 998 ensures that the refundGas
should not be more than gasLimit
to prevent an infinite money glitch.
L: 1002 calculates the fee amount to be paid (minted) to the node.
However, the operator will not receive anything when gasLimit
== refundGas
due to gasPrice * (gasLimit - refundGas) == 0
We recommend asserting payToOperator > 0
condition to eliminate fee bypassing.
During testing we found a test "Should deposit erc20 token successfully", that succeeds, however, it silently fails. It can be verified by adding a revert reason for the test case:
it("Should deposit erc20 token successfully", async () => { const amount = ethers.utils.parseEther("0.001"); const mintValue = ethers.utils.parseEther("0.002"); await l1Weth.connect(randomSigner).deposit({ value: amount }); await (await l1Weth.connect(randomSigner).approve(l1SharedBridge.address, amount)).wait(); + const revertReason = await getCallRevertReason( bridgehub.connect(randomSigner).requestL2TransactionTwoBridges( { chainId, mintValue, l2Value: amount, l2GasLimit: 1000000, l2GasPerPubdataByteLimit: REQUIRED_L2_GAS_PRICE_PER_PUBDATA, refundRecipient: ethers.constants.AddressZero, secondBridgeAddress: l1SharedBridge.address, secondBridgeValue: 0, secondBridgeCalldata: new ethers.utils.AbiCoder().encode( ["address", "uint256", "address"], [l1Weth.address, amount, await randomSigner.getAddress()] ), }, { value: mintValue } + ) ); + expect(revertReason).equal("ShB: WETH deposit not supported"); });
It succeds, even though it shouldn't
Shared Bridge tests ✔ Should deposit erc20 token successfully 103 passing (53s)
It succeeds. That means that the test really doesn't do what it's supposed to. Because there are many places with similar testing patterns, we encourage the team to go through the test cases and make sure that the tests really test what they are supposed to.
The zKsync unique architecture provides almost instant L1 <-> L2 interactions compared to other roll ups. While it´s a big advantage, it has a trade-off not being able to intervene worst-case scenarios.
The finalizeWithdrawal
function serves almost instantly once the required data is provided.
However, this will be an escape hatch for the black hats as withdrawals of the theft funds from L2 to L1 will be smooth compared to other roll ups, despite having a privileged function called revertBatches
.
We recommend considering implementing a cool down period as applicable as CEX implementations, so that the fauled batch can be reverted by the privileged role (Admin / Owner)
executeInstant
and subject to frontrunsThe scheduleShadow
function schedules an upgrade only by the id
and prevents the calldata from being studied.
Contract: Governance.sol 142: function scheduleShadow(bytes32 _id, uint256 _delay) external onlyOwner { 143: _schedule(_id, _delay); 144: emit ShadowOperationScheduled(_id, _delay); 145: } 146:
And the SecurityCouncil
executes the upgrade by executeInstant
function:
Contract: Governance.sol 185: function executeInstant(Operation calldata _operation) external payable onlySecurityCouncil { 186: bytes32 id = hashOperation(_operation); 187: // Check if the predecessor operation is completed. 188: _checkPredecessorDone(_operation.predecessor); 189: // Ensure that the operation is in a pending state before proceeding. 190: require(isOperationPending(id), "Operation must be pending before execution"); 191: // Execute operation. 192: _execute(_operation.calls); 193: // Reconfirming that the operation is still pending before execution. 194: // This is needed to avoid unexpected reentrancy attacks of re-executing the same operation. 195: require(isOperationPending(id), "Operation must be pending after execution"); 196: // Set operation to be done 197: timestamps[id] = EXECUTED_PROPOSAL_TIMESTAMP; 198: emit OperationExecuted(id); 199: }
But since the Operation calldata is the input, it has a MEV risk anyway.
MsgValueSimulator checks how much gas was passed, and it reserves MSG_VALUE_SIMULATOR_STIPEND_GAS
for the call:
fallback(bytes calldata _data) external onlySystemCall returns (bytes memory) { // Firstly we calculate how much gas has been actually provided by the user to the inner call. // For that, we need to get the total gas available in this context and subtract the stipend from it. uint256 gasInContext = gasleft(); // Note, that the `gasInContext` might be slightly less than the MSG_VALUE_SIMULATOR_STIPEND_GAS, since // by the time we retrieve it, some gas might have already been spent, e.g. on the `gasleft` opcode itself. // @audit gasInContext might be set to a specific value that will uint256 userGas = gasInContext > MSG_VALUE_SIMULATOR_STIPEND_GAS ? gasInContext - MSG_VALUE_SIMULATOR_STIPEND_GAS : 0;
Technically it's possible to set gasleft specifically to 27001 (MSG_VALUE_SIMULATOR_STIPEND_GAS = 27000
), so that given that the gas is substracted during userGas
calculations, actual gas will be smaller than 27000, because of the costs of opcode execution after gasleft
is cached in gasInContext
. While currently, we haven't identified any possibilities of doing so, please consider adding additional gas.
#0 - c4-sponsor
2024-04-18T09:49:30Z
razzorsec (sponsor) acknowledged
#1 - razzorsec
2024-04-18T09:51:42Z
We acknowledge some of the findings, and we want to make comments for them as: L01 => sort of true, but the situation is unreal (the current system does not support arbitrary turning off/on of hyperbridging, what more, even turning on is not supported) L06 => looks legit, but changing governance is hard, so unless there is a specific bug, we wont change N01 => Design Choice N02 => Expected
#2 - c4-judge
2024-04-29T13:39:48Z
alex-ppg marked the issue as grade-b
#3 - 0xSorryNotSorry
2024-05-01T13:25:20Z
Thank you so much for judging this large codebase and spending more time looking into this escalation again @alex-ppg ,
The contest page declares below (At the first paragraph of this section):
While the assumption is that either governance or security council are not malicious, neither governance, nor the security council should be able to circumvent the limitations imposed on them.
We believe that our L-05 Issue breaks above declaration circumventing the limitations with a valid path.
Looking forward to your consideration for upgrading it to Med.
Thank you again.
#4 - alex-ppg
2024-05-02T14:48:21Z
Hey @0xSorryNotSorry, thanks for contributing to the PJQA process! The owner
has more privileges than the securityCouncil
by the system's design; the only party able to propose is the owner
. The only way to adjust the security council is for a proposal to go through, either instantaneously or via a normal delay. Only the owner is able to cancel proposals which is a deliberate change per the contest's Changelog.
Culminating the above, only the owner
is able to change the security council and the security council can only execute proposals the owner has specified and cannot act independently. As such, issue L-05 outlines a valid QA / Analysis observation that the owner has more privileges than the security council but does not outline an actual vulnerability or misbehavior of the code.
If the security council was able to cancel proposals, then I would deem this a correct M vulnerability but that would also open up an attack vector whereby the security council holds the owner hostage, which is a scenario I presume the zkSync Era team wanted to avoid in the first place.