A Web2.5 Vulnerability Story: Between Backend and Onchain

Most DeFi protocols are not purely onchain. This walks through a real vulnerability in the seam between backend and contract, where smart contract auditors and backend developers each assume the other has it covered.

· 7 MIN READ · DECURITY

0x00 Introduction

Most DeFi protocols aren’t purely onchain. They rely on backends for coordination, better UX, or cross-chain communication. This creates a blind spot. Smart contract auditors focus on the onchain logic. Backend developers trust the blockchain to handle security. But when a protocol spans both worlds, the gaps between them become an attack surface. These are web2.5 vulnerabilities: bugs that exist not in the smart contract or the backend alone, but in the way of their interaction.

We have already covered several web2.5 attack vectors in the post “Web2.5 Security: Pentesting Blockchain Infra”. In this article we examine a critical vulnerability in a cross-chain stablecoin swap protocol that we have audited recently. Both the contracts and the backend worked fine on their own, but the interaction between them allowed attackers to double-spend funds.

0x01 Protocol Overview

The protocol we examined is a cross-chain stablecoin bridge. It allows users to swap stablecoins 1:1 between different chains — for example, USDC on Ethereum to USDT on Arbitrum. No AMM, no slippage — just a direct transfer, coordinated by a centralized backend.

The core challenge in cross-chain swaps is atomicity. How do you ensure that either both sides complete, or neither does? This protocol uses a hash time-locked contract (HTLC) pattern with a twist: a session signature mechanism that gives users control over swap completion.

Here’s how it works:

  • The backend generates a secret and shares only its hash with the user
  • Funds lock on both chains, each requiring the secret to unlock
  • The user must sign off before the backend reveals the secret
  • If anything goes wrong, timeouts allow refunds

User Flow

Step by step:

  1. User generates a session keypair and sends a swap request with the session public key to the backend
  2. Backend generates a secret, signs swap parameters and returns only the secret hash to the user
  3. User submits _lockSource to lock funds on the source chain
  4. Backend submits _lockDestination to lock funds on the destination chain
  5. User signs the secret hash with the session key and submits the signature to the backend
  6. Backend submits _claimSource (with session signature and plaintext secret) and _claimDestination to complete the swap

Why Session Signatures?

The session signature adds a trust layer. The backend cannot unlock funds without the user’s session signature. This gives users control: they can refuse to sign and reclaim their funds via _refundSource after a timeout.

Refund Mechanism

What if something goes wrong? The protocol has a timeout-based refund system. If a swap isn’t completed within a set period, users can call _refundSource to recover their locked funds on the source chain. Similarly, _refundDestination returns funds on the destination chain. This protects users if the backend goes offline or a swap gets stuck.

0x02 The Naive Attack

Before diving into the real vulnerability, let’s consider the obvious attack. The goal is simple: get funds on both chains. Receive stablecoins on the destination chain, then refund the original funds on the source chain — double spend.

The attack would look like this:

  1. Initiate swap normally
  2. Call _claimDestination to receive funds on the destination chain
  3. Wait for the timeout period
  4. Call _refundSource to recover funds on the source chain

Why doesn’t this work? Two reasons.

You can’t skip the backend. To initiate a swap, _lockSource requires a signature from the backend (maintainer's signature). You can't lock funds on the source chain without backend approval. The backend controls who can start a swap.

You don’t know the secret. Both _claimDestination and _claimSource require the plaintext secret to unlock funds. The backend generates this secret and never shares it directly — only the hash goes onchain. Without the secret, you can't claim funds on either chain.

The backend sits at the center of this design. It authorizes swaps, holds the secret, and coordinates both sides. This is intentional — it prevents users from gaming the system. But it also means the backend’s behavior is critical to security.

0x03 The Actual Vulnerability

So the naive attack fails because you don’t know the secret. But what if you could make the backend reveal it?

How? Reverted transactions are public. When a transaction fails onchain, all its calldata, including function parameters, is visible on the blockchain. Anyone can read it.

Now consider what happens when you submit an invalid session signature to the backend. The backend receives it, doesn’t validate it, and broadcasts the _claimSource transaction with the corrupted signature and the plaintext secret. The smart contract verifies the signature, finds it invalid, and reverts. But the damage is done - the secret is now public in the failed transaction's calldata.

With the secret exposed, the attacker can:

  • Call _claimDestination directly to receive funds on the destination chain
  • Wait for the timeout period
  • Call _refundSource to recover the original funds on the source chain

Double spend achieved.

Exploit steps:

  1. Initiate swap normally — get the secret hash and maintainer signature from backend
  2. Lock funds on the source chain via _lockSource
  3. Submit a corrupted or random session signature to the backend
  4. Backend calls _claimSource with invalid signature → transaction reverts → secret is leaked in calldata
  5. Read the secret from the failed transaction on the blockchain
  6. Call _claimDestination with the leaked secret, to receive funds on the destination chain
  7. Wait for the timeout and call _refundSource to recover funds on the source chain

Result? Attacker receives all funds on both chains.

0x04 The Fix and The Bypass

After we reported the vulnerability, developers implemented a fix: verify the session signature in the backend before broadcasting the transaction. If signature verification fails, reject the request and don’t submit anything onchain.

func verifySessionSignature(hash []byte, sig []byte, expectedAddr common.Address) bool {
    if len(sig) != 65 {
        return false
    }

    v := sig[64]
    if v == 27 || v == 28 {
        sig[64] = v - 27
    }

    recoveredPubKey, err := crypto.SigToPub(hash, sig)
    if err != nil {
        return false
    }

    recoveredAddr := crypto.PubkeyToAddress(*recoveredPubKey)
    return recoveredAddr == expectedAddr
}

The logic is straightforward: recover the signer’s address from the signature and compare it to the expected session address. If they match, the signature is valid. Problem solved?

Not quite. This fix has two critical bypasses, both stemming from inconsistencies between how Go’s crypto library and OpenZeppelin’s Solidity library handle ECDSA signatures.

Bypass 1: V Parameter Inconsistency

ECDSA signatures consist of three components: r, s, and v. The v value is the recovery identifier - it tells you which of the possible public keys to recover from the signature.

Here’s the problem: different libraries use different conventions for v.

This converts 27→0 and 28→1 for the Go library. But what if v is already 0 or 1? The code doesn't touch it - go-ethereum accepts it as is.

The attack: submit a valid signature, but with v = 0 or v = 1 instead of 27 or 28.

  • Backend: go-ethereum accepts v = 0 or v = 1 → verification passes
  • Onchain: OpenZeppelin rejects v = 0 or v = 1 → transaction reverts

The secret leaks. The fix is bypassed.

Bypass 2: Signature Malleability

ECDSA has an inherent property called signature malleability. For any valid signature (r, s, v), you can compute another valid signature (r, s', v') where:

  • s' = n - s (where n is the curve order)
  • v' flips accordingly

Both signatures are mathematically valid — they recover to the same public key. This is a known issue that can cause problems like transaction replay.

OpenZeppelin’s ECDSA library protects against this by rejecting signatures where s > n/2 (the “upper half” of the curve). It only accepts “low-s” signatures.

go-ethereum’s crypto library doesn’t care. It accepts both low-s and high-s signatures.

The attack: take a valid signature and compute its malleable counterpart (flip s to n - s).

  • Backend: go-ethereum accepts the malleable signature → verification passes
  • Onchain: OpenZeppelin rejects s > n/2 → transaction reverts

The secret leaks again.

0x05 A Simpler Attack Path

During our analysis, we noticed something interesting: the backend sends _claimSource and _claimDestination simultaneously. It doesn't wait for _claimSource to succeed before calling _claimDestination.

What if the backend simulates _claimSource before sending? If simulation fails, the transaction never hits the chain - no secret leak. But it doesn't matter. Since both claims are sent in parallel, _claimDestination is already submitted before the backend knows _claimSource will fail. The attacker receives funds on the destination, waits for the timeout, and refunds on the source. No secret leak needed.

0x06 Conclusion

What makes web2.5 bugs tricky is that each component works correctly on its own. The smart contract validates signatures properly. The backend validates signatures properly. But they use different libraries with different rules, and the mismatch creates an exploit.

When auditing systems like this, focus on boundaries. What happens when the backend sends data to a contract, and the transaction reverts? Is sensitive data exposed? When the backend validates something off-chain, does onchain validation follow the same rules? When operations span multiple chains, what happens if one succeeds and the other fails?

Never forget that a revert is still a leak. If your backend validates something off-chain, the contract must validate it the same way — same library behavior, same edge cases. Never assume parallel cross-chain calls will both succeed. The blockchain remembers every failed transaction, and attackers read them too.

A Web2.5 Vulnerability Story: Between Backend and Onchain was originally published in Decurity on Medium, where people are continuing the conversation by highlighting and responding to this story.

RELATEDWEB2.5 SECURITY AUDITS

Need this expertise on your protocol?

The researchers who write this are the ones who run the audits.

REQUEST FORM