Solana development is error-prone. Often, people cite Anchor as a remedy to this problem: “write it in Anchor, and your Solana programs will be secure.” Not so fast! There are still many gotchas even when using Anchor. Let’s explore some of them.
Background
Writing secure Solana programs is no easy task. There are many foot guns one may encounter while creating Solana programs. To safely operate in this ecosystem, a developer needs to be intimately familiar with invariants upheld by the runtime, under what conditions are those invariants no longer guaranteed, and a myriad of account confusion, confused deputy, integer over and underflow, and a dozen more classic security vulnerability classes.
Anchor was created because barebones Solana is extremely error-prone. Anchor is a Solana framework designed to make the process of writing programs significantly easier and safer than writing them without. Anchor addresses many of the most common and impactful issues that you’ll face when writing Solana smart contracts. Here are just a few:
- Account confusion
- Account types
- Account addresses
- Account liveness
- Ownership
- Missing account constraints
As a smart-contract auditing firm we’ve reviewed a lot of Solana programs, especially those written with Anchor. Even though Anchor addresses many of the major issues found in Solana programs, there are plenty of issues that crop up regardless.
Seed Collisions
Program Derived Addresses (PDA) are a way to programmatically derive an off-curve account address. They’re commonly used for Cross Program Invocations (CPI), as well as creating accounts that hold stateful information. For example, a liquidity pool program may create a PDA to track funds allocated for a particular purpose or use a PDA to hold the protocol’s global configuration.
Using PDAs in Anchor is simple - we define the elements that make up the seed used in deriving the account address and we’re off to the races. Here’s an example↗:
#[derive(Accounts)]
pub struct ChangeUserName<'info> {
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"user-stats", user.key().as_ref()],
bump = user_stats.bump
)]
pub user_stats: Account<'info, UserStats>, // <-- PDA account
}
Here we’re using a static string user-stats and the fixed length (32 bytes) public key address from the user account to validate the address for the provided UserStats account. This is a typical example of how seeds are often defined and used on PDAs.
Because no two distinct users share a public key, their UserStats accounts will also have unique addresses. This isn’t always the case for PDAs, though! Collisions can result in a Denial of Service vulnerability in the best cases and complete compromise on the other end of the spectrum.
Take this example of a CreateNewProduct instruction account guard, which creates a product PDA based on the product name and a leading static string.
#[derive(Accounts)]
#[instruction(product_name: String)]
pub struct CreateNewProduct<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
space = 8 + Product::SIZE,
seeds = [b"product", product_name.as_ref()]
)]
pub product: Account<'info, Product>, // <-- PDA account
pub system_program: Program<'info, System>,
}
It might not appear to have any issues, but we begin to see problems when considering the other PDAs in our program.
#[instruction(product_name: String)]
pub struct CreateNewBid<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"product", product_name.as_ref()],
bump = product.bump
)]
pub product: Account<'info, Product>, // <-- PDA account
#[account(
init,
payer = user,
space = 8 + Bid::SIZE,
seeds = [product_name.as_ref(), user.key().as_ref()]
)]
pub bid: Account<'info, Bid>, // <-- PDA account
pub system_program: Program<'info, System>,
}
Here we add a Bid PDA account that is meant to hold a user’s bid on a product. The bid is scoped to a product and a user. While this might appear safe at first, because this Bid and this Product cannot collide with each other, an attacker can leverage this to prevent users from submitting bids or products from being created.
In certain circumstances, an attacker can create a product that generates the same program address as a Bid account. This would prevent a specific user from creating a bid for that product.
This is because elements from a seed array are effectively treated as one large collection of bytes and processed in 32 byte chunks. Therefore this seed (used by the attacker’s Product account)
[b”product”, b”youshouldbuythisproductfast”]
and this seed (used by the victim’s Bid account)
[b”pr”, 0x6f64756374796f7573686f756c646275797468697370726f6475637466617374]
generate the same PDA public key. There is an underwhelming warning about this specific behavior in the solana-program crate documentation for Pubkey.
Misuse of Anchor’s ctx.remaining_accounts
As we’ve seen in the code samples in the seed collision examples above, Anchor’s instruction account guards define a fixed number of accounts and the constraints between them. These constraints are important to upholding invariants in your protocol, but the limitation that we use a fixed number of accounts can be limiting.
Oftentimes a variable number of accounts need to be passed to instructions for processing. For instance, when attempting to cash out participants in a pool or dynamically CPI to another program. To support this, Anchor exposes ctx.remaining_accounts to access any additional accounts not included in the original instruction account guard.
It is important to note that absolutely none of the protections that Anchor typically provides are present on the accounts in ctx.remaining_accounts. That means that if you intend to use these accounts for anything sensitive you’ll need to perform some of the following checks:
- Account Ownership
- Account Type (anchor uses an 8 byte discriminator for this)
- Account Liveness (anchor’s discriminator solves this - basically ‘is the data field set to 0x00…00’)
- Account Address
- For PDA’s ensure the bump is correct as well!
There are plenty of ways that using ctx.remaining_accounts can go wrong. It’s important to treat all interactions with AccountInfo types from ctx.remaining_accounts as dangerous.
Confused Deputy with CPI
Solana is a composable ecosystem of programs. From calling out to the System program, creating new accounts, to forwarding liquidity to Solend generating yield, Cross Program Invocations are a normal part of writing Solana programs.
For all the good CPIs bring, they also hide a danger. When a CPI is performed, accounts forwarded along that also signed the transaction carries with them their is_signer status. This means that if Program A calls Program B with some account having signed the transaction, Program B can use that account as a signer. This is very important to understand. This means that Program B can create new accounts, send lamports and any associated tokens (provided the associated token account is passed as well), and much more.
#[derive(Accounts)]
pub struct RiskyInstruction<'info> {
pub lending_program: AccountInfo<'info>, // <-- no validation of account
#[account(
mut,
seeds = [ b"pool" ],
bump = pool.bump
)]
pub pool: Account<'info, Pool>
#[account]
pub caller: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn risky_instruction(ctx: Context<RiskyInstruction>, amount: u64) -> Result<()> {
// ...
let account_infos = [
ctx.accounts.caller.to_account_info().clone(),
ctx.accounts.lending_program.to_account_info().clone(),
ctx.accounts.pool.to_account_info().clone(),
ctx.accounts.system_program.to_account_info().clone(),
];
let instruction = create_lending_instruction(
ctx.accounts.lending_program.key(), // <-- program we're calling out to
ctx.accounts.caller.key(),
ctx.accounts.pool.key(),
amount
);
// v----- this is dangerous if we don't fully trust the lending_program account
invoke_signed(
&instruction,
&account_infos,
&[&[b"pool"]]
)?;
// ...
}
This issue compounds with invoke_signed which is commonly used when interfacing with PDAs. PDA accounts are frequently used as an authority account in protocols. If calls to invoke_signed pass this authority in, alongside the required signer seeds, then a substantial third-party risk is introduced. The target program could be dynamically passed in by the caller allowing an attacker to call their malicious program leveraging the authority PDA. Even statically defined programs introduce risk if they can be upgraded.
Account Reloading
The problems CPIs can introduce do not end there. While Anchor does a lot for you automatically, one thing it doesn’t do is update deserialized accounts after a CPI.
For example, imagine you have a Mint account and are just about to mint some token for the caller to track their contribution to a liquidity pool. You perform a CPI to the token program to mint these tokens and then read the current supply from the Mint account for a calculation later on. While, intuitively, you might expect the supply to be accurate, accounts in Anchor don’t update their data after a CPI!
let authority_seeds = /* seeds */;
let mint_to = MintTo {
mint: self.liquidity_mint.to_account_info(),
to: self.user.to_account_info(),
authority: self.liquidity_mint_authority.to_account_info()
};
msg!("Supply before: {}", self.liquidity_mint.supply);
anchor_spl::token::mint_to(
CpiContext::new_with_signer(
self.token_program.to_account_info(),
mint_to,
authority_seeds
),
amount
)?;
msg!("Supply after: {}", self.liquidity_mint.supply); // stays the same!
To get the expected behavior, make sure to call Anchor’s reload method on the account. This will refresh the struct’s fields with the current underlying data.
Conclusion
Anchor is a fantastic way to level up your Solana program’s security, but it isn’t a silver bullet. There are still several ways that vulnerabilities can manifest in Anchor-based Solana programs. These bugs’ impacts can range from denial-of-service to an attacker making off with your funds.
Acknowledgements
Thanks to Hana↗ for helping fact-check this article.
About Us
Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.
Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.
Contact us↗ for an audit that’s better than the rest. Real audits, not rubber stamps.