zkSync Era - bin2chen's results

General Information

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

zkSync

Findings Distribution

Researcher Performance

Rank: 1/24

Findings: 3

Award: $128,509.53

🌟 Selected for report: 2

🚀 Solo Findings: 1

Findings Information

🌟 Selected for report: bin2chen

Labels

bug
3 (High Risk)
disagree with severity
satisfactory
selected for report
sponsor confirmed
H-01

Awards

112726.3214 USDC - $112,726.32

External Links

Lines of code

https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/system-contracts/bootloader/bootloader.yul#L1591

Vulnerability details

Vulnerability details

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

Impact

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
            }

Assessed type

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

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
:robot:_07_group
duplicate-97

Awards

565.1582 USDC - $565.16

External Links

Lines of code

https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/ethereum/contracts/state-transition/StateTransitionManager.sol#L166

Vulnerability details

Vulnerability details

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.

Impact

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();
    }

Assessed type

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

Findings Information

🌟 Selected for report: bin2chen

Also found by: rvierdiiev

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor confirmed
:robot:_50_group
M-02

Awards

15218.0534 USDC - $15,218.05

External Links

Lines of code

https://github.com/code-423n4/2024-03-zksync/blob/4f0ba34f34a864c354c7e8c47643ed8f4a250e13/code/contracts/zksync/contracts/bridge/L2SharedBridge.sol#L68

Vulnerability details

Vulnerability details

The migration steps for L1ERC20Bridge/L2ERC20Bridge are as follows:

https://github.com/code-423n4/2024-03-zksync/blob/main/docs/Protocol%20Section/Migration%20process.md

II. Upgrade L1ERC20Bridge contract

  1. 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.

  1. 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

Impact

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
        }
    }

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter