Closed code423n4 closed 1 year ago
GalloDaSballo marked the issue as primary issue
The L1 contract will make sure that for every hash published the preimage will be shown as well. In other words, the preimage of the bytecodehash is shown on L1 smart contract. so if the hash is marked as known, the preimage will be publicly known as well.
miladpiri marked the issue as sponsor disputed
Closing because Bootloader, the Sponsor has agree with the Staff that they will follow up with the Warden privately
The Warden can follow up with me for any additional question
GalloDaSballo marked the issue as unsatisfactory: Out of scope
Lines of code
https://github.com/code-423n4/2023-03-zksync/blob/21d9a364a4a75adfa6f1e038232d8c0f39858a64/bootloader/bootloader.yul#L1324-L1345
Vulnerability details
This is a report of a finding in
bootloader.yul
. While the file is out of scope of the contest, the sponsor stated that they would still accept findings in the file and would judge them separately from the contest.Impact
A bytecode hash for which the bytecode (preimage) wasn't published to the L1 can be marked as known on the L2. This will break mining and proving of blocks because calls to contracts with invalid or missing bytecode cannot be proved. The issue cannot be resolved after deployment: the preimage (i.e. bytecode) of a butecode hash that has been marked as known cannot be re-published to the L1 since the bootloader doesn't allow publishing known bytecodes to avoid overpaying.
Proof of Concept
As per the description of the project:
Also:
And also:
In accordance with this description, in the beginning of transaction execution, the bootloader marks bytecode hashes provided by the operator as known. It also validates and sends compressed bytecode to L1. This is done in the ZKSYNC_NEAR_CALL_markFactoryDepsL2 function:
sendCompressedBytecode
.publishCompressedBytecode
verifies that the bytecodes were compressed correctly and sends them to L1–notice that it sends compressed bytecodes (not original bytecodes or their hashes). It then marks the hashes of the sent bytecodes as known on L2 by calling KNOWN_CODE_STORAGE_CONTRACT.markBytecodeAsPublished.This aligns with the description of how new contract bytecodes are marked as known and sent to L1 to guarantee that every bytecode hash on L2 has a valid bytecode published on L1.
Now, let's return to
ZKSYNC_NEAR_CALL_markFactoryDepsL2
and see what happens after compressed bytecodes were processed:markFactoryDepsForTx
is called with the second argument set tofalse
, meaning that this is not an L1 transaction. The line is accompanied by this comment:markFactoryDepsForTx
calls KnownCodesStorage.markFactoryDeps and sets the first argument totrue
(it toggles theisL1Tx
parameter).markFactoryDeps
does three things: validates a bytecode hash, sends bytecode hashes to L1, and marks bytecode hashes as known on L2.markFactoryDeps
doesn't send bytecodes (neither original, nor compressed): it only sends bytecode hashes, as can be seen from the code:This results in a flaw in the bytecodes handling logic: if a bytecode is not compressed and not provided by the operator (i.e. it's not handled by the
sendCompressedBytecode
function), it won'be sent to L1–only its hash will be sent (KnownCodesStorage.markFactoryDeps
sends only bytecode hashes, not bytecodes). Yet it'll still be marked as known on L2. The latter means that, if the operator doesn't provide the bytecode of a contract, it cannot be provided afterwards because the bytecode hash will be known and the bootloader will revert on this line, while trying to publish an already known bytecode. There doesn't seem to be a resolution to this situation, since bytecodes can only be published via thesendCompressedBytecode
function. This will break the requirement that all bytecode deployed on the L2 must be published on the L1:And it will also break the prover, since a call to a contract with unknown bytecode cannot be proved.
It's also worth noting that, while the operator is trusted to provide compressed bytecode that's cheaper to send to L1 than sending the original bytecode, the specification doesn't mention that it's also trusted to provide all bytecodes and hashes:
Thus, the operator might not provide all bytecodes required by a transaction, either deliberately or due to a bug.
Tools Used
Manual review
Recommended Mitigation Steps
It seems that the implementation of
KnownCodesStorage.markFactoryDeps
should publish full bytecodes to L1, since, besides being used to mark hashes as known, it's also calls the_sendBytecodeToL1
function. This is also pointed at by the documentation of the_shouldSendToL1
argument of the internal_markBytecodeAsPublished
function:And also the documentation of the
_sendBytecodeToL1
function:However, the function only sends bytecode hashes, not bytecodes.
An alternative solutions seems requiring that the operator provides all compresses bytecodes for all factory dependencies of a transactions. This can be implemented as a revert when the bytecode hashes don't match on this line.
To put it more generally, the
ZKSYNC_NEAR_CALL_markFactoryDepsL2
function of the bootloader should mark a bytecode hash as known only if:a. its bytecode (preimage) is provided by the operator and matches the hash (this is not implemented currently);
b. or, its compressed bytecode is provide by the operator and is verified (this is implemented in the
sendCompressedBytecode
function of the bootloader);