When Your Bridge Signature Doesn’t Sign What You Think It Signs
--
A deep dive into incomplete ECDSA hash binding in cross-chain bridge authorization
Cross-chain bridges move billions of dollars. Their security models vary — some use validator committees, some use cryptographic state proofs, some use a simpler co-signing model where a trusted relayer authorizes each deposit. This article is about a class of vulnerability in that last category. No protocol names, no addresses, no exploit code targeting live contracts — just the math, the pattern, and the lesson.
I found this pattern during a bug bounty engagement. The finding was mathematically proven, demonstrated with a deterministic on-chain proof of concept, and formally modeled. What follows is the generalized version of the research, written so that any builder using ECDSA co-signing in a bridge can check their own code.
The co-signing model
The pattern works like this. A bridge contract sits on the origin chain. It holds locked tokens or has the authority to burn them. A relayer — a trusted off-chain service — watches for bridge requests and co-signs them. The flow:
- User requests a bridge transfer through the relayer’s API: “I want to send 1000 USDC from chain A to chain B.”
- Relayer validates the request, computes a hash of the operation parameters, and signs it with its private key.
- User receives the signature and submits it to the bridge contract on-chain along with the operation parameters.
- The bridge contract recomputes the hash from the submitted parameters, recovers the signer from the signature, verifies the signer has the relayer role, and executes the deposit.
- The bridge emits a Deposit event.
- On the destination chain, the relayer (or another instance) observes the Deposit event and calls a withdrawal function to release tokens to the recipient.
This is a well-understood pattern. The signature prevents users from depositing arbitrary amounts or to arbitrary recipients without relayer approval. The hash includes a salt and deadline for replay protection. The relayer role is access-controlled.
The entire security model rests on one property: the hash must uniquely identify the operation the relayer authorized. Every parameter that affects what happens on-chain must be in the hash. If any parameter is missing, the user can substitute a different value for that parameter while the signature remains valid. The relayer authorized operation A, but the user executes operation B.
The architecture
To understand the vulnerability, you need to understand how these bridges typically structure their token mappings.
A bridge doesn’t just move “tokens.” It moves tokens according to mappings — configuration entries that define: which token on the origin chain, which token on the destination chain, which chains, and what mechanism (Lock the tokens in the bridge? Burn them? On the destination side: Unlock from bridge liquidity? Mint new tokens?).
Each mapping has a unique ID — let’s call it mapId. The mapping contains:
originTokenAddress— the token the user deposits on the origin chaintargetTokenAddress— the token the user receives on the destination chainoriginChainId— the origin chaintargetChainId— the destination chaindepositType— Lock (bridge holds the tokens) or Burn (tokens are destroyed)withdrawType— Unlock (bridge releases held tokens) or Mint (new tokens are created)isCoin— whether this mapping is for native coin or an ERC20 tokenisAllowed— whether the mapping is active
The user supplies the mapId when calling the bridge. The contract reads the mapping from storage and uses its fields to execute the deposit.
A separate Mapper contract manages these mappings. The Mapper enforces that each (targetChainId, originTokenAddress) pair has at most one active deposit mapping. This prevents duplicate mappings for the same origin token to the same destination chain.
Note the uniqueness key: (targetChainId, originTokenAddress). Not targetTokenAddress. This distinction is critical.
The hash
Here is the ECDSA hash construction I encountered (generalized):
hash = keccak256(abi.encodePacked(
msg.sender, // who is depositing
toAddress, // who receives on destination
mapInfo.targetTokenAddress, // what they receive
gasAmount, // gas fee
amount, // how much
mapInfo.originChainId, // from which chain
mapInfo.targetChainId, // to which chain
deadline, // signature expiry
salt // replay protection
));Nine parameters. The contract then verifies:
require(!usedHashes[hash], "Hash already used");
require(deadline >= block.timestamp, "Signature expired");
address signer = ecrecover(toEthSignedMessageHash(hash), v, r, s);
require(hasRole(RELAYER_ROLE, signer), "Invalid signer");
usedHashes[hash] = true;Replay protection via usedHashes. Deadline check. Signer verification. The hash is consumed after use. This all looks correct.
Now look at what’s in the hash and what isn’t:
In the hash Not in the hash msg.sender mapId toAddress originTokenAddress targetTokenAddress depositType gasAmount isCoin amount useTransfer originChainId targetChainId deadline salt
The hash includes fields derived from the mapping (targetTokenAddress, originChainId, targetChainId) but not the mapping identifier itself (mapId) and not all of the mapping's fields (originTokenAddress, depositType, isCoin).
The user supplies mapId. The contract reads the mapping. The contract puts some of the mapping's fields in the hash. The user controls which mapping is read. The signature cannot distinguish between two mappings that share the fields that are in the hash.
The collision
The Mapper enforces uniqueness on (targetChainId, originTokenAddress). This means two mappings with different originTokenAddress but the same targetTokenAddress have different uniqueness keys. Both registrations succeed. Both mappings are active simultaneously.
This is not a bug in the Mapper — it’s a legitimate configuration. A bridge that supports multiple stablecoins (USDC, USDT, DAI) all bridging to the same wrapped stablecoin (wUSD) on the destination chain would have exactly this setup. Three deposit mappings, three different origin tokens, one shared target token.
Now the math. Given two deposit mappings:
mapId=1: originToken=USDC, targetToken=wUSD, chains=(X,Y)
mapId=2: originToken=SHITCOIN, targetToken=wUSD, chains=(X,Y)The hash for each, given the same (sender, toAddress, gasAmount, amount, deadline, salt):
H(mapId=1) = keccak256(sender ‖ toAddr ‖ wUSD ‖ gas ‖ amt ‖ X ‖ Y ‖ deadline ‖ salt)
H(mapId=2) = keccak256(sender ‖ toAddr ‖ wUSD ‖ gas ‖ amt ‖ X ‖ Y ‖ deadline ‖ salt)Every byte of the abi.encodePacked input is identical. The hashes are identical. A valid ECDSA signature over H(mapId=1) is equally valid over H(mapId=2).
H(mapId=1) ≡ H(mapId=2)
∴ ecrecover(H(mapId=1), σ) == ecrecover(H(mapId=2), σ)
∴ σ valid for mapId=1 ⟹ σ valid for mapId=2The relayer signed for USDC. The user submits SHITCOIN. The contract accepts it.
I confirmed this on-chain. The hashes are byte-identical. The signature validates for both mapIds. The user chooses which to execute.
Three exploit variants
This single root cause — incomplete hash binding — enables three distinct attack vectors depending on what differs between the two mappings.
Variant 1: Token substitution
The simplest variant. Two ERC20 deposit mappings with the same target token but different origin tokens. The user deposits the cheaper origin token using a signature the relayer issued for the expensive one.
Concrete scenario: The bridge supports both USDC ($1.00) and SHITCOIN ($0.001) bridging to wUSD. The relayer signs for a 1000-unit USDC deposit. The user submits with the SHITCOIN mapping instead. The bridge locks 1000 SHITCOIN (worth $1) instead of 1000 USDC (worth $1000). The Deposit event shows originToken=SHITCOIN, amount=1000, targetToken=wUSD.
On the destination chain, the relayer sees a Deposit event for 1000 units targeting wUSD. If the relayer processes it without cross-referencing which origin token was actually deposited, it releases 1000 wUSD. The user profits $999.
State diff:
USDC.balanceOf(attacker): unchanged (never touched)
SHITCOIN.balanceOf(attacker): -1000 (deposited)
SHITCOIN.balanceOf(bridge): +1000 (received)
Deposit event: originToken=SHITCOIN, amount=1000Variant 2: Coin-to-token swap
This variant exploits the fact that isCoin is not in the hash, combined with the different gasAmount computation paths.
When isCoin=true (native coin deposit): gasAmount = msg.value - amount. The user sends amount + gasAmount as ETH.
When isCoin=false (ERC20 deposit): gasAmount = msg.value. The entire msg.value is the gas fee. Tokens are transferred separately via transferFrom.
If a coin mapping and a token mapping share the same target token, the relayer signs for the coin deposit with gasAmount = G. The expected msg.value is amount + G. But the user submits via the token path with msg.value = G. The token path computes gasAmount = msg.value = G — identical to what the relayer signed. The hash matches. The signature validates.
The user sends only G ETH (the gas fee) instead of amount + G ETH. The user deposits a cheap ERC20 instead of native coin. The savings equal the entire amount in native coin.
Concrete scenario: Relayer signs for a 1 ETH coin deposit with 0.01 ETH gas. Expected msg.value = 1.01 ETH. User submits via the token path with msg.value = 0.01 ETH, depositing 1e18 units of a cheap ERC20 instead. User saves ~1 ETH.
Variant 3: Lock-to-Burn swap (the killer)
This is the most severe variant and the one with no off-chain mitigation.
depositType is not in the hash. The Mapper permits a Lock mapping and a Burn mapping with the same target token (they have different origin tokens, so different uniqueness keys). The user substitutes the Burn mapping for the Lock mapping.
Here’s what happens in the Burn path:
// Tokens transferred from user to bridge
transferFrom(user, bridge, amount);// Then immediately burned from bridge's balance
if (depositType == Burn) {
ERC20Burnable(originToken).burn(actualAmount);
}Tokens arrive at the bridge and are immediately destroyed. The bridge’s token balance does not increase. But the Deposit event is emitted with the full amount:
emit Deposit({
fromAddress: user,
toAddress: recipient,
originTokenAddress: originToken,
targetTokenAddress: targetToken,
amount: actualAmount,
originChainId: X,
targetChainId: Y
});On the destination chain, the relayer sees a valid Deposit event. If the destination mapping is withdrawType=Unlock, the relayer releases locked tokens from the destination bridge's liquidity.
But the origin bridge holds nothing. The tokens were burned.
Origin chain: bridge.balance += 0 (tokens burned, not held)
Destination chain: bridge.balance -= amount (tokens unlocked and sent to user)
Net: `amount` tokens exit the system without entering itThe conservation-of-value invariant — the fundamental property that makes a Lock/Unlock bridge work — is broken. Tokens are created from nothing on the destination side.
Why there’s no off-chain mitigation for this variant: The Deposit event does not contain depositType. It emits originTokenAddress, targetTokenAddress, amount, and chain IDs. The relayer cannot distinguish a Lock deposit from a Burn deposit by inspecting the event. A correctly-behaving relayer that processes valid Deposit events will drain destination liquidity for deposits that have zero backing on the origin chain.
For variants 1 and 2, the relayer can potentially check originTokenAddress in the event against the original request and reject mismatches. For variant 3, the mismatch is in depositType, which is invisible in the event. The relayer has no signal to reject.
The formal model
I built a formal state machine model to verify these findings independently of the PoC. The model defines six invariants:
- Gas solvency:
eth_balance >= gasAccumulatedat all times - Hash uniqueness: each hash used at most once
- Daily limit: volume per token per relayer never exceeds limit
- Mapper consistency: index references point to valid mappings
- Mapper uniqueness: no duplicate active mappings per uniqueness key
- ECDSA binding completeness: for any two active deposit mappings m1 ≠ m2, H(m1) ≠ H(m2)
The model tests all six invariants across all state transition orderings. Five hold. Invariant 6 fails:
INV_6 (ECDSA binding completeness): VIOLATED
Hash for mapId=1: 262b29197be3e98d…
Hash for mapId=2: 262b29197be3e98d…
Hashes equal: True
⟹ Signature for mapId=1 is VALID for mapId=2The proof of concept
The PoC uses the project’s own Hardhat test framework and deployment utilities. It includes a Solidity exploit contract that executes the substitution atomically:
- Receives cheap tokens from the attacker
- Approves them to the bridge
- Calls
bridgeTokenswith the substitutedmapIdand the relayer's signature - The bridge accepts the signature and locks/burns the cheap tokens
Five tests, all passing:
✔ PROOF-1: H(mapId=1) ≡ H(mapId=2) when targetTokenAddress matches
✔ EXPLOIT-1: Solidity contract substitutes cheap ERC20 for expensive ERC20
✔ EXPLOIT-2: Solidity contract swaps coin deposit for cheap ERC20 deposit
✔ EXPLOIT-3: Lock→Burn swap — bridge receives nothing, Deposit event emitted
✔ SANITY: Substituted signature is consumed — not replayThe SANITY test confirms this is not a replay attack. After the substituted use with mapId=2, attempting to use the same signature with mapId=1 reverts with “Hash already used.” The signature is consumed exactly once. The vulnerability is parameter substitution within a single use.
The fix
One line. Add mapId to the hash:
hash = keccak256(abi.encodePacked(
msg.sender,
mapId, // ADD THIS
toAddress,
originTokenAddress, // ADD THIS for defense-in-depth
targetTokenAddress,
gasAmount,
amount,
originChainId,
targetChainId,
deadline,
salt
));mapId uniquely determines all mapping parameters — origin token, target token, deposit type, coin flag, transfer method. Adding it to the hash binds the signature to exactly one mapping. All three variants are eliminated in a single change.
Adding originTokenAddress alongside mapId provides defense-in-depth. Even if the Mapper contract is upgraded and mapId semantics change, the origin token is still explicitly bound.
The general pattern
This vulnerability is not specific to one bridge. It’s a pattern that can appear anywhere a smart contract does:
data = storage[user_supplied_key];
hash = keccak256(... data.field_A ... data.field_B ...);
// but NOT data.field_C, NOT data.field_D, NOT user_supplied_key
verify(signature, hash);
execute(data); // uses field_C and field_D tooThe user controls user_supplied_key. The signature covers field_A and field_B. The execution uses field_C and field_D. If two keys produce the same (field_A, field_B) but different (field_C, field_D), the signature is ambiguous. The user picks which field_C and field_D get executed.
The fix is always the same: bind the key in the hash. If the user controls which record is read, the record’s identifier must be in the signed message. This is the principle of complete binding — every parameter that affects execution must be in the authorization.
This principle is well-established in other contexts. EIP-712 typed structured data hashing exists precisely to prevent this class of issue by making the signed data structure explicit and complete. EIP-2612 permit signatures include the token address. EIP-4494 NFT permits include the token ID. The pattern is: if it affects what happens, it must be signed.
Lessons
For bridge builders: Audit your ECDSA hash construction. List every field that affects execution. Verify each one is in the hash. If you derive fields from a user-supplied lookup key, bind the key itself. Don’t assume the derived fields are sufficient — the storage layer may permit configurations where derived fields collide across different keys.
For auditors: When you see a co-signing pattern, don’t just check for replay protection and signer verification. Check for completeness — does the hash bind every parameter that affects the execution path? Map the user-controlled inputs to the hash inputs. Any gap is a substitution surface.
For protocol teams: When a security researcher presents a mathematically proven invariant violation with a deterministic PoC, evaluate it on its mathematical merits. The question is not “does our current deployment trigger this?” The question is “does our contract’s invariant hold for all states the contract permits?” If the answer is no, the invariant is broken — regardless of current configuration or off-chain compensating controls.
Check your bridges.