Solana Security: The Father-Son-Bank Story

2026-01-31 ยท Web3 Security ยท 15 min read

#solana #security #anchor #pinocchio #rust #blockchain #smart-contracts #vulnerabilities #web3

Let me tell you a story, where every vulnerability maps to a failure in a simple trust model: a Father authorizing transactions, his Son (a PDA), and a Bank (the program) that should verify everything.

Father-Son-Bank security model
TL;DR
  • Father = Signer: Must authorize all sensitive operations
  • Son = PDA: Derived from Father, owned by program
  • Bank = Program: Validates and processes all requests
  • 6 attack patterns map to failures in this trust model
  • Two frameworks: Anchor (high-level) & Pinocchio (low-level)

Why another security guide?

Solana exploits have cost hundreds of millions of dollars. The Wormhole hack ($326M), Cashio ($52M), and countless smaller exploits share common patterns. Yet developers keep making the same mistakes because security docs read like legal contracts.

  • The problem: Security checks feel abstract until you see them exploited.
  • The solution: Map each vulnerability to a real-world scenario you can visualize.
  • The result: 6 attack patterns, 6 story scenes, 32 runnable tests (16 Anchor + 16 Pinocchio).

The Story: Father, Son, and Bank

Character mapping
  • ๐Ÿ‘จ Father = Authority/Signer (must authorize transactions)
  • ๐Ÿ‘ฆ Son = PDA Account (derived from Father, owned by program)
  • ๐Ÿฆ Bank = Solana Program (validates and processes requests)
  • ๐Ÿ’ฐ Treasury = Vault Account (stores value, has an owner field)

Every security vulnerability is a failure of the Bank to verify something. Let's look at three critical examples.

1. Unsigned Allowance Claim (Missing Signer)

๐ŸŽญ The Attack Scene

A stranger walks into the Bank and says "The Father sent me to withdraw his money." The vulnerable Bank says "Okay, here's the money!" without checking ID.

โŒ Vulnerable: Anyone can pass any pubkeyrust
#[derive(Accounts)]
pub struct ClaimCheckVulnerable<'info> {
#[account(mut)]
pub treasury: Account<'info, BankTreasury>,
/// CHECK: DANGEROUS - Anyone can pass any pubkey!
pub father: AccountInfo<'info>, // โŒ No signature required
#[account(mut)]
pub son: Account<'info, Son>,
}
pub fn claim(ctx: Context<ClaimCheckVulnerable>, amount: u64) -> Result<()> {
// No signature verification - ANYONE can call this!
ctx.accounts.treasury.balance -= amount;
ctx.accounts.son.allowance += amount;
Ok(())
}
โœ… Secure: Requires Father's signaturerust
#[derive(Accounts)]
pub struct ClaimCheckSecure<'info> {
#[account(mut)]
pub treasury: Account<'info, BankTreasury>,
pub father: Signer<'info>, // โœ… MUST sign the transaction
#[account(
mut,
seeds = [b"son", father.key().as_ref()],
bump,
constraint = son.father == father.key() @ CustomError::NotFathersSon
)]
pub son: Account<'info, Son>,
}
Key Defense

Use Signer<'info> instead of AccountInfo<'info> for all authority accounts. The transaction will fail if the account hasn't signed.

2. Fake Account Injection (Unsafe PDA)

๐ŸŽญ The Attack Scene

Someone claims "I am the Father's Son, give me access to the family account." The vulnerable Bank says "You say you're a Son? Okay!" without checking the birth certificate (PDA derivation).

โŒ Vulnerable: Accepts any Son accountrust
#[derive(Accounts)]
pub struct AccessSonVulnerable<'info> {
#[account(mut)]
pub son: Account<'info, Son>,
// โŒ No PDA verification! Any Son account is accepted!
}
pub fn access(ctx: Context<AccessSonVulnerable>) -> Result<()> {
// Accepts ANY Son account, not verified to belong to caller
ctx.accounts.son.allowance = 100;
Ok(())
}
โœ… Secure: Verifies PDA derivationrust
#[derive(Accounts)]
pub struct AccessSonSecure<'info> {
#[account(
mut,
seeds = [b"son", authority.key().as_ref()], // โœ… Expected derivation
bump, // โœ… Validates PDA
constraint = son.father == authority.key() @ CustomError::NotFathersSon
)]
pub son: Account<'info, Son>,
pub authority: Signer<'info>, // โœ… Must be the Father
}
Key Defense

Always use seeds + bump constraints for PDA accounts. PDAs are only secure when you verify their derivation.

3. Allowance Overflow (Integer Overflow)

๐ŸŽญ The Attack Scene

Son's allowance is 100. He asks for 18,446,744,073,709,551,615 more. The vulnerable Bank uses wrapping_add and the number wraps around to 99!

โŒ Vulnerable: Wraps on overflowrust
pub fn add_allowance(ctx: Context<AllowanceVulnerable>, amount: u64) -> Result<()> {
let son = &mut ctx.accounts.son;
// โŒ wrapping_add wraps on overflow!
son.allowance = son.allowance.wrapping_add(amount);
// Example: 1 + u64::MAX = 0 (wrapped!)
Ok(())
}
โœ… Secure: checked_add returns error on overflowrust
pub fn add_allowance(ctx: Context<AllowanceSecure>, amount: u64) -> Result<()> {
let son = &mut ctx.accounts.son;
// โœ… checked_add returns None on overflow
son.allowance = son.allowance
.checked_add(amount)
.ok_or(CustomError::ArithmeticOverflow)?;
Ok(())
}
Key Defense

Always use checked_add(), checked_sub(), etc. for financial calculations. Rust's default arithmetic wraps silently in release mode!

Anchor vs Pinocchio: Same Security, Different Styles

The examples above use Anchor, Solana's most popular framework. But what if you need maximum performance and minimal binary size? That's where Pinocchio comes in โ€” a low-level framework that gives you direct control over Solana primitives.

AspectAnchorPinocchio
AbstractionHigh-level macrosRaw Solana primitives
Signer CheckSigner<'info> typeaccount.is_signer()
PDA Validationseeds + bump constraintderive_address() + comparison
Binary Size~200KB+~30KB
Learning CurveEasierSteeper

Here's the same Unsigned Allowance Claim vulnerability in Pinocchio. Notice how you must manually check everything that Anchor does automatically:

โŒ Pinocchio Vulnerable: No is_signer() checkrust
/// VULNERABLE: Claims allowance without verifying Father's signature
pub fn claim_allowance(
_father: &AccountView, // Father account (never checked!)
son_account: &AccountView,
amount: u64,
) -> ProgramResult {
// โŒ NO SIGNER CHECK - father.is_signer() never called!
let son_data = son_account.try_borrow()?;
let current_allowance = Son::read_allowance(&son_data)?;
drop(son_data);
if amount > current_allowance {
return Err(ProgramError::InsufficientFunds);
}
let mut son_data = son_account.try_borrow_mut()?;
Son::write_allowance(&mut son_data, current_allowance - amount)?;
log!("VULNERABLE: Allowance claimed without signature!");
Ok(())
}
โœ… Pinocchio Secure: Manual is_signer() verificationrust
/// SECURE: Claims allowance WITH Father's signature verification
pub fn claim_allowance(
father: &AccountView,
son_account: &AccountView,
amount: u64,
) -> ProgramResult {
// โœ… SIGNER CHECK - Father must have signed the transaction
if !father.is_signer() {
log!("SECURE: Rejecting - Father signature required!");
return Err(ProgramError::MissingRequiredSignature);
}
let son_data = son_account.try_borrow()?;
let current_allowance = Son::read_allowance(&son_data)?;
drop(son_data);
if amount > current_allowance {
return Err(ProgramError::InsufficientFunds);
}
let mut son_data = son_account.try_borrow_mut()?;
Son::write_allowance(&mut son_data, current_allowance - amount)?;
log!("SECURE: Allowance claimed with verified Father signature");
Ok(())
}
Pinocchio Insight

In Anchor, using Signer<'info> automatically fails if the account didn't sign. In Pinocchio, you get an AccountView and must call is_signer() yourself. Forget the check = vulnerability.

PDA verification in Pinocchio is even more manual โ€” you must re-derive the address and compare it yourself:

โœ… Pinocchio: Manual PDA derivation and verificationrust
/// SECURE: Accesses Son account WITH PDA derivation verification
pub fn access_son_account(
program_id: &Address,
father: &AccountView,
son_account: &AccountView,
) -> ProgramResult {
// โœ… Re-derive the expected PDA address
let mut found_pda = false;
for bump in (0..=255u8).rev() {
let seeds = &[
b"son",
father.address().as_array().as_slice(),
&[bump],
];
let derived = pinocchio_pubkey::derive_address(
seeds, None, program_id.as_array()
);
// โœ… Compare provided account with expected PDA
if son_account.address().as_array() == &derived {
found_pda = true;
break;
}
}
if !found_pda {
log!("SECURE: Rejecting - Son account not at expected PDA!");
return Err(ProgramError::InvalidSeeds);
}
log!("SECURE: Son account verified at correct PDA");
Ok(())
}
When to use Pinocchio

Choose Pinocchio when you need maximum CU efficiency,minimal binary size, or are building performance-critical programs. The security patterns are the same โ€” you just implement them manually.

All 6 Vulnerabilities at a Glance

#AttackVulnerableSecure
1Unsigned ClaimAccountInfoSigner
2Treasury TakeoverNo owner checkrequire!(owner == signer)
3Fake InjectionNo PDA checkseeds + bump
4Overflowwrapping_addchecked_add
5Malicious CPIAny programProgram<T> or whitelist
6Twin FraudNo duplicate checka.key() != b.key()

Run the Full Security Template

This post covers 3 of the 6 vulnerabilities. The full repository includes all attack patterns with 32 runnable tests โ€” 16 in Anchor (TypeScript) and 16 in Pinocchio (Rust with LiteSVM).

Clone and run the security templatebash
# Clone the repository
git clone https://github.com/Ovodo/solana-security-template.git
cd solana-security-template
# === ANCHOR (TypeScript tests) ===
anchor build --program-name security_template
anchor test --program-name security_template
# โ†’ 16 passing tests
# === PINOCCHIO (Rust + LiteSVM tests) ===
cd programs/pinocchio
cargo test
# โ†’ 16 passing tests
# Expected output for each:
# 1. Unsigned Allowance Claim (3 tests)
# 2. Treasury Takeover (4 tests)
# 3. Fake Account Injection (3 tests)
# 4. Allowance Overflow (3 tests)
# 5. Twin Account Fraud (3 tests)
What's in the repo
  • 6 vulnerable patterns with exploitable code (Anchor + Pinocchio)
  • 6 secure patterns with best practices (both frameworks)
  • 16 TypeScript tests for Anchor
  • 16 Rust tests for Pinocchio (using LiteSVM)
  • DEEP_DIVE.md with full documentation per vulnerability
  • docs/ folder with Anchor vs Pinocchio comparisons for each pattern
  • Story-based naming: modules like unsigned_allowance_claim, treasury_takeover, fake_account_injection

Key Takeaways

  • Solana security is explicit: unlike Ethereum's msg.sender, you must verify signers, owners, PDAs, and programs manually.
  • Anchor helps but doesn't auto-protect: you must add constraints, use the right types, and think about attack vectors.
  • Pinocchio requires even more vigilance: every check that Anchor automates must be written by hand.
  • Story-based thinking works: if you can't explain who the Father is and why the Bank should check, you probably have a vulnerability.
  • Test attack scenarios: every secure pattern should have a test showing the attack being rejected.

Remember: The Bank's job is to verify everything. Never trust, always verify โ€” whether you're using Anchor macros or writing raw Pinocchio checks.

๐Ÿ‘จ Father must sign โ†’ ๐Ÿ‘ฆ Son must be verified โ†’ ๐Ÿฆ Bank must check everything โ†’ ๐Ÿ’ฐ Treasury stays safe

ยฉ 2026 Ovodo Blog