Platform: Code4rena
Start Date: 10/03/2023
Pot Size: $180,500 USDC
Total HM: 6
Participants: 19
Period: 9 days
Judge: GalloDaSballo
Total Solo HM: 2
Id: 221
League: ETH
Rank: 5/19
Findings: 3
Award: $8,354.37
🌟 Selected for report: 0
🚀 Solo Findings: 0
6749.8318 USDC - $6,749.83
https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/DefaultAccount.sol#L223
The fallback function in DefaultAccount
is defined as fallback() external {...}
, but it must be fallback() external payable {...}
. This bug causes transfers with calldata to default accounts always to be reverted. On the other hand, they are not be reverted in Ethereum. Since DefaultAccount
is required to behave like EOA, this fact is inconsistent with the specification.
In addition, in the ignoreNonBootloader
and ignoreInDelegateCall
modifiers, there is the following statement, but as mentioned above, DefaultAccount
implements an empty fallback instead of an empty payable fallback, so it can be distinguished.
If all functions will use this modifier AND the contract will implement an empty payable fallback() then the contract will be indistinguishable from the EOA when called.
This bug makes it impossible to transfer Ether to any default account from any contract that implements transfers with calldata. In particular, if Ether deposited in a contract can only be withdrawn via a transfer with calldata, the assets will be permanently frozen.
For example, calling the returnEther
function of the following contract has no risk in Ethereum. The Ether is simply transferred to Foo
and returned. However, in zkSync Era, the above bug causes the Ether to not be returned, and it remains in Foo
and is lost.
contract Foo { function returnEther() public payable { payable(msg.sender).call{value: msg.value}("00"); } }
Transfers with calldata are general, as seen in 2023-03-zksync/blob/main/contracts/openzeppelin/utils/Address.sol#L159 (this functionCallWithValue
checks for success
, so the asset is not lost and only reverted). Therefore, the possibility of asset loss in various protocols due to this vulnerability is very high.
The code causing this vulnerability: https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/DefaultAccount.sol#L223
The test below is a transfer with calldata to a default account address (0x100000
); for Ethereum, this succeeds, but for zkSync Era, it fails. This is dangerous.
//! { "cases": [ { //! "name": "test", //! "inputs": [ //! { //! "method": "test", //! "calldata": [ //! ], //! "value": "1 ETH" //! } //! ], //! "expected": [ //! ] //! } ] } // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.4.16; contract Test { function test() external payable { address defaultAccountAddress = address(0x100000); (bool success, ) = payable(defaultAccountAddress).call{value: msg.value}("00"); require(success); // failed } }
The above vulnerability can cause users' assets to be lost in a variety of scenarios. For example, the following test shows that the user's assets are locked to the Foo
contract forever. Since it seems to be not simple to call transfer with calldata from DefaultAccount
using era-compiler-tester
, SimplifiedDefaultAccountForTest
compatible with DefaultAccount
is used. The result is the same when DefaultAccount
is used.
//! { "cases": [ { //! "name": "test", //! "inputs": [ //! { //! "method": "test", //! "calldata": [ //! ], //! "value": "1 ETH" //! } //! ], //! "expected": [ //! ] //! } ] } // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.4.16; contract Test { function test() external payable { // A simplified contract is used instead of a DefaultAccount contract, but the result is the same. SimplifiedDefaultAccountForTest account = new SimplifiedDefaultAccountForTest{value: 1 ether}(); Foo foo = new Foo(); account.callFooReturnEther(address(foo)); require(address(account).balance == 1 ether); // failed } } uint160 constant SYSTEM_CONTRACTS_OFFSET = 0x8000; address payable constant BOOTLOADER_FORMAL_ADDRESS = payable(address(SYSTEM_CONTRACTS_OFFSET + 0x01)); contract SimplifiedDefaultAccountForTest { constructor() payable {} // This process is implemented as a function here, but in reality, it is a transaction by an EOA. function callFooReturnEther(address fooAddress) external { Foo(fooAddress).returnEther{value: address(this).balance}(); } fallback() external { // fallback of default account shouldn't be called by bootloader under no circumstances assert(msg.sender != BOOTLOADER_FORMAL_ADDRESS); // If the contract is called directly, behave like an EOA } receive() external payable { // If the contract is called directly, behave like an EOA } } contract Foo { function returnEther() public payable { payable(msg.sender).call{value: msg.value}("00"); } }
The above test will succeed by changing fallback() external
to fallback() external payable
.
None
Change from fallback() external {...}
to fallback() external payable {...}
in DefaultAccount
.
#0 - c4-judge
2023-03-23T13:29:57Z
GalloDaSballo marked the issue as duplicate of #93
#1 - c4-judge
2023-04-04T09:31:57Z
GalloDaSballo marked the issue as satisfactory
#2 - c4-judge
2023-04-04T09:32:03Z
GalloDaSballo changed the severity to 2 (Med Risk)
🌟 Selected for report: HE1M
Also found by: 0x73696d616f, minaminao, rvierdiiev
1366.8409 USDC - $1,366.84
https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/SystemContext.sol#L116
In https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/SystemContext.sol#L116 , require(_newTimestamp >= currentBlockTimestamp, "Timestamps should be incremental");
is incorrect. The correct condition is _newTimestamp > currentBlockTimestamp
. This is as written in the Ethereum Yellow Paper as follows (this is also true in Ethereum when it was a PoW).
($H_s$ is the timestamp of the current block and $P(H)_{H_s}$ is the timestamp of the parent block.)
Note that below that require
statement is the following code.
// The correctness of this block hash and the timestamp will be checked on L1: SystemContractHelper.toL1(false, bytes32(_newTimestamp), _prevBlockHash);
However, the L1 contract does not verify the block timestamp condition as described below, so it must be verified in SystemContext
.
// NOTE: We don't check that _newBlock.timestamp > _previousBlock.timestamp, it is checked inside the L2
In the specs and the bootloader code, some block parameters given by an operator (such as gas prices) are currently trusted, but the block timestamp is designed to be not. At the phase when operators are decentralized, this bug could result in unanticipated attacks on contracts deployed in zkSync Era and unexpected impacts on the zkSync ecosystem (including off-chain).
It can be said that allowing newTimestamp = currentBlockTimestamp
can lead to a more severe timestamp dependency. Generally, contract developers should develop contracts cautiously, treating block.timestamp
as an unreliable and unsafe source, as is well-known in here and here. However, they do not consider that "block.timestamp = previousBlockTimestamp
can also occur when block.number > previouBlockNumber
."
Therefore, applications/contracts that assume that block.timestamp > previousBlockTimestamp
when block.number > previousBlockNumber
will be broken. For example, it will not be possible to calculate something per unit of time. Executing foo / timeDiff
with timeDiff = block.timestamp - previousBlockTimestamp
will result in a division by zero error. The details are explained in Proof of Concept.
https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/SystemContext.sol#L116 is incorrect. The following is a simple and impractical contract, but any DeFi and other contracts that accidentally contain such a mechanism will be attacked so that it cannot be executed forever.
contract Foo { uint256 prevBlockNumber; uint256 prevTimestamp; uint256 prev2Timestamp; function foo() public { require(prevBlockNumber < block.number); if (prevTimestamp > 0 && prev2Timestamp > 0) { uint256 timeDiff = prevTimestamp - prev2Timestamp; uint256 fooPerSecond = 1337 / timeDiff; // Something } prevBlockNumber = block.number; prev2Timestamp = prevTimestamp; prevTimestamp = block.timestamp; } }
An attack scenario is as follows. This test uses the Foundry test because it seems to be not easy to set block numbers and block timestamps in the era-compiler-tester. However, this would also be true for zkSync Era.
pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "../src/Foo.sol"; contract FooAttackTest is Test { function test() public { Foo foo = new Foo(); // normal operator vm.roll(1000); vm.warp(1000); foo.foo(); // attacker vm.roll(1001); vm.warp(1000); foo.foo(); // normal operator vm.roll(1002); vm.warp(1020); foo.foo(); // divison by zero } }
None
Change the code as follows:
require(_newTimestamp > currentBlockTimestamp, "Timestamps should be incremental");
#0 - c4-judge
2023-03-22T19:22:33Z
GalloDaSballo marked the issue as duplicate of #59
#1 - c4-judge
2023-04-07T10:22:00Z
GalloDaSballo marked the issue as unsatisfactory: Invalid
#2 - c4-judge
2023-04-12T08:17:38Z
GalloDaSballo marked the issue as not a duplicate
#3 - c4-judge
2023-04-12T08:18:04Z
GalloDaSballo marked the issue as duplicate of #70
#4 - c4-judge
2023-04-12T08:19:52Z
GalloDaSballo marked the issue as partial-50
#5 - GalloDaSballo
2023-04-12T08:20:06Z
50% because they got the issue of block with same ts, but did not focus on that
🌟 Selected for report: unforgiven
Also found by: 0xSmartContract, Dravee, HE1M, Madalad, brgltd, bshramin, gjaldon, joestakey, minaminao, rbserver, rvierdiiev, supernova
237.7048 USDC - $237.70
heapSize
and auxHeapSize
of ZkSyncMeta
returned by getZkSyncMeta()
are always 0
call
in inline assemblerprecompileCall
is slightly unfair depending on the amount of gasleft()
require
in increaseMinNonce
when nonceOrdering
is Sequential
baseFee
description SystemContext
is incorrectgetCallFlags
is incorrectoverride
to interface functions_value > 0
should be checked in increaseMinNonce
msg.value > 0
should be checked in withdraw
of L2EthToken
extra_abi_data
in VM specs is incorrectheapSize
and auxHeapSize
of ZkSyncMeta
returned by getZkSyncMeta()
are always 0
In getZkSyncMeta()
(https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/libraries/SystemContractHelper.sol#L198), heapSize
and auxHeapSize
are not getting.
function getZkSyncMeta() internal view returns (ZkSyncMeta memory meta) { uint256 metaPacked = getZkSyncMetaBytes(); meta.gasPerPubdataByte = getGasPerPubdataByteFromMeta(metaPacked); meta.shardId = getShardIdFromMeta(metaPacked); meta.callerShardId = getCallerShardIdFromMeta(metaPacked); meta.codeShardId = getCodeShardIdFromMeta(metaPacked); }
The following two lines must be added.
meta.heapSize = getHeapSizeFromMeta(metaPacked); meta.auxHeapSize = getAuxHeapSizeFromMeta(metaPacked);
Fortunately, getZkSyncMeta
is not used by any system contract and does not lead to direct attacks. In the future, it must be fixed as it may cause attacks or failures when getZkSyncMeta
is used.
call
in the inline assemblerThe following cleanup is done for the function arguments that are less than 32 bytes. However, some variables are not cleaned up.
// Clearing values before usage in assembly, since Solidity doesn't do it by default whoToMimic := and(whoToMimic, cleanupMask)
List of locations where cleanups appear:
List of variables that are not cleaned up:
_address
is address
type and not cleaned up._address
is address
type and not cleaned up._address
is address
type and not cleaned up._address
is address
type and not cleaned up._address
is address
type and not cleaned up.to
is address
type and not cleaned up.to
is address
type and not cleaned up.However, in general, manual cleanup as above is not required for Ethereum because solc (not zksolc) cleans up function arguments that are stacked on the stack ([ref](https://docs.soliditylang.org/en/latest/ internals/variable_cleanup.html)). For example, if the following function with a variable of address
type as an argument is compiled with solc and the mnemonics are checked, it can be confirm that it is cleaned up. The same is true for other types (uintN
, bool
, etc.).
contract A { function f(address x) public pure {} }
The mnemonics:
... 0x050: PUSH20 0xffffffffffffffffffffffffffffffffffffffff 0x065: DUP3 0x066: AND ...
We can see that the mask is pushed onto the stack with the PUSH20
opcode and cleaned up with the AND
opcode.
Hence, the cleanup processes present in the system contracts seem necessary for the zkEVM/zksolc specifications (registers, memory management, etc.). (If they are unnecessarily doing cleanup, they should be removed).
Therefore, depending on the zkEVM/zksolc specification, the variables listed earlier that are not cleaned up should be done. Also, if these do not need to be cleaned up, it would be better to clearly state in the comments why they do not need to be cleaned up.
precompileCall
is slightly unfair depending on the amount of gasleft()
In https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/libraries/SystemContractHelper.sol#L152, it is more fair to put require(gasleft() >= _gasToBurn);
after uint256 cleanupMask = UINT32_MASK;
.
If gas is sufficient, the require
statement consumes _gasToBurn
+ α no matter where it is placed. However, if gas is just barely enough to meet gasleft() >= _gasToBurn
, then for example, if gasleft()
is just _gasToBrun
, it is lower because the execution cost of uint256 cleanupMask = UINT32_MASK;
can be ignored.
require
in increaseMinNonce
when nonceOrdering
is Sequential
In setValueUnderNonce
(https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/NonceHolder.sol#L81), as follows, when nonceOrdering
is sequential, the check exists to see if the previous nonce is used.
if (accountInfo.nonceOrdering == IContractDeployer.AccountNonceOrdering.Sequential && _key != 0) { require(isNonceUsed(msg.sender, _key - 1), "Previous nonce has not been used"); }
On the other hand, in increaseMinNonce
(https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/NonceHolder.sol#L64), since there is no check when nonceOrdering
is sequential, it is possible to increase nonce by two or more despite sequential.
However, as the following statement on https://github.com/code-423n4/2023-03-zksync/blob/main/README.md?plain=1#L641 says, It might be better to remove the check on the setValueUnderNonce
.
This ordering is not enforced in any way by system contracts, but it is more of a suggestion to the operator on how it should order the transactions in the mempool.
Anyway, it should be consistent whether nonceOrdering
is checked or not.
baseFee
description SystemContext
is incorrectAt https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/SystemContext.sol#L43, they say that baseFee
is a constant. On the other hand, https://github.com/code-423n4/2023-03-zksync/blob/main/README.md?plain=1#L327 says that baseFee
is given by the operator and will be hard-coded (constant) in the future. In other words, it is currently dynamic, not constant.
Also, in bootloader.yul
, it can be confirmed that baseFee
is calculated dynamically and baseFee
is not a constant, as shown below.
let baseFee, GAS_PRICE_PER_PUBDATA := getBaseFee(L1_GAS_PRICE, FAIR_L2_GAS_PRICE)
/// @dev Returns the baseFee for this block based on the /// L1 gas price and the fair L2 gas price. function getBaseFee(l1GasPrice, fairL2GasPrice) -> baseFee, gasPricePerPubdata { // By default, we want to provide the fair L2 gas price. // That it means that the operator controls // what the value of the baseFee will be. In the future, // a better system, aided by EIP1559 should be added. let pubdataBytePriceETH := safeMul(l1GasPrice, L1_GAS_PER_PUBDATA_BYTE(), "aoa") baseFee := max( fairL2GasPrice, ceilDiv(pubdataBytePriceETH, MAX_L2_GAS_PER_PUBDATA()) ) gasPricePerPubdata := ceilDiv(pubdataBytePriceETH, baseFee) }
Therefore, the comment is incorrect and needs to be changed.
getCallFlags
is incorrecthttps://github.com/code-423n4/2023-03-zksync/blob/main/contracts/libraries/SystemContractHelper.sol#L282 and https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/EventWriter.yul#L49 say Call flags are the value of the first register at the start of the call.
, but, as described in https://github.com/code-423n4/2023-03-zksync/blob/main/README.md?plain=1#L156 , call flags are in r2
. Therefore the comment is incorrect and needs to be changed to the second register
.
override
to interface functionsSince Solidity 0.8.8, the override
keyword is optional for interface functions (ref). The NonceHolder
functions do not have it (https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/NonceHolder.sol#L45), while the AccountCodeStorage
functions do (https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/AccountCodeStorage.sol#L34). It can be added or not, but it would be easier to understand if it is not added to all functions or added to all functions.
_value > 0
should be checked in increaseMinNonce
increaseMinNonce
(https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/NonceHolder.sol#L64) can be executed even if _value
is 0. It should be clearly commented on whether it contains 0
or not.
msg.value > 0
should be checked in withdraw
of L2EthToken
withdraw
(https://github.com/code-423n4/2023-03-zksync/blob/main/contracts/L2EthToken.sol#L80
) can be executed even if msg.value
is 0. It should be clearly commented on whether it contains 0
or not.
extra_abi_data
in VM specs is incorrectIn https://github.com/code-423n4/2023-03-zksync/blob/main/docs/VM-specific_v1.3.0_opcodes_simulation.pdf, CALL param 0 (gas)
in extra_abi_data
is 0, but the correct value is an index.
#0 - GalloDaSballo
2023-03-31T09:42:45Z
L-1: heapSize and auxHeapSize of ZkSyncMeta returned by getZkSyncMeta() are always 0 R
L-2: Some variables are not cleaned up before call in inline assembler TODO (R / L)
L-3: The amount of gas burned by precompileCall is slightly unfair depending on the amount of gasleft() L
L-4: No require in increaseMinNonce when nonceOrdering is Sequential TODO
NC-1: The baseFee description SystemContext is incorrect L
NC-2: The description of call flags in getCallFlags is incorrect L
NC-3: Unify whether to add override to interface functions NC
NC-4: Whether or not _value > 0 should be checked in increaseMinNonce TODO: R
NC-5: Whether or not msg.value > 0 should be checked in withdraw of L2EthToken TODO
NC-6 (out of scope): CALL param 0 in extra_abi_data in VM specs is incorrect
#1 - c4-judge
2023-04-06T18:59:49Z
GalloDaSballo marked the issue as grade-b