← Back to list

Cyfrin Solana Course — Common Bugs

This blog brings me to the end of the series, ending with something really important: common bugs in the Solana ecosystem. This is not a…

Zuhaib Mohammed in Coinmonks · 2026-02-07 09:08 · 4 claps · 6.7 min read
#rust #rust-programming-language #anchor-protocol #solana-network #solana-blockchain
Open on Medium ↗
Wiki topics: GEN · Genomics & Sequencing CRY · Crypto & Web3 💻 · Programming

Cyfrin Solana Course — Common Bugs

This blog brings me to the end of the series, ending with something really important: common bugs in the Solana ecosystem. This is not a comprehensive list, but just enough to give you a good idea about the issues existing in the Solana ecosystem! We discuss 4 types of issues here, mostly relevant to access control themes itself. In blockchain ecosystems, where we are dealing with actual money, writing secure code should come automatically to a developer, and they should be made aware of common issues that exist as well. So here are the four issues, we start one by one.

The four issues we’ll cover are:

  1. Missing Signer Check — Not verifying that an account actually signed the transaction
  2. Missing Authorization Check — Not verifying that the signer is authorized to perform the action
  3. Missing PDA Validation — Not verifying that a PDA matches the expected derivation
  4. Missing Rent Cleanup — Not verifying that only the owner can sweep their funds

Let’s dive into each one!

Issue 1: Missing Signer Check

The Problem

The first vulnerability occurs when a program checks if an account’s public key matches the owner, but doesn’t verify that the account actually signed the transaction. This means an attacker can pass the owner’s public key as a non-signer account and bypass the check.

The Vulnerable Code

pub fn update(accounts: &[AccountInfo], price: u64) -> Result<(), ProgramError> {
    let oracle_account = next_account_info(account_iter)?;
    let signer = next_account_info(account_iter)?;

    let mut oracle = Oracle::try_from_slice(&data)?;

    // ✅ Checks: Is this the owner's pubkey?
    if oracle.owner != *signer.key {
        return Err(ProgramError::IllegalOwner);
    }

    // ❌ MISSING: Does NOT check if signer.is_signer == true

    oracle.price = price;
    Ok(())
}

The code checks if the signer’s key matches the owner, but never verifies that the account actually signed the transaction!

How the Exploit Works

In the test, an attacker can exploit this by passing the owner’s public key as a non-signer account:

let update_ix = Instruction {
    accounts: vec![
        AccountMeta::new(oracle.pubkey(), false),
        AccountMeta::new(owner.pubkey(), false),  // Owner's key, but NOT a signer!
    ],
    data: borsh::to_vec(&Cmd::Update(1234)).unwrap(),
};

// Attacker signs, not the owner!
svm.send_transaction(Transaction::new_signed_with_payer(
    &[update_ix],
    Some(&attacker.pubkey()),
    &[&attacker],  // Attacker signs, owner doesn't
    svm.latest_blockhash(),
))

The program sees owner.pubkey() matches oracle.owner, so it passes the check—even though the owner never signed!

The Fix

Always check both that the account is a signer AND that it’s authorized:

pub fn update(accounts: &[AccountInfo], price: u64) -> Result<(), ProgramError> {
    // ...

    // ✅ Check 1: Must be a signer
    if !signer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // ✅ Check 2: Must be the owner
    if oracle.owner != *signer.key {
        return Err(ProgramError::IllegalOwner);
    }

    oracle.price = price;
    Ok(())
}

Key Takeaway: Always verify is_signer before checking authorization. Anyone can pass any public key, but only the actual owner can sign with their private key.

Issue 2: Missing Authorization Check

The Problem

This is the opposite of Issue 1! The program checks if an account signed the transaction, but doesn’t verify that the signer is actually authorized (e.g., is the owner). This means any signer can perform the action, not just the authorized party.

The Vulnerable Code

pub fn update(accounts: &[AccountInfo], price: u64) -> Result<(), ProgramError> {
    let signer = next_account_info(account_iter)?;
    let mut oracle = Oracle::try_from_slice(&data)?;

    // ✅ Checks: Did this account sign?
    if !signer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // ❌ MISSING: Does NOT check if signer is the owner!

    oracle.price = price;
    Ok(())
}

The code verifies the account signed, but never checks if it’s the owner!

How the Exploit Works

An attacker can simply sign the transaction themselves:

let update_ix = Instruction {
    accounts: vec![
        AccountMeta::new(oracle.pubkey(), false),
        AccountMeta::new(attacker.pubkey(), true),  // Attacker signs!
    ],
    data: borsh::to_vec(&Cmd::Update(1234)).unwrap(),
};

svm.send_transaction(Transaction::new_signed_with_payer(
    &[update_ix],
    Some(&attacker.pubkey()),
    &[&attacker],  // Attacker signs as themselves
    svm.latest_blockhash(),
))

The program sees attacker.is_signer == true, so it passes—even though the attacker isn't the owner!

The Fix

Check both that the account signed AND that it’s authorized:

pub fn update(accounts: &[AccountInfo], price: u64) -> Result<(), ProgramError> {
    // ...

    // ✅ Check 1: Must be a signer
    if !signer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // ✅ Check 2: Must be the owner
    if oracle.owner != *signer.key {
        return Err(ProgramError::IllegalOwner);
    }

    oracle.price = price;
    Ok(())
}

Key Takeaway: Signing a transaction doesn’t mean you’re authorized. Always verify both the signature AND the authorization.

A security review today is cheaper than an incident response tomorrow. Secure your Solana protocol before going live. Book an Audit now!

Issue 3: Missing PDA Validation

The Problem

When working with Program Derived Addresses (PDAs), you must verify that the PDA passed in matches the expected derivation. Without this check, an attacker can pass any PDA owned by your program and drain funds from it.

The Vulnerable Code

In the unlock function, the program accepts a PDA but never verifies it belongs to the payer:

pub fn unlock(program_id: &Pubkey, accounts: &[AccountInfo], bump: u8) -> Result<(), ProgramError> {
    let payer = next_account_info(account_iter)?;
    let pda = next_account_info(account_iter)?;

    // ❌ MISSING: No check that pda matches get_pda(program_id, payer.key, bump)

    // Check if lock expired
    if lock_exp >= now {
        return Err(ProgramError::InvalidArgument);
    }

    // Transfer all lamports from PDA to payer (attacker!)
    let pda_lamports = pda.lamports();
    **pda.try_borrow_mut_lamports()? = 0;
    **payer.try_borrow_mut_lamports()? += pda_lamports;

    Ok(())
}

The function checks if the lock expired, but never verifies the PDA belongs to the payer!

How the Exploit Works

An attacker can pass any PDA owned by the program (like a victim’s PDA) and drain it:

// Victim's PDA (derived from victim's pubkey)
let (victim_pda, bump) = Pubkey::find_program_address(
    &[b"lock", victim.pubkey().as_ref()],
    &program_id,
);

// Attacker passes victim's PDA but signs as themselves!
let unlock_ix = Instruction {
    accounts: vec![
        AccountMeta::new(attacker.pubkey(), true),  // Attacker signs
        AccountMeta::new(victim_pda, false),        // Victim's PDA!
    ],
    data: borsh::to_vec(&Cmd::Unlock { bump }).unwrap(),
};
svm.send_transaction(Transaction::new_signed_with_payer(
    &[unlock_ix],
    Some(&attacker.pubkey()),
    &[&attacker],
    svm.latest_blockhash(),
))

The program sees the lock expired and transfers all funds from the victim’s PDA to the attacker!

The Fix

Always verify the PDA matches the expected derivation:

pub fn unlock(program_id: &Pubkey, accounts: &[AccountInfo], bump: u8) -> Result<(), ProgramError> {
    let payer = next_account_info(account_iter)?;
    let pda = next_account_info(account_iter)?;

    // ✅ FIX: Verify PDA matches expected derivation
    if *pda.key != get_pda(program_id, payer.key, bump)? {
        return Err(ProgramError::InvalidSeeds);
    }

    // Now safe to proceed...
    Ok(())
}

Key Takeaway: Never trust the PDA passed in. Always recalculate it from the seeds and verify it matches. This ensures users can only access their own PDAs.

Issue 4: Missing Rent Cleanup

The Problem

The sweep function is designed to allow owners to withdraw excess SOL from their PDA (above the locked amount). This excess typically comes from rent that accumulates in the account. However, if you don't verify that the payer is the owner, anyone can sweep excess funds from any user's PDA.

The Vulnerable Code

pub fn sweep(program_id: &Pubkey, accounts: &[AccountInfo], bump: u8) -> Result<(), ProgramError> {
    let payer = next_account_info(account_iter)?;
    let owner = next_account_info(account_iter)?;
    let pda = next_account_info(account_iter)?;

    // ✅ Check 1: payer must sign
    if !payer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    // ✅ Check 2: PDA must match owner's PDA
    if *pda.key != get_pda(program_id, owner.key, bump)? {
        return Err(ProgramError::InvalidSeeds);
    }

    // ❌ MISSING: Does NOT check if payer == owner!

    // Calculate excess SOL and transfer to payer
    let diff = pda_lamports - lock_amt;
    **payer.try_borrow_mut_lamports()? += diff;

    Ok(())
}

The code validates the PDA belongs to the owner, but never checks if the payer (who receives the funds) is the owner!

How the Exploit Works

An attacker can pass the owner’s PDA but receive the funds themselves:

let sweep_ix = Instruction {
    accounts: vec![
        AccountMeta::new(attacker.pubkey(), true),  // Attacker receives funds
        AccountMeta::new(owner.pubkey(), false),     // Owner's pubkey (not signing)
        AccountMeta::new(owner_pda, false),          // Owner's PDA
    ],
    data: borsh::to_vec(&Cmd::Sweep { bump }).unwrap(),
};

svm.send_transaction(Transaction::new_signed_with_payer(
    &[sweep_ix],
    Some(&attacker.pubkey()),
    &[&attacker],  // Attacker signs and receives funds
    svm.latest_blockhash(),
))

The program validates the PDA belongs to the owner, then transfers excess funds to the attacker!

The Fix

Ensure only the owner can sweep their own PDA:

pub fn sweep(program_id: &Pubkey, accounts: &[AccountInfo], bump: u8) -> Result<(), ProgramError> {
    let payer = next_account_info(account_iter)?;
    let owner = next_account_info(account_iter)?;
    let pda = next_account_info(account_iter)?;

    if !payer.is_signer {
        return Err(ProgramError::MissingRequiredSignature);
    }

    if *pda.key != get_pda(program_id, owner.key, bump)? {
        return Err(ProgramError::InvalidSeeds);
    }

    // ✅ FIX: Only owner can sweep their own PDA
    if payer.key != owner.key {
        return Err(ProgramError::IllegalOwner);
    }

    // Now safe to transfer excess to payer (who is the owner)
    Ok(())
}

Key Takeaway: Even if you validate the PDA belongs to someone, always verify that the person receiving funds is actually that owner. When implementing rent cleanup functions, ensure only the account owner can reclaim their excess funds. Don’t let anyone sweep other people’s excess funds!

Do not let preventable bugs become public incidents. Review your Solana program before users interact with it. Book a Consultation.

Real-World Impact

These vulnerabilities might seem simple, but they can lead to complete loss of funds:

  • Missing Signer Check: Attackers can manipulate state without signing
  • Missing Authorization Check: Any user can perform admin actions
  • Missing PDA Validation: Attackers can drain any PDA owned by the program
  • Missing Rent Cleanup: Attackers can steal excess funds from any user’s account

In DeFi applications handling millions of dollars, these bugs can be catastrophic. That’s why security audits and understanding these patterns is so important.

Key Takeaways

  1. Always verify signatures: Check is_signer before trusting an account
  2. Always verify authorization: Check that the signer is authorized (e.g., is the owner)
  3. Always validate PDAs: Recalculate and verify PDAs match expected derivation
  4. Always check ownership: Ensure users can only access their own resources
  5. Think in terms of access control: Every function that modifies state needs proper authorization checks

Conclusion

Learning about these four security vulnerabilities was eye-opening. They’re all related to access control. Ensuring that only authorized users can perform certain actions. In blockchain ecosystems where we’re dealing with real money, writing secure code isn’t optional — it’s essential.

The good news is that these patterns are learnable and preventable. By understanding these common issues, we can write more secure Solana programs from the start. The key is to always think: “Who is authorized to do this? How do I verify that authorization?”

If you’re working through the Cyfrin Solana Course, these CTF challenges are excellent practice for understanding security in Solana development. They teach you to think like an attacker and write code that’s secure by design.

Thanks for reading!


메타데이터
post_id
f2a564ee11ef
slug
cyfrin-solana-course-common-bugs-f2a564ee11ef
url
https://medium.com/coinmonks/cyfrin-solana-course-common-bugs-f2a564ee11ef
canonical_url
https://medium.com/coinmonks/cyfrin-solana-course-common-bugs-f2a564ee11ef
author_url
https://medium.com/@zuhaibmd
status
ok
fetched_at
2026-09-06 15:25:43