Program security in anchor framework, Solana smart contract security.

Syed Ashar Saghir
8 min readOct 8, 2024

--

Ashar is a multiple decentralized business creator and Top Rated Solana Developer with 5 years of working experience. His article will introduce you to a range of common security exploits unique to Solana development.

The main aim of this article is to:

  1. Expand your awareness of the Solana programming model and highlight areas where you need to focus to close security loopholes.
  2. Introduce you to the tools provided by Anchor to help keep your programs secure, and show native Rust users how to implement similar techniques on their own.

Even though each security vulnerability may seem “simple,” there’s a lot to discuss. These lessons contain less prose and more code, ensuring you gain a solid understanding of the security risks discussed. Let us begin.

1) Signer Authorization

  • Signer Checks are essential to verify that specific accounts have signed a transaction. Without proper signer checks, unauthorized accounts may execute instructions they shouldn’t be allowed to perform.
  • In Anchor, you can use the Signer account type in your account validation struct to automatically perform a signer check on a given account.
  • Anchor also provides the #[account(signer)] constraint, which automatically verifies that a specified account has signed the transaction.

2) Owner Checks

  • Owner checks ensure that accounts are owned by the expected program. Without owner checks, accounts owned by other programs can be used in an instruction handler.
  • Anchor program account types implement the Owner trait, allowing Account<'info, T> to automatically verify program ownership.
  • You can also use Anchor’s #[account(owner = <expr>)] constraint to define an account's owner when it's external to the current program.
  • To implement an owner check in native Rust, verify that the account’s owner matches the expected program ID.

3) Account Data Matching

  • Use data validation checks to verify that account data matches an expected value. Without appropriate data validation checks, unexpected accounts may be used in an instruction handler.
  • To implement data validation checks in Rust, simply compare the data stored on an account to an expected value.
#[derive(Accounts)]
pub struct UpdateAdmin<'info> {
#[account(
mut,
constraint = admin_config.admin == admin.key()
)]
pub admin_config: Account<'info, AdminConfig>,
#[account(mut)]
pub admin: Signer<'info>,
/// CHECK: This account will not be checked by anchor
pub new_admin: UncheckedAccount<'info>,
}

4) Reinitialization Attacks

  • Prevent Account Reinitialization: Use an account discriminator or initialization flag to prevent an account from being reinitialized and overwriting existing data.
  • Anchor Approach: Simplify this by using Anchor’s init constraint to create an account via a CPI to the system program, automatically setting its discriminator.
  • Native Rust Approach: In native Rust, set an is_initialized flag during account initialization and check it before reinitializing:

Caution

  • Anchor’s init_if_needed constraint, guarded by a feature flag, should be used with caution.It initializes an account only if it hasn't been initialized yet. If the account is already initialized, the instruction handler will still execute, so it's extremely important to include checks in your instruction handler to prevent resetting the account to its initial state.

5) Duplicate Mutable Accounts

  • When an instruction requires two mutable accounts of the same type, an attacker can pass in the same account twice, leading to unintended mutations.
  • To check for duplicate mutable accounts in Rust, simply compare the public keys of the two accounts and throw an error if they are the same.

6) Type Cosplay

  • Discriminators are 8-byte identifiers written to accounts that distinguish between different account types, ensuring programs interact with the correct data.
  • Implement a discriminator in Rust by including a field in the account struct to represent the account type.
use anchor_lang::prelude::*;

declare_id!("G36iNpB591wxFeaeq55qgTwHKJspBrETmgok94oyqgcc");

const DISCRIMINATOR_SIZE: usize = 8;

#[program]
pub mod type_checked {
use super::*;

pub fn initialize_admin(ctx: Context<InitializeAdmin>) -> Result<()> {
ctx.accounts.admin_config.admin = ctx.accounts.admin.key();
Ok(())
}

pub fn initialize_user(ctx: Context<InitializeUser>) -> Result<()> {
ctx.accounts.user_account.user = ctx.accounts.user.key();
Ok(())
}

pub fn update_admin(ctx: Context<UpdateAdmin>) -> Result<()> {
ctx.accounts.admin_config.admin = ctx.accounts.admin.key();
Ok(())
}
}

#[derive(Accounts)]
pub struct InitializeAdmin<'info> {
#[account(
init,
payer = admin,
space = DISCRIMINATOR_SIZE + AdminConfig::INIT_SPACE
)]
pub admin_config: Account<'info, AdminConfig>,
#[account(mut)]
pub admin: Signer<'info>,
pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct InitializeUser<'info> {
#[account(
init,
payer = user,
space = DISCRIMINATOR_SIZE + User::INIT_SPACE
)]
pub user_account: Account<'info, User>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct UpdateAdmin<'info> {
#[account(
mut,
has_one = admin
)]
pub admin_config: Account<'info, AdminConfig>,
pub new_admin: SystemAccount<'info>,
#[account(mut)]
pub admin: Signer<'info>,
}

#[account]
#[derive(InitSpace)]
pub struct AdminConfig {
admin: Pubkey,
}

#[account]
#[derive(InitSpace)]
pub struct User {
user: Pubkey,
}

7) Arbitrary CPI

  • To generate a CPI, the target program must be passed into the invoking instruction handler as an account. This means that any target program could be passed into the instruction handler. Your program should check for incorrect or unexpected programs.
  • Perform program checks in native programs by simply comparing the public key of the passed-in program to the progam you expected.
  • If a program is written in Anchor, then it may have a publicly available CPI module. This makes invoking the program from another Anchor program simple and secure. The Anchor CPI module automatically checks that the address of the program passed in matches the address of the program stored in the module.

Additionally and depending on the program you’re making the CPI to, you may be able to use Anchor’s Program account type to validate the passed-in program in your account validation struct. Between the anchor_lang and anchor_spl crates, the following Program types are provided out of the box:

  • System
  • AssociatedToken
  • Token

Every Anchor program uses the declare_id() macro to define the address of the program. When a CPI module is generated for a specific program, it uses the address passed into this macro as the "source of truth" and will automatically verify that all CPIs made using its CPI module target this program id.

While at the core no different than manual program checks, using CPI modules avoids the possibility of forgetting to perform a program check or accidentally typing in the wrong program ID when hard-coding it.

The program below shows an example of using a CPI module for the SPL Token Program to perform the transfer shown in the previous examples.

8) Bump Seed Canonicalization

  • The create_program_address function derives a PDA but does so without searching for the canonical bump. It allows multiple valid bumps to produce different addresses. While this can still generate a valid PDA, it lacks determinism, as multiple bumps may yield different addresses for the same set of seeds.
  • Using find_program_address ensures that the highest valid bump, often referred to as the canonical bump, is used in the PDA derivation. This provides a deterministic way to compute an address for a given set of seeds, ensuring consistency across the program.
  • In Anchor, you can specify the seeds and the bump to ensure that PDA derivations in your account validation struct always align with the correct canonical bump.
  • Anchor also allows you to specify a bump directly in the validation struct using the bump = <some_bump> constraint. This ensures that the correct bump is used when verifying the PDA.
  • Using find_program_address can be computationally expensive due to the process of searching for the highest valid bump. It's considered best practice to store the derived bump in an account's data field upon initialization. This allows the bump to be referenced in subsequent instruction handlers, avoiding the need to repeatedly call find_program_address to re-derive the PDA.

Bump seeds are a number between 0 and 255, inclusive, used to ensure that an address derived using create_program_address is a valid PDA. The canonical bump is the highest bump value that produces a valid PDA. The standard in Solana is to always use the canonical bump when deriving PDAs, both for security and convenience.

#[derive(Accounts)]
pub struct VerifyAddress<'info> {
#[account(
seeds = [DATA_PDA_SEED.as_bytes()],
bump = data.bump
)]
data: Account<'info, Data>,
}

If you use Anchor for all of the PDA derivations, this particular exploit is pretty simple to avoid. However, if you end up doing anything “non-standard,” be careful to design your program to explicitly use the canonical bump!

9) Closing Accounts and Revival Attacks

  • Closing an account improperly creates an opportunity for reinitialization/revival attacks
  • The Solana runtime garbage collects accounts when they are no longer rent exempt. Closing accounts involves transferring the lamports stored in the account for rent exemption to another account of your choosing.
  • You can use the Anchor #[account(close = <address_to_send_lamports>)] constraint to securely close accounts and set the account discriminator to the CLOSED_ACCOUNT_DISCRIMINATOR
#[account(mut, close = receiver)]
pub data_account: Account<'info, MyData>,
#[account(mut)]
pub receiver: SystemAccount<'info>

While it sounds simple, closing accounts properly can be tricky. There are a number of ways an attacker could circumvent having the account closed if you don’t follow specific steps.

To get a better understanding of these attack vectors, let’s explore each of these scenarios in depth.

The two most important things you can do to close this loophole are to zero out the account data and add an account discriminator that represents the account has been closed. You need both of these things to avoid unintended program behavior.

An account with zeroed out data can still be used for some things, especially if it’s a PDA whose address derivation is used within the program for verification purposes. However, the damage may be potentially limited if the attacker can’t access the previously-stored data.

Fortunately, Anchor makes all of this much simpler with the #[account(close = <target_account>)] constraint. This constraint handles everything required to securely close an account:

  1. Transfers the account’s lamports to the given <target_account>
  2. Zeroes out the account data
  3. Sets the account discriminator to the CLOSED_ACCOUNT_DISCRIMINATOR variant

All you have to do is add it in the account validation struct to the account you want closed:

#[derive(Accounts)]
pub struct CloseAccount {
#[account(
mut,
close = receiver
)]
pub data_account: Account<'info, MyData>,
#[account(mut)]
pub receiver: SystemAccount<'info>
}

10) PDA Sharing

  • Using the same PDA for multiple authority domains opens your program up to the possibility of users accessing data and funds that don’t belong to them
  • Prevent the same PDA from being used for multiple accounts by using seeds that are user and/or domain-specific
  • Use Anchor’s seeds and bump constraints to validate that a PDA is derived using the expected seeds and bump

PDA sharing refers to using the same PDA as a signer across multiple users or domains. Especially when using PDAs for signing, it may seem appropriate to use a global PDA to represent the program. However, this opens up the possibility of account validation passing but a user being able to access funds, transfers, or data not belonging to them.

Anchor’s seeds and bump Constraints

PDAs can be used as both the address of an account and allow programs to sign for the PDAs they own.

The example below uses a PDA derived using the withdraw_destination as both the address of the pool account and the owner of the vault token account. This means that only the pool account associated with the correct vault and withdraw_destination can be used in the withdraw_tokens instruction handler.

You can use Anchor’s seeds and bump constraints with the #[account(...)] attribute to validate the pool account PDA. Anchor derives a PDA using the seeds and bump specified and compares it against the account passed into the instruction handler as the pool account. The has_one constraint is used to further ensure that only the correct accounts stored on the pool account are passed into the instruction handler.

Conclusion:

To wrap up, the Anchor framework offers a powerful set of tools that streamline Solana smart contract development while embedding security features that help safeguard decentralized applications. However, understanding and applying best practices — such as strict account validation, leveraging PDAs for security, and rigorously testing your code — remains crucial to protecting your program from potential vulnerabilities. By staying vigilant and embracing these practices, developers can harness the full potential of the Anchor framework, creating robust, secure, and efficient smart contracts that are ready for the challenges of the Web3 ecosystem.

Peace ✌🏻

Checkout my Solana projects portfolio: ashar-solana.web.app
My github: github.com/syedashar1

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

Syed Ashar Saghir
Syed Ashar Saghir

Written by Syed Ashar Saghir

24 years old, multiple Decentralized Business Creator, Top Rated Developer.

No responses yet

Write a response