Full Report
Across protocol allows users to bridge funds between various EVM chains very fast - faster than finality. There are a couple of main users. First, the relayer who has funds on all chains. Second, the data worker for slow relays who always has enough liquidity. The relayers search for a given transaction to have occurred within the EVM smart contract. If it's profitable and they have the liquidity on the other chain, they will do a transfer for them. If it's going too slow (aka it's not profitable) then the user can increase the fee. If it's profitable but the relayer doesn't have the funds then they call fillRelay() and the dataworker will handle it. There are two types of events being used: deposit and fill. The deposit is what the relayer does and the fill is what the dataworker does, after something has happened to the initial fill. Being able to tie a fill to a deposit is important to ensuring that double spends don't occur - both for the on chain and off chain infrastructure. There needs to be some fairly complex logic for ensuring that two deposits are not made to the user. Onchain, to prevent this, a hash of the deposit is made in order to track it. Offchain, the function validateFillForDeposit Fill() filters all recent fills to find the proper deposit for it. The goal is to trick either the dataworker or the relayer to process the event when it should not. Within the relayer code, the function getValidUnfilledAmountForDDeposit() obtains the previous fills for the deposit against depositsWithBlockNumbers(). Additionally, there is a function that handles updates() that were being made to the transfer. The relayerFeePct field would be updated for a sped up deposit within the local object. Since the hash of the original object and the new object were different, it saw that as a valid fill! The tying together portion of the code has been broken. To exploit this, the following steps need to be done: Perform a transfer from chain A to chain B. Trigger a slow relay manually. This is to A) get the transfer in a different state and B) get the relayers to stop looking at it. Update the relayerFeePct on the source chain. This will get the relayer to see the deposit to NOT see it as a slow relay anymore. Transfer from relayer and dataworker is made. The hash is different than the original on the chain for both TXs. So, we steal funds! To fix the issue, the client side properties now checked to ensure that clients could see the deposits that had been filled or not, regardless of the state of relayerFeePct. Personally, I don't like the client side fix very much; I feel like doing something with the hash would make more sense. Unfortunately, there are times where hashing too many things is just as bad as hashing too few.
Analysis Summary
# Vulnerability: Across Bridge Off-Chain Double-Spend
## CVE Details
- **CVE ID**: Not Assigned (Protocol-specific bug bounty disclosure)
- **CVSS Score**: High (Estimated 7.0 - 8.9 range)
- **CWE**: CWE-694: Use of Excessively Deep Data Structures (Logic flaw in object comparison)
## Affected Systems
- **Products**: Across Protocol Relayer and Dataworker clients.
- **Versions**: Relayer-v2 at or around commit `7f9ef4b979ede6e1e9ff3fdf043f536df4871243`.
- **Configurations**: Occurs when a deposit is "sped up" (fee updated) via the `Spokepool::speedUpDeposit()` function on the source chain.
## Vulnerability Description
The vulnerability resided in the off-chain logic used to tie bridge "fills" (payouts) to original "deposits."
The `validateFillForDeposit()` function compared a deposit object against a fill event by iterating through their properties (such as `relayerFeePct`). When a user updates their fee (speeds up a deposit), the `relayerFeePct` changes.
Because the off-chain client updated the local deposit object with the new fee, its hash/identity no longer matched existing fill events associated with the *original* fee. This caused the relayer and dataworker to fail to recognize that the deposit had already been processed, allowing a single deposit to be filled twice (once as a "slow relay" and once as a standard relay) because the software saw them as two distinct, valid requests.
## Exploitation
- **Status**: PoC available/Identified (Reported via bug bounty; not exploited in the wild).
- **Complexity**: Medium
- **Attack Vector**: Network
- **Steps to Exploit**:
1. Perform a cross-chain transfer from Chain A to Chain B.
2. Trigger a "slow relay" manually to get the relayer to stop monitoring the initial state.
3. Call `speedUpDeposit()` on the source chain to update the `relayerFeePct`.
4. The change in the fee percentage causes the off-chain clients to see the deposit as "new" and "unfilled" because the properties no longer match the previous "fill" event.
5. The relayer/dataworker processes the "new" deposit, resulting in a double-payout.
## Impact
- **Confidentiality**: None
- **Integrity**: High (Double-spending of bridge liquidity/funds).
- **Availability**: None
## Remediation
### Patches
- **Relayer Update**: Software was updated to include additional client-side properties that track fill status regardless of changes to the `relayerFeePct` field.
- **Repository**: `across-protocol/relayer-v2` (Fix applied shortly after August 24, 2022).
### Workarounds
- **Temporary Pause**: At the time of discovery, Risk Labs temporarily paused the relayers to prevent exploitation until the fix was deployed.
## Detection
- **Indicators of Compromise**: Multiple `FilledRelay` events on the destination chain corresponding to a single `depositId` (or a single original transaction hash).
- **Detection Methods**: Monitor for discrepancies between the number of `FundsDeposited` events on source chains and `FilledRelay` payouts on destination chains.
## References
- **Vendor Advisory**: hxxps://across[.]to/
- **Original Disclosure**: hxxps://iosiro[.]com/blog/high-risk-bug-disclosure-across-bridge-double-spend
- **Source Code**: hxxps://github[.]com/across-protocol/relayer-v2/tree/7f9ef4b979ede6e1e9ff3fdf043f536df4871243