Blockchain bridge security - Part 2
This is 2nd article of the Bridge security series. see the Intro and Part 1.
This article explains 2 vulnerabilities mentioned below:
- Cross chain signature replay
- Variations of signature replay
1. Cross chain signature replay
test_sendMsgPermit_cross_chain_signature_replay() shows how cross chain signature replay attacks work.

Before that let's understand the use of domain separator:
It is used to differentiate the signature from signatures signed on this and on any other contract and even to differentiate the signature signed on different chains. Generally it contains these parameters:
name: Name of the contract.version: Version of the contract. it can happen that the same project deploys their contract again so the version can be greater than older version.chainId: The chainId to differentiate between the chain for which the signature was signed.verifyingContract: Generally the address of this contract (address(this)).
Let's talk about the example test.
Please refer the diagram as you read, for more visual understanding.
In the test_sendMsgPermit_cross_chain_signature_replay() It simply creates a transaction/message to transfer 50e18 tokens to the user address on destination chain. Then it signs the message. sends the message with sendMsgPermit().
Then it creates the new instance of the bridge contract on chain id 2 called destBridgeOnChainId2. it also mints 100 tokens to the Alice. (the reason to mint these 100 tokens is to have some amount on the destination chain, since the transaction here was created for transferring 50 tokens to the user on destination chain from the tokens Alice owns on the destination chain).
It checks the balances and then executes transaction on BridgeSignatureReplay.t.sol#L214 with executeMessage(). After checking the balances it can be seen that both now have 50 tokens. which is correct and expected. since the Alice signed the transaction to transfer 50 tokens.
Now, on BridgeSignatureReplay.t.sol#L222 malicious user/attacker again calls sendMsgPermit() with the same signature components and same transaction struct.
Then it executes the same transaction with the destBridgeOnChainId2.executeMessage() on destination chain bridge.
Interestingly, you can see the last assertion for balances are passing that means the attacker was able to execute the cross chain replay attack successfully and able to get all the tokens (total 100) from the Alice's balance.
This attack is possible because the commented part of BridgeSignatureReplay.sendMsgPermit() where it fails to assign the transaction.srcChainId. Because of that, even though the signer signed the transaction struct which has specific srcChainId (let's say 31337, Or chain 1 in the diagram), it is possible to use that same signature on any other chain than 31337 (Or the chain for which the struct was originally created and signed).
So what happens on the contract level is, in the BridgeSignatureReplay.sendMsgPermit() while creating ethSignedMessageHash the same transactionHash is used that would be used on the chain with id 31337 and because the attacker used original transaction struct and correct signature, the recovered signature matches with the transaction.from.
If BridgeSignatureReplay.sol#L81 assignment would have happen (which currently is not happening because it's commented), the transaction will revert on BridgeSignatureReplay.sol#L89 because in-contract transaction struct will now have different chain id (Not the 31337 assuming that the attacker is calling BridgeSignatureReplay.sendMsgPermit() on different chain and not 31337 for which the transaction struct was created and signed) and because of that the transactionHash will change, so the ethSignedMessageHash will change and eventually the signature used by the attacker will be invalid (because originally it was created for different struct with srcChainId as 31337).
So in this current case of example in test_sendMsgPermit_cross_chain_signature_replay() attacker could use the signature multiple times, on multiple chains. Not just only on the chain for which the signer signed the message. And that's how the attacker was able to get more tokens (100e18) on the destination chain instead of what signer wanted to transfer (50e18).
To avoid this vulnerability in this project, the assignment for srcChainId can be added so it will create different transactionHash which will lead the cross chain replay transaction to revert as discussed above.
There can be variations of this attack, it's just not related to the missing assignment or unused chain id comparisons, but simply with the absence of anything that is required to be there! more info below.
Note: In this test on BridgeSignatureReplay.t.sol#L219-L222 the comment says "Signature replay on the chain id 3" but it still uses the bridge instance that was used for sending message on chain 1 on BridgeSignatureReplay.t.sol#L201. In reality that would be bridge deployed on that chain 3 as the comment says.
2. Variations of signature replay
Absence of other parameters can be responsible for this kind of vulnerability, especially included in the domain separator like name, version, verifying contract.
Imagine the project decides to upgrade (or say update, where it deprecates the old contracts) a contract where things like name, version , verifying contract address (address(this)) can change.
E.g. If there's a project called XYZProject and it uses ECDSA with EIP712. the initial domain separator values are name : "XYZProject"version : 1chainId : 111 Or chain on which chain the contract is deployed.verifyingContract : address(0x1XyZprOject) Or any address.
All the signatures signed for this contract will use these values off chain in their domain separator, which then will be used for creating the digest which will be signed.
After the contract upgrade or say new contract deployment, these values may change. let's say new values are:name : "NewXYZProject"version : 2chainId : 111 Or any different chain on which chain the contract is deployed.verifyingContract : address(0x1NeWXyZprOject) Or any different address.
So now for in-contract verification the digest (ethSignedMessageHash) created will be completely different (because the domain separator values are different), so the signatures created for the previous contract version won't be useful because previous signature used to use old/initial domain separator values.
So there can be variation of the signature replay depending on if the contract is updated and if still uses same domain separator params or just don't use these params at all. E.g Any contract with signature verification logic that does not take care of these values after updating a contract or say deploying a new contract, can be vulnerable to signature replay because previously signed signatures can be used with new contract.
Next part (Part 3) shows Arbitrary call execution.