GithubHelp home page GithubHelp logo

2023-10-zksync-findings's Introduction

zkSync Audit

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Respond to issues.
  2. Weigh in on severity.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Respond to issues

For each High or Medium risk finding that appears in the dropdown at the top of the chrome extension, please label as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, select the disagree with severity option, along with a comment indicating your reasoning for the judge to review. You may also add questions for the judge in the comments. (Note: even if you disagree with severity, please still choose one of the sponsor confirmed or sponsor acknowledged options as well.)

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

QA reports, Gas reports, and Analyses

All warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

For QA reports, Gas reports, and Analyses, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2023-10-zksync-findings's People

Contributors

c4-submissions avatar c4-bot-5 avatar c4-bot-4 avatar c4-bot-7 avatar c4-bot-9 avatar c4-bot-3 avatar c4-bot-6 avatar c4-bot-1 avatar c4-bot-10 avatar c4-bot-8 avatar c4-bot-2 avatar c4-judge avatar code423n4 avatar kartoonjoy avatar thebrittfactor avatar

Stargazers

Mohamed Asif avatar The Last of the Mohicans avatar VictoryGod avatar

Watchers

Ashok avatar  avatar

2023-10-zksync-findings's Issues

[M-09] Reentrancy in the L2Weth contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2Weth.sol#L78-L85

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Function vulnerable to reentrancy

// 2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2Weth.sol#L78-L85
    function bridgeBurn(address _from, uint256 _amount) external override onlyBridge {
        _burn(_from, _amount);
        // sends Ether to the bridge
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Failed withdrawal");


        emit BridgeBurn(_from, _amount);
    }

Proof of Concept

Exploit

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.13;

import "./L2Weth.sol";

contract tL2Weth {

   L2Weth public x1;

  constructor(L2Weth _x1) {

      x1 = L2Weth(_x1);

   }

  function testReentrancy(L2ERC20Bridge _x1) external payable {

      x1.bridgeBurn(address(_x1), uint256(1e18));

   }

  receive() external payable {
        x1.depositTo(msg.sender);
    }

}

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

Lack of Access Control for `diamondCut` Function

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/libraries/Diamond.sol#L95-L121

Vulnerability details

Impact

The lack of access control for the diamondCut function can have a significant impact. Without proper access restrictions, any user or contract can call this function and modify the Diamond contract's facets and functions. This could lead to unauthorized changes in contract behavior, potential security vulnerabilities, or unintended consequences.

Proof of Concept

The diamondCut function is a crucial part of the Diamond library, allowing changes to the Diamond proxy's facets and functions. However, it lacks access control, meaning that anyone with knowledge of the contract's address can potentially call this function and make arbitrary modifications to the contract's behavior and storage.

function diamondCut(DiamondCutData memory _diamondCut) internal {
    // ... (other code)

    for (uint256 i = 0; i < facetCutsLength; i = i.uncheckedInc()) {
        // ... (other code)

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

    // ... (other code)
}

Tools Used

Manual

Recommended Mitigation Steps

Access control should be implemented to restrict who can call the diamondCut function.

Assessed type

Access Control

[M-11] The same contract library names in the SystemContractsCaller contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/system-contracts/contracts/libraries/SystemContractsCaller.sol#L68
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/SystemContractsCaller.sol#L36

Vulnerability details

Impact

Byte code information is created once one compiles a solidity contract.
When two solidity contracts are created with the same contract and file name in your codebase and you want to compile them, you will end up with errors.

Proof of Concept

Vulnerable duplicate contracts

// File: code/system-contracts/contracts/libraries/SystemContractsCaller.sol
// Line 68
library SystemContractsCaller {
// File: code/contracts/zksync/contracts/SystemContractsCaller.sol
// Line: 36
library SystemContractsCaller {

Tools Used

VS Code.

Recommended Mitigation Steps

Make all the contracts in your repository to have individual names.
Once all your contract names are individual then compile will be achievable.

Assessed type

Library

lack of ``fallback()`` and ``receive()`` function

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L198
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L37-L429

Vulnerability details

Impact

incoming transaction with eth value is not read in the contract, then all incoming ETH transactions are considered bridge process

Proof of Concept

For ZkSync chain, L2 to L1 communication is free, but L1 to L2 communication requires a certain
amount of ETH to be supplied to cover the base cost of the transaction (including the _l2Value) + layer 2 operator
tip.
The deposit function of L1ERC20Bridge.sol relies on the zkSync.requestL2Transaction
function to send messages from L1 to L2.

 function deposit(
        address _l2Receiver,
        address _l1Token,
        uint256 _amount,
        uint256 _l2TxGasLimit,
        uint256 _l2TxGasPerPubdataByte,
        address _refundRecipient
    ) public payable nonReentrant senderCanCallFunction(allowList) returns (bytes32 l2TxHash) {
......
      l2TxHash = zkSync.requestL2Transaction{value: msg.value}(

although, the requestL2Transaction call will succes because there's
payable function.
However, this is dangerous to do
because there are no receive() and fallback() functions implemented in the contract L1ERC20Bridge.sol and
contract MailboxFacet.sol to receive ETH.

As a result, L1ERC20Bridge#deposit function relies on the zkSync.requestL2Transaction for forward messages, will cryptic or same with MailboxFacet#requestL2Transaction function for bridge eth.

Because these two transactions are cryptic, in theory this will give rise to the potential for a re-entrancy attack.

Tools Used

Manual and from case studies

Recommended Mitigation Steps

  • add fallback() function in contract MailBox.sol
  • add receive() function in contract L1ERC20Bridge.sol

Assessed type

Other

`s.verifier.verify` will always fail when `proofPublicInput` contains more than 1 elements

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L356
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/Verifier.sol#L522

Vulnerability details

Impact

proveBatches will always fail when _committedBatches.length is greater than 1, as verifier expects only one public input: https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/Verifier.sol#L522

Recommended Mitigation Steps

Make the caller and callee side consistent.

Assessed type

Invalid Validation

Potential Reentrancy Vulnerability in MsgValueSimulator Contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/contracts/MsgValueSimulator.sol#L25-L59

Vulnerability details

Impact:

The MsgValueSimulator contract contains a potential reentrancy vulnerability that could allow an attacker to bypass certain security checks, manipulate the contract's internal state, and disrupt its accounting logic. This vulnerability arises from the contract's behavior when called with non-zero msg.value and controlled calldata.

Proof of Concept:

The vulnerability can be exploited as follows:

  1. An attacker triggers a call to the MsgValueSimulator contract with non-zero msg.value and controls the calldata.

  2. The MsgValueSimulator contract, upon receiving this call, executes the EfficientCall.mimicCall function, effectively calling itself with the provided calldata.

  3. This self-call within the same transaction allows the attacker to bypass certain security checks based on the assumption of distinct msg.sender values within a single transaction.

  4. The attacker can manipulate the contract's internal state and potentially disrupt its accounting logic, as some contracts rely on the assumption that they are not called by themselves within the same transaction.

Impact Severity:

The impact severity of this vulnerability is significant. It could lead to unauthorized access to contract functions, unexpected behavior, and potential financial losses or disruptions to the affected contracts. The severity is classified as critical.

Recommended Mitigation Steps with Code:

To mitigate this vulnerability, it is recommended to modify the MsgValueSimulator contract to prevent self-calls when called with non-zero msg.value. The contract should also enforce stricter security checks to ensure that calls with non-zero msg.value do not result in unintended consequences. Below is an example of how this mitigation could be implemented:

function fallback(bytes calldata _data) external onlySystemCall returns (bytes memory) {
    (uint256 value, bool isSystemCall, address to) = _getAbiParams();

    require(to != address(this), "MsgValueSimulator calls itself is not allowed");

    if (value != 0) {
        // Perform the transfer only if value is non-zero.
        (bool success, ) = address(ETH_TOKEN_SYSTEM_CONTRACT).call(
            abi.encodeCall(ETH_TOKEN_SYSTEM_CONTRACT.transferFromTo, (msg.sender, to, value))
        );

        // Revert if the transfer fails.
        require(success, "Transfer of ETH failed");
    }

    // For the next call, set `msg.value` to 0 to prevent self-calls.
    SystemContractHelper.setValueForNextFarCall(0);

    return EfficientCall.mimicCall(gasleft(), to, _data, msg.sender, false, isSystemCall);
}

Assessed type

Reentrancy

Lack of Full Permissionless Priority Queue and Missing User Fee Mechanism

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L37

Vulnerability details

The contract lacks full adherence to the documentation's requirement for a fully permissionless priority queue. Additionally, it does not implement the expected user fee mechanism for transaction submission to the priority queue.

Impact

The lack of full permissionlessness in the priority queue may hinder the open participation of users, potentially limiting the diversity of transactions processed and causing centralization concerns. Additionally, the absence of a user fee mechanism may make the system susceptible to spam attacks and increased resource consumption.

Proof of Concept

The contract documentation specifies the need for a fully permissionless priority queue to prevent malicious activity. However, the contract introduces access control mechanisms and sender checks, which partially restrict access to the priority queue. This deviation from full permissionlessness could limit the openness of the system.

Furthermore, the documentation suggests that users must pay a fee to the operator when submitting transactions to the priority queue to prevent spam and malicious activity. However, the contract does not include any code or mechanisms for users to pay such fees.

Tools Used

Manual

Recommended Mitigation Steps

  • Review and remove unnecessary access control mechanisms to ensure the priority queue is fully permissionless as per the documentation's requirement.

  • Implement a user fee mechanism that allows users to pay a fee to the operator when submitting transactions to the priority queue. This fee should help deter spam and malicious activity, aligning with the documentation's recommendation.

Assessed type

Other

Wrong Result When EcAdd Precompile Contract is Called in Delegatecall

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/system-contracts/contracts/precompiles/EcAdd.yul#L5

Vulnerability details

Impact

The observed inconsistency in the behavior of the EcAdd precompile contract when accessed via delegatecall introduces unpredictability within the zkSync Era environment. This inconsistency may affect the reliability and expected functionality of contracts using this precompile.

Proof of Concept

Within the zkSync Era environment, an inconsistency emerges regarding the behavior of the EcAdd precompile contract, found at address 0x06, when accessed through a delegatecall. This behavior diverges from the standard Ethereum Virtual Machine (EVM) operation, where the outcomes are consistent across call, staticcall, and delegatecall methods.
https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/system-contracts/contracts/precompiles/EcAdd.yul#L5

In the zkSync Era, when the EcAdd precompile contract is invoked using delegatecall, it deviates from the typical behavior. Instead of performing as a precompile contract, it delegates the call to the body of the contract itself and executes its code within the caller's context. Consequently, the returned value does not align with the expected outcome of a precompileCall.

To illustrate this discrepancy, consider the following example. In the Ethereum Virtual Machine, when executing the provided code, the returned struct G1Point value consistently appears as follows:

{X: 1368015179489954701390400359078579693043519447331113978918064868415326638035, Y: 9918110051302171585080402603319702774565515993150576347155970296011118125764}

for all three scenarios: ecAddStaticcall, ecAddCall, and ecAddDelegatecall. However, in the zkSync Era, while ecAddStaticcall and ecAddCall produce the same results as in the EVM, ecAddDelegatecall yields an incorrect outcome.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

struct G1Point {
    uint256 X;
    uint256 Y;
}

contract EcAddPoC {
    uint256[4] input = [1, 2, 1, 2];

    function ecAddStaticcall() public returns (G1Point memory r) {
        uint256[4] memory _input = input;

        assembly {
            pop(staticcall(gas(), 0x06, _input, 0xc0, r, 0x60))
        }
    }

    function ecAddCall() public returns (G1Point memory r) {
        uint256[4] memory _input = input;

        assembly {
            pop(call(gas(), 0x06, 0, _input, 0xc0, r, 0x60))
        }
    }

    function ecAddDelegatecall() public returns (G1Point memory r) {
        uint256[4] memory _input = input;

        assembly {
            pop(delegatecall(gas(), 0x06, _input, 0xc0, r, 0x60))
        }
    }
}

This discrepancy is of particular significance because it deviates from the expected EVM response. While the likelihood of encountering this issue is not high, as precompile contracts are typically invoked through staticcall rather than delegatecall, it remains a point of concern within the zkSync Era environment.

Tools Used

Recommended Mitigation Steps

The following revised code is recommended:

function delegateCall(
        uint256 _gas,
        address _address,
        bytes calldata _data
    ) internal returns (bytes memory returnData) {
        bool success;
        if(_address == address(0x06){
            success = rawStaticCall(_gas, _address, _data);
        } else {
            success = rawDelegateCall(_gas, _address, _data);
        }
        returnData = _verifyCallResult(success);
    }

https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/system-contracts/contracts/libraries/EfficientCall.sol#L88

Assessed type

Context

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Inconsistency in L1ERC20Bridge and L1WethBridge deposit could lead to user fund loss

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L194-L197
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/bridge/L1WethBridge.sol#L181-L184
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L247-L250

Vulnerability details

Impact

Inconsistency check of refundRecipient in bridges contracts deposits could lead to loss of user funds during a deposit.

As it is clearly explained in the comments, refund address needs to be under least control such that if _refundRecipient:

  • is a contract on L1 => refund sent to aliased _refundRecipient
  • is address(0) and sender has no deployed bytecode on L1 => sent to msg.sender.
  • is address(0) and sender has deployed bytecode on L1 => sent to aliased msg.sender.

Problem arise from the first statement / check which is not performed as expected.

Proof of Concept

Supposing a user deposits an amount of WETH or Tokens (depending on the bridge) and for some reason, he ends up providing a contract address as _refundRecipient argument, while the deposit on L2 fails.

Nothing in the code will actually prevent him from setting such a parameter, which will lead to loss of user funds in case of issue during the process.

Here are the checks performed so far:

address refundRecipient = _refundRecipient;
if (_refundRecipient == address(0)) {
    refundRecipient = msg.sender != tx.origin ? AddressAliasHelper.applyL1ToL2Alias(msg.sender) : msg.sender;
}

The logic will apply the alias IF and ONLY IF _refundRecipient hasn't been set / set to address(0).

As a result, the address won't match the aliased one expected on executeL2Transaction in Mailbox contract, preventing user from accessing his refund on L2 performed by this code:

// Change the sender address if it is a smart contract to prevent address collision between L1 and L2.
// Please note, currently zkSync address derivation is different from Ethereum one, but it may be changed in the future.
address sender = msg.sender;
if (sender != tx.origin) {
    sender = AddressAliasHelper.applyL1ToL2Alias(msg.sender);
}

Here having defined a contract in deposit _refundRecipient, which won't be aliased, won't be able to call the execution on L2 as called will be applied an alias here.

Tools Used

Manual review

Recommended Mitigation Steps

In order to avoid such a case, it is recommended to standardize the logic here between the bridges and Mailbox, such that both logic matches and prevent from a gab leading to loss of funds.

If the purpose if effectively to only perform aliasing over smart contracts _refundRecipient, then quoted code blocks from both L1WethBridge and L1ERC20Bridge should be:

// Util function that will help checking if the given address is a contract / EOA or contract under construction, the context of use below will prevent such ambiguousness.
function isContract(address account) private returns (bool) {
    uint size;
    assembly {
        size := extcodesize(account)
    }
    return size > 0;
}

...

address refundRecipient;
// Same logic here
if (_refundRecipient == address(0)) {
    refundRecipient = msg.sender != tx.origin ? AddressAliasHelper.applyL1ToL2Alias(msg.sender) : msg.sender;
} else {
    // Here we can rely on extcodesize since caller would already be checked above
    refundRecipient = isContract(_refundRecipient) ? AddressAliasHelper.applyL1ToL2Alias(_refundRecipient) : _refundRecipient;
}

In the below code, we also check on the given _refundRecipient address and apply the alias if needed.
Reducing the surface of errors and possibility of locking funds forever.

Assessed type

Invalid Validation

Lack of Explicit Check for Existing Facet in `_addOneFunction`

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/libraries/Diamond.sol#L206-L229

Vulnerability details

Impact

The lack of an explicit check to ensure that _facet is an existing facet in the DiamondStorage.facets array may lead to unintended behavior. If _facet is expected to be an existing facet but is not, the function will still execute, potentially adding the function to an incorrect or non-existent facet.

Proof of Concept

The _addOneFunction function is responsible for adding a single function to an existing facet in the diamond proxy contract. Here's the relevant code:

function _addOneFunction(
    address _facet,
    bytes4 _selector,
    bool _isSelectorFreezable
) private {
    DiamondStorage storage ds = getDiamondStorage();

    uint16 selectorPosition = (ds.facetToSelectors[_facet].selectors.length).toUint16();

    // if selectorPosition is nonzero, it means it is not a new facet
    // so the freezability of the first selector must be matched to _isSelectorFreezable
    // so all the selectors in a facet will have the same freezability
    if (selectorPosition != 0) {
        bytes4 selector0 = ds.facetToSelectors[_facet].selectors[0];
        require(_isSelectorFreezable == ds.selectorToFacet[selector0].isFreezable, "J1");
    }

    ds.selectorToFacet[_selector] = SelectorToFacet({
        facetAddress: _facet,
        selectorPosition: selectorPosition,
        isFreezable: _isSelectorFreezable
    });
    ds.facetToSelectors[_facet].selectors.push(_selector);
}
  • The function calculates the selectorPosition within the facet's list of selectors and checks if it's non-zero to determine whether the _facet is new.
  • If selectorPosition is zero, the code assumes that _facet is a new facet and proceeds to add the function.

Tools Used

Manual

Recommended Mitigation Steps

Add an explicit check to ensure that _facet is an existing facet before calling _addOneFunction.

Assessed type

Invalid Validation

[M-06] The same contract library names for IL2StandardToken

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/contracts/zksync/contracts/bridge/interfaces/IL2StandardToken.sol#L5
https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/system-contracts/contracts/interfaces/IL2StandardToken.sol#L5

Vulnerability details

Impact

Byte code information is created once one compiles a solidity contract.
When two solidity contracts are created with the same contract and file name in your codebase and you want to compile them, you will end up with errors.

Proof of Concept

First Vulnerable Duplicate Contract Name

// contracts/zksync/contracts/bridge/interfaces/IL2StandardToken.sol => Line: 5
interface IL2StandardToken {

Second Vulnerable Duplicate Contract Name

// system-contracts/contracts/interfaces/IL2StandardToken.sol => Line: 5
interface IL2StandardToken {

Tools Used

VS Code.

Recommended Mitigation Steps

Make all the contracts in your repository to have individual names.
Once all your contract names are individual then compile will be achievable.

Assessed type

Library

QA Report

See the markdown file with the details of this report here.

[M-10] Reentrancy in the L2WethBridge contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L88-L107
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L63-L81

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.
Functions vulnerable to reentrancy

// https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L88-L107
    function finalizeDeposit(
        address _l1Sender,
        address _l2Receiver,
        address _l1Token,
        uint256 _amount,
        bytes calldata // _data
    ) external payable override {
        require(
            AddressAliasHelper.undoL1ToL2Alias(msg.sender) == l1Bridge,
            "Only L1 WETH bridge can call this function"
        );


        require(_l1Token == l1WethAddress, "Only WETH can be deposited");
        require(msg.value == _amount, "Amount mismatch");


        // Deposit WETH to L2 receiver.
        IL2Weth(l2WethAddress).depositTo{value: msg.value}(_l2Receiver);


        emit FinalizeDeposit(_l1Sender, _l2Receiver, l2WethAddress, _amount);
    }
// https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L63-L81
    function withdraw(
        address _l1Receiver,
        address _l2Token,
        uint256 _amount
    ) external override {
        require(_l2Token == l2WethAddress, "Only WETH can be withdrawn");
        require(_amount > 0, "Amount cannot be zero");


        // Burn WETH on L2, receive ETH.
        IL2StandardToken(l2WethAddress).bridgeBurn(msg.sender, _amount);


        // WETH withdrawal message.
        bytes memory wethMessage = abi.encodePacked(_l1Receiver);


        // Withdraw ETH to L1 bridge.
        L2_ETH_ADDRESS.withdrawWithMessage{value: _amount}(l1Bridge, wethMessage);


        emit WithdrawalInitiated(msg.sender, _l1Receiver, l2WethAddress, _amount);
    }

Proof of Concept

Reentrancy Exploit

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.0;

import "./L2WethBridge.sol";

contract tL2WethBridge {

   L2WethBridge public x1;

   uint160 constant offset = uint160(0x1111000000000000000000000000000000001111);

   constructor(L2WethBridge _x1) {

      x1 = L2WethBridge(_x1);

   }

   function testReentrancy(L2ERC20Bridge _x1) external payable {

      bytes[] memory bitten = new bitten[](1);
      bitten[0] = bytes("0x1e18");

      x1.finalizeDeposit(
         address(msg.sender),
         address(_x1),
         address(uint160(0x800a) - offset),
         uint256(1e18),
         bitten);

      x1.withdraw(address(_x1),address(0x800a),uint256(1e18));
   }

   receive() external payable {
        require(msg.sender == address(_x1), "pd");
        emit x1.EthReceived(msg.value);
    }

} 

Tools Used

VS Code

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

Incomplete Initialization of Contracts via `forceDeployOnAddress` Can Cause Fund Loss and Protocol Disruption

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/contracts/ContractDeployer.sol#L214-L233

Vulnerability details

Impact

When the forceDeployOnAddress function is used to deploy a contract with the callConstructor parameter set to false, the deployed contract's bytecode remains in a "constructing" state. In this state, the contract cannot be called or interacted with as its constructor has not been executed. This vulnerability can have significant consequences:

  • Protocol Disruption: The incomplete deployment of contracts using this method can disrupt the functioning of the protocol. Other contracts and components relying on the correct behavior of the deployed contract may break or operate unexpectedly.

  • Fund Loss: If users or other contracts send funds to an incompletely deployed contract, those funds can become trapped in the contract and may be irrecoverable. This can lead to a loss of assets.

  • Operational Impact: Contracts in a constructing state can interfere with various processes like bridging, message passing, or fund withdrawals, affecting the overall operation of the system.

Proof of Concept

The vulnerability can be observed in the forceDeployOnAddress function of the ContractDeployer contract. When callConstructor is set to false, the contract's bytecode hash remains in the "constructing" state, preventing it from being callable.

function forceDeployOnAddress(ForceDeployment calldata _deployment, address _sender) external payable onlySelf {
    _ensureBytecodeIsKnown(_deployment.bytecodeHash);
    _storeConstructingByteCodeHashOnAddress(_deployment.newAddress, _deployment.bytecodeHash);

    AccountInfo memory newAccountInfo;
    newAccountInfo.supportedAAVersion = AccountAbstractionVersion.None;
    // Accounts have sequential nonces by default.
    newAccountInfo.nonceOrdering = AccountNonceOrdering.Sequential;
    _storeAccountInfo(_deployment.newAddress, newAccountInfo);

    if (_deployment.callConstructor) {
        _constructContract(_sender, _deployment.newAddress, _deployment.input, false);
    }

    emit ContractDeployed(_sender, _deployment.bytecodeHash, _deployment.newAddress);
}

The constructing state is intended to prevent other contracts from interacting with the deployed contract until its constructor is executed. However, if the constructor is not called, the contract remains in the constructing state.

Tools Used

Manual Code audit

Recommended Mitigation Steps

To mitigate this critical vulnerability, it is recommended to ensure that even when callConstructor is set to false, the code should set the deployed contract's bytecode hash to the "constructed" state after deployment. This would allow the contract to be callable and prevent fund losses and protocol disruptions. Care should be taken to fully initialize contracts during deployment, especially if they are part of critical protocol processes like bridging, messaging, or fund management.

Assessed type

Other

Privileged function lacks access control: `LiquidityMiningPath::setConcRewards()` and `LiquidityMiningPath::setAmbRewards()`

Lines of code

https://github.com/code-423n4/2023-10-canto/blob/37a1d64cf3a10bf37cbc287a22e8991f04298fa0/canto_ambient/contracts/callpaths/LiquidityMiningPath.sol#L65-L81

Vulnerability details

Impact

In LiquidityMiningPath contract, setConcRewards() and setAmbRewards() function can set rewards, those two function should only be called by privileged roles such as admins, however the developers seem to comment out the access control code, which is confusing and leaves the protocol vulnerable.

Proof of Concept

The hacker can direct call the functions to set reward.

Tools Used

VScode, hardhat

Recommended Mitigation Steps

Enable the access control code.

Assessed type

Access Control

[M-01] Use of TX.GASPRICE in Mailbox contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L283-L327
https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L304

Vulnerability details

Impact

It is good practice for the user to set the TX.GASPRICE and not for the developer to do that.
This contract is using TX.GASPRICE on line referenced.

Proof of Concept

Vulnerable Code Snippet

// code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol => Line: 304
            params.l2GasPrice = _isFree ? 0 : _deriveL2GasPrice(tx.gasprice, _l2GasPerPubdataByteLimit);

Vulnerable Function

// code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol => Lines: 283-327
    function _requestL2Transaction(
        address _sender,
        address _contractAddressL2,
        uint256 _l2Value,
        bytes calldata _calldata,
        uint256 _l2GasLimit,
        uint256 _l2GasPerPubdataByteLimit,
        bytes[] calldata _factoryDeps,
        bool _isFree,
        address _refundRecipient
    ) internal returns (bytes32 canonicalTxHash) {
        require(_factoryDeps.length <= MAX_NEW_FACTORY_DEPS, "uj");
        uint64 expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION); // Safe to cast
        uint256 txId = s.priorityQueue.getTotalPriorityTxs();


        // Here we manually assign fields for the struct to prevent "stack too deep" error
        WritePriorityOpParams memory params;


        // Checking that the user provided enough ether to pay for the transaction.
        // Using a new scope to prevent "stack too deep" error
        {
            params.l2GasPrice = _isFree ? 0 : _deriveL2GasPrice(tx.gasprice, _l2GasPerPubdataByteLimit);
            uint256 baseCost = params.l2GasPrice * _l2GasLimit;
            require(msg.value >= baseCost + _l2Value, "mv"); // The `msg.value` doesn't cover the transaction cost
        }


        // If the `_refundRecipient` is not provided, we use the `_sender` as the recipient.
        address refundRecipient = _refundRecipient == address(0) ? _sender : _refundRecipient;
        // If the `_refundRecipient` is a smart contract, we apply the L1 to L2 alias to prevent foot guns.
        if (refundRecipient.code.length > 0) {
            refundRecipient = AddressAliasHelper.applyL1ToL2Alias(refundRecipient);
        }


        params.sender = _sender;
        params.txId = txId;
        params.l2Value = _l2Value;
        params.contractAddressL2 = _contractAddressL2;
        params.expirationTimestamp = expirationTimestamp;
        params.l2GasLimit = _l2GasLimit;
        params.l2GasPricePerPubdata = _l2GasPerPubdataByteLimit;
        params.valueToMint = msg.value;
        params.refundRecipient = refundRecipient;


        canonicalTxHash = _writePriorityOp(params, _calldata, _factoryDeps);
    }

Tools Used

VS Code.

Recommended Mitigation Steps

When developers suggest the gas amount using TX.GASPRICE, it might open up vulnerabilities in the contract based on potential incorrect business logic.
The developer should set a maximum tx.gasprice to protect the users funds.

Assessed type

Invalid Validation

Discrepancy Between Documentation and Code Implementation in Timing Requirements

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/24b4b0c1ea553106a194ef36ad4eb05b3b50275c/code/system-contracts/contracts/SystemContext.sol#L402

Vulnerability details

Impact

Discrepancy between the documentation and the code implementation regarding the Timing Invariants of batches and blocks.

Proof of Concept

The documentation specifies the following requirement:

The timestamp of a batch must be ≥ the timestamp of the latest L2 block which belonged to the previous batch.

Link to Documentation

However, the actual code implementation enforces a condition where the timestamp of a batch should simply be greater than the timestamp of the latest L2 block belonging to the previous batch:

function _ensureBatchConsistentWithL2Block(uint128 _newTimestamp) internal view {
    uint128 currentBlockTimestamp = currentL2BlockInfo.timestamp;
    require(
        _newTimestamp > currentBlockTimestamp,
        "The timestamp of the batch must be greater than the timestamp of the previous block"
    );
}

https://github.com/code-423n4/2023-10-zksync/blob/24b4b0c1ea553106a194ef36ad4eb05b3b50275c/code/system-contracts/contracts/SystemContext.sol#L402

To align the code with the documented specification, the code should explicitly enforce that the timestamp of a batch must be greater than or equal to the timestamp of the previous L2 block.

Tools Used

Recommended Mitigation Steps

It should be allowed to have a batch with a timestamp equal to the latest L2 block belongs to the previous batch.

function _ensureBatchConsistentWithL2Block(uint128 _newTimestamp) internal view {
        uint128 currentBlockTimestamp = currentL2BlockInfo.timestamp;
        require(
            _newTimestamp >= currentBlockTimestamp,
            "The timestamp of the batch must be greater than or equal to the timestamp of the previous block"
        );
    }

Assessed type

Context

QA Report

See the markdown file with the details of this report here.

zksync doesn't stick to the assumptions of L2

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/bridge/L1WethBridge.sol#L233

Vulnerability details

Impact

Currently user can only withdraw after a tx calling L2ERC20Bridge::withdraw or L2WethBridge::withdraw is batched to L1. This means if L2 is gone, user won't be able to withdraw the funds. But the assumptions of L2 is that, as long as l1 is alive, user should be able to withdraw their funds.

Recommended Mitigation Steps

Should add a force quit mechanism to stick to the assumption that, as long as l1 is alive, user should be able to withdraw their funds.

Assessed type

Rug-Pull

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

Initializers can be front run

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2ERC20Bridge.sol#L40
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L45
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2Weth.sol#L39
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/bridge/L1WethBridge.sol#L81
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L83

Vulnerability details

Impact

Initializers can be front run resulting in attacker setting critical contract parameters to their liking or even taking access control aspects

Proof of Concept

The indicated links all have initialize functions that can be front run as they can be called by anyone who can provide more gas, it is important to have safety guards and checks in deployments that parameters, configs, addresses, values after deployment are set as expected from the initialize calls

Tools Used

Manual Analysis

Recommended Mitigation Steps

It is recommended to use the Factory deployment that immediately calls the initializers and or make use of hardhat proxy plugins that may help with such. Additionally deployment scripts can be written to check the initialization called by deployer is the one that succeeded and not a front runnned one.

Assessed type

Other

Proof of concept

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/system-contracts/package.json#L15

Vulnerability details

Impact

Detailed description of the impact of this finding.

The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren't very intuitive and can ultimately end up making it easy for attackers to take your site down

[email protected][email protected][email protected]

[email protected] › @nomiclabs/[email protected][email protected][email protected]

[email protected][email protected][email protected][email protected]

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Affected versions of this package are vulnerable to Regular Expression Denial of Service (ReDoS) via the function new Range, when untrusted user data is provided as a range.

Tools Used

[email protected], @6.3.1, @7.5.2

Recommended Mitigation Steps

Try relocking your lockfile or deleting node_modules. If the problem persists, one of your dependencies may be bundling outdated modules

Assessed type

Access Control

should ensure the length of `_newBatch.systemLogs` is a multiple of `L2_TO_L1_LOG_SERIALIZE_SIZE` in `_processL2Logs`

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L123

Vulnerability details

Impact

Invalid/incomplete systemLogs may be allowed to commit.

Recommended Mitigation Steps

Add a check require(emittedL2Logs.length%L2_TO_L1_LOG_SERIALIZE_SIZE==0,"invalid systemLogs"); before line 123.

Assessed type

Invalid Validation

Analysis

See the markdown file with the details of this report here.

[M-07] The same contract library names for IMailbox

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/contracts/ethereum/contracts/zksync/interfaces/IMailbox.sol#L16
https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/system-contracts/contracts/interfaces/IMailbox.sol#L5

Vulnerability details

Impact

Byte code information is created once one compiles a solidity contract.
When two solidity contracts are created with the same contract and file name in your codebase and you want to compile them, you will end up with errors.

Proof of Concept

First Vulnerable Duplicate Interface Name

// code/contracts/ethereum/contracts/zksync/interfaces/IMailbox.sol => Line: 16
interface IMailbox is IBase {

Second Vulnerable Duplicate Interface Name

// code/system-contracts/contracts/interfaces/IMailbox.sol => Line: 5
interface IMailbox {

Tools Used

VS Code.

Recommended Mitigation Steps

Make all the contracts in your repository to have individual names.
Once all your contract names are individual then compile will be achievable.

Assessed type

Library

QA Report

See the markdown file with the details of this report here.

Lack of Validation for Selector Association in `_addOneFunction`

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/ethereum/contracts/zksync/libraries/Diamond.sol#L206-L229

Vulnerability details

Impact

The lack of validation for selector association could lead to unexpected and undesirable behavior in a Diamond proxy contract. If a selector is associated with multiple facets simultaneously, it may become unclear which facet's logic will be executed when that selector is called. This can result in logic conflicts, security vulnerabilities, or unintended changes in contract behavior.

Proof of Concept

The _addOneFunction function is responsible for adding a single function (selector) to a specified facet in the Diamond proxy. While it performs checks related to the freezability of the selector within the specified facet, it does not verify whether the selector is already associated with another facet. This lack of validation means that a selector could be associated with multiple facets simultaneously, leading to unexpected behavior.

Here's the relevant code snippet from the _addOneFunction function:

function _addOneFunction(
    address _facet,
    bytes4 _selector,
    bool _isSelectorFreezable
) private {
    DiamondStorage storage ds = getDiamondStorage();

    uint16 selectorPosition = (ds.facetToSelectors[_facet].selectors.length).toUint16();

    // if selectorPosition is nonzero, it means it is not a new facet
    // so the freezability of the first selector must be matched to _isSelectorFreezable
    // so all the selectors in a facet will have the same freezability
    if (selectorPosition != 0) {
        bytes4 selector0 = ds.facetToSelectors[_facet].selectors[0];
        require(_isSelectorFreezable == ds.selectorToFacet[selector0].isFreezable, "J1");
    }

    ds.selectorToFacet[_selector] = SelectorToFacet({
        facetAddress: _facet,
        selectorPosition: selectorPosition,
        isFreezable: _isSelectorFreezable
    });
    ds.facetToSelectors[_facet].selectors.push(_selector);
}

Tools Used

Manual

Recommended Mitigation Steps

Implement a check within the _addOneFunction function to ensure that the _selector being added is not already associated with another facet. This check can be performed by verifying that the _selector does not exist in the selectorToFacet mapping before adding it.

Assessed type

Invalid Validation

[M-08] Reentrancy within the L2ERC20Bridge contract

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2ERC20Bridge.sol#L63-L88
https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/contracts/zksync/contracts/bridge/L2ERC20Bridge.sol#L105-L119

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Vulnerable functions to reentrancy

// code/contracts/zksync/contracts/bridge/L2ERC20Bridge.sol => Lines: 63-88
    function finalizeDeposit(
        address _l1Sender,
        address _l2Receiver,
        address _l1Token,
        uint256 _amount,
        bytes calldata _data
    ) external payable override {
        // Only the L1 bridge counterpart can initiate and finalize the deposit.
        require(AddressAliasHelper.undoL1ToL2Alias(msg.sender) == l1Bridge, "mq");
        // The passed value should be 0 for ERC20 bridge.
        require(msg.value == 0, "Value should be 0 for ERC20 bridge");


        address expectedL2Token = l2TokenAddress(_l1Token);
        address currentL1Token = l1TokenAddress[expectedL2Token];
        if (currentL1Token == address(0)) {
            address deployedToken = _deployL2Token(_l1Token, _data);
            require(deployedToken == expectedL2Token, "mt");
            l1TokenAddress[expectedL2Token] = _l1Token;
        } else {
            require(currentL1Token == _l1Token, "gg"); // Double check that the expected value equal to real one
        }


        IL2StandardToken(expectedL2Token).bridgeMint(_l2Receiver, _amount);


        emit FinalizeDeposit(_l1Sender, _l2Receiver, expectedL2Token, _amount);
    }
// code/contracts/zksync/contracts/bridge/L2ERC20Bridge.sol => Lines: 105-119
    function withdraw(
        address _l1Receiver,
        address _l2Token,
        uint256 _amount
    ) external override {
        IL2StandardToken(_l2Token).bridgeBurn(msg.sender, _amount);


        address l1Token = l1TokenAddress[_l2Token];
        require(l1Token != address(0), "yh");


        bytes memory message = _getL1WithdrawMessage(_l1Receiver, l1Token, _amount);
        L2ContractHelper.sendMessageToL1(message);


        emit WithdrawalInitiated(msg.sender, _l1Receiver, _l2Token, _amount);
    }

Proof of Concept

Reentrancy Exploit

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.13;

import "./L2ERC20Bridge.sol";

contract tL2ERC20Bridge {

   L2ERC20Bridge public x1;
   uint160 constant offset = uint160(0x1111000000000000000000000000000000001111);

   constructor(L2ERC20Bridge _x1) {
      x1 = L2ERC20Bridge(_x1);
   }

   function testReentrancy(L2ERC20Bridge _x1) external payable {
      
      bytes[] memory bitten = new bytes[](1);
      bitten[0] = bytes("0x1e18");

      x1.finalizeDeposit(
         address(msg.sender),
         address(_x1),
         address(uint160(0x800a) - offset),
         uint256(1e18), 
         bitten);

      x1.withdraw(address(_x1), address(0x800a), uint256(1e18));
   }

    receive() external payable {
      msg.sender.transfer(payable(address(_x1)).balance);
   }

}

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

Operator can mint for himself all the transaction gas by overflowing the refundGas calculation

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/c857609bfdc41a0ee2c1b245217a785f66b42a56/code/system-contracts/bootloader/bootloader.yul#L921

Vulnerability details

Impact

Operator can mint for himself arbitrary ETH by overflowing the refund gas calculation. This is because the refund gas is added to an operator-controlled value via the assembly opcode add without checks for overflows and the pay the operator receives depend on that.

Proof of concept

In bootloader, function processL1Tx, the excess of gas provided by the user on L1 is the sum of

  1. The difference between the gas provided and the gas the operator is willing to provide, by calling getGasLimitForTx -> reservedGas
  2. The difference between the gas the operator is willing to provide and the gas used in both transaction preparation, by calling l1TxPreparation, and execution, by calling getExecuteL1TxAndGetRefund -> refundGas

However, if the potential refund returned by getExecuteL1TxAndGetRefund is less than the one provided by the operator, then the latter is the used one:

bootloader, lines 911 to 918

                    potentialRefund, success := getExecuteL1TxAndGetRefund(txDataOffset, sub(gasLimitForTx, gasUsedOnPreparation))

                    // Asking the operator for refund
                    askOperatorForRefund(potentialRefund)
                    
                    // In case the operator provided smaller refund than the one calculated
                    // by the bootloader, we return the refund calculated by the bootloader.
                    refundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund)

Because refundGas is added to reserveGas with a simple add without further checks for overflows:

bootloader, line 921

                refundGas := add(refundGas, reservedGas)

then the operator can return an over-inflated refundGas so that it gets picked by the max() function and added to reservedGas, overflowing the result (as arithmetic operations in assembly do not have over/underflow checks)

bootloader, lines 921 to 927

                refundGas := add(refundGas, reservedGas) // overflow, refundGas = 0 while gasLimit != 0

                if gt(refundGas, gasLimit) { // correct, 0 < x for all x iff x != 0
                    assertionError("L1: refundGas > gasLimit")
                }

                let payToOperator := safeMul(gasPrice, safeSub(gasLimit, refundGas, "lpah"), "mnk") // gasPrice * (gasLimit - refundGas) == gasPrice * (gasLimit - 0) == gasPrice * gasLimit

being paid all the gas provided by the user and the user would receive none.

Recommended Mitigation Steps

Just use safeAdd:

bootloader, line 921

-               refundGas := add(refundGas, reservedGas)
+               refundGas := safeAdd(refundGas, reservedGas, "whatever error you want")

Assessed type

Under/Overflow

L1WethBridge do not increase user's deposit limit and uses it's own for all bridging

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L261

Vulnerability details

Impact

User can bridge more using the bridge. L1WethBridge limit can be flushed so noone will be able to use bridge.

Proof of Concept

When Execute.requestL2Transaction function is called, then user can provide amount that he would like to bridge to l2. Together with fees this amount is going to be checked for msg.sender to restrict it from bridging more than allowed anount.
This will just increase totalDepositedAmountPerUser for msg.sender and check if he is still fine with current limit.
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L275-L281

    function _verifyDepositLimit(address _depositor, uint256 _amount) internal {
        IAllowList.Deposit memory limitData = IAllowList(s.allowList).getTokenDepositLimitData(address(0)); // address(0) denotes the ETH
        if (!limitData.depositLimitation) return; // no deposit limitation is placed for ETH


        require(s.totalDepositedAmountPerUser[_depositor] + _amount <= limitData.depositCap, "d2");
        s.totalDepositedAmountPerUser[_depositor] += _amount;
    }

So when user has reached limit, he should not be able to bridge anymore.

L1WethBridge.deposit function allows anyone to bridge weth and receive it on l2. Function will withdraw eth from weth and then will call Execute.requestL2Transaction function. As result, msg.sender in Execute.requestL2Transaction will be L1WethBridge and not deposit initiator.

As a lot of people will be using L1WethBridge contract, that means that it's deposit limit can be flushed quickly, so user's will not be able to use bridge anymore.

I understand that currently limit is not used, but just explained potential problem if it will be used.

Tools Used

VsCode

Recommended Mitigation Steps

I think, that totalDepositedAmountPerUser variable should be increased for msg.sender of bridge.

Assessed type

Error

Analysis

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

_maxU256() wont return the higher value when 'a' and 'b' arguments are equal

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/7ed3944429f437a611c32e782a12b320f6a67c17/code/contracts/ethereum/contracts/zksync/facets/Executor.sol#L417

Vulnerability details

Impact

Internal function _maxU256() is supposed to return larger of values between arguments 'a' and 'b'. But when 'a' and 'b' uint256 values are equal, The function will just return the value of 'a' instead of non because "a<b" assess to false and the ternary operator will return the second value 'a'. This may lead to false computation of the work. Please refer to my below foundry test demonstrating the scenario.

Proof of Concept

   contract TestLogic { 

    function _maxU256(uint256 a, uint256 b) external pure returns (uint256) { 
        return a < b ? b : a;  //made the function external to use it on test.
    }
  }
 
  contract TestLogicTest is Test {

    TestLogic internal testlogic;

    // value set for non equal testing
    uint256 value_a = 1987;
    uint256 value_b = 3213;

    // value set for equal testing
    uint256 value_c = 1000;
    uint256 value_d = 1000;


    function setUp() public {
        testlogic = new TestLogic();


    }

    function test__maxU256() public { 
        console.log("Testing with non equal uint256 values");
        vm.startPrank(address(this));
        assertEq(testlogic._maxU256(value_a,value_b), value_b); // should return the larger value
        vm.stopPrank();
    }


    

    // basically the maxU256() function supposed to return the higher value,
    //But when both input aruments are equal,It will just return input value of a.
    function test_maxU256_error() public {
        console.log("Testing with equal uint256 argument values which will just return the value of a");
        vm.startPrank(address(this));
        assertEq(testlogic._maxU256(value_c,value_d), value_c);
        vm.stopPrank();
    }


}

You can run forge test

So in the above test, Both tests passes, In Test number 1 (test__maxU256()) value 'b' is higher so it returns the correct higher value. But in test number 2 (test_maxU256_error()) as both input arguments are equal it just returns expression a. So basically expression a will be the return result as the higher value when both input values are equal. So proper validation requires to make sure both parameter values aren't equal.

Tools Used

hardhat/foundry, vscode

Recommended Mitigation Steps

Mitigation Steps would be to make sure both values are not equal or if equal a condition to proceed.

Assessed type

Math

ETH can be forcibly sent to any L2 contract that doesn't have any code deployed on L1 - undocumented attack vector

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/bootloader/bootloader.yul#L953-L959
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/zksync/facets/Mailbox.sol#L310-L314
https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/zksync/contracts/bridge/L2WethBridge.sol#L120

Vulnerability details

Description

zkSync Era allow user to request transaction from L1 to L2 using a component called the Mailbox facet. Such transaction happen in 2 steps (2 different transactions), firstly L1 side which will create the transaction and submit it to L2, and then when L2 pick it up and execute it. Step 2 is asynchron. In case the transaction fails on L2 for whatever reasons, the ETH sent during the transaction at L1 level are not refunded on L1, but on L2, that is a design decision choosen by the zkSync team. So zkSync implemented a refund mechanism in order to refund the user on L2 if this happen.

This refund mechanism introduce one key drawback which I consider Medium severity, as it open the doors for griefing attack against zkSync Era protocol itself or any application running on it, which can translate to zero impact to major impact (eg: DoS, funds stuck, etc) depending on the application implementation.

Essentially, the current implementation allow an attacker to introduce similar negative side effects as what SELFDESTRUCT (opcode not implemented in zkSync) is doing on L1, BUT this is not documented anywhere.

  • It allows sending ETH to contract that tries to disallow incoming ETH transfer (have no payable functions).
  • It allows sending ETH to contract that tries to actively react on incoming ETH transfers, in such a way that the contract will not notice the transfer. (this is what my PoC will show)
  • It could break contract logic that relies on address(this).balance

To acheive this, we need 3 things:

  1. The L2 contract that the attacker wants to attack MUST NOT have any code deployed on L1 (it could be an EOA thought)
  2. Request a transaction successfully from a L1 perspective (so generating NewPriorityRequest event)
  3. But make the call fails at L2 level (in bootloader) when executing the transaction, which will consequently mint ETH for the refundRecipient on L2. The easiest way to make L2 transaction fails but L1 works is to provide a bad values for address _contractL2 and/or with the bytes calldata _calldata parameters in the Mailbox::requestL2Transaction call, those are not verified on the L1 transactions and passing wrong data will surely make L2 transaction fail.

Impact

ETH can be forcibly sent to any L2 contract in zkSync Era that don't have any code deployed on L1 which can creates all sort of side effects. That translate essentially in bypassing the implementation of smart contracts that have a receive() external payable function. For example for MsgValueSimulator system contract have no code deployed on L1 (like all system contracts most likely), so could be attacked. Or L2EthToken is an active EOA on L1 and doesn't seems to be allowed to hold any as it's simulate ETH on L2, but this would allow to add some which would look weird from a dev/user looking at this contract from the explorer.

Another example, any dApp that use the OpenZeppelin Proxy would assume that receiving ETH will be delegated to the implementation (which might do some accounting or business logic), but with this attack, that would be bypassed. This contract is actually used for WETH9 on L2 (check Proxy.sol), and can be attacked as it's an EOA on L1.

Another example, one high TVL app on zkSync Era. Maverick Protocol

In their Router they have the following code. Which mean the require in receive can be bypassed, would that cause a problem to the application? I will need to investigate more, the impact of this grief attack is really a case by case basis. This contract can be attacked as it's an EOA on L1.

    receive() external payable {
        require(IWETH9(msg.sender) == WETH9, "Not WETH9");
    }

    function unwrapWETH9(uint256 amountMinimum, address recipient) public payable override {
        uint256 balanceWETH9 = WETH9.balanceOf(address(this));
        require(balanceWETH9 >= amountMinimum, "Insufficient WETH9");
        if (balanceWETH9 > 0) {
            WETH9.withdraw(balanceWETH9);
            TransferHelper.safeTransferETH(recipient, balanceWETH9);
        }
    }

   function sweepToken(IERC20 token, uint256 amountMinimum, address recipient) public payable {
        uint256 balanceToken = token.balanceOf(address(this));
        require(balanceToken >= amountMinimum, "Insufficient token");
        if (balanceToken > 0) {
            TransferHelper.safeTransfer(address(token), recipient, balanceToken);
        }
    }

    function safeTransferETH(address to, uint256 value) internal {
        (bool success, ) = to.call{value: value}(new bytes(0));
        require(success, "STE");
    }

Proof of Concept

Here is how this can happen showcasing the new wETH bridge.

  1. Attacker call Mailbox::requestL2Transaction simulating a bad L1WethBridge::deposit (so using same parameters as the bridge use, but changing _contractL2 to a dummy contract) , which will make the L1 transaction successfull, but L2 fails. Also providing _refundRecipient with the L2 contract target to grief attack. Let's consider simply L2WethBridge for now as a _refundRecipient. Mailbox::_requestL2Transaction will not touch refundRecipient as not address(0) and since the attack target (L2WethBridge) will not have any contract on L1 (that is the assumption for the sake of this PoC, if this target would have code deployed on L1, it would not work).
  2. At some point the transaction ^^ will be picked up and executed by the bootloader. Let's assume the call fails as planned by the attacker since he injected bad _contractL2. This will cause all the ETH that left to be minted directly to the refundRecipient, which in this case is L2WethBridge. L2WethBridge is only allowed to receive ETH when called by l2WethAddress and should revert otherwise, but here those ETH will be added to L2WethBridge without any problem (even if not coming from l2WethAddress) and without emiting EthReceived event.

As you can see the revert is bypassed, as well as the business logic, which doesn't do much grief in this case, but could have a bigger impact depending on the application or system contract involved.

Another alternative to do this even in a more simpler way is to send a valid transaction for both L1 and L2 perspective, specifying as always the L2 refundRecipient to attack, but sending more ETH, the excess will be refunded to refundRecipient and cause the same behavior.

Tools Used

Manual inspection and Remix todo some testing.

Recommended Mitigation Steps

While receiving ETH forcibly is possible on L1 due to mainly SELFDESTRUCT, the fact that zkZync doesn't implement SELFDESTRUCT is on purpose, and probably since it's deprecated from L1 (and recently EIP-6780 being implemented in go-ethereum), this could translate in dApp developers thinking this cannot happen on zkSync Era, which could create a false sense of security and bad surprises for dApp developers as their implementation might not account for this invariant to be broken. Also doing this attack on L1 involve deploying a contract and is more involving then in zkSyncEra to simply submit special crafted L1 -> L2 transaction or simply a valid L1 -> L2 transaction with excess ETH.

At first glance, it doesn't seem like this grief attack could do any harm to the zkSync system contracts, but I'll let zkSync team confirm this. It's hard to understand the full impact of this attack as this is a per application basis. Nevertheless, for any of the current or future application, that is always a potential issue that developers and auditors might miss and SHOULD be warned accordingly, even if, granted, this is also a responsability of the dApps developer.

The fact that this is not documented anywhere probably means that zkSync team is not aware of this grief attack either. Additionally, from the documentation from the first zkSync C4 contest the documentation state the following regarding the L1Bridge (which is now renamed to L1WethBridge) which concurs (since it's not true) also to the thesis that it's an unknown issue:

The ether bridge is special because it is the only place where native L2 ether can be minted. 

Furthermore, the zkSync team seems to think only directETHTransfer can do such behavior (when refunding excess gas for example), when it's not the case as show in my submission.

There should be a big warning about this here at least.

The root problem is really caused by the L2 aliasing logic. It's clear that if the refund addess is omitted, the sender will be used as refund, and aliasing is clear as we know the sender is coming from L1, so we alias if it's a contract. The problem with the refund address is that the implemention assume it's an L1 based recipient, but the hack here is that the user could use an L2 recipient directly instead, and there is no way to detect this really.

Assessed type

Other

Unassigned Return Variable in `upgrade` Function

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/contracts/ethereum/contracts/upgrades/BaseZkSyncUpgrade.sol#L67

Vulnerability details

Impact

The upgrade function in the BaseZkSyncUpgrade contract has an unassigned return variable (bytes32) in certain code paths. This issue could lead to unexpected behavior or erroneous results when calling the upgrade function. Depending on the specific logic of the contract, it may have security implications and could potentially affect the correctness of upgrades.

Proof of Concept

The issue was found in the following contract code:

function upgrade(ProposedUpgrade calldata _proposedUpgrade) public virtual returns (bytes32) {
    require(block.timestamp >= _proposedUpgrade.upgradeTimestamp, "Upgrade is not ready yet");
    // Missing return statement or value assignment
}

Tools Used

code reviewed manually

Recommended Mitigation Steps

To mitigate this issue, we recommend modifying the upgrade function to ensure that it returns a bytes32 value in all code paths. Here's an example of how to do it:

function upgrade(ProposedUpgrade calldata _proposedUpgrade) public virtual returns (bytes32) {
    require(block.timestamp >= _proposedUpgrade.upgradeTimestamp, "Upgrade is not ready yet");
    
    // Add your logic here to compute the bytes32 value to return
    bytes32 result = computeResult(); // Replace with your actual logic
    
    return result;
}

By adding the necessary logic to calculate the bytes32 value and returning it, you can resolve this issue.

Assessed type

Other

Bridging wETH from L2 to L1 will have funds stuck in L1WethBridge

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/contracts/L2EthToken.sol#L113

Vulnerability details

Description

ZKSync Era allow user to bridge wETH from L1 to L2 in a very convenient way with their new bridge (L1WethBridge/L2WethBridge). This new feature introduced one major problem with the widthdraw which I consider High severity as it compromise directly the assets which would be stuck in L1WethBridge until ZKSync Era upgrade the brige implementation to be able to recover those funds manually, which is not convenient and would hurt reputation.

Impact

Bridging wETH from L2 to L1 (widthdraw) will have ETH stuck in the L1WethBridge (until bridge implementation is upgraded).

Proof of Concept

DEPOSIT: Here is the flow when bridging wETH from L1 to L2

  1. L1WethBridge::deposit (wETH sent from msg.sender to L1WethBridge)
  2. Unwrap wETH (using wETH9 contract) and ETH are sent to L1WethBridge
  3. ETH are sent from L1WethBridge to Mailbox facet which submit an L2 transaction (but native ETH remains here in the Mailbox facet)
  4. L2 process the transaction at some point (async)
    a) which mint ETH in L2 (this happen in the bootloader) for the L2WethBridge
    b) and call L2WethBridge::finalizeDeposit which wrap this ETH into wETH for the L2 recipient.

WITHDRAW: Here is the flow when bridging wETH from L2 to L1

  1. L2WethBridge::withdraw (wETH sent from msg.sender to L2WethBridge)
  2. Unwrap wETH (using L2Weth::bridgeBurn contract) and ETH are sent to L2WethBridge
  3. ETH are then send to L2EthToken (L2EthToken::withdrawWithMessage) system contract which will burn those ETH and create a message that will be sent to L1 (using L1Messenger) to IMailbox.finalizeEthWithdrawal.selector (Mailbox facet).
  4. L2 process the transaction at some point (async)
    a) Mailbox::finalizeEthWithdrawal get called, proof is verified and withdraw finalized and ETH are sent to L1WethBridge.

As you can see, ETH are NOT going back to the L1 recipient, but stuck in the L1WethBridge. Here what's should happen instead:

  1. ETH are then send to L2EthToken (L2EthToken::withdrawWithMessage) system contract which will burn those ETH and create a transaction that will be sent to L1 (using L1Messenger) to IL1Bridge.finalizeWithdrawal.selector (L1WethBridge).
  2. L2 process the transaction at some point (async)
    a) L1WethBridge::finalizeWithdrawal get called, which call Mailbox::finalizeEthWithdrawal to finalized the withdraw etc and send the ETH to L1WethBridge.
    b) Then it will wrap those in wETH (using wETH9 contract) and transfer those fresh wETH to the L1 recipient.

Tools Used

Manual review

Recommended Mitigation Steps

As indicated in the PoC, the fix would be to simply use the proper selector (L1WethBridge::finalizeWithdrawal instead of Mailbox::finalizeEthWithdrawal) from L2EthToken system contract at step 3 of the withdraw process.

diff --git a/code/system-contracts/contracts/L2EthToken.sol b/code/system-contracts/contracts/L2EthToken.sol
index 6a2ca48..4c2066d 100644
--- a/code/system-contracts/contracts/L2EthToken.sol
+++ b/code/system-contracts/contracts/L2EthToken.sol
@@ -6,6 +6,8 @@ import {IEthToken} from "./interfaces/IEthToken.sol";
 import {ISystemContract} from "./interfaces/ISystemContract.sol";
 import {MSG_VALUE_SYSTEM_CONTRACT, DEPLOYER_SYSTEM_CONTRACT, BOOTLOADER_FORMAL_ADDRESS, L1_MESSENGER_CONTRACT} from "./Constants.sol";
 import {IMailbox} from "./interfaces/IMailbox.sol";
+import {IL1Bridge} from "./interfaces/IL1Bridge.sol";

 /**
  * @author Matter Labs
@@ -120,7 +122,8 @@ contract L2EthToken is IEthToken, ISystemContract {
         address _sender,
         bytes memory _additionalData
     ) internal pure returns (bytes memory) {
-        return abi.encodePacked(IMailbox.finalizeEthWithdrawal.selector, _to, _amount, _sender, _additionalData);
+        return abi.encodePacked(IL1Bridge.finalizeWithdrawal.selector, _to, _amount, _sender, _additionalData);
     }
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

/// @author Matter Labs
interface IL1Bridge {
    function finalizeWithdrawal(
        uint256 _l2BatchNumber,
        uint256 _l2MessageIndex,
        uint16 _l2TxNumberInBatch,
        bytes calldata _message,
        bytes32[] calldata _merkleProof
    ) external;
}

Assessed type

Token-Transfer

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.