Signer's withdrawal message can be replayed by anyone and be repeated until no funds remain in the Bridge Vault.
The protocol allows trusted signers to read deposit information on the L2 side
and to release the corresponding funds on the L1 side through the
withdrawTokensToL1()
function. Anyone can call this withdraw function and
the protocol verifies that the withdrawal is authorized by verifying that the
signature originates from one of the trusted signers.
It then reads the properties from the message:
target address - the recipient of the funds
value - ETH to send to the recipient
message - calldata to call the target address with
The intended usage is that the target is the token to be withdrawn
and that the calldata represents a call to transferFrom() which
moves the funds from the vault to the bridge user.
The problem with this is the verification of this message, which looks
like (from sendToL1()
in L1BossBridge.sol
):
The only verification is that the signer is trusted.
There is no logic to verify that the message hasn't already been processed.
That means it can be reused an arbitrary amount of times by an attacker to
withdraw funds until there are none left in the bridge vault.
The following code shows a POC.
Add the the below test function to L1TokenBridge.t.sol
.
The test always passes but logs the effects of the attack.
Run the test case using: forge test -vv --mt ReplayWithdrawals
Below is the resulting log output. As can be seen the attacker
ends up with the whole amount previously in the bridge by reusing
the signed message 999 extra times. Instead of the 1 ETH bridged, the attacker was able to withdraw a total of 1000 ETH (everything in the vault).
Furthermore, depending on the exact contract addresses of tokens and vaults on different
chains, this attack could potentially also be replayed on other chains.
As is, the implementation also makes it very hard for multiple bridge signers to
operate since they could accidentally all sign withdrawal messages for the same
deposit.
Anyone can drain the L1 vault of all its funds by bridging a relatively small amount once.
Manual review
The message that is signed must contain some sort of ID for that bridging transaction, and
the protocol must have a mapping of which IDs have already been acted on.
Since there might also be a risk of messages being replayed on multiple chains
(Mainnet and zkSync for now and maybe others in the future) a message should also
be unique between different source chains and destination chains. I suggest adding the following fields to the signed message:
source deposit nonce -this is a counter for every deposit made to that contract.
source chainId
destination chainId
Source deposit nonces may be the same for vaults on different L1s and L2s but combined
with source chainId we get an ID that is unique across the protocol on all chains.
destination chainId is also used in order to protect a message to be authorized on
a chain it was not intended for.
The flow would then go something like this:
User deposits on L2 - the deposit event will include the nonce and the
intended destination chainId.
A signer picks up the event and prepares a message that includes
the source chainId, the destination chainId, and the deposit nonce.
The signer signs the message and calls the withdraw function on the
destination side.
The protocol will verify the signature as before, but in addition it will
also create a hash of the source chainId and nonce. It must then require
that this hash has not been processed before
(a mapping from hash to a boolean could be used here).
It must also verify that the destination chainId of the message matches
the current chainId.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.