zkSync Era - peanuts's results

Future-proof zkEVM on the mission to scale freedom for all.

General Information

Platform: Code4rena

Start Date: 02/10/2023

Pot Size: $1,100,000 USDC

Total HM: 28

Participants: 64

Period: 21 days

Judge: GalloDaSballo

Total Solo HM: 13

Id: 292

League: ETH

zkSync

Findings Distribution

Researcher Performance

Rank: 32/64

Findings: 2

Award: $1,050.37

Analysis:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

273.5673 USDC - $273.57

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-425

External Links

Lines of code

https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L340-L350

Vulnerability details

Impact

Users will lose their deposits if the transaction from L1->L2 fails and deposit limit is turned on in that duration.

Proof of Concept

When a user calls L1ERC20Bridge.deposit(), _verifyDepositLimit() will be called. _verifyDepositLimit() checks whether the token has a deposit limitation or not. If it does not, return the function. If it does, append the amount deposited to the totalDepositedAmountPerUser mapping.

function _verifyDepositLimit(address _l1Token, address _depositor, uint256 _amount, bool _claiming) internal { IAllowList.Deposit memory limitData = IAllowList(allowList).getTokenDepositLimitData(_l1Token); //@audit -- the function will return if there is no deposit limitation if (!limitData.depositLimitation) return; // no deposit limitation is placed for this token if (_claiming) { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount; } else { require(totalDepositedAmountPerUser[_l1Token][_depositor] + _amount <= limitData.depositCap, "d1"); // If there is a limitation, add the total deposited amount totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; } }
//@audit The deposit struct in IAllowList.sol struct Deposit { bool depositLimitation; uint256 depositCap; }

When the transaction from L1-L2 fails, the user can call claimFailedDeposit() to claim back the deposited amount. Likewise, the function calls _verifyDepositLimit() and subtracts the deposited amount from the totalDepositedAmountPerUser struct.

if (_claiming) { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount;

The issue arises when the deposit limit is turned off at first and then turned on later. For example, User A deposits 10000 DOGE tokens. Since there is no deposit Limit, _verifyDepositLimit() will be skipped.

A second later, the deposit limit for DOGE is turned on and set to 1 million.

User A encounters a failed transaction from L1 to L2. He calls claimFailedDeposit() which calls _verifyDepositLimit(). Since the deposit limit is turned on now, the function runs and the first clause of the if statement is executed because _claiming is true. The user will not get back any tokens because the mapping totalDepositedAmountPerUser is zero and the function will result in underflow.

User A loses 10000 DOGE tokens.

//@audit Owner can turn the deposit limit on or off using this function in AllowList.sol function setDepositLimit(address _l1Token, bool _depositLimitation, uint256 _depositCap) external onlyOwner { tokenDeposit[_l1Token].depositLimitation = _depositLimitation; tokenDeposit[_l1Token].depositCap = _depositCap; }

Tools Used

Manual Review

Refacor the _verifyDepositLimit() function in such a way that it still stores the amount deposited even if there is no deposit limit

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-10-31T14:56:05Z

bytes032 marked the issue as duplicate of #425

#1 - c4-pre-sort

2023-10-31T14:56:42Z

bytes032 marked the issue as sufficient quality report

#2 - c4-judge

2023-11-24T20:01:01Z

GalloDaSballo marked the issue as satisfactory

Findings Information

🌟 Selected for report: zkLotus

Also found by: K42, Sathish9098, catellatech, peanuts, pfapostol

Labels

analysis-advanced
grade-b
sufficient quality report
A-02

Awards

776.8033 USDC - $776.80

External Links

1. Summary of the protocol

Zksync era is a new version of the zksync network, which is an L2 that is built on top of Ethereum. In the context of zkSync, an era represents a period where features are implemented or upgraded. The solidity side of the protocol is mostly on the bridge part, where users can bridge their tokens or ETH from L1(Ethereum) to L2(Zksync) and vice versa. During the bridging process, zksync uses zero-knowledge proofs to validate transactions off-chain and submit a SNARK on chain (zero-knowledge Succinct Non-interactive ARgument of Knowledge) to ensure the correctness and security of those transaction. The proofing is done in the circuits part of the protocol using rust.

2. Comments and Disclaimer

Looked through the solidity side of the protocol, mostly bridging, minting, sending transactions and gas calculations. Also looked through the bootloader contract

3. Flow of protocol functionalities

Bridging ERC20 tokens from L1 to L2
  • Starts with deposit() in L1ERC20Bridge.sol. User must be in the special allowlist or the access mode is set to public in Allowlist.sol.
  • User deposit funds via _depositFunds(). The fund amount must be the same amount as the parameter.
  • The user's deposit limit is checked by _verifyDepositLimit()
  • _getDepositL2Calldata() is called.
  • IL2Bridge.finalizeDeposit is called (in L2ERC20Bridge.sol).
  • L2 Token is deployed through _deployL2Token()
  • _deployL2Token() calls _deployBeaconProxy and initializes a bridge in L2StandardERC20.sol with bridgeInitialize()
  • L2Tokens are minted through bridgeMint() in L2StandardToken.
  • Back to L1ERC20Bridge contract, requestL2Transaction() is called in the zkSync Mailbox contract.
  • Mailbox.sol, _requestL2Transaction() is called (Take note that _requestL2Transaction can be called directly by the user)
  • _requestL2Transaction() calls _writePriorityOp()
  • _writePriorityOp() serializes the L2 transaction through _serializeL2Transaction()
  • _serializeL2Transaction() calls validateL1ToL2Transaction() and pushes the transaction into the priority queue.
  • The function process ends here. The next continuing function calld will be commitBatches() and executeBatch() in Executor.sol
Bridging native tokens from L1 to L2
  • Process is similar to bridging ERC20 tokens. Starts with deposit() in L1WethBridge.sol
  • The difference is that when _getDepositL2Calldata is called which calls IL2Bridge.finalizeDeposit(), there is no deploying of proxy and minting of tokens
  • Instead, the ETH is converted to WETH via IL2Weth(l2WethAddress).depositTo.
  • The rest of the process follows a similar pattern as bridging ERC20 tokens
Redeeming a failed deposit on L1 Bridge
  • If a transaction fails to be transmitted from L1 to L2, the user can call claimFailedDeposit() to withdraw their locked tokens.
  • claimFailedDeposit() calls proveL1ToL2TransactionStatus() in Mailbox.sol contract.
  • proveL1ToL2TransactionStatus() gets the L2Log and calls _proveL2LogInclusion().
  • _proveL2LogInclusion() calculates the root has and the actual root has and compares them with each other. If both hash are the same, that means the specific L2 log was sent to L2 in a specific L2 batch number.
  • Once the transaction is proven, the L1ERC20Bridge claimFailedDeposit() function will verify the deposit limit through _verifyDepositLimit() and transfer the token back to the user

Bridging ERC20 tokens from L2 to L1

  • withdraw() is called on L2ERC20Bridge
  • bridgeBurn() is called. The L2 ERC20 token is burned first.
  • _getL1WithdrawMessage() is called to encode the message
  • L2ContractHelper.sendMessageToL1() is called with the encoded message as the parameter.
  • L2ContractHelper calls L2_MESSENGER.sendToL1() to send the message to L1
  • Seems like the function stops here to prepare for another call.

Bridging native tokens from L2 to L1

  • Similar to ERC20 token bridging, withdraw() is called on L2WethBridge
  • WETH is burned through IL2StandardToken(l2WethAddress).bridgeBurn
  • Native token is sent through L2_ETH_ADDRESS.withdrawWithMessage{value: _amount}(l1Bridge, wethMessage);

4. Codebase Analysis and Review

ContractFunctionExplanationReview
AllowlistcanCallWhether the caller can call the specific function on target contractUsed in AllowListed senderCanCallFunction modifer in Bridges Contracts
AllowlistsetAccessModeSet the permission mode to call the target contractonlyOwner, changes AccessMode (public, special access)
AllowlistsetBatchAccessModeSame as setAccessMode(), just with batchesLoops and calls _setAccessMode()
AllowlistsetPermissionToCallGive permission to call if access is restrictedonlyOwner, gives special access permission
AllowlistsetBatchPermissionToCallSame as setPermissionToCall, just with batchesLoops and calls _setPermissionToCall()
AllowlistsetDepositLimitSet deposit limit data for a L1 tokenonlyOwner, checks if limit is active and sets limit
AllowlistgetTokenDepositLimitDataGet deposit limit data of a tokenview, returns deposit limit data
GovernancegetOperationStateChecks the operation state of the given idview, returns operation state
GovernancescheduleTransparentPropose a fully transparent upgradeonlyOwner, schedules operation with a delay, delay must be < minDelay
GovernancescheduleShadowPropose "shadow" upgradeonlyOwner, gets _id instead of _operation,
GovernancecancelCancel the scheduled operationonlyOwnerOrSecurityCouncil, cancels the operation
GovernanceexecuteExecutes the scheduled operationonlyOwnerOrSecurityCouncil, make sure time delay has passed
GovernanceexecuteInstantExecutes the scheduled operation instantlyonlySecurityCouncil , no need for time delay
GovernanceupdateDelayChanges the minimum timelock duration for future operationsonlySelf , must be called through execute (only contract can call)
GovernanceupdateSecurityCouncilUpdates the address of the security councilonlySelf , must be called through execute (only contract can call)
L1ERC20BridgedepositLock funds in the contract and sends the requestcalls finalizeDeposit on L2 Bridge and requestL2Transaction in Mailbox
L1ERC20BridgeclaimFailedDepositWithdraw funds that failed when finalizing on L2proveL1ToL2TransactionStatus, call proveL2MessageInclusion
L1ERC20BridgefinalizeWithdrawalFinalize the withdrawal and release fundscalled from L2 bridge, call proveL2MessageInclusion
L1WethBridgedepositDeposits WETH in contract, contract unwraps WETH to ETHdifferent from normal bridge, directly call requestL2Transaction
L1WethBridgeclaimFailedDepositWithdraw funds that failed when finalizing on L2Not Supported
L1WethBridgefinalizeWithdrawalFinalize the withdrawal from L2 and release funds to L1called from L2 bridge, call extra isEthWithdrawalFinalized
L2ERC20BridgefinalizeDepositFinalize the deposit from L1 and mint funds on L2called in deposit in L1ERC20 Bridge, creates a beacon proxy L2 Token
L2ERC20BridgewithdrawBurns funds on L2 and send message to L1Call L2ContractHelper.sendMessageToL1
L2StandardERC20bridgeMintMint tokens to a given accountonlyBridge, onlyL2Bridge can call
L2StandardERC20bridgeBurnBurn tokens from a given accountonlyBridge, onlyL2Bridge can call
L2WethBridgefinalizeDepositFinalize the deposit from L1 and mint WETH on L2onlyL1Bridge can call
L2WethBridgewithdrawWithdraw WETH on L2 and send to L1must be WETH token, not native ETH (no payable modifier)
L2WethbridgeMintMint tokens on L2Not Supported
L2WethbridgeBurnBurn tokens from a given account and send to L2 BridgeonlyL2WethBridge can call, burns WETH, transfer ETH to bridge
L2WethdepositDeposit Ether to mint WETHSpecial L2 Weth minted from native ETH
L2WethwithdrawBurn WETH to get Ether backSpecial L2 Weth burned for native ETH
MailboxproveL2MessageInclusionProve that message was sent in a specific L2 batch numberview, used to finalize L1 withdrawals
MailboxproveL2LogInclusionProve that log was sent in a specific L2 batchview, Returns true or false, (success or failure)
MailboxproveL1ToL2TransactionStatusProve that the L1 -> L2 transaction was processedpure, used to claim failed deposits
Mailboxl2TransactionBaseCostEstimate ETH cost of requestion execution of L2 from L1derives the L2 Gas price
MailboxfinalizeEthWithdrawalFinalize ETH withdrawal and release fundssets isEthWithdrawalFinalized to true, calls _withdrawFunds
MailboxrequestL2TransactionProve that L1->L2 transaction was processedpayable, gets the canonicalTxhash (txId is the nonce)
In-depth Review

Allowlist

  • There is quite a bit of centralization risk going on. Most importantly, the owner can set a deposit limit on any ERC20 tokens. If the owner is malicious, he can enable the deposit limit and set it to a really low value to grief the bridging process (no one can bridge any ERC20 tokens).
File: AllowList.sol 129: function setDepositLimit(address _l1Token, bool _depositLimitation, uint256 _depositCap) external onlyOwner { 130: tokenDeposit[_l1Token].depositLimitation = _depositLimitation; 131: tokenDeposit[_l1Token].depositCap = _depositCap; 132: }

Governance

  • The owner can schedule and execute an operation. In every operation, the owner can set a delay before the operation is executable.
  • There is also a minDelay variable, which is set in the constructor.
  • The security council can execute any operation. The security council can overrule the delay and execute the operation instantly.
  • There was an issue in the OpenZeppelin's Timelock Contract whereby an operator can overrule an operation and update the delay and the security council address. This reentrancy issue is mitigated here by checking the state of the operation before and after the execution of the operation
  • Reference: https://medium.com/immunefi/openzeppelin-bug-fix-postmortem-66d8c89ed166
  • The issue in this contract is that the security council can override a delay but not the owner, which is pretty confusing. If the security council can override, the owner should be able to override too, otherwise there is too much power given to the security council.
L1ERC20Bridge
  • Fee-on-transfer token is not supported, which is understandable but may be an issue in the future when more tokens are created with a transfer / burn fee.
  • The bridge bridges the exact amount of tokens everytime, so there should not be any leftover amounts in the contract or extra amounts bridged
Mailbox
  • Proving transaction / message / logs and also requesting transaction from L1 -> L2
  • Apparently requestL2Transaction() can be called directly without going through the L1ERC20 Bridge.
  • If called directly, IL2Bridge.finalizeDeposit() is not called, which means that calling requestL2Transaction() directly does not entail bridging tokens.

5. Centralization Risk

ContractActorsPurposeRisk
AllowlistOwnerSet access mode, deposit limitYes
GovernanceOwnerSchedule and execute operationsYes
GovernanceSecurity CouncilExecute operations, bypass operation time limitYes
GovernanceContractUpdateDelay, update security councilNil
L1ERC20BridgeUserCall deposit functions, depending on availability of access modeNo
L1WethBridgeUserCall deposit functions, depending on availability of access modeNo
L2ERC20BridgeUserWithdraw tokens from L2 to L1No
L2ERC20BridgeL1Bridge ContractCall finalizeDeposit and mint tokensNil
L2WethBridgeL1Bridge ContractCall finalizeDeposit and deposit native ETH tokensNil
L2WethL2Bridge ContractBurns L2 WETH token and send ETH to the bridgeNil

6. Learnings

  1. In Governance.sol, the operation status must be checked before and after execute, otherwise the check can be bypassed. There was a similar issue with the TimelockController contract. Before the fix, the check was only done after the execute function, which allows the executor to hijack the contract and set the delay to zero and set themselves as the proposer
  1. In Governance.sol, there is an onlySelf modifier, which means that only the Governance contract itself can call. I find it rather interesting and had to understand how the contract can call its own function. Turns out, the function is called through an operation which calls execute() and subsequently calls the contract itself, so the msg.sender calling the contract is the Governance contract itself.
File: Governance.sol 58: modifier onlySelf() { 59: require(msg.sender == address(this), "Only governance contract itself allowed to call this function"); 60: _; 61: }
  1. I learned about the beacon proxy creation when transferring ERC20 tokens from L1-L2. A new proxy contract is created for any amount of token bridged.
File: L2ERC20Bridge.sol 90: /// @dev Deploy and initialize the L2 token for the L1 counterpart 91: function _deployL2Token(address _l1Token, bytes calldata _data) internal returns (address) { 92: bytes32 salt = _getCreate2Salt(_l1Token); 93: 94: BeaconProxy l2Token = _deployBeaconProxy(salt); 95: L2StandardERC20(address(l2Token)).bridgeInitialize(_l1Token, _data); 96: 97: return address(l2Token); 98: }

Time spent:

60 hours

#0 - c4-pre-sort

2023-11-02T15:57:24Z

141345 marked the issue as sufficient quality report

#1 - c4-judge

2023-11-21T16:50:57Z

GalloDaSballo marked the issue as grade-b

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