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: 1/24
Findings: 3
Award: $128,509.53
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: bin2chen
112726.3214 USDC - $112,726.32
A very important modification of this update is that the GAS spent by pubdata
is collected at the final step of the transaction.
But if there is a paymaster
, when executing paymaster.postTransaction(_maxRefundedGas)
_maxRefundedGas
does not subtract the spentOnPubdata
bootloader.yul
the code is as follow:
function refundCurrentL2Transaction( txDataOffset, transactionIndex, success, gasLeft, gasPrice, reservedGas, basePubdataSpent, gasPerPubdata ) -> finalRefund { setTxOrigin(BOOTLOADER_FORMAL_ADDR()) finalRefund := 0 let innerTxDataOffset := add(txDataOffset, 32) let paymaster := getPaymaster(innerTxDataOffset) let refundRecipient := 0 switch paymaster case 0 { // No paymaster means that the sender should receive the refund refundRecipient := getFrom(innerTxDataOffset) } default { refundRecipient := paymaster if gt(gasLeft, 0) { checkEnoughGas(gasLeft) let nearCallAbi := getNearCallABI(gasLeft) let gasBeforePostOp := gas() pop(ZKSYNC_NEAR_CALL_callPostOp( // Maximum number of gas that the postOp could spend nearCallAbi, paymaster, txDataOffset, success, // Since the paymaster will be refunded with reservedGas, // it should know about it @> safeAdd(gasLeft, reservedGas, "jkl"), basePubdataSpent, reservedGas, gasPerPubdata )) let gasSpentByPostOp := sub(gasBeforePostOp, gas()) gasLeft := saturatingSub(gasLeft, gasSpentByPostOp) } } // It was expected that before this point various `isNotEnoughGasForPubdata` methods would ensure that the user // has enough funds for pubdata. Now, we just subtract the leftovers from the user. @> let spentOnPubdata := getErgsSpentForPubdata( basePubdataSpent, gasPerPubdata ) let totalRefund := saturatingSub(add(reservedGas, gasLeft), spentOnPubdata) askOperatorForRefund( totalRefund, spentOnPubdata, gasPerPubdata ) let operatorProvidedRefund := getOperatorRefundForTx(transactionIndex) // If the operator provides the value that is lower than the one suggested for // the bootloader, we will use the one calculated by the bootloader. let refundInGas := max(operatorProvidedRefund, totalRefund) // The operator cannot refund more than the gasLimit for the transaction if gt(refundInGas, getGasLimit(innerTxDataOffset)) { assertionError("refundInGas > gasLimit") } if iszero(validateUint32(refundInGas)) { assertionError("refundInGas is not uint32") } let ethToRefund := safeMul( refundInGas, gasPrice, "fdf" ) directETHTransfer(ethToRefund, refundRecipient) finalRefund := refundInGas }
paymaster's _maxRefundedGas = gasLeft + reservedGas
, without subtracting spentOnPubdata
.
This way _maxRefundedGas
will be much larger than the correct value
paymaster
will refund the used spentOnPubdata
to the user
paymaster
will refund the spentOnPubdata
already used by the user
function refundCurrentL2Transaction( txDataOffset, transactionIndex, success, gasLeft, gasPrice, reservedGas, basePubdataSpent, gasPerPubdata ) -> finalRefund { setTxOrigin(BOOTLOADER_FORMAL_ADDR()) finalRefund := 0 let innerTxDataOffset := add(txDataOffset, 32) let paymaster := getPaymaster(innerTxDataOffset) let refundRecipient := 0 switch paymaster case 0 { // No paymaster means that the sender should receive the refund refundRecipient := getFrom(innerTxDataOffset) } default { refundRecipient := paymaster + let expectSpentOnPubdata := getErgsSpentForPubdata( + basePubdataSpent, + gasPerPubdata + ) if gt(gasLeft, 0) { checkEnoughGas(gasLeft) let nearCallAbi := getNearCallABI(gasLeft) let gasBeforePostOp := gas() pop(ZKSYNC_NEAR_CALL_callPostOp( // Maximum number of gas that the postOp could spend nearCallAbi, paymaster, txDataOffset, success, // Since the paymaster will be refunded with reservedGas, // it should know about it - safeAdd(gasLeft, reservedGas, "jkl"), + saturatingSub(add(reservedGas, gasLeft), expectSpentOnPubdata), basePubdataSpent, reservedGas, gasPerPubdata )) let gasSpentByPostOp := sub(gasBeforePostOp, gas()) gasLeft := saturatingSub(gasLeft, gasSpentByPostOp) } } // It was expected that before this point various `isNotEnoughGasForPubdata` methods would ensure that the user // has enough funds for pubdata. Now, we just subtract the leftovers from the user. let spentOnPubdata := getErgsSpentForPubdata( basePubdataSpent, gasPerPubdata ) let totalRefund := saturatingSub(add(reservedGas, gasLeft), spentOnPubdata) askOperatorForRefund( totalRefund, spentOnPubdata, gasPerPubdata ) let operatorProvidedRefund := getOperatorRefundForTx(transactionIndex) // If the operator provides the value that is lower than the one suggested for // the bootloader, we will use the one calculated by the bootloader. let refundInGas := max(operatorProvidedRefund, totalRefund) // The operator cannot refund more than the gasLimit for the transaction if gt(refundInGas, getGasLimit(innerTxDataOffset)) { assertionError("refundInGas > gasLimit") } if iszero(validateUint32(refundInGas)) { assertionError("refundInGas is not uint32") } let ethToRefund := safeMul( refundInGas, gasPrice, "fdf" ) directETHTransfer(ethToRefund, refundRecipient) finalRefund := refundInGas }
Context
#0 - saxenism
2024-04-04T12:00:05Z
We confirm the finding. It is good.
We however believe that this is a medium severity issue since this is a rarely used functionality.
#1 - c4-sponsor
2024-04-04T12:00:09Z
saxenism marked the issue as disagree with severity
#2 - c4-sponsor
2024-04-04T12:00:12Z
saxenism (sponsor) confirmed
#3 - alex-ppg
2024-04-29T14:12:52Z
The Warden has identified a discrepancy in the way paymaster refunds are processed for L2 transactions, resulting in an over-compensation that overlaps with the gas spent on public data.
The exhibit is correct, and I am not in complete agreement with the Sponsor's assessment in relation to the submission's severity. The referenced code will trigger if a paymaster has been defined, and I do not believe there is any constraint that permits a malicious user from always triggering the surplus refund and thus from slowly siphoning funds in the form of gas from the system.
As the flaw is always present and its impact is properly considered medium, I consider the combination of those two factors to merit a high severity rating.
#4 - c4-judge
2024-04-29T14:12:55Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-04-29T14:12:57Z
alex-ppg marked the issue as selected for report
🌟 Selected for report: 0x11singh99
Also found by: Bauchibred, Dup1337, Topmark, XDZIBECX, bctester, bin2chen, erebus, forgebyola, oakcobalt, rvierdiiev, yashar, zhanmingjing
565.1582 USDC - $565.16
StateTransitionManager.unfreezeChain()
the code is as follows:
contract StateTransitionManager is IStateTransitionManager, ReentrancyGuard, Ownable2Step { ... function unfreezeChain(uint256 _chainId) external onlyOwner { IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); }
This method is used for unfreeze
, but it calls freezeDiamond()
, which makes it impossible to unfreeze
properly.
StateTransitionManager cannot unfreeze zkSync.
Although admin
of zkSync
can do unfreeze
directly by zkSync.unfreezeDiamond()
However, admin
is usually a multi-sign account, and to execute it would require a new wait for more time
during which time zkSync
would be fully freeze
function unfreezeChain(uint256 _chainId) external onlyOwner { - IZkSyncStateTransition(stateTransition[_chainId]).freezeDiamond(); + IZkSyncStateTransition(stateTransition[_chainId]).unfreezeDiamond(); }
Context
#0 - c4-judge
2024-04-02T17:03:18Z
alex-ppg marked the issue as duplicate of #97
#1 - c4-judge
2024-04-02T17:03:32Z
alex-ppg changed the severity to 3 (High Risk)
#2 - c4-judge
2024-04-29T13:51:53Z
alex-ppg changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-04-29T13:54:13Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: bin2chen
Also found by: rvierdiiev
15218.0534 USDC - $15,218.05
The migration steps for L1ERC20Bridge/L2ERC20Bridge
are as follows:
II. Upgrade L1ERC20Bridge contract
- Upgrade L2 bridge
The new L2ERC20Bridge will upgraded to become the L2SharedBridge, and it will be backwards compatible with all messages from the old L1ERC20Bridge, so we upgrade that first as L1->L2 messages are much faster, and in the meantime we can upgrade the L1ERC20Bridge. The new L2SharedBridge can receive deposits from both the old L1ERC20Bridge and the new L1SharedBridge.
- Upgrade L1ERC20Bridge
We upgrade the L1ERC20Bridge, and move all ERC20 tokens to the L1SharedBridge.
Since L2ERC20Bridge
will be updated first, and then L1ERC20Bridge
will be updated, L2SharedBridge
needs to be compatible with the old L1ERC20Bridge
before L1ERC20Bridge
is updated.
So in L2SharedBridge.initialize()
we need to set l1LegacyBridge = L1ERC20Bridge
and finalizeDeposit()
to allow l1LegacyBridge
to execute.
But the current implementation doesn't set l1LegacyBridge
, it's always address(0)
.
function initialize( address _l1Bridge, address _l1LegecyBridge, bytes32 _l2TokenProxyBytecodeHash, address _aliasedOwner ) external reinitializer(2) { require(_l1Bridge != address(0), "bf"); require(_l2TokenProxyBytecodeHash != bytes32(0), "df"); require(_aliasedOwner != address(0), "sf"); require(_l2TokenProxyBytecodeHash != bytes32(0), "df"); l1Bridge = _l1Bridge; l2TokenProxyBytecodeHash = _l2TokenProxyBytecodeHash; if (block.chainid != ERA_CHAIN_ID) { address l2StandardToken = address(new L2StandardERC20{salt: bytes32(0)}()); l2TokenBeacon = new UpgradeableBeacon{salt: bytes32(0)}(l2StandardToken); l2TokenBeacon.transferOwnership(_aliasedOwner); } else { require(_l1LegecyBridge != address(0), "bf2"); @> // Missing set l1LegecyBridge? // l2StandardToken and l2TokenBeacon are already deployed on ERA, and stored in the proxy } }
The above method just checks _l1LegecyBridge ! = address(0)
, and does not assign a value, l1LegacyBridge
is always adddress(0)
.
This way any messages sent by the user before update L1ERC20Bridge
will fail because finalizeDeposit()
will not pass the validation
Until L1ERC20Bridge
is updated, messages sent by the user will fail.
function initialize( address _l1Bridge, address _l1LegecyBridge, bytes32 _l2TokenProxyBytecodeHash, address _aliasedOwner ) external reinitializer(2) { ... if (block.chainid != ERA_CHAIN_ID) { address l2StandardToken = address(new L2StandardERC20{salt: bytes32(0)}()); l2TokenBeacon = new UpgradeableBeacon{salt: bytes32(0)}(l2StandardToken); l2TokenBeacon.transferOwnership(_aliasedOwner); } else { require(_l1LegecyBridge != address(0), "bf2"); + l1LegacyBridge = _l1LegecyBridge // l2StandardToken and l2TokenBeacon are already deployed on ERA, and stored in the proxy } }
Context
#0 - c4-judge
2024-04-02T16:58:52Z
alex-ppg marked the issue as primary issue
#1 - c4-sponsor
2024-04-04T11:49:41Z
saxenism (sponsor) confirmed
#2 - saxenism
2024-04-04T11:51:51Z
We confirm this finding. Thank you :)
Just adding a little more context here:
The deposits will fail but user can call claimFailedDeposit to get funds back. We have failed to assign the l1LegacyBridge, but that does not pose a security risk since the l1Bridge should work as expected and therefore, the finalizeDeposit function still has a way to work. However, yes, this is an issue because this breaks our intended behaviour.
#3 - alex-ppg
2024-04-29T13:57:59Z
The Warden has demonstrated how a missing assignment will result in legacy transactions failing to finalize. The impact is constrained to legacy transactions, and as it is possible for users to recover their failed deposits the impact is impermanent resulting in a severity of medium being appropriate.
#4 - c4-judge
2024-04-29T13:58:03Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2024-04-29T13:58:05Z
alex-ppg marked the issue as selected for report