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
Rank: 26/64
Findings: 3
Award: $1,813.81
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: HE1M
Also found by: 0xsomeone, AkshaySrivastav, Aymen0909, J4de, Koolex, Mohandes, bin2chen, brgltd, cccz, hals, ladboy233, max10afternoon, peanuts, rvierdiiev, shealtielanz, tsvetanovv, zzzitron
273.5673 USDC - $273.57
https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L255 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L278 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L340 https://github.com/code-423n4/2023-10-zksync/blob/1fb4649b612fac7b4ee613df6f6b7d921ddd6b0d/code/contracts/ethereum/contracts/bridge/L1ERC20Bridge.sol#L345
Admin turning on the deposit limit can make L1ERC20Bridge.sol#claimFailedDeposit revert in underflow
user can deposit ERC20 token on L1ERC20Bridge.sol and then the token should be minted on L2
However, in the case of when the request failed when finalizing on L2, user can call claimFailedDeposit to redeem the L1 token
when claiming the failed deposit, the code tries to validate and update the deposit limit
// Change the total deposited amount by the user _verifyDepositLimit(_l1Token, _depositSender, amount, true);
this is calling this line of code
/// @dev Verify the deposit limit is reached to its cap or not function _verifyDepositLimit(address _l1Token, address _depositor, uint256 _amount, bool _claiming) internal { IAllowList.Deposit memory limitData = IAllowList(allowList).getTokenDepositLimitData(_l1Token); 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"); totalDepositedAmountPerUser[_l1Token][_depositor] += _amount; } }
the problem is that, if admin does not turn on the depositLimitation flag, the user can deposit as usual,
and when admin turn on the flag of depositLimitation and set the deposit cap for a token,
and when user tries to claim the failed deposit, transaction revert in underflow directly in this line of code because the previous deposit limit per user is not tracked
if (_claiming) { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount; }
Manual Review
do not let the code underflow when claiming failed deposit, can change the verifyDepositLimit to the code below
if amount > totalDepositedAmountPerUser[_l1Token][_depositor] { totalDepositedAmountPerUser[_l1Token][_depositor] = 0 } else { totalDepositedAmountPerUser[_l1Token][_depositor] -= _amount; }
DoS
#0 - c4-pre-sort
2023-11-02T04:37:06Z
bytes032 marked the issue as duplicate of #1031
#1 - c4-pre-sort
2023-11-02T13:44:18Z
141345 marked the issue as duplicate of #425
#2 - c4-judge
2023-11-24T20:00:49Z
GalloDaSballo marked the issue as satisfactory
656.3255 USDC - $656.33
Deposit limit is never reduced in Mailbox contract, and the L2 transaction requested on L1 side can be incorrectly blocked once the deposit limit hits
In the current implementation of L1ERC20TokenBridge.sol
when user deposit, the user's deposit limit per token is increased
when user's deposit failed, the user's deposit limit per token is reduced
However, when user requests a L2 transaction from the mailbox, if the admin turn on the deposit limit flag, the ETH deposit limit is accumulated
// The L1 -> L2 transaction may be failed and funds will be sent to the `_refundRecipient`, // so we use `msg.value` instead of `_l2Value` as the bridged amount. _verifyDepositLimit(msg.sender, msg.value);
this is calling
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; }
However, even when finalizeEthWithdrawal
the eth deposit per user is never reduced, the L2 transaction requested on L1 side can be incorrectly blocked once the deposit limit hits unless the admin keep updating and increase the deposit limit
note the L1WETHBridge also replies on the mailbox requestL2Transaction to bridge WETH, so once the deposit limit per address hit, further L2 Transaction request can revert in this check,
the bridge request from L1WETHBridge is blocked as well unless the admin turn off the depositLimitation flag or raise the limit for each depositor address manually (which is not scalable because there may be a lot of depositor address)
Manual Review
reduce the deposit limit if the ETH withdrawal is finalized on mailbox
DoS
#0 - c4-pre-sort
2023-11-01T05:32:53Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2023-11-02T16:24:03Z
141345 marked the issue as sufficient quality report
#2 - miladpiri
2023-11-06T15:19:51Z
This is by design.
#3 - c4-sponsor
2023-11-06T15:19:56Z
miladpiri (sponsor) disputed
#4 - c4-judge
2023-11-15T12:45:26Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#5 - GalloDaSballo
2023-11-15T12:45:56Z
The finding can create a temporary pause but that's a gotcha to end users, not an attack on the system
#6 - c4-judge
2023-11-24T19:26:52Z
This previously downgraded issue has been upgraded by GalloDaSballo
#7 - c4-judge
2023-11-25T16:25:39Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#8 - GalloDaSballo
2023-11-25T16:26:09Z
Agree with QA, have considered scrapping
Ultimately it's a design decision to limit volume
#9 - JeffCX
2023-11-29T21:24:58Z
this is duplicate of https://github.com/code-423n4/2023-10-zksync-findings/issues/246
from #246
The flow bridgeProxy.deposit() -> L1WethBridge.deposit() -> Mailbox.requestL2Transaction() allows one to deposit WETH from L1 to L2. However, when checking the deposit limit for a particular depositor,
Mailbox.requestL2Transaction() checks the limit of msg.sender, which is the address of L1WethBridge. As a result, when the limit of the bridge is reached, nobody can deposit anymore
from this report original submission
the eth deposit per user is never reduced, the L2 transaction requested on L1 side can be incorrectly blocked once the deposit limit hits unless the admin keep updating and increase the deposit limit
note the L1WETHBridge also replies on the mailbox requestL2Transaction to bridge WETH, so once the deposit limit per address hit, further L2 Transaction request can revert in this check,
the bridge request from L1WETHBridge is blocked as well unless the admin turn off the depositLimitation flag or raise the limit for each depositor address manually (which is not scalable because there may be a lot of depositor address)
thanks
#10 - c4-judge
2023-12-02T10:02:37Z
This previously downgraded issue has been upgraded by GalloDaSballo
#11 - c4-judge
2023-12-02T10:03:05Z
GalloDaSballo changed the severity to QA (Quality Assurance)
#12 - liveactionllama
2023-12-12T18:09:43Z
Per request from the judge @GalloDaSballo - I've updated the labels on this issue.
#13 - GalloDaSballo
2023-12-12T18:17:44Z
Confirmed, thank you!
🌟 Selected for report: erebus
Also found by: 0xTheC0der, Bauchibred, HE1M, Jorgect, Udsen, alexfilippov314, chaduke, evmboi32, hash, ladboy233, lsaudit, oakcobalt, rvierdiiev, ustas, wangxx2026, xuwinnie, zero-idea, zkrunner
883.9114 USDC - $883.91
# | Issues |
---|---|
1 | Should remove all debug statement in bootloader |
2 | Upgrade transaction via mailbox may missing gas amount validation |
3 | Governance contract cannot handle ERC721 and ERC1155 NFT token transfer |
4 | Governance execute function missing reentrancy protection |
5 | execute and executeInstant missing payable |
6 | owner of the governance contract can grief security council instant execution |
7 | Governance contract cannot batch transaction |
8 | ISystemContract should only contains interface |
9 | The Diamond Getter contract should expose a few crucial state |
10 | Rebase token such stETH is not supported for L1ERC20Bridge.sol |
11 | L1ERC20Bridge.sol is not capable of handling airdrop |
12 | Bridge contract can be used before the bridge contract initialization |
13 | staticcall does not enforce cap the gas limit in _getERC20Getters |
14 | Priority queue does not pop operation by priority of expiration time and layer2 tips |
15 | set deposit limit in allowlist can be frontrun |
16 | no free L2 Transaction via mailbox |
bootloader contains a lot of debugLog, while it is understandable that such code is ok for debugging purpose,
these debug statement consume additional opcode and gas and should definitely be removed before production,
otherwise, the gas that should be refunded to the refund recipient address is wasted to log debug statement instead, so these debug statement should be removed for both readability and gas saving perspective.
An upgrade transaction can start via mailbox on l2
but in the current implementation, the validation is implemented as follow
/// @dev Used to validate upgrade transactions /// @param _transaction The transaction to validate function validateUpgradeTransaction(IMailbox.L2CanonicalTransaction memory _transaction) internal pure { // Restrict from to be within system contract range (0...2^16 - 1) require(_transaction.from <= type(uint16).max, "ua"); require(_transaction.to <= type(uint160).max, "ub"); require(_transaction.paymaster == 0, "uc"); require(_transaction.value == 0, "ud"); require(_transaction.reserved[0] == 0, "ue"); require(_transaction.reserved[1] <= type(uint160).max, "uf"); require(_transaction.reserved[2] == 0, "ug"); require(_transaction.reserved[3] == 0, "uo"); require(_transaction.signature.length == 0, "uh"); require(_transaction.paymasterInput.length == 0, "ul"); require(_transaction.reservedDynamic.length == 0, "um"); }
but the issue is the code does not validate if sufficient amount of gas is provided to completed the upgrade transaction
recommend adding gas validation to upgrade transaction as well
In Governance contract, the contract can receive native ETH,
but cannot receive ERC721 and ERC1155 because when external contract calls safeTransfer from NFT, the smart contract needs to implement xxxx
when a proposal is executed, the execution funciton is not guarded with nonRetrant modifier, allowing one proposal to execute multiple times, we recommend the protocol add the nonRetrant safe guard
when operation is executed via function execute and executeInstant, because these two function are missing payable modifier, the admin cannot attach ETH when calling this function
the exact trust model is that the security council can bypass the owner priority and execute instant operation in the case of emergency
however, even when calling executeInstant proposal, the proposal must still be scheduled
so the owner, if does not want the security council to execute the proposal, can frontrun the executeInstant function by caneling a scheduled proposal
whether such grief attack would depends on whether the security council is capable of scheduling batch operation
such issue can potentially be avoid if the security council can batch schedule and executeInstant into one transaction
according to the comment in the code, the governance contract
It is used for managing and coordinating upgrades /// and changes in all zkSync Era governed contracts.
However, the operation cannot be batched, meaning the contract lacks the function to execute multiple steps of operation in one transaction
and the owner or security council can only execute transaction one by one if not calling executeInstant, each operation is subject to min delay
the recommendation is adding the functionality to let owner schedule batch transaction
the ISystemContract implies that the code should only contains interface definition, while the code file contains a lot of feature and function and modifier
the recommendation is rename the file to SystemContractUtils.sol instead of calling it ISystemContract
In the diamond getter contract
missing expose the state admin and pending admin,
missing exposing the state totalDepositedAmountPerUser
missing exposing the state zkPorterIsAvailable
recommend exposing these function for external protocol query and integration
when using L1ERC20TokenBridge.sol
the code enforce that the underlying token does not charge transfer fee, otherwise if the received amount does not match the transfer amount, transaction revert and block user deposit
/// @dev Transfers tokens from the depositor address to the smart contract address /// @return The difference between the contract balance before and after the transferring of funds function _depositFunds(address _from, IERC20 _token, uint256 _amount) internal returns (uint256) { uint256 balanceBefore = _token.balanceOf(address(this)); _token.safeTransferFrom(_from, address(this), _amount); uint256 balanceAfter = _token.balanceOf(address(this)); return balanceAfter - balanceBefore; }
However, the rebase token cannot be used in L1ERC20Bridge.sol
first of all, when a L1 ERC20 token is bridged to L2, and corresponding L2 token is created
but when L1 token rebases and change the balance, the L2 token that correspondings to the L1 token does not reflect the rebase
rebase can be positive or negative
in the case of positive rebase, meaning the L1 token balance inside L1ERC20Bridge.sol increases, the extra balance cannot be transfered out
in the case of negative rebase, this can block L2 token cross chain token transfer and lock fund
for example,
User bridge 100 rebase token from L1 to L2 and the 100 rebase token is locked in L1ERC20Bridge.sol contract and minting 100 L2 Token
User burn 100 L2 token and want to get back the locked 100 L1 rebase token, but negative rebase happens, the 100 token goes to 98 token in L1ERC20Bridge.sol
the L1ERC20Bridge.sol does not hold the original 100 token and the balance is reduced, so the cross-chain token transfer from L2 to L1 is blocked
the failure for L2 - L1 token transfer if L1 token has rebasing behavior can happen when claimFailedDeposit as well
an example of such very popular rebase token is stETH,
https://docs.lido.fi/guides/lido-tokens-integration-guide#accounting-oracle
the L1ERC20Bridge.sol is likely to hold a large amount of ERC20 token and if there are project that launch airdrop based on the snapshot of the token balance
the airdrop token can go to the L1ERC20Bridge.sol directly and because the contract lack sweeping function to get the fund out, the airdropped token is lost
Can use L1ERC20Bridge.sol as an example
initially the l2Bridge.sol is address(0)
only after the initializer is called and bridge transaction confirmed, l2 bridge address is set
// Deploy L2 bridge proxy contract l2Bridge = BridgeInitializationHelper.requestDeployTransaction( zkSync, _deployBridgeProxyFee, l2BridgeProxyBytecodeHash, l2BridgeProxyConstructorData, // No factory deps are needed for L2 bridge proxy, because it is already passed in previous step new bytes[](0) );
but l2 bridge address is set on l1 bridge does not mean the corresponding l2 bridge address is deployed on l2
if the deployer provide insufficient gas, the deployment can fail
this means even when l2 bridge is set on l1, the l2 bridge is never deployed on l2
in either case (l2 bridge is address(0) or l2 bridge failed to deploy on l2)
user can still use bridge to deposit token by calling deposit
l2TxHash = zkSync.requestL2Transaction{value: msg.value}( l2Bridge, 0, // L2 msg.value l2TxCalldata, _l2TxGasLimit, _l2TxGasPerPubdataByte, new bytes[](0), refundRecipient );
but even user lock token in l1 ERC20 token bridge, the deposit request is never relayed on l2 because the l2 bridge is not never deployed
then the user's l1 token is lost, until the failed deposit is proved and user can reclaim the asset via claimFailedDeposit
same issue happens on L1WETHBridge.sol
but user asset is lost because there is no claimFailedDepsoit, the method always revert
not all token support decimal and name and symbol and if external token does not support decimal or name or decimals and revert in staticcall
the external staticcall forward all gas remaining and the external revert waste 63 / 64
/// @dev Receives and parses (name, symbol, decimals) from the token contract function _getERC20Getters(address _token) internal view returns (bytes memory data) { (, bytes memory data1) = _token.staticcall(abi.encodeCall(IERC20Metadata.name, ())); (, bytes memory data2) = _token.staticcall(abi.encodeCall(IERC20Metadata.symbol, ())); (, bytes memory data3) = _token.staticcall(abi.encodeCall(IERC20Metadata.decimals, ())); data = abi.encode(data1, data2, data3); }
a relevant example I want to quote is in balancer codebase, the reentrancy check staticall always revert and waste too much gas, can check the comment here
then balancer dev enforce the staticall gas limit is enforced here
(, bytes memory revertData) = address(vault).staticcall{ gas: 10_000 }( abi.encodeWithSelector(vault.manageUserBalance.selector, 0) );
going back to our usage
_getERC20Getters, this function is called every time when deposit and bridge ERC20 token, if every time most of the gas (63 / 64) gas wasted in staticcall of quering name, decimal or symbol, the deposit transaction is likely revert in out of gas.
User can request l2 transaction on l1 via mailbox
the transaction operation is written into the priority queue
s.priorityQueue.pushBack( PriorityOperation({ canonicalTxHash: canonicalTxHash, expirationTimestamp: _priorityOpParams.expirationTimestamp, layer2Tip: uint192(0) // TODO: Restore after fee modeling will be stable. (SMA-1230) }) );
as we can see, the layer2Tip is hardcoded to 0, so the when poping from the queue, the code cannot really pick the highest layer2tips
the expiration timestamp is not considered when executing batch and popping from the queue as well
and the PRIORITY_EXPIRATION is currently hardcode to 0, signal lack of usage of expirationTimestamp,
but according to the comments from the prioityOperation struct
/// @param expirationTimestamp The timestamp by which the priority operation must be processed by the operator.
but when validator executing the batch, the pop front operation just follow first-in-first-out out instead of picking the operation that has recent expiration time or higher layer2 tip
// @dev Pops the priority operations from the priority queue and returns a rolling hash of operations function _collectOperationsFromPriorityQueue(uint256 _nPriorityOps) internal returns (bytes32 concatHash) { concatHash = EMPTY_STRING_KECCAK; for (uint256 i = 0; i < _nPriorityOps; i = i.uncheckedInc()) { PriorityOperation memory priorityOp = s.priorityQueue.popFront(); concatHash = keccak256(abi.encode(concatHash, priorityOp.canonicalTxHash)); } }
the impact is some time-sensitve operation may never to processed on time
or in the worst,
assume user request a l2 transaction whats to mint ETH
but before her, there are 100K transaction in the Queue, so the processing time for her transaction can be very long, basically the prioirity cannot schedule time-senstive operation
when setting deposit limit in allowlist
a user can frontrun the set deposit limit
for example, if admin want to set deposit limit from 1.5 ETH to 1 ETH
use can frontrun the transaction to consume the 1.5 ETH limit
then backrun the transcaction to consume the 1 ETH limit
the recommendation is consider implement increase / derease limit
when requestL2Transaction via mailbox, the isFree flag is always set to false
recommend create a seperate function that can only be called by whitelist address to send free l2 transaction, otherwise remove the flag isFree when requesting l2 transaction via mailbox
In ComplexUpgrader, when performing upgrade operation, the payable keywords is used, meaning the upgrade should handle the ETH attached
but when delegatecall, the msg.value or ETH attached is not used
require(_delegateTo.code.length > 0, "Delegatee is an EOA"); (bool success, bytes memory returnData) = _delegateTo.delegatecall(_calldata);
the following warning message shows up when running the code under zk_circuit path
carge build
warning: the feature `generic_const_exprs` is incomplete and may not be safe to use and/or cause compiler crashes --> src/lib.rs:5:12 | 5 | #![feature(generic_const_exprs)] | ^^^^^^^^^^^^^^^^^^^ | = note: see issue #76560 <https://github.com/rust-lang/rust/issues/76560> for more information = note: `#[warn(incomplete_features)]` on by default warning: `zkevm_circuits` (lib) generated 1 warning Finished dev [unoptimized + debuginfo] target(s) in 26.20s
recommend resolve the warning to avoid potential issue
#0 - bytes032
2023-10-30T10:28:02Z
7 l 5 r 4 nc
| 1 | Should remove all debug statement in bootloader | nc
| 2 | Upgrade transaction via mailbox may missing gas amount validation | l
| 3 | Governance contract cannot handle ERC721 and ERC1155 NFT token transfer | r
| 4 | Governance execute function missing reentrancy protection | l
| 5 | execute and executeInstant missing payable | dup of https://github.com/code-423n4/2023-10-zksync-findings/issues/775
| 6 | owner of the governance contract can grief security council instant execution| l
| 7 | Governance contract cannot batch transaction | r
| 8 | ISystemContract should only contains interface | nc
| 9 | The Diamond Getter contract should expose a few crucial state | r
|10 | Rebase token such stETH is not supported for L1ERC20Bridge.sol | l
|11 | L1ERC20Bridge.sol is not capable of handling airdrop | r
|12 | Bridge contract can be used before the bridge contract initialization | l
|13 | staticcall does not enforce cap the gas limit in _getERC20Getters | l
|14 | Priority queue does not pop operation by priority of expiration time and layer2 tips | dup of https://github.com/code-423n4/2023-10-zksync-findings/issues/1027
|15 | set deposit limit in allowlist can be frontrun | l
|16 | no free L2 Transaction via mailbox | r
ComplexUpgrader does not use msg.value nc
Should resolve the warning in rust compiler nc
#1 - JeffCX
2023-11-29T16:42:15Z
The issue 6 is a duplicate of #260 and https://github.com/code-423n4/2023-10-zksync-findings/issues/364
from issue 6 QA report
so the owner, if does not want the security council to execute the proposal, can frontrun the executeInstant function by caneling a scheduled proposal
from #364
Furthermore, the owner and the security council have the capability to cancel any previously scheduled upgrades, permitting a compromised owner to nullify any previously arranged patches.
This is due to the security council's capacity to cancel any scheduled upgrades, which would prevent the owner from autonomously conducting upgrades.
I admit there are not that much detail provided like report 260 and 364, and in my QA report, I list a lot of issue so for each I aim to use the most concise words
but I think issue 6 capture the core issue of report 260 and 364
Thanks!
#2 - JeffCX
2023-12-03T01:11:33Z
bridge-contract-can-be-used-before-the-bridge-contract-initalization
is a duplicate of https://github.com/code-423n4/2023-10-zksync-findings/issues/428
#3 - nethoxa
2023-12-03T15:53:10Z
About the first message, just asking, why schedule a proposal and front-run the security council before they execute it instantly? Why don't just not schedule the proposal? It does not make sense.
Seems off and does not talk about any of the core ideas of the primary issue.
#4 - JeffCX
2023-12-03T15:59:53Z
Hello, I will paste the full report of issue 6 and leave it up to judge
#5 - c4-judge
2023-12-10T20:17:07Z
GalloDaSballo marked the issue as grade-a
#6 - GalloDaSballo
2023-12-10T20:32:27Z
I have considered upgrading as a "generic admin privilege" but after reviewing this and 260, I don't believe this qualifies as a duplicate
This finding asserts that the owner can cancel any proposal, that is their admin privilege
260 shows how in case of a compromised council, the owner would be unable to perform operations
Keeping as is