Invocation Security: Navigating Vulnerabilities in Solana CPIs

Cross-program invocation (CPI) is the mechanism on Solana through which one program calls another. It's used for system instruction calls, SPL token transfers, custom program execution, and even event emissions, making it a core part of writing functional programs in Solana. Solana’s permission model and ability to call programs differ significantly from the EVM, and while this creates powerful capabilities, it also introduces novel security risks.

This post will dissect the properties of CPIs, the security issues that can arise, and how developers can build safer smart contracts.

How CPI Works in Solana


To make a CPI, the caller must provide the program ID, instruction data, and the accounts necessary to execute the instruction. These accounts must also be present in the original call.

        let cpi_ctx = CpiContext::new(
            ctx.accounts.program.to_account_info(),
            BobAddOp {
                bob_data_account: ctx.accounts.bob_data_account.to_account_info(),
            }
        );

        let res = bob::cpi::bob_function_call(cpi_ctx, a, b);

A CPI can be signed or unsigned, depending on whether invoke_signed or invoke is used. In Solana, a Program Derived Address (PDA) can act as a signer of a CPI, necessary for permission checks to ensure the caller has the right to act.

To use a PDA as a signer, invoke_signed requires the original seeds used to derive it. If the program owns the PDA, it can recreate it using its program ID and those seeds. This lets developers create context-specific signers—for example, tying access to the current caller—which helps isolate access to resources.

let seed = to_pubkey.key();
        let bump_seed = ctx.bumps.pda_account;
        let signer_seeds: &[&[&[u8]]] = &[&[b"pda", seed.as_ref(), &[bump_seed]]];

        let instruction =
            &system_instruction::transfer(&from_pubkey.key(), &to_pubkey.key(), amount);

        invoke_signed(
            instruction,
            &[from_pubkey, to_pubkey, program_id],
            signer_seeds,
        )?;

In Solana, reverts during CPIs cannot currently be handled. If an inner instruction returns an error, the entire instruction fails. Another key difference from the EVM is that the recursive call stack limit is much lower—only 4, compared to EVM's 1024. Another notable difference is that reentrancy is not allowed except in the case of self-reentrancy used for event emissions.

For more information on Solana CPIs, this article from RareSkills is excellent for learning these concepts.

Solana CPIs offer powerful capabilities, but that power comes with tradeoffs. The ability to forward signer privileges and execute instructions across programs introduces complexity, especially when combined with Solana’s unique execution model. These features make CPIs a common site for subtle bugs and exploitation, particularly in high-stakes programs. 

The following sections cover some security pitfalls that developers should be aware of and how to avoid them.

The Arbitrary CPI Problem


Accounts are used for all data storage in Solana, including a program’s executable code. A program can own an unlimited number of these accounts. When executing an instruction in Solana, all accounts used in the instruction must be passed in as inputs to the call. Missing verification of these accounts is a common source of bugs in Solana.


In Ethereum, a contract can control its external calls by hardcoding the target address or storing it in the contract’s state. In Solana, this isn’t possible. Instead, all accounts involved in an instruction, including the target program, must be explicitly provided up front by the transaction’s caller.

If the program ID of a provided CPI is not correctly verified, an attacker can control the program that gets called. Given that the external call is a requirement of the call, this usually violates a security invariant.

#[derive(Accounts)]
pub struct Deposit<'info> {
	#[account(mut)]
	pub caller: Signer<'info>,

	#[account(
    	seeds = [CONFIG_SEED.as_bytes()],
    	has_one = mint,
    	bump
	)]
	pub state: Account<'info, Config>,

	#[account(
    	init_if_needed,
    	space = 8 * 3,
    	payer = caller,
    	seeds = [USER_SEED.as_bytes(), &caller.key().to_bytes()],
    	bump
	)]  
	pub user_info: Account<'info, UserInfo>,

	#[account(
    	token::authority = caller.key(),
	)]
	pub caller_token_account: Box>,

	#[account(
    	associated_token::authority = state.key(),
    	associated_token::mint = mint.to_account_info(),
    	associated_token::token_program = token_program
	)]
	pub bank_token_owner: Account<'info, TokenAccount>,

	// Token account mint being used for the deposit
	pub mint: Account<'info, Mint>,

	/// CHECK: Bug is here. Should use 'Program' instead.
	pub token_program: AccountInfo<'info>,

	pub system_program: Program<'info, System>,
}

pub fn deposit(ctx: Context, amount: u64) -> Result<()>{

    	// Prepare transfer
    	let cpi_accounts = Transfer {
        	from: ctx.accounts.caller_token_account.to_account_info().clone(),
        	to: ctx.accounts.bank_token_owner.to_account_info().clone(),
        	authority: ctx.accounts.state.to_account_info().clone(),
    	};
    	let cpi_program = ctx.accounts.token_program.to_account_info();
   	 
    	// Do the transfer
    	token::transfer(
        	CpiContext::new(cpi_program, cpi_accounts),
        	amount)?;

    	// Track the transfer
    	ctx.accounts.user_info.amount += amount as u128;
    	Ok(())
}


An example of this concept is a simple bank. The bank has deposit and withdraw functions. Upon deposit, the program invokes the SPL transfer instruction to transfer tokens from the caller's token account to a program-owned token account. After the CPI returns successfully, the user’s account balance, tracked with an unsigned integer, is incremented by the deposit amount. However, as the token_program account is not properly validated, a malicious caller could specify a program that they control as the token program. Their program would simply return successfully without any value transfer, while still incrementing their account balance in the bank program. The malicious actor could then later withdraw funds from the bank that they never deposited. Arbitrary CPI is a well-known attack vector in Solana programs, addressed in various write-ups from Solana and many other sources.

Remediation


In many cases, where this is a single program ID, the validation is easy. Simply check that the program ID matches a particular public key. Anchor, a popular Solana development framework, contains the Program account type for this reason. This strictly checks that the program is on an allowlisted group of programs.

For situations involving multiple valid programs, verify that the passed-in program is included in a list.

Anchor’s Missing Reload Pitfall


In Anchor, the PDA data is loaded into memory at the beginning of the instruction. In raw Solana, the data must also be copied from the account into a usable structure. If the account is writable, then at the end of the instruction, the data of the in-memory version of the account will be serialized and replaced with the new account data.

If a CPI is made to a program that modifies an account being read in the current instruction, the in-memory copy of the data is not updated automatically. As a consequence, a program could be operating on stale data. For instance, the calculations will be incorrect if a token transfer occurs in a CPI, but the program operates on the old version of the TokenAccount.

It should be noted that properties on AccountInfo, such as the owner, data, and lamports on the account, are automatically updated. This issue occurs when programs take in-memory copies of the account data to use it.

Remediation


To refresh this data, call reload() using the account type. This will get the freshest version of the data in the account.

ctx.accounts.receiver.reload();

Signer Privileges Can Be Abused


Once a user account or a PDA signs an instruction, that signer privilege is retained for the duration of the transaction and can be reused in subsequent CPIs. When a program makes a CPI and includes a signed account in the instruction, the callee program receives that account with its signer status intact. In other words, any account marked as a signer in the current execution context is passed along with that status to the next one, allowing it to be reused without additional user interaction. This is a functional design of Solana, but it means that signing a benign-looking transaction can easily result in a user’s assets being stolen.

For instance, if it’s a user account, all of its SOL can be transferred out of it. Or, the signer can be used in another program, such as a perpetual market, to steal assets from it. In the context of a PDA, this is also true. An interesting note is that PDAs are commonly used for authentication checks. Consequently, abusing a signed PDA can have drastic program-specific consequences as well.

Combining the signer privileges with an Arbitrary CPI can have devastating consequences. For instance, let’s use the same bank as before. A signed CPI must be used to perform the token transfer. By replacing the program ID with an attacker-controlled program, the account is still signed for the inner instruction. Now, the same signed account can be used to perform unanticipated actions, such as transferring all of the tokens the signer owns. 

These reasons are why arbitrary callbacks and complete control over CPI accounts are high risk in Solana.

Remediation


User accounts must be careful about what they sign, as calls to random programs can have catastrophic consequences. Application developers should also check program IDs against static values. If the application requires arbitrary program calls, it becomes difficult to do so securely. Be sure to follow the tips in the next section.

When Arbitrary CPI is Intentional


As discussed in the previous section, a signer's permissions are passed along. However, in some cases, such as with cross-chain bridging protocols or protocols with callbacks, the specification of an arbitrary program is required. This section considers the ability to spend arbitrary tokens alongside signer reuse and how to protect against this.

Use Account Isolation to Contain Risk


A program can own an unlimited number of accounts. To prevent cross-user access, using specific seeds is common to isolate resources to a derivable account specific to the user. Signed PDAs used for authentication checks can benefit from account isolation. By requiring a specific user account as part of a PDA’s derivation, a program can limit the blast radius of a potential downstream bug to only a single user account, rather than all user accounts. As a result, the permissions cannot be used to call other Solana applications. One account, one permission. An example of this is the Solana Layer Zero implementation. It has PDA signer isolation for authentication checks by the Endpoint using a unique signer seed for each SendLibrary.

Account isolation is a general security best practice in Solana to prevent cross-user access. In the previous bank example, all tokens were sent to the same TokenAccount owned by the program, ultimately leading to the arbitrary CPI stealing all of the tokens from it. A better strategy would have been to have a token account specifically for the bank user. Then, even if there is an accounting vulnerability (such as the arbitrary CPI example), the impact is limited because the account will only have the tokens that the user deposited.

Account Signer PassThroughs


When making an arbitrary CPI, the program, accounts, and instruction data must be provided for the call to succeed. Without verification, these accounts can contain anything. In particular, they can have signers from a previous call. This creates a security concern for SOL transfers, permission abuse, and more.

Before performing the arbitrary call, verify that the accounts provided do not have unnecessary signers. This can be done by iterating over the accounts used on the CPI and confirming that none are signers, as indicated by the boolean field signer. This prevents the callee program from abusing the permissions of the original signers provided in the transaction.

for account in account_params {
    	require!(!account.is_signer, Error::FoundSigner);
}

Prevent Stealing Funds on Signer


There are situations where passing the signer is necessary.
If the signer is provided, an application can call system_instruction::transfer() to transfer SOL out of the account. However, only accounts without data can be used for calls to transfer. And, unlike Ethereum, Solana doesn’t have a msg.value to control how much SOL is spent. Instead, the callee can access all lamports (SOL) held by the signing account. This means an application can spend the user’s entire balance—a serious security risk.

The lamports on an account are a global field that tracks the amount of SOL. When performing an arbitrary CPI, some lamports may be required, but there is no built-in limit on the amount. Checking the account’s balance before and after the CPI is a great way to prevent the arbitrary CPI from stealing all of the SOL in the account. An example of how to do this is shown below:

let balance_before = ctx.accounts.signer.lamports();
  .....
let balance_after = ctx.accounts.signer.lamports();

require!(
 balance_before <= balance_after + spendable_amount,
  Error
 );


Verify Ownership—or Lose It


On Solana, all accounts, including user wallets and PDAs, are owned. By default, they're owned by the system_program, but this can be changed using the assign instruction. Once ownership is transferred, only the new owning program can interact with the account's data or transfer its lamports (SOL).

While it may be unintuitive that a private key can be owned in Solana, this is how program deployment works. A key is derived, and the code is deployed to this location. Solana has no logical separation between private keys used for signing and keys where data is located.

While the previous section (“Prevent Stealing Funds on Signer”) suggests checking account balances before and after a CPI to detect unexpected transfers, that alone isn’t sufficient. An attacker can still steal funds by changing the account’s owner instead of transferring SOL directly.

Here’s how this would work:

  • The application passes a signer account (like a wallet or PDA) into a CPI.
  • The callee contract then uses the assign instruction to change the owner of that account to itself. The account still holds its SOL, but now only the attacker-controlled program can move it. 
  • In a future transaction, the attacker (now the owner of the account) drains the account’s funds.

Since the account has been reassigned, the original private key can no longer sign for it, as it’s now fully controlled by the new owner.

To prevent this, always verify that the account's owner is still the system_program after the CPI call completes.

require!(
        	ctx.accounts.signer.owner.key() == system_program::ID,
        	WrongOwner
    	);


Defensive Design is Non-Negotiable


CPI is a powerful part of Solana development, enabling complex interactions between programs, but its flexibility comes with real risk. Its unique design, particularly the handling of signers and accounts, introduces a range of security vulnerabilities that developers must diligently address. This post outlined several critical attack vectors, including Arbitrary CPI, Missing Reload, and Reuse of Signer Privileges, and how these vulnerabilities can be exploited to manipulate program logic, steal funds, or gain unauthorized access. Building secure programs means understanding these patterns and designing defensively.

Asymmetric Research is hiring strong security engineers and researchers to solve some of the hardest security challenges in web3.

If you’d like to work with us, check out our open positions.
If you want to support our work securing Solana, consider staking to our validator.

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

// Blog

Research

Ghost in the Block: Ethereum Consensus Vulnerability

In this blog post, we will show how a small difference in SSZ deserialization between the Prysm and Lighthouse clients could have allowed an attacker to severely degrade Ethereum consensus.

Research

Circle's CCTP Noble Mint Bug

We privately disclosed a vulnerability to Circle via their bug bounty program. The vulnerability could have been exploited by circumventing the CCTP message sender verification process to mint fake USDC tokens on Noble.

View All Posts