solana-vulnerability-scanner
Scans Solana programs for 6 critical vulnerabilities including arbitrary CPI, improper PDA validation, missing signer/ownership checks, and sysvar spoofing. Use when auditing Solana/Anchor programs.
Install
npx skills add https://github.com/trailofbits/skills/tree/main/plugins/building-secure-contracts/skills/solana-vulnerability-scanner
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
git clone https://github.com/trailofbits/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Solana Vulnerability Scanner
1. Purpose
Systematically scan Solana programs (native and Anchor framework) for platform-specific security vulnerabilities related to cross-program invocations, account validation, and program-derived addresses. This skill encodes 6 critical vulnerability patterns unique to Solana's account model.
2. When to Use This Skill
- Auditing Solana programs (native Rust or Anchor)
- Reviewing cross-program invocation (CPI) logic
- Validating program-derived address (PDA) implementations
- Pre-launch security assessment of Solana protocols
- Reviewing account validation patterns
- Assessing instruction introspection logic
3. Platform Detection
File Extensions & Indicators
- Rust files:
.rs
Language/Framework Markers
// Native Solana program indicators
use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
pubkey::Pubkey,
program::invoke,
program::invoke_signed,
};
entrypoint!(process_instruction);
// Anchor framework indicators
use anchor_lang::prelude::*;
#[program]
pub mod my_program {
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
// Program logic
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub authority: Signer<'info>,
}
// Common patterns
AccountInfo, Pubkey
invoke(), invoke_signed()
Signer<'info>, Account<'info>
#[account(...)] with constraints
seeds, bump
Project Structure
programs/*/src/lib.rs- Program implementationAnchor.toml- Anchor configurationCargo.tomlwithsolana-programoranchor-langtests/- Program tests
Tool Support
- Trail of Bits Solana Lints: Rust linters for Solana
- Installation: Add to Cargo.toml
- anchor test: Built-in testing framework
- Solana Test Validator: Local testing environment
4. How This Skill Works
When invoked, I will:
- Search your codebase for Solana/Anchor programs
- Analyze each program for the 6 vulnerability patterns
- Report findings with file references and severity, above them a coverage table carrying a verdict for every pattern
- Provide fixes for each identified issue
- Check account validation and CPI security
5. Example Output
6. Vulnerability Patterns (6 Patterns)
I check for 6 critical vulnerability patterns unique to Solana. For detailed detection patterns, code examples, mitigations, and testing strategies, see VULNERABILITY_PATTERNS.md.
Pattern Summary:
- Arbitrary CPI ⚠️ CRITICAL - User-controlled program IDs in CPI calls
- Improper PDA Validation ⚠️ CRITICAL - Using create_program_address without canonical bump
- Missing Ownership Check ⚠️ HIGH - Deserializing accounts without owner validation
- Missing Signer Check ⚠️ CRITICAL - Authority operations without is_signer check
- Sysvar Account Check ⚠️ HIGH - Spoofed sysvar accounts (pre-Solana 1.8.1)
- Improper Instruction Introspection ⚠️ MEDIUM - Absolute indexes allowing reuse
For complete vulnerability patterns with code examples, see VULNERABILITY_PATTERNS.md.
7. Scanning Workflow
Step 1: Platform Identification
- Verify Solana program (native or Anchor)
- Check Solana version (1.8.1+ for sysvar security)
- Locate program source (
programs/*/src/lib.rs) - Identify framework (native vs Anchor)
Step 2: CPI Security Review
# Find all CPI calls
rg "invoke\(|invoke_signed\(" programs/
# Check for program ID validation before each
# Should see program ID checks immediately before invoke
For each CPI:
- Program ID validated before invocation
- Cannot pass user-controlled program accounts
- Anchor: Uses
Program<'info, T>type
Step 3: PDA Validation Check
# Find PDA usage
rg "find_program_address|create_program_address" programs/
rg "seeds.*bump" programs/
# Anchor: Check for seeds constraints
rg "#\[account.*seeds" programs/
For each PDA:
- Uses
find_program_address()or Anchorseedsconstraint - Bump seed stored and reused
- Not using user-provided bump
Step 4: Account Validation Sweep
# Find account deserialization
rg "try_from_slice|try_deserialize" programs/
# Should see owner checks before deserialization
rg "\.owner\s*==|\.owner\s*!=" programs/
For each account used:
- Owner validated before deserialization
- Signer check for authority accounts
- Anchor: Uses
Account<'info, T>andSigner<'info>
Step 5: Instruction Introspection Review
# Find instruction introspection usage
rg "load_instruction_at|load_current_index|get_instruction_relative" programs/
# Check for checked versions
rg "load_instruction_at_checked|load_current_index_checked" programs/
- Using checked functions (Solana 1.8.1+)
- Using relative indexing
- Proper correlation validation
Step 6: Trail of Bits Solana Lints
# Add to Cargo.toml
[dependencies]
solana-program = "1.17" # Use latest version
[lints.clippy]
# Enable Solana-specific lints
# (Trail of Bits solana-lints if available)
8. Reporting Format
Coverage Table
Report on every pattern in §6, whether or not it turned anything up. Emit this table above the findings, with all 6 rows present:
| # | Pattern | Verdict | Evidence |
|---|---|---|---|
| 1 | Arbitrary CPI | n/a |
this program makes no cross-program invocations |
| 2 | Improper PDA Validation | ||
| 3 | Missing Ownership Check | ||
| 4 | Missing Signer Check | ||
| 5 | Sysvar Account Check | ||
| 6 | Improper Instruction Introspection |
Each verdict is one of:
found— citefile:lineand write the finding up in full below.clear— the pattern applies to this program and the program handles it. Name the constraint, account type, or check you searched for, so a reader can repeat the search.n/a— the pattern cannot apply here. Give the reason in one clause ("this program makes no CPI calls"). Not having looked is notn/a. Pattern 5 is version-scoped (pre-Solana 1.8.1): cite thesolana-programversion the program targets rather than dropping the row, since "targets 1.17, fixed upstream" and "did not look" are otherwise the same answer.
A table with fewer than 6 rows is an incomplete scan and must be reported as one. A row whose Verdict cell is empty is incomplete in the same way: row 1 above is filled in to show the shape, and every row is filled in the same way before the report is done. Six clear verdicts is a
result a reader can act on. A report that covers two patterns and says nothing about the other four reads
exactly like a clean program, and that is the failure this table exists to prevent.
Finding Template
## [CRITICAL] Arbitrary CPI - Unchecked Program ID
**Location**: `programs/vault/src/lib.rs:145-160` (withdraw function)
**Description**:
The `withdraw` function performs a CPI to transfer SPL tokens without validating that the provided `token_program` account is actually the SPL Token program. An attacker can provide a malicious program that appears to perform a transfer but actually steals tokens or performs unauthorized actions.
**Vulnerable Code**:
```rust
// lib.rs, line 145
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let token_program = &ctx.accounts.token_program;
// WRONG: No validation of token_program.key()!
invoke(
&spl_token::instruction::transfer(...),
&[
ctx.accounts.vault.to_account_info(),
ctx.accounts.destination.to_account_info(),
ctx.accounts.authority.to_account_info(),
token_program.to_account_info(), // UNVALIDATED
],
)?;
Ok(())
}
```
**Attack Scenario**:
1. Attacker deploys malicious "token program" that logs transfer instruction but doesn't execute it
2. Attacker calls withdraw() providing malicious program as token_program
3. Vault's authority signs the transaction
4. Malicious program receives CPI with vault's signature
5. Malicious program can now impersonate vault and drain real tokens
**Recommendation**:
Use Anchor's `Program<'info, Token>` type:
```rust
use anchor_spl::token::{Token, Transfer};
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(mut)]
pub vault: Account<'info, TokenAccount>,
#[account(mut)]
pub destination: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>, // Validates program ID automatically
}
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let cpi_accounts = Transfer {
from: ctx.accounts.vault.to_account_info(),
to: ctx.accounts.destination.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
};
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
cpi_accounts,
);
anchor_spl::token::transfer(cpi_ctx, amount)?;
Ok(())
}
```
**References**:
- building-secure-contracts/not-so-smart-contracts/solana/arbitrary_cpi
- Trail of Bits lint: `unchecked-cpi-program-id`
9. Priority Guidelines
Critical (Immediate Fix Required)
- Arbitrary CPI (attacker-controlled program execution)
- Improper PDA validation (account spoofing)
- Missing signer check (unauthorized access)
High (Fix Before Launch)
- Missing ownership check (fake account data)
- Sysvar account check (authentication bypass, pre-1.8.1)
Medium (Address in Audit)
- Improper instruction introspection (logic bypass)
10. Testing Recommendations
Unit Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_rejects_wrong_program_id() {
// Provide wrong program ID, should fail
}
#[test]
#[should_panic]
fn test_rejects_non_canonical_pda() {
// Provide non-canonical bump, should fail
}
#[test]
#[should_panic]
fn test_requires_signer() {
// Call without signature, should fail
}
}
Integration Tests (Anchor)
import * as anchor from "@coral-xyz/anchor";
describe("security tests", () => {
it("rejects arbitrary CPI", async () => {
const fakeTokenProgram = anchor.web3.Keypair.generate();
try {
await program.methods
.withdraw(amount)
.accounts({
tokenProgram: fakeTokenProgram.publicKey, // Wrong program
})
.rpc();
assert.fail("Should have rejected fake program");
} catch (err) {
// Expected to fail
}
});
});
Solana Test Validator
# Run local validator for testing
solana-test-validator
# Deploy and test program
anchor test
11. Additional Resources
- Building Secure Contracts:
building-secure-contracts/not-so-smart-contracts/solana/ - Trail of Bits Solana Lints: https://github.com/trailofbits/solana-lints
- Anchor Documentation: https://www.anchor-lang.com/
- Solana Program Library: https://github.com/solana-labs/solana-program-library
- Solana Cookbook: https://solanacookbook.com/
12. Quick Reference Checklist
Before completing Solana program audit:
CPI Security (CRITICAL):
- ALL CPI calls validate program ID before
invoke() - Cannot use user-provided program accounts
- Anchor: Uses
Program<'info, T>type
PDA Security (CRITICAL):
- PDAs use
find_program_address()or Anchorseedsconstraint - Bump seed stored and reused (not user-provided)
- PDA accounts validated against canonical address
Account Validation (HIGH):
- ALL accounts check owner before deserialization
- Native: Validates
account.owner == expected_program_id - Anchor: Uses
Account<'info, T>type
Signer Validation (CRITICAL):
- ALL authority accounts check
is_signer - Native: Validates
account.is_signer == true - Anchor: Uses
Signer<'info>type
Sysvar Security (HIGH):
- Using Solana 1.8.1+
- Using checked functions:
load_instruction_at_checked() - Sysvar addresses validated
Instruction Introspection (MEDIUM):
- Using relative indexes for correlation
- Proper validation between related instructions
- Cannot reuse same instruction across multiple calls
Testing:
- Unit tests cover all account validation
- Integration tests with malicious inputs
- Local validator testing completed
- Trail of Bits lints enabled and passing
- Coverage table emitted with all 6 rows, each carrying a verdict of
found,clearorn/awith a reason
13. Rationalizations to Reject
- "The program is small, so most patterns obviously don't apply." Obvious to whom? An
n/acosts one clause and makes the judgment reviewable. Silence records nothing, and a reader cannot tell it apart from not having checked. - "The Trail of Bits lints pass, so the program is clean." The lints cover a subset of these 6 patterns. A clean lint run is one row of evidence, not a verdict on the patterns it never examined. Say which patterns it covered.
- "I checked the patterns that matter for this program." Deciding which patterns matter is the scan, not a precondition for starting it. Rank by severity after the table is complete, not by leaving rows out.
- "No findings, so there is nothing to report." A zero-finding scan still emits the full coverage table. That table is the deliverable: it is what distinguishes a program that was examined from one that was glanced at.
- "Anchor handles account validation." Name the constraint. Anchor validates what the account struct
declares and nothing more:
#[account(mut)]is not an ownership check, andUncheckedAccountopts out entirely. Cite the attribute, not the framework. - "The PDA derivation makes it safe." Derivation is not validation.
create_program_addresswithout the canonical bump admits multiple valid addresses, which is pattern 2 in full.
Files (skills)
-
agents
-
openai.yaml 258 B
interface: display_name: "Solana Vulnerability Scanner" short_description: "Find security vulnerabilities in Solana and Anchor programs" icon_small: "assets/trail-of-bits-mark.svg" icon_large: "assets/trail-of-bits-mark.svg" brand_color: "#D83A34"
-
-
assets
-
trail-of-bits-mark.svg 3 KB · in bundle
-
-
resources
-
VULNERABILITY_PATTERNS.md 19.9 KB
### 6.1 ARBITRARY CPI (Cross-Program Invocation) ⚠️ CRITICAL **Description**: Using `invoke()` or `invoke_signed()` with user-controlled program IDs allows attackers to call malicious programs instead of the intended program. **Detection Patterns**: ```rust // VULNERABLE: User-provided program ID without validation pub fn transfer_tokens( ctx: Context<TransferTokens>, amount: u64, ) -> Result<()> { // User provides token_program account let token_program = &ctx.accounts.token_program; // WRONG: No check that token_program.key() == spl_token::ID! invoke( &spl_token::instruction::transfer(...), &[ ctx.accounts.from.to_account_info(), ctx.accounts.to.to_account_info(), token_program.to_account_info(), // ATTACKER CONTROLLED! ], )?; Ok(()) } // VULNERABLE: Native Solana without validation pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let token_program = next_account_info(accounts_iter)?; // WRONG: No validation of token_program.key invoke( &transfer_instruction, &[from_account, to_account, token_program], // Unvalidated! )?; Ok(()) } ``` **What to Check**: - [ ] ALL CPI program IDs validated before `invoke()` or `invoke_signed()` - [ ] Validation: `program.key() == EXPECTED_PROGRAM_ID` - [ ] Cannot pass arbitrary program accounts from user - [ ] Anchor: Use `Program<'info, T>` type with constraint **Mitigation**: ```rust // SECURE: Validate program ID (Native) use spl_token; pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let token_program = next_account_info(accounts_iter)?; // CRITICAL: Validate program ID if token_program.key != &spl_token::ID { return Err(ProgramError::IncorrectProgramId); } // Safe to invoke invoke( &spl_token::instruction::transfer(...), &[from_account, to_account, token_program], )?; Ok(()) } // SECURE: Use Anchor Program type with constraint use anchor_spl::token::{Token, TokenAccount}; #[derive(Accounts)] pub struct TransferTokens<'info> { #[account(mut)] pub from: Account<'info, TokenAccount>, #[account(mut)] pub to: Account<'info, TokenAccount>, pub authority: Signer<'info>, // Program<'info, Token> automatically validates program ID pub token_program: Program<'info, Token>, } pub fn transfer_tokens(ctx: Context<TransferTokens>, amount: u64) -> Result<()> { // Anchor ensures token_program.key() == Token::id() let cpi_accounts = Transfer { from: ctx.accounts.from.to_account_info(), to: ctx.accounts.to.to_account_info(), authority: ctx.accounts.authority.to_account_info(), }; let cpi_ctx = CpiContext::new( ctx.accounts.token_program.to_account_info(), cpi_accounts, ); anchor_spl::token::transfer(cpi_ctx, amount)?; Ok(()) } ``` **Tool Detection**: - Trail of Bits lint: `unchecked-cpi-program-id` - Look for: `invoke()` without prior program ID check **References**: building-secure-contracts/not-so-smart-contracts/solana/arbitrary_cpi --- ### 4.2 IMPROPER PDA VALIDATION ⚠️ CRITICAL **Description**: Program-Derived Addresses (PDAs) can have multiple valid bumps for the same seeds. Using `create_program_address()` without verifying canonical bump allows PDA spoofing attacks. **Detection Patterns**: ```rust // VULNERABLE: Using create_program_address without bump validation pub fn withdraw(ctx: Context<Withdraw>, bump: u8) -> Result<()> { // User provides bump let vault_seeds = &[ b"vault", ctx.accounts.user.key().as_ref(), &[bump], // WRONG: Attacker can provide non-canonical bump! ]; let vault = Pubkey::create_program_address(vault_seeds, ctx.program_id)?; // This vault might not be the canonical PDA! // Attacker could create multiple PDAs and drain wrong vault Ok(()) } // VULNERABLE: Not comparing with find_program_address result pub fn initialize(ctx: Context<Initialize>, bump: u8) -> Result<()> { let pda_seeds = &[b"state", &[bump]]; let pda = Pubkey::create_program_address(pda_seeds, ctx.program_id)?; // WRONG: Not verifying this is the canonical PDA // Should check pda == ctx.accounts.pda_account.key() } ``` **What to Check**: - [ ] PDAs use `find_program_address()` to get canonical bump - [ ] OR `create_program_address()` result compared with expected PDA - [ ] Bump seed stored and reused (not provided by user) - [ ] Anchor: Use `seeds` and `bump` constraints **Mitigation**: ```rust // SECURE: Use find_program_address (Native) pub fn withdraw( program_id: &Pubkey, accounts: &[AccountInfo], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault_account = next_account_info(accounts_iter)?; let user_account = next_account_info(accounts_iter)?; // Find canonical PDA with bump let (vault_pda, bump) = Pubkey::find_program_address( &[b"vault", user_account.key.as_ref()], program_id, ); // Verify provided account matches canonical PDA if vault_account.key != &vault_pda { return Err(ProgramError::InvalidAccountData); } // Use bump for signing let vault_seeds = &[ b"vault", user_account.key.as_ref(), &[bump], ]; invoke_signed( &transfer_instruction, &[vault_account, destination], &[vault_seeds], )?; Ok(()) } // SECURE: Anchor with seeds constraint #[derive(Accounts)] pub struct Withdraw<'info> { #[account( mut, seeds = [b"vault", user.key().as_ref()], bump, // Anchor automatically validates canonical bump )] pub vault: Account<'info, VaultAccount>, pub user: Signer<'info>, } pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { // Anchor has already validated vault is canonical PDA let bump = *ctx.bumps.get("vault").unwrap(); let vault_seeds = &[ b"vault", ctx.accounts.user.key().as_ref(), &[bump], ]; // Safe to use in CPI let signer_seeds = &[&vault_seeds[..]]; // CPI with PDA signer Ok(()) } // BETTER: Store bump in account #[account] pub struct VaultAccount { pub bump: u8, // Store canonical bump pub owner: Pubkey, pub balance: u64, } #[derive(Accounts)] pub struct Initialize<'info> { #[account( init, payer = user, space = 8 + 1 + 32 + 8, seeds = [b"vault", user.key().as_ref()], bump, )] pub vault: Account<'info, VaultAccount>, #[account(mut)] pub user: Signer<'info>, pub system_program: Program<'info, System>, } pub fn initialize(ctx: Context<Initialize>) -> Result<()> { let vault = &mut ctx.accounts.vault; vault.bump = *ctx.bumps.get("vault").unwrap(); // Store canonical bump vault.owner = ctx.accounts.user.key(); vault.balance = 0; Ok(()) } ``` **Tool Detection**: - Trail of Bits lint: `improper-pda-validation` - Look for: `create_program_address` without `find_program_address` comparison **References**: building-secure-contracts/not-so-smart-contracts/solana/pda_validation --- ### 4.3 MISSING OWNERSHIP CHECK ⚠️ HIGH **Description**: Accounts without owner validation can be spoofed by attackers. User provides account with attacker-controlled data, bypassing program logic. **Detection Patterns**: ```rust // VULNERABLE: Deserializing account without owner check pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault_account = next_account_info(accounts_iter)?; // WRONG: No owner check before deserializing! let vault: Vault = Vault::try_from_slice(&vault_account.data.borrow())?; // vault could be fake account owned by attacker with fake balance! if vault.balance >= amount { // Process withdrawal using fake balance } Ok(()) } // VULNERABLE: Anchor without owner constraint #[derive(Accounts)] pub struct Withdraw<'info> { /// CHECK: This is unsafe - no owner validation! pub vault: AccountInfo<'info>, } ``` **What to Check**: - [ ] ALL accounts validated for correct owner before deserialization - [ ] Native: Check `account.owner == expected_program_id` - [ ] Anchor: Use `Account<'info, T>` type (automatic owner check) - [ ] System accounts: Check `account.owner == system_program::ID` - [ ] Token accounts: Check `account.owner == spl_token::ID` **Mitigation**: ```rust // SECURE: Validate owner before deserializing (Native) pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault_account = next_account_info(accounts_iter)?; // CRITICAL: Validate owner if vault_account.owner != program_id { return Err(ProgramError::IncorrectProgramId); } // Safe to deserialize - we own this account let vault: Vault = Vault::try_from_slice(&vault_account.data.borrow())?; Ok(()) } // SECURE: Use Anchor Account type (automatic validation) #[derive(Accounts)] pub struct Withdraw<'info> { #[account(mut)] pub vault: Account<'info, VaultAccount>, // Anchor checks owner automatically pub user: Signer<'info>, } // For third-party program accounts #[derive(Accounts)] pub struct ProcessToken<'info> { #[account(mut)] pub token_account: Account<'info, TokenAccount>, // Validates owner == Token program pub token_program: Program<'info, Token>, } ``` **Tool Detection**: - Trail of Bits lint: `missing-ownership-check` - Look for: Deserialization without owner validation **References**: building-secure-contracts/not-so-smart-contracts/solana/ownership_check --- ### 4.4 MISSING SIGNER CHECK ⚠️ CRITICAL **Description**: Sensitive operations without `is_signer` validation allow unauthorized users to call functions intended for specific authorities. **Detection Patterns**: ```rust // VULNERABLE: No signer check on authority account pub fn withdraw( program_id: &Pubkey, accounts: &[AccountInfo], amount: u64, ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault = next_account_info(accounts_iter)?; let authority = next_account_info(accounts_iter)?; // WRONG: No check that authority.is_signer == true! // Attacker can provide any authority account and withdraw let vault_data: Vault = Vault::try_from_slice(&vault.data.borrow())?; // Check authority matches (but attacker provided this!) if vault_data.authority != *authority.key { return Err(ProgramError::InvalidAccountData); } // Process withdrawal - ATTACKER CAN CALL THIS! Ok(()) } // VULNERABLE: Anchor without Signer type #[derive(Accounts)] pub struct Withdraw<'info> { #[account(mut)] pub vault: Account<'info, VaultAccount>, /// CHECK: Missing signer constraint! pub authority: AccountInfo<'info>, } ``` **What to Check**: - [ ] ALL authority accounts validated with `is_signer` - [ ] Native: Check `account.is_signer == true` - [ ] Anchor: Use `Signer<'info>` type (automatic validation) - [ ] Access-controlled functions require signer check **Mitigation**: ```rust // SECURE: Check is_signer (Native) pub fn withdraw( program_id: &Pubkey, accounts: &[AccountInfo], amount: u64, ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let vault = next_account_info(accounts_iter)?; let authority = next_account_info(accounts_iter)?; // CRITICAL: Verify authority signed the transaction if !authority.is_signer { return Err(ProgramError::MissingRequiredSignature); } let vault_data: Vault = Vault::try_from_slice(&vault.data.borrow())?; // Now safe to check authority matches if vault_data.authority != *authority.key { return Err(ProgramError::InvalidAccountData); } // Process withdrawal - only if authority signed Ok(()) } // SECURE: Use Anchor Signer type (automatic validation) #[derive(Accounts)] pub struct Withdraw<'info> { #[account( mut, has_one = authority, // Also validate vault.authority == authority.key() )] pub vault: Account<'info, VaultAccount>, pub authority: Signer<'info>, // Anchor checks is_signer automatically } pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { // Anchor has already validated: // 1. authority.is_signer == true // 2. vault.authority == authority.key() // Safe to proceed with withdrawal Ok(()) } // For admin functions #[derive(Accounts)] pub struct UpdateConfig<'info> { #[account( mut, has_one = admin, )] pub config: Account<'info, Config>, pub admin: Signer<'info>, // Must be signer } ``` **Tool Detection**: - Trail of Bits lint: `missing-signer-check` - Look for: Authority checks without `is_signer` validation **References**: building-secure-contracts/not-so-smart-contracts/solana/signer_check --- ### 4.5 SYSVAR ACCOUNT CHECK ⚠️ HIGH (Pre-Solana 1.8.1) **Description**: In Solana versions before 1.8.1, users can pass spoofed sysvar accounts (Instructions, Clock, etc.) to bypass authentication. This affects `load_instruction_at()` and similar functions. **Detection Patterns**: ```rust // VULNERABLE: Using unchecked load functions (Solana < 1.8.1) use solana_program::sysvar::instructions; pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let instructions_sysvar = next_account_info(accounts_iter)?; // WRONG: load_instruction_at() doesn't validate sysvar account! let current_ix = instructions::load_instruction_at(0, instructions_sysvar)?; // Attacker can provide fake Instructions sysvar with spoofed instruction data! // Bypass authentication by faking previous instruction } // VULNERABLE: load_current_index() without validation let current_index = instructions::load_current_index(instructions_sysvar)?; ``` **What to Check**: - [ ] Using Solana 1.8.1 or higher - [ ] Using checked functions: `load_instruction_at_checked()`, `load_current_index_checked()` - [ ] NOT using: `load_instruction_at()`, `load_current_index()` (unchecked versions) - [ ] Sysvar accounts validated against known addresses **Mitigation**: ```rust // OPTION 1: Upgrade to Solana 1.8.1+ and use checked functions use solana_program::sysvar::instructions; pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let instructions_sysvar = next_account_info(accounts_iter)?; // SECURE: load_instruction_at_checked validates sysvar account let current_ix = instructions::load_instruction_at_checked( 0, instructions_sysvar )?; // Safe - sysvar is validated Ok(()) } // OPTION 2: Manual validation (if on old Solana version) pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); let instructions_sysvar = next_account_info(accounts_iter)?; // Validate sysvar account address if instructions_sysvar.key != &solana_program::sysvar::instructions::ID { return Err(ProgramError::InvalidAccountData); } // Now safe to use unchecked function let current_ix = instructions::load_instruction_at(0, instructions_sysvar)?; Ok(()) } // SECURE: Anchor with address constraint #[derive(Accounts)] pub struct CheckInstructions<'info> { /// CHECK: Validated against known sysvar address #[account(address = solana_program::sysvar::instructions::ID)] pub instructions_sysvar: AccountInfo<'info>, } ``` **Tool Detection**: - Trail of Bits lint: `unchecked-sysvar-account` - Look for: `load_instruction_at()` instead of `load_instruction_at_checked()` **References**: building-secure-contracts/not-so-smart-contracts/solana/sysvar_get --- ### 4.6 IMPROPER INSTRUCTION INTROSPECTION ⚠️ MEDIUM **Description**: Using absolute indexes in instruction introspection allows reusing the same instruction context across multiple program calls. Should use relative indexes to ensure proper correlation. **Detection Patterns**: ```rust // VULNERABLE: Absolute index in load_instruction_at use solana_program::sysvar::instructions; pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let instructions_sysvar = &accounts[0]; // WRONG: Using absolute index 0 let prev_ix = instructions::load_instruction_at_checked(0, instructions_sysvar)?; // Attacker can craft transaction where instruction 0 is benign, // but instruction 1 (malicious) also loads instruction 0 for validation // Same instruction 0 used to validate both instruction 0 and 1! } // VULNERABLE: No correlation between instructions pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> { let instructions_sysvar = &ctx.accounts.instructions_sysvar; // Check that previous instruction was deposit let prev_ix = instructions::load_instruction_at_checked(0, instructions_sysvar)?; // WRONG: Not checking that prev_ix is actually related to current instruction // Could be completely unrelated instruction from earlier in transaction } ``` **What to Check**: - [ ] Use relative indexes: `get_instruction_relative(-1, ...)` for previous instruction - [ ] Absolute indexes only when specifically intended - [ ] Validate correlation between current and referenced instructions - [ ] Cannot reuse same instruction validation across multiple calls **Mitigation**: ```rust // SECURE: Use relative indexing use solana_program::sysvar::instructions; pub fn process_instruction( program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { let instructions_sysvar = &accounts[0]; // Get current instruction index let current_index = instructions::load_current_index_checked(instructions_sysvar)?; // SECURE: Get immediately preceding instruction with relative index if current_index > 0 { let prev_ix = instructions::load_instruction_at_checked( (current_index - 1) as usize, instructions_sysvar )?; // This is guaranteed to be the instruction immediately before current // Validate prev_ix is the expected setup instruction } Ok(()) } // BETTER: Use get_instruction_relative (if available) let prev_ix = instructions::get_instruction_relative(-1, instructions_sysvar)?; // Explicitly relative to current instruction // SECURE: Additional correlation validation pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> { let instructions_sysvar = &ctx.accounts.instructions_sysvar; let current_ix_index = instructions::load_current_index_checked(instructions_sysvar)?; // Must have previous instruction require!(current_ix_index > 0, ErrorCode::NoPreviousInstruction); // Get previous instruction let prev_ix_index = current_ix_index - 1; let prev_ix = instructions::load_instruction_at_checked( prev_ix_index as usize, instructions_sysvar )?; // Validate previous instruction is deposit to same program require!( prev_ix.program_id == ctx.program_id, ErrorCode::InvalidPreviousProgram ); // Validate accounts in previous instruction match expectations // This ensures proper correlation between deposit and withdraw require!( prev_ix.accounts[0].pubkey == ctx.accounts.vault.key(), ErrorCode::VaultMismatch ); Ok(()) } ``` **References**: building-secure-contracts/not-so-smart-contracts/solana/insecure_instruction_introspection ---
-
-
SKILL.md 14.1 KB
--- name: solana-vulnerability-scanner description: Scans Solana programs for 6 critical vulnerabilities including arbitrary CPI, improper PDA validation, missing signer/ownership checks, and sysvar spoofing. Use when auditing Solana/Anchor programs. --- # Solana Vulnerability Scanner ## 1. Purpose Systematically scan Solana programs (native and Anchor framework) for platform-specific security vulnerabilities related to cross-program invocations, account validation, and program-derived addresses. This skill encodes 6 critical vulnerability patterns unique to Solana's account model. ## 2. When to Use This Skill - Auditing Solana programs (native Rust or Anchor) - Reviewing cross-program invocation (CPI) logic - Validating program-derived address (PDA) implementations - Pre-launch security assessment of Solana protocols - Reviewing account validation patterns - Assessing instruction introspection logic ## 3. Platform Detection ### File Extensions & Indicators - **Rust files**: `.rs` ### Language/Framework Markers ```rust // Native Solana program indicators use solana_program::{ account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, program::invoke, program::invoke_signed, }; entrypoint!(process_instruction); // Anchor framework indicators use anchor_lang::prelude::*; #[program] pub mod my_program { pub fn initialize(ctx: Context<Initialize>) -> Result<()> { // Program logic } } #[derive(Accounts)] pub struct Initialize<'info> { #[account(mut)] pub authority: Signer<'info>, } // Common patterns AccountInfo, Pubkey invoke(), invoke_signed() Signer<'info>, Account<'info> #[account(...)] with constraints seeds, bump ``` ### Project Structure - `programs/*/src/lib.rs` - Program implementation - `Anchor.toml` - Anchor configuration - `Cargo.toml` with `solana-program` or `anchor-lang` - `tests/` - Program tests ### Tool Support - **Trail of Bits Solana Lints**: Rust linters for Solana - Installation: Add to Cargo.toml - **anchor test**: Built-in testing framework - **Solana Test Validator**: Local testing environment --- ## 4. How This Skill Works When invoked, I will: 1. **Search your codebase** for Solana/Anchor programs 2. **Analyze each program** for the 6 vulnerability patterns 3. **Report findings** with file references and severity, above them a coverage table carrying a verdict for every pattern 4. **Provide fixes** for each identified issue 5. **Check account validation** and CPI security --- ## 5. Example Output --- ## 6. Vulnerability Patterns (6 Patterns) I check for 6 critical vulnerability patterns unique to Solana. For detailed detection patterns, code examples, mitigations, and testing strategies, see [VULNERABILITY_PATTERNS.md](resources/VULNERABILITY_PATTERNS.md). ### Pattern Summary: 1. **Arbitrary CPI** ⚠️ CRITICAL - User-controlled program IDs in CPI calls 2. **Improper PDA Validation** ⚠️ CRITICAL - Using create_program_address without canonical bump 3. **Missing Ownership Check** ⚠️ HIGH - Deserializing accounts without owner validation 4. **Missing Signer Check** ⚠️ CRITICAL - Authority operations without is_signer check 5. **Sysvar Account Check** ⚠️ HIGH - Spoofed sysvar accounts (pre-Solana 1.8.1) 6. **Improper Instruction Introspection** ⚠️ MEDIUM - Absolute indexes allowing reuse For complete vulnerability patterns with code examples, see [VULNERABILITY_PATTERNS.md](resources/VULNERABILITY_PATTERNS.md). ## 7. Scanning Workflow ### Step 1: Platform Identification 1. Verify Solana program (native or Anchor) 2. Check Solana version (1.8.1+ for sysvar security) 3. Locate program source (`programs/*/src/lib.rs`) 4. Identify framework (native vs Anchor) ### Step 2: CPI Security Review ```bash # Find all CPI calls rg "invoke\(|invoke_signed\(" programs/ # Check for program ID validation before each # Should see program ID checks immediately before invoke ``` For each CPI: - [ ] Program ID validated before invocation - [ ] Cannot pass user-controlled program accounts - [ ] Anchor: Uses `Program<'info, T>` type ### Step 3: PDA Validation Check ```bash # Find PDA usage rg "find_program_address|create_program_address" programs/ rg "seeds.*bump" programs/ # Anchor: Check for seeds constraints rg "#\[account.*seeds" programs/ ``` For each PDA: - [ ] Uses `find_program_address()` or Anchor `seeds` constraint - [ ] Bump seed stored and reused - [ ] Not using user-provided bump ### Step 4: Account Validation Sweep ```bash # Find account deserialization rg "try_from_slice|try_deserialize" programs/ # Should see owner checks before deserialization rg "\.owner\s*==|\.owner\s*!=" programs/ ``` For each account used: - [ ] Owner validated before deserialization - [ ] Signer check for authority accounts - [ ] Anchor: Uses `Account<'info, T>` and `Signer<'info>` ### Step 5: Instruction Introspection Review ```bash # Find instruction introspection usage rg "load_instruction_at|load_current_index|get_instruction_relative" programs/ # Check for checked versions rg "load_instruction_at_checked|load_current_index_checked" programs/ ``` - [ ] Using checked functions (Solana 1.8.1+) - [ ] Using relative indexing - [ ] Proper correlation validation ### Step 6: Trail of Bits Solana Lints ```toml # Add to Cargo.toml [dependencies] solana-program = "1.17" # Use latest version [lints.clippy] # Enable Solana-specific lints # (Trail of Bits solana-lints if available) ``` --- ## 8. Reporting Format ### Coverage Table Report on every pattern in §6, whether or not it turned anything up. Emit this table above the findings, with all 6 rows present: | # | Pattern | Verdict | Evidence | |---|---------|---------|----------| | 1 | Arbitrary CPI | `n/a` | this program makes no cross-program invocations | | 2 | Improper PDA Validation | | | | 3 | Missing Ownership Check | | | | 4 | Missing Signer Check | | | | 5 | Sysvar Account Check | | | | 6 | Improper Instruction Introspection | | | Each verdict is one of: - **`found`** — cite `file:line` and write the finding up in full below. - **`clear`** — the pattern applies to this program and the program handles it. Name the constraint, account type, or check you searched for, so a reader can repeat the search. - **`n/a`** — the pattern cannot apply here. Give the reason in one clause ("this program makes no CPI calls"). Not having looked is not `n/a`. Pattern 5 is version-scoped (pre-Solana 1.8.1): cite the `solana-program` version the program targets rather than dropping the row, since "targets 1.17, fixed upstream" and "did not look" are otherwise the same answer. A table with fewer than 6 rows is an incomplete scan and must be reported as one. A row whose Verdict cell is empty is incomplete in the same way: row 1 above is filled in to show the shape, and every row is filled in the same way before the report is done. Six `clear` verdicts is a result a reader can act on. A report that covers two patterns and says nothing about the other four reads exactly like a clean program, and that is the failure this table exists to prevent. ### Finding Template ````markdown ## [CRITICAL] Arbitrary CPI - Unchecked Program ID **Location**: `programs/vault/src/lib.rs:145-160` (withdraw function) **Description**: The `withdraw` function performs a CPI to transfer SPL tokens without validating that the provided `token_program` account is actually the SPL Token program. An attacker can provide a malicious program that appears to perform a transfer but actually steals tokens or performs unauthorized actions. **Vulnerable Code**: ```rust // lib.rs, line 145 pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { let token_program = &ctx.accounts.token_program; // WRONG: No validation of token_program.key()! invoke( &spl_token::instruction::transfer(...), &[ ctx.accounts.vault.to_account_info(), ctx.accounts.destination.to_account_info(), ctx.accounts.authority.to_account_info(), token_program.to_account_info(), // UNVALIDATED ], )?; Ok(()) } ``` **Attack Scenario**: 1. Attacker deploys malicious "token program" that logs transfer instruction but doesn't execute it 2. Attacker calls withdraw() providing malicious program as token_program 3. Vault's authority signs the transaction 4. Malicious program receives CPI with vault's signature 5. Malicious program can now impersonate vault and drain real tokens **Recommendation**: Use Anchor's `Program<'info, Token>` type: ```rust use anchor_spl::token::{Token, Transfer}; #[derive(Accounts)] pub struct Withdraw<'info> { #[account(mut)] pub vault: Account<'info, TokenAccount>, #[account(mut)] pub destination: Account<'info, TokenAccount>, pub authority: Signer<'info>, pub token_program: Program<'info, Token>, // Validates program ID automatically } pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> { let cpi_accounts = Transfer { from: ctx.accounts.vault.to_account_info(), to: ctx.accounts.destination.to_account_info(), authority: ctx.accounts.authority.to_account_info(), }; let cpi_ctx = CpiContext::new( ctx.accounts.token_program.to_account_info(), cpi_accounts, ); anchor_spl::token::transfer(cpi_ctx, amount)?; Ok(()) } ``` **References**: - building-secure-contracts/not-so-smart-contracts/solana/arbitrary_cpi - Trail of Bits lint: `unchecked-cpi-program-id` ```` --- ## 9. Priority Guidelines ### Critical (Immediate Fix Required) - Arbitrary CPI (attacker-controlled program execution) - Improper PDA validation (account spoofing) - Missing signer check (unauthorized access) ### High (Fix Before Launch) - Missing ownership check (fake account data) - Sysvar account check (authentication bypass, pre-1.8.1) ### Medium (Address in Audit) - Improper instruction introspection (logic bypass) --- ## 10. Testing Recommendations ### Unit Tests ```rust #[cfg(test)] mod tests { use super::*; #[test] #[should_panic] fn test_rejects_wrong_program_id() { // Provide wrong program ID, should fail } #[test] #[should_panic] fn test_rejects_non_canonical_pda() { // Provide non-canonical bump, should fail } #[test] #[should_panic] fn test_requires_signer() { // Call without signature, should fail } } ``` ### Integration Tests (Anchor) ```typescript import * as anchor from "@coral-xyz/anchor"; describe("security tests", () => { it("rejects arbitrary CPI", async () => { const fakeTokenProgram = anchor.web3.Keypair.generate(); try { await program.methods .withdraw(amount) .accounts({ tokenProgram: fakeTokenProgram.publicKey, // Wrong program }) .rpc(); assert.fail("Should have rejected fake program"); } catch (err) { // Expected to fail } }); }); ``` ### Solana Test Validator ```bash # Run local validator for testing solana-test-validator # Deploy and test program anchor test ``` --- ## 11. Additional Resources - **Building Secure Contracts**: `building-secure-contracts/not-so-smart-contracts/solana/` - **Trail of Bits Solana Lints**: https://github.com/trailofbits/solana-lints - **Anchor Documentation**: https://www.anchor-lang.com/ - **Solana Program Library**: https://github.com/solana-labs/solana-program-library - **Solana Cookbook**: https://solanacookbook.com/ --- ## 12. Quick Reference Checklist Before completing Solana program audit: **CPI Security (CRITICAL)**: - [ ] ALL CPI calls validate program ID before `invoke()` - [ ] Cannot use user-provided program accounts - [ ] Anchor: Uses `Program<'info, T>` type **PDA Security (CRITICAL)**: - [ ] PDAs use `find_program_address()` or Anchor `seeds` constraint - [ ] Bump seed stored and reused (not user-provided) - [ ] PDA accounts validated against canonical address **Account Validation (HIGH)**: - [ ] ALL accounts check owner before deserialization - [ ] Native: Validates `account.owner == expected_program_id` - [ ] Anchor: Uses `Account<'info, T>` type **Signer Validation (CRITICAL)**: - [ ] ALL authority accounts check `is_signer` - [ ] Native: Validates `account.is_signer == true` - [ ] Anchor: Uses `Signer<'info>` type **Sysvar Security (HIGH)**: - [ ] Using Solana 1.8.1+ - [ ] Using checked functions: `load_instruction_at_checked()` - [ ] Sysvar addresses validated **Instruction Introspection (MEDIUM)**: - [ ] Using relative indexes for correlation - [ ] Proper validation between related instructions - [ ] Cannot reuse same instruction across multiple calls **Testing**: - [ ] Unit tests cover all account validation - [ ] Integration tests with malicious inputs - [ ] Local validator testing completed - [ ] Trail of Bits lints enabled and passing - [ ] Coverage table emitted with all 6 rows, each carrying a verdict of `found`, `clear` or `n/a` with a reason --- ## 13. Rationalizations to Reject - **"The program is small, so most patterns obviously don't apply."** Obvious to whom? An `n/a` costs one clause and makes the judgment reviewable. Silence records nothing, and a reader cannot tell it apart from not having checked. - **"The Trail of Bits lints pass, so the program is clean."** The lints cover a subset of these 6 patterns. A clean lint run is one row of evidence, not a verdict on the patterns it never examined. Say which patterns it covered. - **"I checked the patterns that matter for this program."** Deciding which patterns matter *is* the scan, not a precondition for starting it. Rank by severity after the table is complete, not by leaving rows out. - **"No findings, so there is nothing to report."** A zero-finding scan still emits the full coverage table. That table is the deliverable: it is what distinguishes a program that was examined from one that was glanced at. - **"Anchor handles account validation."** Name the constraint. Anchor validates what the account struct declares and nothing more: `#[account(mut)]` is not an ownership check, and `UncheckedAccount` opts out entirely. Cite the attribute, not the framework. - **"The PDA derivation makes it safe."** Derivation is not validation. `create_program_address` without the canonical bump admits multiple valid addresses, which is pattern 2 in full.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.