Blockchain bridge security - Intro & Part 1:
Introduction:
Blockchain bridges serve as critical infrastructure in the web3 ecosystem, enabling cross chain communication and asset transfers between different blockchain networks. this series will explain some of the common/possible bridge smart contract vulnerabilities with examples.
The examples used are from my past sample project repository Blockchain-bridge-vulnerabilities.
Before moving ahead, a primer on EIP712 would be useful, which can be found here.
This series explains these vulnerabilities:
- Message replay
- Signature replay
- Cross chain signature replay
- Variations of signature replay
- Arbitrary call execution
- Attacker steals ether - test_sendMsg_nested()
- Attacker tricks minting logic - test_sendMsg_nested_transfer()
- Chain id spoofing
- Hash collision
- Unable to ensure signature expiry
This article gives an overview of the contracts in sample project and the 2 vulnerabilities mentioned:
- Overview
- BridgeSafeTokenSend.sol
- SignalProcessor.sol
- Vulnerabilities:
- Message replay
- Signature replay
Overview:
The example used in this sample project is a bridge that allows mainly two things:
- Allows burning tokens on the source chain and minting the same amount on the destination chain.
- Allows transferring tokens owned on destination chain to a new address on the destination chain itself.

Overview of BridgeSafeTokenSend.sol:
As the name suggests it shows functionality that is assumed safe from the other attacks mentioned in this example. (Still can be vulnerable.)
- sendMsg():
Records the message in the SignalProcessor with SignalProcessor.sendTx() call. This message will be sent to destination chain by the relayer (will cover more below). In general it helps sending the message from source chain. additionally expects some staticFee(which can be dynamic in the real world) - sendMsgPermit():
Works same as sendMsg() but uses the ECDSA verification for verifying the messages sent on behalf of the actual signer.

- executeMessage():
Executes the message on the destination chain. by verifying it first on the destination chain's SignalProcessor. - And then there are some helper functions like:
- getDomainSeparator():
Returns the domain separator value. - pauseBridge():
Pauses the bridge. - unpauseBridge():
Un-pauses the bridge.
- getDomainSeparator():
All functions come with many important checks/validations.
Overview of SignalProcessor.sol:
The SignalProcessor is the contract that stores and verifies the message is valid. the stored message is shared across the chains by relayers. This contract acts as mock signal processor for simplicity.
- sendTx():
Stores the message hash shared by the bridge contract. let's assume there are enough checks in place to check if it's coming from the bridge contract only. - verifyTx():
Verifies that the message was sent from source chain and returns the true/false boolean value. this mock contract does not contain the proof verification logic. but we can assume there is proof verification mechanism in place.
It's important to note that for these examples, only one SignalProcessor instance would be deployed and both the bridges on source and destination chain will interact with the same SignalProcessor.sol instance's address. In real life there would be two SignalProcessor contracts deployed on source and destination chain and the relayer will transfer and store the messages sent from source chain to destination chain.
Apart from these two contracts mentioned. For every vulnerability explained in this series, there's different contract and or respective test used to showcase that vulnerability. The BridgeSafeTokenSend.sol mainly shows the contract code without other vulnerabilities that are explained throughout the project/article.
Vulnerabilities:
1. Message replay:
test_executeMessage_replay() shows how the message can be reused. As can be seen in the executeMessage() function, it doesn't checks the message is stale or not (you can see it's commented on BridgeSignatureReplay.sol#L114-L116).
The test shows same thing where it first creates the transaction struct then it sends the message with bridge.sendMsg() the transaction is to mint 1e18 tokens on other chain by burning 1e18 tokens on current chain.
Then it calls destBridge.executeMessage() two times, first on BridgeSignatureReplay.t.sol#L126 and on BridgeSignatureReplay.t.sol#L129 which shows using message more than one times is possible because of not storing and validating the message hashes. This was simple.
2. Signature replay:
test_sendMsgPermit_signature_replay() shows how the signature can be replayed (on same chain and same bridge) by malicious users. On BridgeSignatureReplay.t.sol#L160 it sends the message with transaction and signature params (v, r, s). The transaction here is for transferring 50e18 tokens to specific address (in this case user address). as can be seen in the BridgeSignatureReplay.sendMsgPermit() on BridgeSignatureReplay.sol#L82 it shows that the transaction.id assignment is commented just to assume it's absent.
But why that's needed ?
The reason behind every transaction message has id variable is to make difference between different transactions. So generally when the transaction will be signed by the owner off-chain, it will contain this unique id.
As can be seen, the transactionHash is created with keccak256 hash of the transaction struct variable. It is then used in the ethSignedMessageHash digest creation which is then passed to the ecrecover to recover the signer's address.
The function should be assigning the transaction.id = messageId++ which would create a different ethSignedMessageHash digest for every transaction while executing the BridgeSignatureReplay.sendMsgPermit(). In case any transaction uses same id (in order to maliciously use old signature) the function will revert because the address recovered by the ecrecover would not be equal to transaction.from on BridgeSignatureReplay.sol#L89 and that's because the ethSignedMessageHash calculated in contract would be different than the one for which the old signature (which is getting replayed) was created.
After sending the message on BridgeSignatureReplay.t.sol#L160, it calls destBridge.executeMessage() on BridgeSignatureReplay.t.sol#L165, after that on BridgeSignatureReplay.t.sol#L167-L168 it checks that the Alice transferred 50 tokens to the user address.
After that on the BridgeSignatureReplay.t.sol#L173 it calls the destBridge.executeMessage() again to perform signature replay attack. In the current example since the assignment was commented and hence the user could use the previous signature (with old id), which is similar to reusing any previously signed transaction i.e. replaying it.
Lastly, on the BridgeSignatureReplay.t.sol#L175-L176 it can be seen that the user now has 100 tokens, even though the Alice only wanted to transfer 50 tokens.
The id in this implementation (currently commented) works like a nonce which will prevent signature replay attacks by forcing the signer to use new Id for every new transaction struct to be signed.
That concludes this article. Next part (Part 2) shows:
- Cross chain signature replay
- Variations of signature replay