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: 5/64
Findings: 5
Award: $25,342.89
🌟 Selected for report: 3
🚀 Solo Findings: 0
🌟 Selected for report: zero-idea
Also found by: 0x1337, 0xTheC0der, 0xstochasticparrot, Audittens, HE1M, Jeiwan, erebus, gumgumzum, leviticus1129, lsaudit, quarkslab, rvierdiiev
656.3255 USDC - $656.33
CURRENT_MAX_PRECOMPILE_ADDRESS
is set to SHA256_SYSTEM_CONTRACT
, whose address is 0x02, for ECADD_SYSTEM_CONTRACT
and ECMUL_SYSTEM_CONTRACT
, whose addresses are 0x06 and 0x07, the function will return a non-zero value as those addresses are higher than 0x02.CURRENT_MAX_PRECOMPILE_ADDRESS
is set to
uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(SHA256_SYSTEM_CONTRACT));
that is
address constant SHA256_SYSTEM_CONTRACT = address(0x02);
whilst ECADD_SYSTEM_CONTRACT
and ECMUL_SYSTEM_CONTRACT
are
address constant ECADD_SYSTEM_CONTRACT = address(0x06); address constant ECMUL_SYSTEM_CONTRACT = address(0x07);
The solution for this codebase is:
address constant ECRECOVER_SYSTEM_CONTRACT = address(0x01); address constant SHA256_SYSTEM_CONTRACT = address(0x02); address constant ECADD_SYSTEM_CONTRACT = address(0x06); address constant ECMUL_SYSTEM_CONTRACT = address(0x07); /// @dev The current maximum deployed precompile address. - /// Note: currently only two precompiles are deployed: + /// Note: currently only four precompiles are deployed: /// 0x01 - ecrecover /// 0x02 - sha256 + /// 0x06 - ecAdd + /// 0x07 - ecMul /// Important! So the constant should be updated if more precompiles are deployed. - uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(SHA256_SYSTEM_CONTRACT)); + uint256 constant CURRENT_MAX_PRECOMPILE_ADDRESS = uint256(uint160(ECMUL_SYSTEM_CONTRACT));
Take into account that there are more precompiles being developed by lambda class, so upgrade such a variable accordingly as you add more precompiles to the project (and update the comment too).
Other
#0 - c4-pre-sort
2023-10-31T06:51:11Z
bytes032 marked the issue as duplicate of #142
#1 - c4-judge
2023-11-23T19:31:03Z
GalloDaSballo marked the issue as satisfactory
4281.3064 USDC - $4,281.31
If any of the governance actors, either $securityCouncil$ or $owner$, gets compromised, the whole governance logic will enter a deadlock (not a centralization risk as the Governance
contract is exactly designed to prevent this kind of issues, ask Vlad).
The actions that each governance actor can do are the following:
cancel
execute
executeInstant
scheduleTransparent
scheduleShadow
cancel
execute
By extension, as the Governance
contract can call itself through execute
and executeInstant
and the only one who can propose upgrades is $owner$, then he can do the following too:
updateSecurityCouncil
updateDelay
It becomes clear that
scheduleTransparent
or scheduleShadow
.cancel
to ban all upgrades made by the $owner$, even the ones trying to remove $securityCouncil$ by calling updateSecurityCouncil
, as the $owner$ cannot call executeInstant
and the proposals delay (although not enforced) is supposed to be non-zero.So the governance logic will be broken.
After a little talk with Vlad, it seems in the future there will be some sort of escape hatch mechanism in which users will be able to withdraw their funds from zkSync Era to Ethereum or another L2 if any of the above happens. For the time being, the solutions we talked about were
cancel
permission of the $securityCouncil$- function cancel(bytes32 _id) external onlyOwnerOrSecurityCouncil { + function cancel(bytes32 _id) external onlyOwner { require(isOperationPending(_id), "Operation must be pending"); delete timestamps[_id]; emit OperationCancelled(_id); }
Governance, function updateDelay
function updateDelay(uint256 _newDelay) external onlySelf { + require(_newDelay >= MINIMUM_UPGRADE_DELAY, "whatever";) emit ChangeMinDelay(minDelay, _newDelay); minDelay = _newDelay; }
The constant MINIMUM_UPGRADE_DELAY
is gonna be a config one whose value is higher than the Solidity 1 day
, as withdrawals from zkSync Era to Ethereum takes around 1 day, so users would have enough time to withdraw their funds if a malicious upgrade is ever scheduled.
Other
#0 - c4-pre-sort
2023-10-31T06:57:36Z
bytes032 marked the issue as low quality report
#1 - miladpiri
2023-11-08T14:25:49Z
If owner or security council are compomised, it breaks the full functionality of the governance mechanism. Indeed security council shouldn’t be able to break upgradability with delay. Medium can be fair. Duplicate https://github.com/code-423n4/2023-10-zksync-findings/issues/364
#2 - c4-sponsor
2023-11-08T14:25:59Z
miladpiri (sponsor) confirmed
#3 - c4-judge
2023-11-23T16:47:18Z
GalloDaSballo marked the issue as primary issue
#4 - c4-judge
2023-11-28T12:54:10Z
GalloDaSballo marked the issue as selected for report
#5 - GalloDaSballo
2023-11-28T12:54:57Z
The Wardens have highlighted a governance deadlock scenario, which allows the security council to hold governance hostage
The finding is at the edge of Medium Severity, however I'm marking it as such for now
14682.1206 USDC - $14,682.12
In bootloader, function processL1Tx, it is possible for a malicious operator to steal all the gas provided by ANY user who requests an L1 ⇒ L2 transaction simply by returning an overinflated refundGas
for the transaction execution.
The refundGas
the user will receive is the sum of
and
being reservedGas
the excess of gas between what the user provided and what the operator is willing to use, and refundGas
(not the final one) the remaining gas that is left after preparing and executing the requested transaction. As both quantities are added via the opcode add
refundGas := add(refundGas, reservedGas)
the operator can return an over-inflated value so that it gets picked by the max()
function and is added to reservedGas
, overflowing the result as there are no checks for overflows in assembly
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") } // gasPrice * (gasLimit - refundGas) == gasPrice * (gasLimit - 0) == gasPrice * gasLimit let payToOperator := safeMul(gasPrice, safeSub(gasLimit, refundGas, "lpah"), "mnk")
As anyone will be able to be an operator once zkSync Era becomes decentralized, this issue becomes critical as it would be possible for ANY operator to "farm" the gas provided by ANY user.
Just use safeAdd
:
- refundGas := add(refundGas, reservedGas) + refundGas := safeAdd(refundGas, reservedGas, "The operator is being an asshole")
Other
#0 - c4-pre-sort
2023-10-31T09:40:49Z
bytes032 marked the issue as duplicate of #187
#1 - c4-pre-sort
2023-10-31T09:40:54Z
bytes032 marked the issue as sufficient quality report
#2 - c4-judge
2023-11-25T19:13:12Z
GalloDaSballo marked the issue as satisfactory
#3 - c4-judge
2023-11-25T19:14:19Z
GalloDaSballo marked the issue as selected for report
#4 - GalloDaSballo
2023-11-25T19:15:00Z
Best because:
🌟 Selected for report: HE1M
Also found by: 0xstochasticparrot, erebus, quarkslab, rvierdiiev
4574.0453 USDC - $4,574.05
getCodeHash
will return 0 instead of EMPTY_STRING_KECCAK
when queried for EOAs who have ever been touched by a call-like operation.
In AccountCodeStorage, function getCodeHash is an abstraction to simulate the EXTCODEHASH
EVM opcode. For that, per EIP-1052 and EIP-161, getCodeHash
will return the following values
0
, if the account does not exists or is emptyc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470
(from now on, EMPTY_STRING_KECCAK
), if the account does exist but it has no code attachedOn top of that, if the queried address is a precompile or is a contract in construction, it will return EMPTY_STRING_KECCAK
. The issue with getCodeHash
is that it does not take into account that, for an address to exist, one of the following must be true:
call
related operationso, if an account has ever received an ETH transfer (even if the amount was 0), then it will be considered as exist so calling EXTCODEHASH
on him will return EMPTY_STRING_KECCAK
instead of 0. That logical branch is not covered in getCodeHash
, so it returns the wrong value under that situation. The runnable POC to showcase such a change in Ethereum is the following (except the whale
address, all the other addresses are mine):
contract POC is Test { event LOG_WHALE_HASH(bytes32 c); event LOG_EMPTY_ACCOUNT_HASH_BEFORE(bytes32 c); event LOG_EMPTY_ACCOUNT_HASH_AFTER(bytes32 c); function testPOC() external { uint256 mainnet = vm.createFork("https://mainnet.infura.io/v3/594fb3fab2a14f33abcd641d955787ab"); vm.selectFork(mainnet); // this is to show the hash of an EOA bytes32 whateHash; uint256 whale = uint256(uint160(address(0x022E34B9Ac13f6206469ff11c55466032585cB5e))); assembly { whateHash := extcodehash(whale) } emit LOG_WHALE_HASH(whateHash); // this is to show the hash of an EOA under my control with no tx on Ethereum nor balance bytes32 emptyAccountHashBefore; uint256 emptyAccount = uint256(uint160(address(0xE8B3Adf28Dc418A4c0303cF5C177018A2d19fD74))); assembly { emptyAccountHashBefore := extcodehash(emptyAccount) } emit LOG_EMPTY_ACCOUNT_HASH_BEFORE(emptyAccountHashBefore); console2.log("Previous eth balance of emptyAccount", address(0xE8B3Adf28Dc418A4c0303cF5C177018A2d19fD74).balance); // send some eth console2.log("Sending eth to emptyAccount..."); (bool success, ) = address(0xE8B3Adf28Dc418A4c0303cF5C177018A2d19fD74).call{value : 5 ether}(""); require(success); // show that the hash changes per EIP-1052 (https://eips.ethereum.org/EIPS/eip-1052) bytes32 emptyAccountHashAfter; assembly { emptyAccountHashAfter := extcodehash(emptyAccount) } emit LOG_EMPTY_ACCOUNT_HASH_AFTER(emptyAccountHashAfter); console2.log("Updated eth balance of emptyAccount", address(0xE8B3Adf28Dc418A4c0303cF5C177018A2d19fD74).balance); } }
function getCodeHash(uint256 _input) external view override returns (bytes32) { // We consider the account bytecode hash of the last 20 bytes of the input, because // according to the spec "If EXTCODEHASH of A is X, then EXTCODEHASH of A + 2**160 is X". address account = address(uint160(_input)); if (uint160(account) <= CURRENT_MAX_PRECOMPILE_ADDRESS) { return EMPTY_STRING_KECCAK; } bytes32 codeHash = getRawCodeHash(account); // The code hash is equal to the `keccak256("")` if the account is an EOA with at least one transaction. // Otherwise, the account is either deployed smart contract or an empty account, // for both cases the code hash is equal to the raw code hash. - if (codeHash == 0x00 && NONCE_HOLDER_SYSTEM_CONTRACT.getRawNonce(account) > 0) { + if (codeHash == 0x00 && (NONCE_HOLDER_SYSTEM_CONTRACT.getRawNonce(account) > 0 || wasCalled(account))) { codeHash = EMPTY_STRING_KECCAK; } // The contract is still on the constructor, which means it is not deployed yet, // so set `keccak256("")` as a code hash. The EVM has the same behavior. else if (Utils.isContractConstructing(codeHash)) { codeHash = EMPTY_STRING_KECCAK; } return codeHash; } + function wasCalled(address account) external returns (bool) { + (bool success, bytes memory is) = account.call(abi.encodeWithSignature("wasCalled()")); + require(success); + return abi.decode(is, (bool)); + }
+ bool private wasCalled = false; + function wasCalled() external { + return wasCalled; + } fallback() external payable { // 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 + wasCalled = true; } receive() external payable { // If the contract is called directly, behave like an EOA + wasCalled = true; }
NOTE: I may have made some assumptions about your code, the changes are illustrative, just to show you the idea I would have when fixing the code.
Other
#0 - bytes032
2023-10-27T02:47:49Z
Leaving notes:
EIP-1052
In case the account does not exist or is empty (as defined by EIP-161) 0 is pushed to the stack.
EIP-161
An account is considered to be touched when it is involved in any potentially state-changing operation. This includes, but is not limited to, being the recipient of a transfer of zero value. An account is considered empty when it has no code and zero nonce and zero balance.
#1 - c4-pre-sort
2023-10-31T13:27:08Z
bytes032 marked the issue as duplicate of #133
#2 - c4-judge
2023-11-24T19:40:50Z
GalloDaSballo marked the issue as satisfactory
🌟 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
1149.0848 USDC - $1,149.08
Talking with Vlad, these are the ones we did not reach a consensus about their possible impact:
msg.timestamp
as an expiration timestamp invites MEV manipulationsSetting the expiration timestamp to a value that depends directly of block.timestamp
is useless, as block.timestamp
is the timestamp when the transaction is EXECUTED, not when it is submitted to the network, opening the door to MEV manipulations. Think of the typical Uniswap-based bug in which expiration timestamp is set to block.timestamp + K
, but applied to inter-chain swaps or highly-sensitive time-dependant operations (e. g. arbitrage or inter-chain DEXes like Interport).
Thanks to Proof of Stake, validators and, in fact, everyone in the Ethereum network knows who is gonna submit the next block with a margin of $6$ minutes and $24$ seconds to $12$ minutes and $48$ seconds (see the References section). That's enough time for bots and validators to do MEV calculations for many of the transactions waiting in the mempool AND submit their own with the right amount of gas to make sure their transactions are executed within the next block. Because of this, highly-sensitive operations like swaps between tokens need 1) slippage control and 2) expirarion timestamps so that users will NOT incur in extreme losses due to MEV bots sandwiching/front-running them or validators "storing" transactions until they can make a profitable "chain".
This idea can be extended to inter-chain operations pretty easily, so that the requested transactions from one layer to another do not wait idly in the mempool of the first one. However, doing request from Ethereum to zkSync Era through Mailbox
, the expiration timestamp is set to
uint64 expirationTimestamp = uint64(block.timestamp + PRIORITY_EXPIRATION); // Safe to cast
with
uint256 constant PRIORITY_EXPIRATION = 0 days;
so it equals block.timestamp
, which is bad as its value will NOT be the time the user submitted the transaction to the network but the time the transaction is picked by a validator and included in a block. That means, virtually, there is no expiration timestamp for such a transaction.
Some ways to exploit this, from a hacker's POV, is via rogue validators (see here) or "chained transaction-blocks". The idea is that, thanks to POS, the odds for a validator to execute sequentially $n > 2$ blocks increases with the amount of ETH staked so it is possible to delay a highly profitable transaction and place it between two validator-controlled blocks and sandwich it on zkSync Era (as they are executed sequentially via a priority queue). I'm not gonna deepen more in that, you can go to the References section if you wanna know the details. This issue is real and feasible, although pretty expensive, but the damages to users may be huge if they are doing, say, an inter-chain Furocombo combo worth 3 million dollars or any other type of highly-sensitive inter-chain operation.
Pass the expiration timestamp as a function argument retrieved from the front-end and revert if it is less than the block.timestamp
in which the transaction is executed:
Mailbox, function requestL2Transaction
function requestL2Transaction( address _contractL2, uint256 _l2Value, bytes calldata _calldata, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByteLimit, bytes[] calldata _factoryDeps, - address _refundRecipient + address _refundRecipient, + uint64 _expirationTimestamp ) external payable nonReentrant senderCanCallFunction(s.allowList) returns (bytes32 canonicalTxHash) { + require(_expirationTimestamp > block.timestamp, "MEV trying to do their things"); // 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); } // Enforcing that `_l2GasPerPubdataByteLimit` equals to a certain constant number. This is needed // to ensure that users do not get used to using "exotic" numbers for _l2GasPerPubdataByteLimit, e.g. 1-2, etc. // VERY IMPORTANT: nobody should rely on this constant to be fixed and every contract should give their users the ability to provide the // ability to provide `_l2GasPerPubdataByteLimit` for each independent transaction. // CHANGING THIS CONSTANT SHOULD BE A CLIENT-SIDE CHANGE. require(_l2GasPerPubdataByteLimit == REQUIRED_L2_GAS_PRICE_PER_PUBDATA, "qp"); // 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); canonicalTxHash = _requestL2Transaction( sender, _contractL2, _l2Value, _calldata, _l2GasLimit, _l2GasPerPubdataByteLimit, _factoryDeps, false, - _refundRecipient + _refundRecipient, + _expirationTimestamp ); }
Mailbox, function _requestL2Transaction
function _requestL2Transaction( address _sender, address _contractAddressL2, uint256 _l2Value, bytes calldata _calldata, uint256 _l2GasLimit, uint256 _l2GasPerPubdataByteLimit, bytes[] calldata _factoryDeps, bool _isFree, - address _refundRecipient + address _refundRecipient, + uint64 _expirationTimestamp ) 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.expirationTimestamp = _expirationTimestamp; params.l2GasLimit = _l2GasLimit; params.l2GasPricePerPubdata = _l2GasPerPubdataByteLimit; params.valueToMint = msg.value; params.refundRecipient = refundRecipient; canonicalTxHash = _writePriorityOp(params, _calldata, _factoryDeps); }
On top of that, change the deposit
functions in both bridges to match the ABI of the new requestL2Transaction
and remove the PRIORITY_EXPIRATION
constant in Config, line 46.
debugLog
in ∆gas calculations leads to an artificially increased valueIn bootloader, function debugLog does consume gas()
, so using it inside $\Delta gas$ calculations leads to an artificially little increase above the real gas usage. This is bad because the amount users will be refunded depends on this "difference blocks" and they must NOT pay for debugging functionalities. You can argue that it is the same as Ethereum's event
s, but they are executed in the "transaction context" regardless of where you place them, whilst these debugLog
s can be "moved" to be executed in the "bootloader context" (where they should have been since the beggining). Like it is right now, users are paying for the next additional opcodes:
mstore
add
mul
callValue
sub
I asked Vlad for the gas cost of each of them but he did not know, so I will do the estimations with the Ethereum ones. Just keep in mind that, as the underlying logic for these opcodes are ZK circuits, the gas cost will be higher to cover the computational overhead:
NOTE ⇒ this is NOT a gas optimization, this is a theft of gas (just to make it clear).
l1TxPreparation, lines 1006 to 1019
let gasBeforePreparation := gas() - debugLog("gasBeforePreparation", gasBeforePreparation) // Even though the smart contracts on L1 should make sure that the L1->L2 provide enough gas to generate the hash // we should still be able to do it even if this protection layer fails. canonicalL1TxHash := getCanonicalL1TxHash(txDataOffset) - debugLog("l1 hash", canonicalL1TxHash) // Appending the transaction's hash to the current L2 block appendTransactionHash(canonicalL1TxHash, true) markFactoryDepsForTx(innerTxDataOffset, true) gasUsedOnPreparation := safeSub(gasBeforePreparation, gas(), "xpa") + debugLog("gasBeforePreparation", gasBeforePreparation) + debugLog("l1 hash", canonicalL1TxHash)
Reduction of $80 * 2 = 160$ gas.
l2TxValidation, lines 1185 to 1213
let gasBeforeValidate := gas() - debugLog("gasBeforeValidate", gasBeforeValidate) // Saving the tx hash and the suggested signed tx hash to memory saveTxHashes(txDataOffset) // Appending the transaction's hash to the current L2 block appendTransactionHash(mload(CURRENT_L2_TX_HASHES_BEGIN_BYTE()), false) checkEnoughGas(gasLimitForTx) // Note, that it is assumed that `ZKSYNC_NEAR_CALL_validateTx` will always return true // unless some error which made the whole bootloader to revert has happened or // it runs out of gas. let isValid := 0 + let validateABI := 0 // Only if the gasLimit for tx is non-zero, we will try to actually run the validation if gasLimitForTx { - let validateABI := getNearCallABI(gasLimitForTx) + validateABI := getNearCallABI(gasLimitForTx) - debugLog("validateABI", validateABI) isValid := ZKSYNC_NEAR_CALL_validateTx(validateABI, txDataOffset, gasPrice) } - debugLog("isValid", isValid) let gasUsedForValidate := sub(gasBeforeValidate, gas()) + debugLog("gasBeforeValidate", gasBeforeValidate) + debugLog("validateABI", validateABI) // TODO review this, how can you differentiate between validateABI being 0 because gasLimitForTx is 0 or because there was an error in getNearCallABI ?? + debugLog("isValid", isValid)
Reduction of $80 * 3 = 240$ gas.
In L1WethBridge, function deposit, and L1ERC20Bridge, function deposit, some invariants must hold to apply address aliasing to the refund recipient:
_refundRecipient
on L2 $=$ _refundRecipient
if it is an EOA in L1_refundRecipient
on L2 $=$ alias(_refundRecipient)
if it is NOT an EOA in L1_refundRecipient
on L2 $=$ msg.sender
if it is an EOA in L1 AND _refundRecipient
is address(0)
_refundRecipient
on L2 $=$ alias(msg.sender)
if it is NOT an EOA in L1 AND _refundRecipient
is address(0)
For that, both bridges check $3$ and $4$ by doing
address refundRecipient = _refundRecipient; if (_refundRecipient == address(0)) { refundRecipient = msg.sender != tx.origin ? AddressAliasHelper.applyL1ToL2Alias(msg.sender) : msg.sender; }
and refundRecipient
is used as an argument to Mailbox, requestL2Transaction. The issue is that Mailbox, requestL2Transaction does apply address aliasing too (checks $1$ and $2$)
// 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); }
so there is an edge case that breaks our four invariants:
_refundRecipient
on L2 = alias(alias(msg.sender))
if msg.sender
is NOT an EOA in L1 AND alias(msg.sender)
is NOT an EOA in L1 and _refundRecipient
is address(0)
.This is bad because the excess of gas may be sent to the wrong _refundRecipient
or, even worst, if a WETH transfer fails on L2, the whole amount transferred. Although VERY unlikely to occur ($2^k$ execution steps to find a collision), I would remove this possibility from happening by modifying the code in both bridges:
// If the refund recipient is not specified, the refund will be sent to the sender of the transaction. // Otherwise, the refund will be sent to the specified address. // If the recipient is a contract on L1, the address alias will be applied. address refundRecipient = _refundRecipient; if (_refundRecipient == address(0)) { - refundRecipient = msg.sender != tx.origin ? AddressAliasHelper.applyL1ToL2Alias(msg.sender) : msg.sender; + refundRecipient = msg.sender; // it will be aliased by Mailbox if it is a contract and will default to msg.sender if EOA }
In Mailbox, function _verifyDepositLimit, the code after the if
statement is never executed as address(0)
is hard-coded and ETH deposits are uncapped (see the comment):
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; }
Although AllowList, function setDepositLimit does NOT enforce it:
function setDepositLimit( address _l1Token, bool _depositLimitation, uint256 _depositCap ) external onlyOwner { tokenDeposit[_l1Token].depositLimitation = _depositLimitation; tokenDeposit[_l1Token].depositCap = _depositCap; }
consider changing it to revert if trying to cap ETH deposits
function setDepositLimit( address _l1Token, bool _depositLimitation, uint256 _depositCap ) external onlyOwner { + require(_l1Token != address(0), "ETH deposits cannot be limited"); tokenDeposit[_l1Token].depositLimitation = _depositLimitation; tokenDeposit[_l1Token].depositCap = _depositCap; }
and consider removing the whole _verifyDepositLimit
function as it will always return without modifying the state of the contract (tautology)
- 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; - }
NOTE ⇒ Mailbox
is for ETH, which is uncapped, and tokens are bridged via the bridges, which do check for the deposit limits themselves, so there is no place for that function here).
In ImmutableSimulator, line 20, it says @notice that address uses uint256 type to leave the option to introduce 32-bytes address space in future
which is a pretty bad idea as
address
to uint160
pretty often. Changing the size of the address
type may lead to collisions or losing information with addresses whose value is higher than type(uint160).max
.TLDR ⇒ do NOT do that.
dataLength
and data
are not used. I'm putting this as a low as I do not know if there is some missing logic.innerTxDataOffset
is not used. I'm putting this as a low as I do not know if there is some missing logic.In ContractDeployer, lines 159 and 182 say @dev In case of a revert, the zero address should be returned
but the functions create2Account
and createAccount
revert the whole transaction instead. Consider either removing those comments or putting the code in a try
/catch
block, so that it returns address(0)
in the catch
block.
basefee
may be higher than maxPriorityFeePerGas
In bootloader, function getGasPrice checks the correctness of the fee parameters, namely:
But it does not check that:
so the operator may charge users requesting priority operations more than what they initially allowed.
/// @dev Returns the gas price that should be used by the transaction /// based on the EIP1559's maxFeePerGas and maxPriorityFeePerGas. /// The following invariants should hold: /// maxPriorityFeePerGas <= maxFeePerGas - /// baseFee <= maxFeePerGas + /// baseFee <= maxPriorityFeePerGas /// While we charge baseFee from the users, the method is mostly used as a method for validating /// the correctness of the fee parameters function getGasPrice( maxFeePerGas, maxPriorityFeePerGas ) -> ret { let baseFee := basefee() if gt(maxPriorityFeePerGas, maxFeePerGas) { revertWithReason( MAX_PRIORITY_FEE_PER_GAS_GREATER_THAN_MAX_FEE_PER_GAS(), 0 ) } - if gt(baseFee, maxFeePerGas) { + if gt(baseFee, maxPriorityFeePerGas) { revertWithReason( - BASE_FEE_GREATER_THAN_MAX_FEE_PER_GAS(), + BASE_FEE_GREATER_THAN_MAX_PRIORITY_FEE_PER_GAS(), // add it and remove `BASE_FEE_GREATER_THAN_MAX_FEE_PER_GAS` at line 3384 0 ) } // We always use `baseFee` to charge the transaction ret := baseFee }
so that we have $baseFee \le maxPriorityFeePerGas \le maxFeePerGas$. Right now, $maxPriorityFeePerGas$ is hard-coded to $0$
// Mailbox function _serializeL2Transaction( WritePriorityOpParams memory _priorityOpParams, bytes calldata _calldata, bytes[] calldata _factoryDeps ) internal pure returns (L2CanonicalTransaction memory transaction) { transaction = L2CanonicalTransaction({ txType: PRIORITY_OPERATION_L2_TX_TYPE, from: uint256(uint160(_priorityOpParams.sender)), to: uint256(uint160(_priorityOpParams.contractAddressL2)), gasLimit: _priorityOpParams.l2GasLimit, gasPerPubdataByteLimit: _priorityOpParams.l2GasPricePerPubdata, maxFeePerGas: uint256(_priorityOpParams.l2GasPrice), maxPriorityFeePerGas: uint256(0),<========================================================= HERE paymaster: uint256(0), // Note, that the priority operation id is used as "nonce" for L1->L2 transactions nonce: uint256(_priorityOpParams.txId), value: _priorityOpParams.l2Value, reserved: [_priorityOpParams.valueToMint, uint256(uint160(_priorityOpParams.refundRecipient)), 0, 0], data: _calldata, signature: new bytes(0), factoryDeps: _hashFactoryDeps(_factoryDeps), paymasterInput: new bytes(0), reservedDynamic: new bytes(0) });
so the Low, instead of the Medium BUT when you update that make sure that the invariants above hold, making the operator unable to charge users more than what they specified.
getZkSyncMeta
does not populate fully the ZkSyncMeta
structIn SystemContractHelper, function getZkSyncMeta constructs the ZkSyncMeta
struct from the bytes returned by getZkSyncMetaBytes()
. However, the fields heapSize
and auxHeapSize
are not populated, so they default to 0. As the only field that is being used in the repo is gasPerPubdataByte
, I am putting it as a Low.
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); + meta.heapSize = getHeapSizeFromMeta(metaPacked); + meta.auxHeapSize = getAuxHeapSizeFromMeta(metaPacked); }
For reference, the struct is:
struct ZkSyncMeta { uint32 gasPerPubdataByte; uint32 heapSize; uint32 auxHeapSize; uint8 shardId; uint8 callerShardId; uint8 codeShardId; }
In Mailbox, function l2TransactionBaseCost is not called in the whole project so it seems it will be called by the front-end to show users an estimation of their transaction cost. However, they do permit to pass an arbitrary _l2GasPerPubdataByteLimit
whilst function requestL2Transaction
requires that it is equal to REQUIRED_L2_GAS_PRICE_PER_PUBDATA
, whose value is 800
(see here). As I do not know a shit about Vue and TS (and your front-end is written in that), I do recommend to hard-code the value in l2TransactionBaseCost
to NOT return misleading estimations to users:
function l2TransactionBaseCost( uint256 _gasPrice, - uint256 _l2GasLimit, - uint256 _l2GasPerPubdataByteLimit + uint256 _l2GasLimit ) public pure returns (uint256) { - uint256 l2GasPrice = _deriveL2GasPrice(_gasPrice, _l2GasPerPubdataByteLimit); + uint256 l2GasPrice = _deriveL2GasPrice(_gasPrice, REQUIRED_L2_GAS_PRICE_PER_PUBDATA); return l2GasPrice * _l2GasLimit; }
rawCall
does permit calls to MsgValueSimulator
with the _isSystem
flag set to false (it will revert)In MsgValueSimulator, fallback can only be called with the systemCall
flag set (not the ABI one, which is retrieved through SystemContractHelper.getExtraAbiData
)
fallback(bytes calldata _data) external onlySystemCall returns (bytes memory)
ISystemContract, lines 15 to 21
modifier onlySystemCall() { require( SystemContractHelper.isSystemCall() || SystemContractHelper.isSystemContract(msg.sender), "This method require system call flag" ); _; }
However, in EfficientCall, function rawCall does let the door open for doing calls to MsgValueSimulator
with the _isSystem
flag set to false
even if the corresponding ABI parameter is hard-coded to true
EfficientCall, lines 139 to 146
_loadFarCallABIIntoActivePtr(_gas, _data, false, true); <========= isSystem hard-coded to true // If there is provided `msg.value` call the `MsgValueSimulator` to forward ether. address msgValueSimulator = MSG_VALUE_SYSTEM_CONTRACT; address callAddr = SYSTEM_CALL_BY_REF_CALL_ADDRESS; // We need to supply the mask to the MsgValueSimulator to denote // that the call should be a system one. uint256 forwardMask = _isSystem ? MSG_VALUE_SIMULATOR_IS_SYSTEM_BIT : 0; <========= may be false
Consider just using MSG_VALUE_SIMULATOR_IS_SYSTEM_BIT
, as the _isSystem
parameter is expected to be used for non-zero value calls.
function rawCall( uint256 _gas, address _address, uint256 _value, bytes calldata _data, bool _isSystem ) internal returns (bool success) { if (_value == 0) { _loadFarCallABIIntoActivePtr(_gas, _data, false, _isSystem); address callAddr = RAW_FAR_CALL_BY_REF_CALL_ADDRESS; assembly { success := call(_address, callAddr, 0, 0, 0xFFFF, 0, 0) } } else { _loadFarCallABIIntoActivePtr(_gas, _data, false, true); // If there is provided `msg.value` call the `MsgValueSimulator` to forward ether. address msgValueSimulator = MSG_VALUE_SYSTEM_CONTRACT; address callAddr = SYSTEM_CALL_BY_REF_CALL_ADDRESS; - // We need to supply the mask to the MsgValueSimulator to denote - // that the call should be a system one. - uint256 forwardMask = _isSystem ? MSG_VALUE_SIMULATOR_IS_SYSTEM_BIT : 0; + uint256 forwardMask = MSG_VALUE_SIMULATOR_IS_SYSTEM_BIT; assembly { success := call(msgValueSimulator, callAddr, _value, _address, 0xFFFF, forwardMask, 0) } } }
as seen in SystemContractsCaller, lines 103 to 112 (the comments are the same, so it seems it was copy-pasted).
In Mailbox, function requestL2Transaction, the isFree
parameter of the call to _requestL2Transaction
is hard-coded to false
:
canonicalTxHash = _requestL2Transaction( sender, // from _contractL2, // to _l2Value, // bridged ETH _calldata, // the usual calldata _l2GasLimit, // the amount of gas user is willing to spend _l2GasPerPubdataByteLimit, // the amount of gas per pubdata the operator may charge _factoryDeps, // bytecode to be marked as known on L2 false, // isFree _refundRecipient // who is gonna receive the excess of gas or the whole ETH if the tx fails );
so the calculation of the L2 gas price
params.l2GasPrice = _isFree ? 0 : _deriveL2GasPrice(tx.gasprice, _l2GasPerPubdataByteLimit); uint256 baseCost = params.l2GasPrice * _l2GasLimit;
defaults to
params.l2GasPrice = _deriveL2GasPrice(tx.gasprice, _l2GasPerPubdataByteLimit); uint256 baseCost = params.l2GasPrice * _l2GasLimit;
taking into account that its value can never be zero because
function _deriveL2GasPrice(uint256 _l1GasPrice, uint256 _gasPricePerPubdata) internal pure returns (uint256) { uint256 pubdataPriceETH = L1_GAS_PER_PUBDATA_BYTE * _l1GasPrice; uint256 minL2GasPriceETH = (pubdataPriceETH + _gasPricePerPubdata - 1) / _gasPricePerPubdata; return Math.max(FAIR_L2_GAS_PRICE, minL2GasPriceETH); }
returns at least FAIR_L2_GAS_PRICE
. If we go to bootloader, function processL1Tx, the refundGas
the user will receive is the sum of
and
being reservedGas
the excess of gas between what the user provided and what the operator is willing to use, and refundGas
(not the final one) the remaining gas that is left after preparing and executing the requested transaction. It is expected then that _refundRecipient
will receive $refundGas * gasPrice$ as a compensation for providing more gas than the needed for the execution of the L1 ⇒ L2 transaction. However, we see that
// Note, that for now, the L1->L2 transactions are free, i.e. the gasPrice // for such transactions is always zero, so the `refundGas` is not used anywhere // except for notifications for the operator for API purposes. notifyAboutRefund(refundGas)
and it is never used as it assumes that "L1 ⇒ L2 transactions are free", which is not true. If the transaction does not revert, then _refundRecipient
will receive:
toRefundRecipient := safeSub(getReserved0(innerTxDataOffset), safeAdd(getValue(innerTxDataOffset), payToOperator, "kpa"), "ysl")
which equals to $msg.value - (l2Value + payToOperator)$. I tried reversing the equation and simplifying it in order to prove that $refundGas * gasPrice \not = msg.value - (l2Value + payToOperator)$ but it was a fucking rabbit hole and I started making assumptions that weren't robust enough to be in this POC. I kindly suggest changing the code to:
- // Note, that for now, the L1->L2 transactions are free, i.e. the gasPrice - // for such transactions is always zero, so the `refundGas` is not used anywhere - // except for notifications for the operator for API purposes. notifyAboutRefund(refundGas) // Paying the fee to the operator mintEther(BOOTLOADER_FORMAL_ADDR(), payToOperator, false) let toRefundRecipient switch success case 0 { // If the transaction reverts, then minting the msg.value to the user has been reverted // as well, so we can simply mint everything that the user has deposited to // the refund recipient toRefundRecipient := safeSub(getReserved0(innerTxDataOffset), payToOperator, "vji") } default { - // If the transaction succeeds, then it is assumed that msg.value was transferred correctly. However, the remaining - // ETH deposited will be given to the refund recipient. + // If the transaction succeeds, then it is assumed that msg.value was transferred correctly, so refundGas is + // sent to the refund recipient. - toRefundRecipient := safeSub(getReserved0(innerTxDataOffset), safeAdd(getValue(innerTxDataOffset), payToOperator, "kpa"), "ysl") + toRefundRecipient := safeMul(refundGas, gasPrice, "whatever error you want") }
If both equations are equal, that is, $refundGas * gasPrice = msg.value - (l2Value + payToOperator)$, then this is more explicit and saves gas by not doing safeSub
s nor safeAdd
s (QA). If they are NOT, then this code is the correct one, as you are giving the refund recipient exactly the calculated refundGas
, no more, no less (Medium).
The bootloader is provided with $2³² - 1$ gas to execute all submitted transactions in a given batch, so it is expected that all the gas refunds will be below that amount. For L2 transactions it is checked in
bootloader, lines 1463 to 1465
if iszero(validateUint32(refundInGas)) { assertionError("refundInGas is not uint32") }
but for L1 ⇒ L2 transactions it is not enforced, leading to a possible situation in which the operator refunds, or even pay himself, more than what the bootloader really has. Consider doing in
if gt(refundGas, gasLimit) { assertionError("L1: refundGas > gasLimit") } + if iszero(validateUint32(refundInGas)) { + assertionError("refundInGas is not uint32") + } let payToOperator := safeMul(gasPrice, safeSub(gasLimit, refundGas, "lpah"), "mnk")
max
pickIn bootloader, line 1456, it is being picked the maximum between the refund provided by the operator and the one calculated by the bootloader plus the initial excess of gas. As askOperatorForRefund
is called with the leftover gas found by the bootloader, it will return a value less than $reservedGas + gasLeft$ so the max
function is broken. Consider doing the same as in
// 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) } refundGas := add(refundGas, reservedGas)
that is, rewrite
bootloader, lines 1450 to 1456
askOperatorForRefund(gasLeft) let operatorProvidedRefund := getOperatorRefundForTx(transactionIndex) // If the operator provides the value that is lower than the one suggested for // the bootloader, we will use the one calculated by the bootloader. - let refundInGas := max(operatorProvidedRefund, add(reservedGas, gasLeft)) + let refundInGas := max(operatorProvidedRefund, gasLeft) + refundInGas := add(reservedGas, refundInGas)
bytes32(0) // zkPorter batch hash
it says "zkPorter batch HASH", so it seems it should be
- bytes32(0) // zkPorter batch hash + EMPTY_STRING_KECCAK // zkPorter batch hash
ValidatorTimelock
ValidatorTimelock, function setExecutionDelay
+ uint32 MIN_EXECUTION_DELAY = uint32(1 hours); // whatever function setExecutionDelay(uint32 _executionDelay) external onlyOwner { + require(_executionDelay >= MIN_EXECUTION_DELAY, "Don't do that"); executionDelay = _executionDelay; emit NewExecutionDelay(_executionDelay); }
Both Utils, function bytecodeLenInWords and L2ContractHelper, function _bytecodeLen do calculate the length in words of a given bytecode like the following:
codeLengthInWords = uint256(uint8(_bytecodeHash[2])) * 256 + uint256(uint8(_bytecodeHash[3]));
However, when hashing the bytecode, the bytes at position [2]
are set to 0:
function hashL2Bytecode(bytes memory _bytecode) internal pure returns (bytes32 hashedBytecode) { ... hashedBytecode = sha256(_bytecode) & 0x00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // Setting the version of the hash hashedBytecode = (hashedBytecode | bytes32(uint256(1 << 248))); // Setting the length hashedBytecode = hashedBytecode | bytes32(bytecodeLenInWords << 224); }
It seems that codeLengthInWords
should be equal to the bytes at position [3]
alone, as the variable's name bytecodeLenInWords
suggests, that is:
L2ContractHelper, function _bytecodeLen
function _bytecodeLen(bytes32 _bytecodeHash) private pure returns (uint256 codeLengthInWords) { - codeLengthInWords = uint256(uint8(_bytecodeHash[2])) * 256 + uint256(uint8(_bytecodeHash[3])); + codeLengthInWords = uint256(uint8(_bytecodeHash[3])); }
Utils, function bytecodeLenInWords
function bytecodeLenInWords(bytes32 _bytecodeHash) internal pure returns (uint256 codeLengthInWords) { unchecked { - codeLengthInWords = uint256(uint8(_bytecodeHash[2])) * 256 + uint256(uint8(_bytecodeHash[3])); + codeLengthInWords = uint256(uint8(_bytecodeHash[3])); } }
_enumerationIndexSize
is 0
, compressedEnumIndex
equals metadata
In Compressor, lines 175 to 177
uint256 compressedEnumIndex = _sliceToUint256(_compressedStateDiffs[stateDiffPtr:stateDiffPtr + _enumerationIndexSize]); require(enumIndex == compressedEnumIndex, "rw: enum key mismatch"); stateDiffPtr += _enumerationIndexSize; uint8 metadata = uint8(bytes1(_compressedStateDiffs[stateDiffPtr]));
if _enumerationIndexSize
is zero, then compressedEnumIndex
and metadata
will have the same value, as stateDiffPtr += 0
equals stateDiffPtr
.
function verifyCompressedStateDiffs( uint256 _numberOfStateDiffs, uint256 _enumerationIndexSize, bytes calldata _stateDiffs, bytes calldata _compressedStateDiffs ) external payable onlyCallFrom(address(L1_MESSENGER_CONTRACT)) returns (bytes32 stateDiffHash) { // We do not enforce the operator to use the optimal, i.e. the minimally possible _enumerationIndexSize. // We do enforce however, that the _enumerationIndexSize is not larger than 8 bytes long, which is the // maximal ever possible size for enumeration index. + require(_enumerationIndexSize != 0, "whatever"); require(_enumerationIndexSize <= MAX_ENUMERATION_INDEX_SIZE, "enumeration index size is too large"); ...
publishPubdataAndClearState
In L1Messenger, function publishPubdataAndClearState there is a tautology:
require(numberOfL2ToL1Logs <= numberOfL2ToL1Logs, "Too many L2->L1 logs");
As numberOfL2ToL1Logs <= numberOfL2ToL1Logs
always holds, it can pass more logs than numberOfLogsToProcess
. However, the correctness of the amount of sent logs is also checked in
if (_expectedSystemContractUpgradeTxHash == bytes32(0)) { require(processedLogs == 127, "b7"); } else { require(processedLogs == 255, "b8"); }
so the Low.
NOTE ⇒ I did not test it, but it seems it lets the door open for the operator to add an additional log to the queue if there was a system upgrade, so that the first condition reverts, which may be considered as a temporal DOS/griefing. Consider changing
- require(numberOfL2ToL1Logs <= numberOfL2ToL1Logs, "Too many L2->L1 logs"); + require(numberOfL2ToL1Logs <= numberOfLogsToProcess, "Too many L2->L1 logs");
Usually, when we want to chain hashes one after another we start with the empty string hash and then hash the rest by encoding one after another like "" || str1 || str2 || ...
and hashing them in pairs (you know what I mean). However, in many places it is used 0 as the initial "string" which may lead to issues as some of your contracts in L1 do use EMPTY_STRING_KECCAK
instead, namely
Executor, function _collectOperationsFromPriorityQueue
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)); } }
and in the future it seems there will be more. On the other hand, there are some places which do not follow this:
bytes32 reconstructedChainedLogsHash; // defaults to bytes32(0)
bytes32 reconstructedChainedMessagesHash; // defaults to bytes32(0)
bytes32 reconstructedChainedL1BytecodesRevealDataHash; // defaults to bytes32(0)
/// Clear logs state chainedLogsHash = bytes32(0); numberOfLogsToProcess = 0; chainedMessagesHash = bytes32(0); chainedL1BytecodesRevealDataHash = bytes32(0);
currentL2BlockTxsRollingHash = bytes32(0);
Consider changing them to EMPTY_STRING_KECCAK
.
I do not know a shit about affine spaces, but
ecrecover/secp256k1/mod.rs, lines 147 to 153
fn zero() -> Self { PointAffine { x: Fq::zero(), y: Fq::one(), infinity: true, } }
is wrong as is_zero
is defined like
fn is_zero(&self) -> bool { self.infinity }
and self.infinity
is toggled up when self.x
$=$ self.y
$=$ 0
:
ecrecover/secp256k1/mod.rs, lines 189 to 211
fn from_xy_unchecked(x: Self::Base, y: Self::Base) -> Self { let infinity = x.is_zero() && y.is_zero(); Self { x: x, y: y, infinity, } } fn from_xy_checked(x: Self::Base, y: Self::Base) -> Result<Self, GroupDecodingError> { let infinity = x.is_zero() && y.is_zero(); let affine = Self { x: x, y: y, infinity, }; if !affine.is_on_curve() { Err(GroupDecodingError::NotOnCurve) } else { Ok(affine) } }
I'm putting it as a Low as it may be some weird math thing I do not know, BUT the zero element in all "math-things" is defined as the one that makes
and having two "zero-elements" is definetly wrong. If I am right, then it may be a High as some functions like eq or is_on_curve would return true for both. Moreover, I'm gonna say something that mathematicians will laugh at me, but
I can't give you a solution as I do not know which one is the right point at $\infty$, as in some places it says it is the $(0, 0)$ and in others say it is $(0, 1)$.
revertBlocks
and make the chain unusableIn Executor, function revertBlocks there is no way to prevent a rogue validator to spam this function and DOS other validators from executing the commited batches, as executeBlocks has a delay enforced by ValidatorTimelock, which is supposed to be non-zero. As the validator is currently controled by Matter Labs, I'm putting it as a Low but consider implementing a delay or a slashing mechanism to validators that try to abuse their privileges by spamming revertBlocks
for batches that do not benefit them, or even try to DOS the network.
2-bit IS set
, not 2-bit it set
.- // When the system call is passed, the 2-bit it set to 1 + // When the system call is passed, the 2-bit is set to 1
words
.- * the second byte denotes whether the contract is constructed, and the next two bytes denote the length in 32-byte words. - * words. And then the next 28 bytes is the truncated hash. + * the second byte denotes whether the contract is constructed, the next two bytes denote the length in 32-byte words + * and then the next 28 bytes is the truncated hash.
blocks' hashes
, not blocks's hashes
.- /// - Store the latest 257 blocks's hashes. + /// - Store the latest 257 blocks' hashes.
That means the operator controls ...
, not That it means that the operator controls ...
.- // That it means that the operator controls + // That means the operator controls
... make sure the operator ...
, not ... make sure that the operator
.- /// @dev The purpose of this function is to make sure that the operator + /// @dev The purpose of this function is to make sure the operator
for
.- // Calling for the `payForTransaction` method of the account. + // Calling the `payForTransaction` method of the account.
Accepts an uint64
, not Accepts an uint32
.- /// @dev Accepts an uint32 and returns whether or not it is + /// @dev Accepts an uint64 and returns whether or not it is
Accepts an uint128 and returns wether or not it is a valid uint128
, not Accepts an uint32 and returns wether or not it is a valid uint64
.- /// @dev Accepts an uint32 and returns whether or not it is - /// a valid uint64 + /// @dev Accepts an uint128 and returns whether or not it is + /// a valid uint128
MEMORY_QUERIES_PER_CYCLE
, not MEMORY_EQURIES_PER_CYCLE
.- pub const MEMORY_EQURIES_PER_CYCLE: usize = 5; // we need to read as much as possible to use a round function every cycle + pub const MEMORY_QUERIES_PER_CYCLE: usize = 5; // we need to read as much as possible to use a round function every cycle - pub const NUM_U64_WORDS_PER_CYCLE: usize = 4 * MEMORY_EQURIES_PER_CYCLE; + pub const NUM_U64_WORDS_PER_CYCLE: usize = 4 * MEMORY_QUERIES_PER_CYCLE; pub const NEW_BYTES_PER_CYCLE: usize = 8 * NUM_U64_WORDS_PER_CYCLE; // we absorb 136 elements per cycle, and add 160 elements per cycle, so we need to skip memory reads // sometimes and do absorbs instead pub const BUFFER_SIZE_IN_U64_WORDS: usize = - MEMORY_EQURIES_PER_CYCLE * 4 + KECCAK256_RATE_IN_U64_WORDS - 1; + MEMORY_QUERIES_PER_CYCLE * 4 + KECCAK256_RATE_IN_U64_WORDS - 1;
both
is for two elements, not three.- /// includes both Waiting, Ready, and Done operations. + /// includes Waiting, Ready, and Done operations.
mark
, not either marked
as it is a sentence without a replica.- * The users can either marked a range of nonces by increasing the `minNonce`. This way all the nonces + * The users can mark a range of nonces by increasing the `minNonce`. This way all the nonces
at
.- /// The minNonce can be increased by at 2^32 at a time to prevent it from + /// The minNonce can be increased by 2^32 at a time to prevent it from
Ethereum
, not Etherium
.- // NB: Etherium virtual machine is big endian; + // NB: Ethereum virtual machine is big endian;
Aux
, not aux
.- // Axu heap limit + // Aux heap limit
In NonceHolder, line 136 there should be a non-empty error, as it seems you have some in-house bot that listens to the unique errors emitted by your contracts (all errors are different).
In L1ERC20Bridge, line 162, the comment says If the L2 deposit finalization transaction fails, the _refundRecipient will receive the _l2Value
. However,
_l2Value
hard-coded to $0$, so the comment just make no sense. Remove it.In L2ERC20Bridge, line 59, the comment says @param _l2Receiver The account address that would receive minted ether
. However, the bridge is for ERC20 tokens, not for ETH nor WETH. Remove it.
- // The two points are equal, so we double.
ecrecover/secp256k1/mod.rs
In lines 56 to 58, it is explained how two projective points' coordinates are checked against each other to see if both points are equal. However, the comment is wrong, as the formula has switched the places of $Z$ and $Z'$:
// The points (X, Y, Z) and (X', Y', Z') - // are equal when (X * Z^2) = (X' * Z'^2) - // and (Y * Z^3) = (Y' * Z'^3). + // are equal when (X * Z'^2) = (X' * Z^2) + // and (Y * Z'^3) = (Y' * Z^3).
SystemContext::baseFee
is not a constant, although the comment says it isThe state variable baseFee
is not a constant, as the keyword constant
is missing and its value can be changed in setNewBatch
. Remove that comment.
/// @notice The `block.basefee`. - /// @dev It is currently a constant. uint256 public baseFee;
Diamond
The comment /// NOTE: expect but NOT enforce that _selectors is NON-EMPTY array
is wrong, as it is checked inside Diamond, function diamondCut:
require(selectors.length > 0, "B"); // no functions for diamond cut
Remove them (just do Ctrl+f
as there are three occurrences).
AppStorage
The variables zkPorterIsAvaiable
, totalDepositedAmountPerUser
, admin
and pendingAdmin
do not have a propper getter inside the Getters
facet. Consider adding them.
validateUpgradeTransaction
does return as valid non-upgrade transactionsPriority transactions has a type-value of 255
and upgrades 254
, as seen in bootloader, lines 572 to 591. However, validateUpgradeTransaction
, if all the fields of the _transaction
are correct, accepts transactions with txType
field different from 254
, as it is not checked:
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"); }
Just for completeness, check that _transaction.txType == 255
:
function validateUpgradeTransaction(IMailbox.L2CanonicalTransaction memory _transaction) internal pure { + require(_transaction.txType == 255, "WHATEVER"); // 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"); }
virtualBlockInfo
in SystemContext
BlockInfo memory virtualBlockInfo = currentVirtualL2BlockInfo; // TODO why are you using currentVirtualL2BlockInfo.number ?? // TODO it is cached in virtualBlockInfo.number if (currentVirtualL2BlockInfo.number == 0 && virtualBlockInfo.timestamp == 0) {
Do this:
BlockInfo memory virtualBlockInfo = currentVirtualL2BlockInfo; - if (currentVirtualL2BlockInfo.number == 0 && virtualBlockInfo.timestamp == 0) { + if (virtualBlockInfo.number == 0 && virtualBlockInfo.timestamp == 0) {
debugLog
callIn bootloader, line 1110, you "log" that the refund is 0 when it may be not
- debugLog("refund", 0) + debugLog("refund", refund)
refundRecipient
addresses in kernel space// 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); } + require(refundRecipient > address(0xFFFF), "You can't send your pennies to the kernel");
In bootloader, line 1116, it says it calculates the L2 gas limit without taking into account the intrinsic costs and the overhead. However, they are, as seen in
Bootloader, lines 1151 to 1172
let operatorOverheadForTransaction := getVerifiedOperatorOverheadForTx( transactionIndex, totalGasLimit, gasPerPubdata, txEncodingLen ) gasLimitForTx := safeSub(totalGasLimit, operatorOverheadForTransaction, "qr") let intrinsicOverhead := safeAdd( intrinsicGas, // the error messages are trimmed to fit into 32 bytes safeMul(intrinsicPubdata, gasPerPubdata, "qw"), "fj" ) switch lt(gasLimitForTx, intrinsicOverhead) case 1 { gasLimitForTx := 0 } default { gasLimitForTx := sub(gasLimitForTx, intrinsicOverhead) }
Remove that statement:
- /// @dev Calculates the L2 gas limit for the transaction's body, i.e. without intrinsic costs and overhead. + /// @dev Calculates the L2 gas limit for the transaction's body
* @dev The bytecode of the contract is set by default for all addresses for which no other bytecodes are deployed.
* @dev The bytecode of the contract is set by default for all addresses for which no other bytecodes are deployed.
Copy-pasted, remove the EmptyContract
one.
PointProjective::mul_assign
optimizationfn mul_assign<S: Into<<Self::Scalar as PrimeField>::Repr>>(&mut self, other: S) { + if self.is_zero() { + return; // 0 *= a equals 0 + } + if other.is_zero() { + (*self) = Self::zero(); // a *= 0 equals 0 + return; + } let mut res = Self::zero(); ...
PointProjective::add_assign_mixed
missing code... } else { // If we're adding -a and a together, self.z becomes zero as H becomes zero. + if u1 == u2 { + (*self) = Self::zero(); + return; + } // H = U2-X1 let mut h = u2; ...
The Shanghai protocol release has nine precompiled contracts, namely ecrecover
, sha256
, ripemd160
, identity
, modexp
, ecadd
, ecmul
and blake2f
(see Ethereum Specification). However, within the current version of zkSync Era, the only precompiles supported are ecrecover
and sha256
(plus keccak256
) and the ones being developed by lambdaclass, namely ecAdd
, ecMul
and modexp
(plus ecPairing
). Consider implementing the other ones too, although they are rarely used, for full equivalence with Ethereum at the precompile level (ripemd160
, blake2f
and identity
)
#0 - bytes032
2023-10-30T09:38:15Z
10 l 7 r 22 nc
[D-01] Using msg.timestamp as an expiration timestamp invites MEV manipulations r
[D-02] Using debugLog in ∆gas calculations leads to an artificially increased value l
[L-01] Dangerous execution path l
[L-02] Code is unreachable nc
[L-03] Changing addresses sizes from 20 to 32 bytes could break some parts of the system l
[L-04] Unused variables (missing logic?) nv
[L-05] Code and comment mismatch nc
[L-06] basefee may be higher than maxPriorityFeePerGas l
[L-07] getZkSyncMeta does not populate fully the ZkSyncMeta struct r
[L-08] The front-end may return a wrong value for the L2 transaction base cost nc
[L-09] rawCall does permit calls to MsgValueSimulator with the _isSystem flag set to false (it will revert) l
[L-10] L1 ⇒ L2 transactions are not free, but bootloader behaves like they are l
[L-11] It is possible to refund a single transaction more than 2³² - 1 gas, whilst the bootloader only has that amount of gas avaiable for a given batch l
[L-12] Useless max pick l
[L-13] Misleading comment (?) nc
[L-14] Lower-bound the minimum execution delay in ValidatorTimelock r
[L-15] Wrong logic (?) r
[L-16] If _enumerationIndexSize is 0, compressedEnumIndex equals metadata r
[L-17] Tautology in publishPubdataAndClearState dup of https://github.com/code-423n4/2023-10-zksync-findings/issues/853
[L-18] Chaining hashes with the wrong initial value (?) r
[L-19] Wrong initialisation of the zero point (?) l
[L-20] Rogue validators can spam revertBlocks and make the chain unusable l
[NC-01] WTF nc
[NC-02] Missing comments nc
[NC-03] Typos nc
[NC-04] Empty error string nc
[NC-05] Wrong comments in L1 bridges nc
[NC-06] Wrong comment nc
[NC-07] Wrong comment in ecrecover/secp256k1/mod.rs nc
[NC-08] SystemContext::baseFee is not a constant, although the comment says it is nc
[NC-09] Wrong comment in Diamond nc
[NC-10] Add getters to some variables of AppStorage nc
[NC-11] validateUpgradeTransaction does return as valid non-upgrade transactions nc
[NC-12] You are not using your own cached virtualBlockInfo in SystemContext nc
[NC-13] Wrong debugLog call nc
[NC-14] Do not let users put as refundRecipient addresses in kernel space nc
[NC-15] Comment and code mismatch nc
[NC-16] Wrong comment (again) nc
[NC-17] PointProjective::mul_assign optimization nc
[NC-18] PointProjective::add_assign_mixed missing code nc
[I-01] Missing support for certain precompiles r
#1 - nethoxa
2023-11-29T17:35:35Z
About L-19, the following test highlights the issue (it shall be a much higher severity but at the time of the contest I wasn't sure about whether I was right or not, so I put it as a Low). Here is the mathematical proof I wrote in my initial submission too (for completeness):
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC -- --nocapture // #[test] fn POC() { let false_zero = PointAffine::zero(); let real_zero = PointAffine::from_xy_checked(Fq::zero(), Fq::zero()).unwrap(); println!("Is (0, 1) the null elememt (should not) -> {}", false_zero.is_zero()); println!("Is (0, 0) the null elememt -> {}\n", real_zero.is_zero()); println!("Is (0, 1) on curve (should NOT) -> {}", false_zero.is_on_curve()); println!("Is (0, 0) on curve -> {}", real_zero.is_on_curve()); }
The point at infinity is defined as $(0, 0)$, not both $(0, 0)$ and $(0, 1)$. As it is right now, you could craft valid signatures with an invalid point or corrupt the result of other operations like PointProjective::add_assign_mixed due to:
On top of that, we have that all operations defined under $F$ apply ONLY to the elements of $F$ (by definition). Therefore, you cannot use elements outside of $F$ with functions and elements defined under $F$, as it is the case right now (it's mathematically wrong and bug prone).
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC_MIXED -- --nocapture // #[test] fn POC_MIXED() { let false_zero = PointAffine::zero(); let real_zero = PointAffine::from_xy_checked(Fq::zero(), Fq::zero()).unwrap(); let projective = PointProjective::one(); println!("Point projective -> {}", &projective); println!("False zero -> {:?}", &false_zero); println!("Real zero -> {:?}\n", &real_zero); let mut projective1 = projective.clone() as PointProjective; projective1.add_assign_mixed(&false_zero); println!("Added (0, 1) -> {}", projective1); let mut projective2 = projective.clone() as PointProjective; projective2.add_assign_mixed(&real_zero); println!("Added (0, 0) -> {}\n", projective2); }
Moreover, the implementation of this curve is widely used in many cryptographic projects, so if anyone forks this crate and uses it on his own project (for example, due to a recommendation from the own devs), they would be building something fundamentally flawed (and can damage zkSync Era's reputation if such a project gets rekt because of that).The fix is the next one:
ecrecover/secp256k1/mod.rs, line 150
fn zero() -> Self { PointAffine { x: Fq::zero(), - y: Fq::one(), + y: Fq::zero(), infinity: true, } }
as seen in
ecrecover/secp256k1/mod.rs, line 199
fn from_xy_checked(x: Self::Base, y: Self::Base) -> Result<Self, GroupDecodingError> { let infinity = x.is_zero() && y.is_zero(); let affine = Self { x: x, y: y, infinity, }; if !affine.is_on_curve() { Err(GroupDecodingError::NotOnCurve) } else { Ok(affine) } }
#2 - nethoxa
2023-11-29T17:36:59Z
About NC-18, I have been testing and it is indeed a bug (shall not be a NC then as the output is wrong), as you would be returning a corrupted value that, although it would be defined as the point at infinity due to $Z = 0$, the other coordinates remain non-zero/one leading to corrupted results if it is used again (very similar to my previous message $\Uparrow$ so DRY). Here is the POC:
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC -- --nocapture // #[test] fn POC() { let mut projective = PointProjective::one(); let mut projective_into_affine_negated = projective.clone().into_affine(); projective_into_affine_negated.negate(); println!("Point projective -> {}", &projective); println!("Point projective into affine negated -> {}\n", &projective_into_affine_negated); projective.add_assign_mixed(&projective_into_affine_negated); println!("Added A + (-A) (should be zero by definition) -> {:?}", &projective); println!("Zero is defined as -> {:?}", PointProjective::zero()); }
The fix is the one I have written in the QA here. However, it had a typo so the next one is the correct one:
... } else { // If we're adding -a and a together, self.z becomes zero as H becomes zero. + if self.x == u2 { + (*self) = Self::zero(); + return; + } // H = U2-X1 let mut h = u2; ...
as seen here (even the comments are the same)
secp256k1/mod.rs, function add_assign
... } else { // If we're adding -a and a together, self.z becomes zero as H becomes zero. if u1 == u2 { // The two points are equal, so we double. // @audit wrong comment, the one I'm referring is the previous one (*self) = Self::zero(); return; } // H = U2-U1 let mut h = u2; ...
#3 - nethoxa
2023-11-29T17:38:39Z
About L-12 , after re-reading the docs provided in the contest repo, it seems I was wrong and the correct implementation is the one in line 1456. The flawed one is in line 918.
When bootloader calls askOperatorForRefund
it will be provided with the leftover gas PLUS the reserved gas calculated by the operator off-chain (not the one in getGasLimitForTx
). That is
refundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund)
will always pick the one provided by the operator and adding such a value with reservedGas
in
refundGas := add(refundGas, reservedGas)
will output refundGas + reservedGasByOperator + reservedGasByBootloader
, giving users a higher gas refund than the correct one. It seems for high reservedGas
values, such a transaction would always revert due to the check in line 923, so it can be seen as a DOS in those situations and a theft of gas in the others. Neverthless, consider changing the code to:
... // 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) + refundGas := max(getOperatorRefundForTx(transactionIndex), safeAdd(potentialRefund, reservedGas, "whatever")) } - refundGas := add(refundGas, reservedGas) if gt(refundGas, gasLimit) { assertionError("L1: refundGas > gasLimit") } ...
Taking into account my submission Operator can steal all gas provided by ANY user for L1 → L2 transactions and mixing the fixes.
#4 - nethoxa
2023-11-29T17:42:17Z
UPDATE $\Rightarrow$ no need to read this comment, agree with the sponsor that this is QA even though I was misunderstood. See the previous version of this message if you want to know the idea I had in mind whilst writing this comment. Thanks.
Although D-02 can be seen as a little increase of gas (doing the maths with the Ethereum ones, note that the gas cost of the same opcodes in zkSync Era is far more due to ZK processing) and you can argue that this is a QA refactoring
, take into account that the goal of zkSync Era is to increase its TPS much more than what it is right now, which is currently $7.13$ according to l2beat.
For the shake of the example, let's say $3.565$ are from L1 and $3.565$ are from L2. That means, the increased gas cost per second right now is:
In a whole year, the gas taken from users would be $1426 * 60 \ seconds * 60 \ minutes * 24 \ hours * 365 \ days = 44970336000 \ gas$ (around $44$ US billion gas). That's a lot of gas going straight into the operators pockets "by free" as users are paying for the execution of their transaction, not for debugging functionalities. Moreover, in many other ∆gas calculations blocks like here, here, here and here they are totally excluded, which shows the developers intentions to avoid overcharging users.
Either you decide to maintain this as a QA or lift it up to Medium, as it is a theft of gas, please, move those debugLog
s out of the ∆gas calculations blocks as users must pay for what they use, nothing more, nothing less. The fixes are in my QA report
#5 - nethoxa
2023-11-29T17:47:52Z
About D-01, note that the underlying issue has been awarded medium severities in several places, either in code4rena in recent contests (see here and here) or in private audits (see here). This situation is worst as you "support" any type of transaction. Moreover, there has been other reports highlighting something similar (like this one) but has been rejected due to a lack of impact. Although I show two possible and different scenarios, users MUST be able to put a limit on when their transactions can be executed, regardless of any theoretical threat or attack.
Anyway, to show a different example from the classic swap issue, imagine a protocol that is deployed in both zkSync Era and Ethereum, is going to initiate a multi-chain airdrop from Ethereum to both its zkSync and Ethereum users. A malicious or a set of malicious operators on Etehreum can delay the transaction to initiate the airdrop on zkSync Era and sell their tokens to a higher price, as the total supply between chains would be fewer. Once the price spikes, they pass the airdrop transaction to zkSync to dilute the price and harm users who were holding those tokens, as the supply increases, so the price decreases instantly like a market crash. The deadline would stop such an attack, as the protocol could set the airdrop to last a few minutes, otherwise the zkSync airdrop wouldn't take place. This attack can be seen as a real world inflation attack, in which one country holds foreign currency for a long time and dumps all the money to the target country to flood its market and skyrocket its local monetary inflation (making an analogy between layers and countries)
The fix is the one in my QA.
#6 - miladpiri
2023-12-01T07:58:24Z
About L-19, the following test highlights the issue (it shall be a much higher severity but at the time of the contest I wasn't sure about whether I was right or not, so I put it as a Low). Here is the mathematical proof I wrote in my initial submission too:
- The O element is defined as the element in F that meets:
a⋆O=O⋆a=a,∀a∈F
- Say there are two "zero-elements", namely O1 and O2, in F with O1≠O2.
- Then, for example, we have that O1⋆O2=O2⋆O1=O1 but, as both are the "zero-element", the next O1⋆O2=O2⋆O1=O2 holds too
- That implies O1=O2 but our initial thesis was that O1≠O2, so we have a contradiction
∴O is unique under F
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC -- --nocapture // #[test] fn POC() { let false_zero = PointAffine::zero(); let real_zero = PointAffine::from_xy_checked(Fq::zero(), Fq::zero()).unwrap(); println!("Is (0, 1) the null elememt (should not) -> {}", false_zero.is_zero()); println!("Is (0, 0) the null elememt -> {}\n", real_zero.is_zero()); println!("Is (0, 1) on curve (should NOT) -> {}", false_zero.is_on_curve()); println!("Is (0, 0) on curve -> {}", real_zero.is_on_curve()); }
The point at infinity is defined as (0,0), not both (0,0) and (0,1). As it is right now, you could craft valid signatures with an invalid point or corrupt the result of other operations like PointProjective::add_assign_mixed due to:
- (0,1) not being the null element (from the mathematical proof above) AND
- (0,1) not being on the curve as it does not comply with ²³²³y²=x³+b⇒1²≠0³+7
∴(0,1)∉F
On top of that, we have that all operations defined under F apply ONLY to the elements of F (by definition). Therefore, you cannot use elements outside of F with functions and elements defined under F, as it is the case right now (it's mathematically wrong and bug prone).
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC_MIXED -- --nocapture // #[test] fn POC_MIXED() { let false_zero = PointAffine::zero(); let real_zero = PointAffine::from_xy_checked(Fq::zero(), Fq::zero()).unwrap(); let projective = PointProjective::one(); println!("Point projective -> {}", &projective); println!("False zero -> {:?}", &false_zero); println!("Real zero -> {:?}\n", &real_zero); let mut projective1 = projective.clone() as PointProjective; projective1.add_assign_mixed(&false_zero); println!("Added (0, 1) -> {}", projective1); let mut projective2 = projective.clone() as PointProjective; projective2.add_assign_mixed(&real_zero); println!("Added (0, 0) -> {}\n", projective2); }
Moreover, the implementation of this curve is widely used in many cryptographic projects, so if anyone forks this crate and uses it on his own project (for example, due to a recommendation from the own devs), they would be building something fundamentally flawed (and can damage zkSync Era's reputation if such a project gets rekt because of that).The fix is the next one:
ecrecover/secp256k1/mod.rs, line 150
fn zero() -> Self { PointAffine { x: Fq::zero(), - y: Fq::one(), + y: Fq::zero(), infinity: true, } }
as seen in
ecrecover/secp256k1/mod.rs, line 199
fn from_xy_checked(x: Self::Base, y: Self::Base) -> Result<Self, GroupDecodingError> { let infinity = x.is_zero() && y.is_zero(); let affine = Self { x: x, y: y, infinity, }; if !affine.is_on_curve() { Err(GroupDecodingError::NotOnCurve) } else { Ok(affine) } }
This is a valid code quality issue, but there is no impact. Summary of the issue: In affine coordinates, we use both (0,1, infinity=true) and (0,0, infinity=true) to store the point at infinity. As the warden explained, mathematically there should only be one point at infinity. However, throughout the codebase, we check if a point is infinity only by checking the boolean is true (ignoring the x,y coordinates). The warden's own test cases confirm that the code handles both points correctly, so there is no impact
#7 - miladpiri
2023-12-01T08:00:38Z
About NC-18, I have been testing and it seems it is indeed a bug (shall not be a NC then as the output is wrong), as you would be returning a corrupted value that, although it would be defined as the point at infinity due to Z=0, the other coordinates remain non-zero/one leading to corrupted results if it is used again (very similar to my previous message ⇑ so DRY). Here is the POC:
// Put this test inside ecrecover/secp256k1/mod.rs and run from the // era-zkevm_circuits folder the next command // // cargo test POC -- --nocapture // #[test] fn POC() { let mut projective = PointProjective::one(); let mut projective_into_affine_negated = projective.clone().into_affine(); projective_into_affine_negated.negate(); println!("Point projective -> {}", &projective); println!("Point projective into affine negated -> {}\n", &projective_into_affine_negated); projective.add_assign_mixed(&projective_into_affine_negated); println!("Added A + (-A) (should be zero by definition) -> {:?}", &projective); println!("Zero is defined as -> {:?}", PointProjective::zero()); }
The fix is the one I have written in the QA here. However, it had a typo so the next one is the correct one:
... } else { // If we're adding -a and a together, self.z becomes zero as H becomes zero. + if self.x == u2 { + (*self) = Self::zero(); + return; + } // H = U2-X1 let mut h = u2; ...
as seen here (even the comments are the same)
secp256k1/mod.rs, function add_assign
... } else { // If we're adding -a and a together, self.z becomes zero as H becomes zero. if u1 == u2 { // The two points are equal, so we double. // @audit wrong comment, the one I'm referring is the previous one (*self) = Self::zero(); return; } // H = U2-U1 let mut h = u2; ...
This is basically the exact same "bug" as L-19. Valid code quality but no impact. As long as the z-coordinate of a Projective Point is zero, it will be treated as the point at infinity, regardless of the x and y coordinates.
#8 - miladpiri
2023-12-01T08:40:18Z
About L-12 , after re-reading the docs provided in the contest repo, it seems I was wrong and the correct implementation is the one in line 1456. The flawed one is in line 918.
When bootloader calls
askOperatorForRefund
it will be provided with the leftover gas PLUS the reserved gas calculated by the operator off-chain (not the one ingetGasLimitForTx
). That isrefundGas := max(getOperatorRefundForTx(transactionIndex), potentialRefund)
will always pick the one provided by the operator and adding such a value with
reservedGas
inrefundGas := add(refundGas, reservedGas)
will output
refundGas + reservedGasByOperator + reservedGasByBootloader
, giving users a higher gas refund than the correct one. It seems for highreservedGas
values, such a transaction would always revert due to the check in line 923, so it can be seen as a DOS in those situations and a theft of gas in the others. Neverthless, consider changing the code to:... // 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) + refundGas := max(getOperatorRefundForTx(transactionIndex), safeAdd(potentialRefund, reservedGas, "whatever")) } - refundGas := add(refundGas, reservedGas) if gt(refundGas, gasLimit) { assertionError("L1: refundGas > gasLimit") } ...
Taking into account my submission Operator can steal all gas provided by ANY user for L1 → L2 transactions and mixing the fixes.
The original report was wrong, but this comment is correct. However, it does not have any impact, so it should be considered as QA. The reason why it lacks any impact is:
reservedGas = totalGasLimit - max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/bootloader/bootloader.yul#L1145
and
refundGas = max(getOperatorRefundForTx(transactionIndex), potentialRefund) + reservedGas
https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/bootloader/bootloader.yul#L918
in order to have refundGas > gasLimit
(to have DoS as mentioned by the warden) the following condition should be met:
max(getOperatorRefundForTx(transactionIndex), potentialRefund) + totalGasLimit - max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex)) > gasLimit
since totalGasLimit
is the same as gasLimit
, we will have:
max(getOperatorRefundForTx(transactionIndex), potentialRefund) > max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
This is possible if the operator provides refund higher than max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
. Moreover, if the operator provides such large refund amount, the bootloader will be reverted at line 924, so nothing happens, it is kind of self-rekt for the operator. It is as if the operator provides very low gas at the beginning to run the bootloader, so the bootloader will revert, and he should run it again.
#9 - miladpiri
2023-12-01T08:43:13Z
Although D-02 can be seen as a little increase of gas (doing the maths with the Ethereum ones, note that the gas cost of the same opcodes in zkSync Era is far more due to ZK processing) and you can argue that this is a
QA refactoring
, take into account that the goal of zkSync Era is to increase its TPS much more than what it is right now, which is currently 7.13 according to l2beat.For the shake of the example, let's say 3.565 are from L1 and 3.565 are from L2. That means, the increased gas cost per second right now is:
3.565∗160+3.565∗240=1426 gas/second
In a whole year, the gas taken from users would be 1426∗60 seconds∗60 minutes∗24 hours∗365 days=44970336000 gas (around 44 US billion gas). That's a lot of gas going straight into the operators pockets "by free" as users are paying for the execution of their transaction, not for debugging functionalities. Moreover, in many other ∆gas calculations blocks like here, here, here and here they are totally excluded, which shows the developers intentions to avoid overcharging users.
Either you decide to maintain this as a QA or lift it up to Medium, as it is a theft of gas, please, move those
debugLog
s out of the ∆gas calculations blocks as users must pay for what they use, nothing more, nothing less. The fixes are in my QA report
This is QA at most. Yes, it is an additional cost for users. So, it is the fact that we have written DefaultAccount
in Solidity & not in Yul. This is the price everyone pays for better support.
#10 - miladpiri
2023-12-01T08:49:38Z
About D-01, note that it has been awarded medium severities in several places, either in code4rena in recent contests (see here and here) or in private audits (see here). This situation is worst as you "support" any type of transaction. Moreover, there has been other reports highlighting something similar (like this one) but has been rejected due to a lack of impact. Although I show two possible and different scenarios, users MUST be able to put a limit on when their transactions can be executed, regardless of any theoretical threat or attack.
Anyway, to show a different example from the classic swap issue, imagine a protocol that is deployed in both zkSync Era and Ethereum, is going to initiate a multi-chain airdrop from Ethereum to both its zkSync and Ethereum users. A malicious or a set of malicious operators on Etehreum can delay the transaction to initiate the airdrop on zkSync Era and sell their tokens to a higher price, as the total supply between chains would be fewer. Once the price spikes, they pass the airdrop transaction to zkSync to dilute the price and harm users who were holding those tokens, as the supply increases, so the price decreases instantly like a market crash. The deadline would stop such an attack, as the protocol could set the airdrop to last a few minutes, otherwise the zkSync airdrop wouldn't take place. This attack can be seen as a real world inflation attack, in which one country holds foreign currency for a long time and dumps all the money to the target country to flood its market and skyrocket its local monetary inflation (making an analogy between layers and countries)
The fix is the one in my QA.
expirationTimestamp
is not used anywhere. It is set, but it is not responsible for any logic currently. It will be used in the future implementations. This report can be used in Analysis report.
#11 - nethoxa
2023-12-01T10:33:09Z
This is a valid code quality issue, but there is no impact. Summary of the issue: In affine coordinates, we use both (0,1, infinity=true) and (0,0, infinity=true) to store the point at infinity. As the warden explained, mathematically there should only be one point at infinity. However, throughout the codebase, we check if a point is infinity only by checking the boolean is true (ignoring the x,y coordinates). The warden's own test cases confirm that the code handles both points correctly, so there is no impact
But it is wrong. You can just check the boolean, that's true, but it will return true for both of them, as the tests and the mathematical proof highlight. All signatures and some ZK things rely on the correctness of this curve and the UNIQUENESS of the points in there. If you can craft two different points that are gonna be treated as the same one, even if one of them is not on the curve, then you can replay the original one with the wrong one. When you say
The warden's own test cases confirm that the code handles both points correctly, so there is no impact
the code must not accept $(0, 1)$. The result is wrong. $(0, 1)$ is not on the curve and it is not the zero element. They must revert like in from_xy_checked
. Moreover, it is the purpose of is_on_curve
function to return false for those points and prevent the parent function from going on with an invalid point. Math structures like this curve and all the operations defined under it must be correctly implemented for a reason. Right now, from a formal POV, this is not the real secp256k1 curve. It mimics how it works, but it is not the same. Furthermore, it is not even a field, which is the basis of everything you are trying to do with this crate.
I would like to put this comment here, as it is a very similar situation and that issue has been accepted as a medium even with null impact.
#12 - nethoxa
2023-12-01T10:41:59Z
This is basically the exact same "bug" as L-19. Valid code quality but no impact. As long as the z-coordinate of a Projective Point is zero, it will be treated as the point at infinity, regardless of the x and y coordinates.
In fact, it is worst as every point added with its negated will be treated as the zero element. That means you can have infinite zero elements (up to the prime field) who all are DIFFERENT from each other and from $(0, 1, 0)$ floating around.
#13 - nethoxa
2023-12-01T11:03:45Z
The original report was wrong, but this comment is correct. However, it does not have any impact, so it should be considered as QA. The reason why it lacks any impact is:
reservedGas = totalGasLimit - max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/bootloader/bootloader.yul#L1145and
refundGas = max(getOperatorRefundForTx(transactionIndex), potentialRefund) + reservedGas
https://github.com/code-423n4/2023-10-zksync/blob/main/code/system-contracts/bootloader/bootloader.yul#L918in order to have
refundGas > gasLimit
(to have DoS as mentioned by the warden) the following condition should be met:max(getOperatorRefundForTx(transactionIndex), potentialRefund) + totalGasLimit - max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex)) > gasLimit
since
totalGasLimit
is the same asgasLimit
, we will have:max(getOperatorRefundForTx(transactionIndex), potentialRefund) > max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
This is possible if the operator provides refund higher than
max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
. Moreover, if the operator provides such large refund amount, the bootloader will be reverted at line 924, so nothing happens, it is kind of self-rekt for the operator. It is as if the operator provides very low gas at the beginning to run the bootloader, so the bootloader will revert, and he should run it again.
You say
This is possible if the operator provides refund higher than
max(MAX_GAS_PER_TRANSACTION(), getOperatorTrustedGasLimitForTx(transactionIndex))
.
which is correct and I did not take into account, so the DOS part is invalid. However, the theft of gas from the operator by users (which you did not answer in your response) is correct as the bootloader is checking what is higher, the calculated refund for the execution of the transaction or the value provided by the operator, which is returning HIS reserved gas plus the leftover gas he has found for such a transaction. That means the operator's value will always be picked and being added with reserved gas will output more gas than intended. The code responsible for that in the zkSync Era node is in
... let tx_gas_limit = state .memory .read_slot( BOOTLOADER_HEAP_PAGE as usize, tx_description_offset + TX_GAS_LIMIT_OFFSET, ) .value .as_u32(); ... let tx_body_refund = self.tx_body_refund( bootloader_refund, gas_spent_on_pubdata, tx_gas_limit, current_ergs_per_pubdata_byte, pubdata_published, ); ...
If we go to tx_body_refund
we see that the refund provided by the operator is, roughly, $gasLimitFromUser - gasSpentInComputation - gasSpentInPubdata$, that is, the excess of gas provided by the user, or, in other terms, the refund of gas after the execution step plus the reserved gas:
refunds.rs, function tx_body_refund
pub(crate) fn tx_body_refund( &self, bootloader_refund: u32, gas_spent_on_pubdata: u32, tx_gas_limit: u32, current_ergs_per_pubdata_byte: u32, pubdata_published: u32, ) -> u32 { let total_gas_spent = tx_gas_limit - bootloader_refund; let gas_spent_on_computation = total_gas_spent .checked_sub(gas_spent_on_pubdata) .unwrap_or_else(|| { tracing::error!( "Gas spent on pubdata is greater than total gas spent. On pubdata: {}, total: {}", gas_spent_on_pubdata, total_gas_spent ); 0 }); // For now, bootloader charges only for base fee. let effective_gas_price = self.l1_batch.base_fee(); let bootloader_eth_price_per_pubdata_byte = U256::from(effective_gas_price) * U256::from(current_ergs_per_pubdata_byte); let fair_eth_price_per_pubdata_byte = U256::from(eth_price_per_pubdata_byte(self.l1_batch.l1_gas_price)); // For now, L1 originated transactions are allowed to pay less than fair fee per pubdata, // so we should take it into account. let eth_price_per_pubdata_byte_for_calculation = std::cmp::min( bootloader_eth_price_per_pubdata_byte, fair_eth_price_per_pubdata_byte, ); let fair_fee_eth = U256::from(gas_spent_on_computation) * U256::from(self.l1_batch.fair_l2_gas_price) + U256::from(pubdata_published) * eth_price_per_pubdata_byte_for_calculation; let pre_paid_eth = U256::from(tx_gas_limit) * U256::from(effective_gas_price); let refund_eth = pre_paid_eth.checked_sub(fair_fee_eth).unwrap_or_else(|| { tracing::error!( "Fair fee is greater than pre paid. Fair fee: {} wei, pre paid: {} wei", fair_fee_eth, pre_paid_eth ); U256::zero() }); ceil_div_u256(refund_eth, effective_gas_price.into()).as_u32() }
So, my statements were correct and this is a theft of gas from the operator's payout by users.
#14 - nethoxa
2023-12-01T11:22:04Z
Regarding this answer from the sponsor, I was misunderstood and gave a proper answer in the previous version of this message, but as it does not harm users too much, so QA is fine, I have "removed" it to not overload this discussion. If you are interested, see the previous version. Thanks.
#15 - nethoxa
2023-12-01T12:16:34Z
expirationTimestamp
is not used anywhere. It is set, but it is not responsible for any logic currently. It will be used in the future implementations. This report can be used in Analysis report.
I know what you mean by that and I would like to say that I'm not talking about deposits staying idle or not being executed in zkSync Era due to high congestion in Ethereum for days, I am talking about time-sensitive L1 $\Rightarrow$ L2 transactions via requestL2Transaction
(there are projects using them like the ones I mentioned in my report).
The issue is that it's fine to put that "hard-coded" deadline for deposits, as the "environment changes" they suffer from are minimal, but there is no way for a contract in Ethereum to set a deadline of, say, a few minutes, for the execution of his transaction on zkSync Era, leading to the impacts I mentioned above. Neither it is possible now nor when you actually implement the logic regarding the withdrawals without operator's help.
I focused on expirationTimestamp
as it is wrong to assume that msg.timestamp
equals the time the user submitted the transaction to the network and when the transaction is actually picked and executed on-chain, which affects ALL L1 $\Rightarrow$ L2 transactions, whether they are simple deposits or not, and the fact it will be used for all types of transactions, whether the caller wants that deadline or not.
As the intention as well as the "Ethereum counterpart" is implemented in the code in-scope for this contest, plus the real threat rogue nodes in Ethereum are for the network as stated in the real-world examples I mentioned in my report (not hand-wavy hypotheticals) and the precedence of other recent issues judged differently than what you state, please, reconsider it, as users must be able to put the deadline they want/need for their transactions (otherwise transactions going to zkSync Era from Ethereum will become prime target of MEV bots and rogue nodes, due to the "sequential determinism" of your L1 $\Rightarrow$ L2 communication channel)
#16 - lsaudit
2023-12-01T19:17:03Z
Hey, I'm not a judge, but out of curiosity, I took a look at your QA. It's awesome. I've skimmed through a few issues.
D-01 - imho, this is definitely more than QA, other code4rena reports treat most MEV scenarios as Medium. This issue basically means that there are multiple scenarios in zksync environment which may make MEVs attack possible. As your recommendation easily fixes that problem, imho - it deserves Medium :)
D-02 - I don't agree with the assumption that user "must NOT pay for debugging". I think it should be evaluated rather as Refactoring, instead of Low.
L-01 - imho it should be classified as Medium. According to C4 severity categorization: funds at risk with a hypothetical attack path with stated assumptions
L-03 - while I agree with the reasoning, this does not pose any security threat at all in the current implementation. As you mentioned in the issue's description, your issue is related to the comment, that devs plan to introduce 32-bytes address space in the future. It's not introduced yet, thus we cannot evaluate the current code as vulnerability basing on the predictions and future plans. While this is a valid observation, I'd stick with classifying this issue as Refactoring, and not Low (because it does not pose any risk right now).
L-06 - I agree, that there's a missing check, however, this check is missing, because maxPriorityFeePerGas
is hardcoded to 0. It's also mentioned in your report. While this value is hardcoded to 0, lack of this check does not pose any security threat at all. When this value will be changed - devs will need to add additional (missing) check. Since, again, this issue is related to some predictions, this should be classified as Refactoring, and not Low, imho
L-09 - isn't it just a matter of incorrect comment? In that case it should be NC, instead of Low?
L-11 - hmm, in both cases there's already assertion: gt(refundGas, gasLimit)
and gt(refundInGas, getGasLimit(innerTxDataOffset))
, having (or not having) additional check which verifies if it's uint32 does not change anything, does it? In that case, it has no security impact and should be classified rather as Refactoring, than Low.
L-14 - is there any reason why you want to limit the timelock, especially if it's onlyOwner
? Open Zeppelin's TimelockController.sol
also does not implement any lower-bound for the execution delay, thus imho this is not valid
L-19 - i also don't know anything about affine spaces :) - but if this true, the impact is much higher than QA. It'd be nice if someone took another look on it, since this might be more than QA. Imho, it should be either invalid or Medium at least!
L-05, L-13, NC-02, NC-05, NC-06, NC-07, NC-08, NC-09, NC-15, NC-16 - all are related to incorrect comment, shouldn't it be treated as one issue, instead of 10 separate ones?
NC-12 - this is gas-saving-related issue, it should be moved to the Gas report and not be evaluated in QA
I-01 - this is a known issue, docs already mentions which precompiles are available in zksync: https://era.zksync.io/docs/reference/architecture/differences-with-ethereum.html#precompiles
#17 - nethoxa
2023-12-01T19:34:27Z
Thanks @lsaudit for your comment. Please, read the discussion with the sponsor regarding some of the issues you have mentioned as I have provided more info and my initial QA was a shit. About some of your comment:
#18 - GalloDaSballo
2023-12-10T11:46:51Z
Using msg.timestamp as an expiration timestamp invites MEV manipulations L Using debugLog in ∆gas calculations leads to an artificially increased value NC, it's acceptable as long as paid by usr Dangerous execution path TODO MED? Code is unreachable R Changing addresses sizes from to bytes could break some parts of the system I, insufficient proof Unused variables (missing logic?) R Code and comment mismatch L basefee may be higher than maxPriorityFeePerGas TODO getZkSyncMeta does not populate fully the ZkSyncMeta struct NC The front-end may return a wrong value for the L2 transaction base cost I rawCall does permit calls to MsgValueSimulator with the _isSystem flag set to false (it will revert) NC L1 ⇒ L2 transactions are not free, but bootloader behaves like they are TODO It is possible to refund a single transaction more than 2³² gas, whilst the bootloader only has that amount of gas avaiable for a given batch L Useless max pick R Misleading comment (?) NC Lower-bound the minimum execution delay in ValidatorTimelock I Wrong logic (?) L If _enumerationIndexSize is 0, compressedEnumIndex equals metadata L Tautology in publishPubdataAndClearState TODO Chaining hashes with the wrong initial value (?) L Wrong initialisation of the zero point (?) I Rogue validators can spam revertBlocks and make the chain unusable L WTF R Missing comments NC Typos NC Empty error string NC Wrong comments in L1 bridges L Wrong comment NC Wrong comment in ecrecover/secp256k1/mod.rs NC SystemContext::baseFee is not a constant, although the comment says it is L Wrong comment in Diamond NC Add getters to some variables of AppStorage R validateUpgradeTransaction does return as valid non-upgrade transactions L You are not using your own cached virtualBlockInfo in SystemContext R Wrong debugLog call R Do not let users put as refundRecipient addresses in kernel space L Comment and code mismatch L Wrong comment (again) I PointProjective::mul_assign optimization NC PointProjective::add_assign_mixed missing code NC Missing support for certain precompiles I Some ways to exploit this, from a hacker's POV, is via rogue validators (see (https://furucombo.app/combo) combo worth million dollars or any other type of highly-sensitive inter-chain operation. TODO In (https://github.com/matter-labs/era-contracts/blob/f06a58360a2b8e7129f64413998767ac169d1efd/ethereum/contracts/zksync/ValidatorTimelock.solL109C1-L109C111), which is supposed to be non-zero. As the validator is currently controled by Matter Labs, I'm putting it as a Low but consider implementing a delay or a slashing mechanism to validators that try to abuse their privileges by spamming revertBlocks for batches that do not benefit them, or even try to DOS the network. TODO (https://github.com/code-423n4/2023-10-zksync/blob/72f5f16ed4ba94c7689fe38fcb0b7d27d2a3f135/code/era-zkevm_circuits/src/keccak256_round_function/mod.rsL93) ⇒ MEMORY_QUERIES_PER_CYCLE, not MEMORY_EQURIES_PER_CYCLE. TODO
L - 12 NC, it's acceptable as long as paid by usr - 1 TODO MED? - 1 R - 7 I, insufficient proof - 1 TODO - 6 NC - 11 I - 5
12 L 7R 11 NC - TODO Finalize
#19 - c4-judge
2023-12-10T20:17:02Z
GalloDaSballo marked the issue as grade-a
#20 - c4-judge
2023-12-13T16:04:15Z
GalloDaSballo marked the issue as selected for report
#21 - c4-judge
2023-12-13T16:04:20Z
GalloDaSballo marked the issue as grade-c
#22 - c4-judge
2023-12-13T16:04:40Z
GalloDaSballo marked the issue as grade-a
#23 - c4-judge
2023-12-14T09:15:53Z
GalloDaSballo marked the issue as selected for report