
Solana Programs Common Vulnerabilities
1. Integer overflow or underflow
In Solana smart contracts (programs) written in Rust, integer overflow and underflow can occur when arithmetic operations exceed the maximum or minimum values of the integer type. By default, Rust performs checked arithmetic in debug mode but allows wrapping in release mode unless explicitly handled.
Let’s consider the example extracted from Neodyme’s Workshop, which by the way I highly recommend visiting.
fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
...
assert_eq!(vault_account.owner, program_id);
assert_eq!(vault_account.authority, *authority_info.key);
assert!(authority_info.is_signer, "authority must sign!");
let min_balance = rent.minimum_balance(WALLET_LEN as usize);
if min_balance + amount > **vault_account.lamports.borrow_mut() {
return Err(ProgramError::InsufficientFunds);
}
msg!("Min balance: {}", min_balance);
msg!("Amount: {}", amount);
**vault_account.lamports.borrow_mut() -= amount;
**destination_account.lamports.borrow_mut() += amount;
...
}
Happy path scenario:
- User creates a vault (
user_vault ) using our program, then deposits some SOL there. - User withdraws deposited SOL using withdraw() function, using previously created
user_vault asvault_account
and his wallet asdestination_account
Hack scenario:
- Hacker creates his own vault (
hacker_vault ) using our program - Hacker calls withdraw function, but instead withdrawing from
hacker_vault , he setsdestination_account
asuser_vault andvault_account
ashacker_vault . He puts huge number asamount
- All asserts pass:
hacker_vault was created by our program, so the owner check passes- hacker has the authority to withdraw from
hacker_vault - hacker (authority) signs the transaction
- Overflow occurs here
if min_balance + amount > **vault_account.lamports.borrow_mut()
the result of min_balance + amount
is very small number, less than minimum balance required for account to be rent exempt, so the above check will pass, even if hacker did not deposit anything in
**vault_account.lamports.borrow_mut() -= amount;
**destination_account.lamports.borrow_mut() += amount;
where there resulting amount of lamports in vault_account
(our destination_account
(
Mitigation
Use safe integer arithmetic operations:
checked_add()
instead of+
checked_sub()
instead of-
checked_mul()
instead of*
checked_div()
instead of/
2. Missing account signer check
Some instructions should only be accessible to a restricted set of accounts. For example, certain program configurations should be modified only by the admin. If you don’t verify that the admin has signed the transaction, anyone could invoke that instruction!
Let’s consider example from the previous vulnerability about integer overflows/underflows, but with signer check removed:
fn withdraw(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
...
assert_eq!(vault_account.owner, program_id);
assert_eq!(vault_account.authority, *authority_info.key);
// assert!(authority_info.is_signer, "authority must sign!");
let min_balance = rent.minimum_balance(WALLET_LEN as usize);
if min_balance + amount > **vault_account.lamports.borrow_mut() {
return Err(ProgramError::InsufficientFunds);
}
msg!("Min balance: {}", min_balance);
msg!("Amount: {}", amount);
**vault_account.lamports.borrow_mut() -= amount;
**destination_account.lamports.borrow_mut() += amount;
...
}
Hack scenario:
- Hacker calls withdraw function, he correctly passes all accounts for withdrawal, including
vault_account
andauthority
of that account. - None of above accounts are owned or controlled by the hacker, but it doesn’t matter, because the program does not verify signature of
authority
account, we can pass there someone else’s account without having their private key. - Funds are easily withdrawn from
vault_account
to hacker’s account
Mitigation
Just don’t forget to check the signer:
assert!(authority_info.is_signer, "authority must sign!");
3. Missing account owner check
Every account in Solana is owned by a program. By default all new accounts are owned by the System Program. The System Program can then assign ownership to a different program. Only the owner can:
- Modify the account’s
data
field - Deduct lamports from the account’s balance
But anyone can read from an account. If you expect the account to be created and owned by your program, you have to verify owner of the account before reading from it, otherwise someone can inject data into your program.
Let’s consider very simple program where we can withdraw rewards if we are eligible.
use anchor_lang::prelude::*;
declare_id!("Example111111111111111111111111111111111111111");
#[program]
pub mod missing_ownership_check {
use super::*;
pub fn withdraw_rewards(ctx: Context<WithdrawRewards>, amount: u64) -> Result<()> {
let eligibility_data = &ctx.accounts.rewards_eligibility_account;
// 1) Ensure the user transaction signer is the same as the stored authority of rewards_eligibility_account
require!(ctx.accounts.authority.is_signer, CustomError::MissingSignature);
require!(eligibility_data.authority == ctx.accounts.authority.key(), CustomError::NotAuthorized);
// 2) Ensure the user is actually marked as eligible
require!(eligibility_data.is_eligible, CustomError::NotEligible);
// 3) Transfer lamports from the vault to the user's destination
**ctx.accounts.rewards_vault.try_borrow_mut_lamports()? -= amount;
**ctx.accounts.destination.try_borrow_mut_lamports()? += amount;
// 4) Mark the user as no longer eligible after withdrawal
eligibility_data.is_eligible = false;
Ok(())
}
}
#[derive(Accounts)]
pub struct WithdrawRewards<'info> {
// ❌ No ownership/PDA constraints here, so any account can be passed in
pub rewards_eligibility_account: Account<'info, EligibilityData>,
#[account(mut)]
pub rewards_vault: AccountInfo<'info>,
#[account(mut)]
pub destination: AccountInfo<'info>,
pub authority: Signer<'info>,
}
#[account]
pub struct EligibilityData {
pub authority: Pubkey,
pub is_eligible: bool,
}
Happy path scenario:
- User registers in our rewards system and
rewards_eligibility_account
is created for him (the code is ommitted for brevity) - Once user earns the reward (for example through some interaction with our program),
rewards_eligibility_account.is_eligible
turns totrue
- User gets reward using
withdraw_rewards()
function
Hack scenario:
- Hacker calls
withdraw_rewards()
function passing his ownrewards_eligibility_account
withis_eligible
flag set totrue
and rewards are sent to hacker’s account - Repeats doing that, just editing
is_eligible
flag to true before each call rewards_vault
is drained from rewards after a while
Mitigation
- Check the owner of every account that you expect your account created
- Consider using Program Derived Addresses (PDAs) which add another layer of security. When using Anchor framework you can add constraints to accounts:
#[derive(Accounts)]
pub struct SomeInstruction<'info> {
#[account(
mut,
seeds = [b"vault", user.key().as_ref()],
bump
)]
pub vault_pda: Account<'info, VaultData>,
...
}
Anchor will automatically check that:
- The account’s public key is the correct Program-Derived Address (PDA) derived from
(program_id, seeds, bump)
- The account’s owner is your program.
4. Type Cosplay (Type Confusion)
This vulnerability is often named Type Cosplay in Solana, but it is better known as Type Confusion in a more wider security context. Whenever we save data to an account, it is serialized to a byte array, but not everything is serialized, we don’t have:
- type of the data structure
- field names
Hack scenario:
Let’s consider these two accounts:
pub struct Config {
pub admin: Pubkey,
pub fee: u32,
pub user_count: u32,
}
pub struct User {
pub user_authority: Pubkey,
pub balance: u64,
}
For example, such Config
account:
admin: 7DB7UBNvhN2uCbtSD5Vvzhmbu3vWA6riqiJn9BYpN8tY
user_count: 311
fee: 1265170944
after serialization would be:
5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1
(32 bytes) +
00000137
(4 bytes) +
4B68FA00
(4 bytes)
= 5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1000001374B68FA00
but when deserializing above byte array to User
acccount, we will use first 32 bytes for user_authority
public key and then next 8 bytes for u64 balance
5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1
(32 bytes) + 000001374B68FA00
(8 bytes)
user_authority: 7DB7UBNvhN2uCbtSD5Vvzhmbu3vWA6riqiJn9BYpN8tY;
balance: 1337000000000
The hacker could use Config
account wherever our program expects User
. Please note that checking account owner is not enough, because both accounts are owned by our program.
Mitigation
Native programs
Add type field to your struct:
pub struct Config {
pub account_type: AccountType,
pub admin: Pubkey,
pub fee: u32,
pub user_count: u32,
}
pub struct User {
pub account_type: AccountType,
pub user_authority: Pubkey,
pub balance: u64,
}
pub enum AccountType {
Admin,
User,
}
and check account_type
every time you want to use an account.
Anchor programs
Anchor prevents this type of vulnerabilities, because when using #[account]
macro, under the hood Anchor generates 8-byte discriminator from the struct’s name and adds it in the beginning of byte array
#[derive(Accounts)]
pub struct Instruction<'info> {
config: Account<'info, Config>,
}
#[account]
pub struct Config {
pub admin: Pubkey,
pub fee: u32,
pub user_count: u32,
}
#[account]
pub struct User {
pub user_authority: Pubkey,
pub balance: u64,
}
So instead of generic:
5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec1000001374B68FA00
we would have:
9b0caae01efacc825c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec100fa684b37010000
for Config (discriminator:9b0caae01efacc82
)9f755fe3ef973aec5c44863f9618a18da6ba92f5382a32516662ae2038e7055aaa961217e40a1ec100fa684b37010000
for User (discriminator:9f755fe3ef973aec
).
Please note that the way Anchor determines account (type) discriminator is by taking first 8 bytes of SHA256(“account:*type*
”). In our example it will be SHA256("account:User")[0..8]
and SHA256("account:Config")[0..8]
.
Sometimes it is not possible to use Anchor checks. For example, if we use accounts created by native programs. In that case we need to use AccountInfo
or UncheckedAccount
to handle account without Anchor discriminator and manually check validity of it.