Skip to main content
Sigvex

Signature Replay

Detects signature verification without nonce or deadline replay protection, allowing valid signatures to be reused across multiple transactions.

Signature Replay (SVM)

Overview

Remediation Guide: How to Fix Signature Replay (SVM)

The SVM signature replay detector identifies Solana programs that verify a cryptographic signature (ed25519 or secp256k1) without preventing the same signature from being submitted in a future transaction. An attacker who observes a valid signed authorization can resubmit it in a new transaction to repeat the authorized action — draining escrow accounts, duplicating withdrawals, or re-executing privileged operations.

The detector performs a two-pass scan: first identifying known signature verification syscalls (sol_verify_ed25519, secp256k1_recover, Secp256k1 program invocations), then checking whether the same function contains a nonce increment pattern or a deadline/slot comparison. If signature verification is present without either protection, a finding is emitted with confidence 0.70.

Why This Is an Issue

Cryptographic signature verification confirms that a message was signed by the expected key, but it does not guarantee that the message has not been signed before and used already. Without a nonce (a per-use counter) or a deadline (an expiry slot or timestamp), a valid signature remains valid indefinitely and can be replayed by any observer who copies the transaction data.

In Solana’s account model, the natural replay protection mechanism is a nonce stored in the account associated with the signer. After verifying a signature, the program must increment the nonce and store it back — making the same signature invalid for all future transactions. Failure to do this is a structural authorization bypass.

Solana’s durable nonce system provides transaction-level replay protection for gasless or meta-transaction flows and is the recommended approach when off-chain authorization is needed at the transaction level.

How to Resolve

Include a monotonic nonce in the signed message and increment it after each successful verification. Add a deadline to limit the signature’s valid time window.

// Before: Vulnerable — signature verified but no nonce or deadline
pub fn execute_authorized_action(
    accounts: &[AccountInfo],
    signature: [u8; 64],
    recovery_id: u8,
    message_hash: [u8; 32],
    amount: u64,
) -> ProgramResult {
    let recovered_key = secp256k1_recover(&message_hash, recovery_id, &signature)
        .map_err(|_| MyError::InvalidSignature)?;
    if recovered_key.0 != expected_signer_bytes {
        return Err(MyError::WrongSigner.into());
    }
    // VULNERABLE: same signature can be replayed indefinitely
    transfer_funds(accounts, amount)?;
    Ok(())
}
// After: Fixed — nonce and deadline in signed message
use anchor_lang::prelude::*;

#[account]
pub struct AuthState {
    pub authority: Pubkey,
    pub nonce: u64,
}

pub fn execute_authorized_action(
    ctx: Context<AuthorizedAction>,
    signature: [u8; 64],
    recovery_id: u8,
    amount: u64,
    deadline: i64,
) -> Result<()> {
    let state = &mut ctx.accounts.auth_state;

    // Check deadline — signature cannot be replayed after expiry
    let current_time = Clock::get()?.unix_timestamp;
    require!(current_time <= deadline, MyError::SignatureExpired);

    // Message includes nonce — each use produces a different hash
    let message = create_message(amount, state.nonce, deadline);
    let message_hash = hash(&message);

    let recovered = secp256k1_recover(&message_hash, recovery_id, &signature)
        .map_err(|_| MyError::InvalidSignature)?;
    require!(recovered.0 == expected_bytes(state.authority), MyError::WrongSigner);

    // Increment nonce BEFORE executing the action
    state.nonce += 1;

    transfer_funds(&ctx.accounts, amount)?;
    Ok(())
}

Examples

Vulnerable Code

use solana_program::{
    account_info::AccountInfo,
    entrypoint::ProgramResult,
    secp256k1_recover::secp256k1_recover,
};

// VULNERABLE: signature is verified but can be replayed indefinitely
pub fn execute_authorized_action(
    accounts: &[AccountInfo],
    signature: [u8; 64],
    recovery_id: u8,
    message_hash: [u8; 32],
    amount: u64,
) -> ProgramResult {
    let recovered_key = secp256k1_recover(&message_hash, recovery_id, &signature)
        .map_err(|_| MyError::InvalidSignature)?;

    if recovered_key.0 != expected_signer_bytes {
        return Err(MyError::WrongSigner.into());
    }

    // No nonce increment, no deadline check — same signature works forever
    transfer_funds(accounts, amount)?;
    Ok(())
}

Fixed Code

use solana_program::{
    account_info::AccountInfo,
    clock::Clock,
    entrypoint::ProgramResult,
    secp256k1_recover::secp256k1_recover,
    sysvar::Sysvar,
};

pub fn execute_authorized_action(
    accounts: &[AccountInfo],
    signature: [u8; 64],
    recovery_id: u8,
    amount: u64,
    nonce: u64,
    deadline_slot: u64,
) -> ProgramResult {
    let auth_account = &accounts[0];

    // FIXED: check deadline
    let current_slot = Clock::get()?.slot;
    if current_slot > deadline_slot {
        return Err(MyError::SignatureExpired.into());
    }

    // FIXED: verify nonce matches stored value
    let stored_nonce = load_nonce(auth_account);
    if nonce != stored_nonce {
        return Err(MyError::InvalidNonce.into());
    }

    // Build message that binds nonce and deadline
    let message = build_message(amount, nonce, deadline_slot);
    let message_hash = solana_program::hash::hash(&message).to_bytes();

    let recovered_key = secp256k1_recover(&message_hash, recovery_id, &signature)
        .map_err(|_| MyError::InvalidSignature)?;
    if recovered_key.0 != expected_signer_bytes {
        return Err(MyError::WrongSigner.into());
    }

    // FIXED: increment nonce after successful verification
    store_nonce(auth_account, nonce + 1)?;

    transfer_funds(accounts, amount)?;
    Ok(())
}

Sample Sigvex Output

{
  "detector_id": "signature-replay",
  "severity": "high",
  "confidence": 0.70,
  "description": "Function execute_authorized_action contains a secp256k1_recover syscall but no nonce increment pattern or deadline comparison was found. The verified signature can be replayed in future transactions.",
  "location": {
    "function": "execute_authorized_action",
    "offset": 0
  }
}

Detection Methodology

The detector performs a two-pass scan of each function’s HIR:

  1. Protection identification: Scans for nonce increment patterns (HirStmt::Assign where dst = dst + 1) and deadline checks (HirStmt::Branch with a timestamp or slot comparison involving < or >=).
  2. Verification identification: Scans for known signature verification syscalls (names matching sol_verify_ed25519, secp256k1_verify, or Secp256k1 program invocations).
  3. Finding emission: For each known signature verification syscall, checks whether the function contains any nonce counter or deadline check. If neither is present, emits a finding with confidence 0.70.

Limitations

False positives:

  • Programs that use Solana’s built-in durable nonce mechanism at the transaction level (rather than in program logic) may be flagged because the nonce management is outside the program’s HIR.
  • One-time-use programs where the account itself is closed after use (effectively making the signature non-replayable) may be flagged.

False negatives:

  • Nonce management implemented via a bitmap (marking used signatures as spent) rather than a counter is not detected as a nonce pattern.
  • Programs that check nonce validity via a CPI to another program are not detected because the nonce check is in an external program’s HIR.

References