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:
- Protection identification: Scans for nonce increment patterns (
HirStmt::Assignwheredst = dst + 1) and deadline checks (HirStmt::Branchwith a timestamp or slot comparison involving<or>=). - Verification identification: Scans for known signature verification syscalls (names matching
sol_verify_ed25519,secp256k1_verify, or Secp256k1 program invocations). - 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.
Related Detectors
- Missing Signer Check — detects missing transaction signer verification
- Account Reinitialization — detects replay-like account reset attacks that don’t use signatures