From Ethereum to Solana: How Developer Assumptions Can Introduce Critical Security Vulnerabilities

Solana stands out as one of the most popular blockchains, known for its high throughput and scalability that position it as an attractive alternative to Ethereum. These benefits arise from Solana’s distinctive architecture, which is markedly different from Ethereum’s design. While these architectural differences underlie many of Solana’s strengths, they also introduce unique risks that may be unfamiliar to developers transitioning from Ethereum. In this article, we will explore some common errors that Ethereum developers might make when building Solana programs, given the vastly different security models of the two platforms.
Proper Account Validation
State in Ethereum is tightly associated with the smart contract code that controls it. Each contract on Ethereum has a unique storage space that cannot be written to by any other contract. Solana takes a very different approach, separating executable code, called programs, from other types of accounts. This introduces an additional complexity, which can easily be overlooked by Ethereum developers: account validation.
On Solana, users must provide all the accounts on which a program operates. This means that if the program does not enforce the appropriate constraints and validations, a malicious user may inject unexpected accounts, which could lead to critical vulnerabilities. Specifically, all accounts should be checked for correct ownership, correct type, correct address if a specific account is expected, and correct relations with other accounts expected by the program. All of these validations are made simpler using the Anchor framework. However, missed checks and validations are still possible even when leveraging these tools, especially when using remaining_accounts, on which Anchor imposes no checks. For example, consider the following snippet from a simple lending program:
pub fn liquidate_collateral(ctx: Context<LiquidateCollateral>) -> Result<()> {
let borrower = &mut ctx.accounts.borrower;
let collateral = &mut ctx.accounts.collateral;
let liquidator = &mut ctx.accounts.liquidator;
let collateral_in_usd = get_value_in_usd(collateral.amount, collateral.mint);
let borrowed_amount_in_usd = get_value_in_usd(borrower.borrowed_amount, borrower.mint);
if collateral_in_usd * 100 < borrowed_amount_in_usd * 150 {
withdraw_from(liquidator, borrower.borrowed_amount);
transfer_collateral_to_liquidator(ctx);
let liquidated_amount = collateral.amount;
borrower.borrowed_amount = 0;
msg!(
"Liquidated {} collateral tokens due to insufficient collateralisation.",
liquidated_amount
);
} else {
msg!("Collateralisation ratio is sufficient; no liquidation performed.");
}
Ok(())
}
#[derive(Accounts)]
pub struct LiquidateCollateral<'info> {
#[account(mut)]
pub borrower: Account<'info, BorrowerAccount>,
#[account(mut)]
pub collateral: Account<'info, TokenAccount>,
#[account(mut)]
pub liquidator: Account<'info, TokenAccount>,
/// CHECK: signer PDA for collateral account
pub collateral_signer: UncheckedAccount<'info>,
pub token_program: Program<'info, Token>,
}
This function simply checks the collateralisation ratio of a loan and performs liquidation if the ratio is below 1.5. A similar program on Ethereum would likely store collateral data in a mapping, whether in the same contract or a different one. This would require the contract developer to explicitly specify a key for the mapping. However, on Solana, it is the user that chooses the account as opposed to the developer.
Hence, while at first glance this may seem secure coming from Ethereum, the instruction handler is missing a crucial check. In-built Anchor checks ensure that all accounts are of the correct type and have the correct owner, however, there is no check that ensures the collateral account provided is associated with the borrower provided. This means an attacker could provide an arbitrary borrower account and the collateral account of a different borrower. This effectively allows the attacker to liquidate any collateral account, regardless of its collateralisation ratio, by finding (or creating) a borrower account that is just below the required ratio.
This example demonstrates the dangers of insufficient account validation, especially transitioning from Ethereum development, where such validations do not exist. While Ethereum’s model tightly couples state with the source code, limiting potential interference from external actors, Solana’s separation of executable programs and accounts demands that developers take extra precautions. On Solana, every account passed into a program must be meticulously checked for proper ownership, type, and expected relationships.
Signer Account Forwarding
On Ethereum, authorisation is quite straightforward. The global variable msg.sender can be used to securely determine the immediate caller to the function, which is often enough to authorise privileged actions. On Solana, a similar approach can be employed, leveraging signer accounts.
Signer accounts in Solana serve as the entities that have provided a valid signature for a transaction, confirming their intent and authority to perform an action. These accounts can either be traditional user keypairs, where a private key directly authorises actions, or Program Derived Addresses (PDAs). PDAs are account addresses deterministically generated from a set of seeds and a program ID. Unlike keypairs, PDAs do not have a private key. Only the program from which the PDA is defined can mark a PDA as a signer account using the invoke_signed function.
Unlike msg.sender, a signer account does not securely determine the immediate caller. Programs in Solana are allowed to invoke other programs with the same signer accounts they themselves were invoked with, effectively forwarding signer accounts.
Solana program can call other programs through CPI (Cross-Program Invocation). There are two ways to perform CPI, invoke and invoke_signed. As mentioned earlier, invoke_signed is used to mark a PDA account (which must be derived from the calling program) as a signer for the CPI. The invoke function on the other hand, does not add any signers. Both fucntions can forward signer accounts that are already marked as signers.
Hence, when a user or program provides a signer account, they are essentially entrusting downstream programs with a piece of verified authority. The vulnerability emerges when this trust is misplaced. If an untrusted program is invoked with a signer account that possesses sensitive privileges, it can forward this signer with arbitrary arguments to exploit these privileges. For instance, an attacker might leverage this oversight to perform operations on behalf of an unsuspecting user.
Programs are especially at risk when performing a signed CPI on a program that can be determined or influenced by the user. A malicious user may intentionally direct the CPI to a malicious program, effectively hijacking the signer account to impersonate the vulnerable program. The severity of the issue could be even further elevated if the CPI allows the user to specify remaining_accounts to increase the flexibility of the call. While this significantly increases the flexibility and composability of Solana programs for legitimate users, it also carries additional risks. An attacker exploiting insecure signature handling may be able to leverage these remaining_accounts to include any required additional accounts that are necessary to make a privileged call.
Consider the below timelock program:
/// Queue an arbitrary task with a specified delay.
/// The caller provides the target program, instruction data (task_data),
/// and a delay (in seconds) that determines when the task can be executed.
pub fn queue_task(
ctx: Context<QueueTask>,
task_data: Vec<u8>,
target_program: Pubkey,
delay: i64
) -> ProgramResult {
let task = &mut ctx.accounts.task;
// Get the current unix timestamp
let clock = Clock::get()?;
task.release_time = clock.unix_timestamp + delay; // set execution time to now + delay
task.target_program = target_program; // target program to invoke on execute
task.authority = *ctx.accounts.authority.key; // task creator stored for authorisation
task.task_data = task_data; // arbitrary instruction data
Ok(())
}
#[derive(Accounts)]
pub struct QueueTask<'info> {
#[account(
init,
payer = authority,
space = 8 + Task::LEN,
)]
pub task: Account<'info, Task>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
}
This program allows anyone to queue a task with an arbitrary delay, storing the creator of the task for authorisation purposes. The program and arguments are controlled by the creator. Now consider this program’s execute function:
/// Execute the queued task.
/// Anyone can call this instruction, but the task will only execute if the timelock has expired.
pub fn execute_task(ctx: Context<ExecuteTask>) -> ProgramResult {
let task = &ctx.accounts.task;
// Ensure the timelock has passed
let clock = Clock::get()?;
if clock.unix_timestamp < task.release_time {
return Err(ErrorCode::TimelockNotExpired.into());
}
let cpi_accounts: Vec<AccountMeta> =
std::iter::once(&ctx.accounts.task_authority).chain(
ctx
.remaining_accounts
.iter()
).map(|acc| AccountMeta {
pubkey: *acc.key,
is_signer: acc.is_signer,
is_writable: acc.is_writable,
})
.collect();
let ix = Instruction {
program_id: task.target_program,
accounts: cpi_accounts,
data: task.task_data.clone(),
};
invoke_signed(&ix, ctx.remaining_accounts, &[&[TIMELOCK_SIGNER]])?;
Ok(())
}
#[derive(Accounts)]
pub struct ExecuteTask<'info> {
#[account(mut, close = authority)]
pub task: Account<'info, Task>,
#[account(address = task.authority)]
pub task_authority: AccountInfo<'info>,
/// This is only needed to receive the lamports from the closing account.
#[account(mut)]
pub authority: Signer<'info>,
#[account(
seeds = [TIMELOCK_SIGNER],
bump
)]
pub timelock_signer: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
This execute function allows anyone to execute the task once the time has elapsed, with the original task creator being prepended to the accounts list for authorisation purposes. To an Ethereum developer, this may appear secure. However, under Solana’s security model, this program contains a critical error.
The CPI in the execute_task function uses the same signer PDA for all tasks. This means a malicious task could misuse the signer to impersonate the timelock program. Suppose an attacker were to create the following program:
#[program]
pub mod malicious_program {
use super::*;
// This instruction forwards the signer account via CPI to the vulnerable program.
// The vulnerable program then believes that the forwarded account legitimately signed.
pub fn forward_signer(ctx: Context<ForwardSigner>) -> Result<()> {
let accounts = vec![AccountMeta::new(ctx.accounts.user.key(), true)];
let instruction_data: Vec<u8> = vec![]; // attacker controlled data
let instruction = Instruction {
program_id: ctx.accounts.target_program.key(),
accounts,
data: instruction_data,
};
invoke(&instruction, &[ctx.remaining_accounts])?;
Ok(())
}
}
#[derive(Accounts)]
pub struct ForwardSigner<'info> {
/// CHECK: This is the attacker's key as they created the malicious task
pub ignored_task_creator: UncheckedAccount<'info>,
/// CHECK: This is the target program's ID
pub target_program: UncheckedAccount<'info>,
}
This program is designed to receive a CPI from the timelock program, strip away the task creator account that is intended for a vital security check and redirect the call (timelock signature intact) to a different program. If an unsuspecting program exposes a privileged function to the timelock, using the first account as authorisation, the attacker can exploit this. First, simply queue a task with minimal delay to this malicious program, then execute the task providing the target program, followed by the accounts list required for the target invocation. This CPI would be indistinguishable from a legitimate CPI from the timelock. Hence, the attacker can bypass the delay of any existing tasks in the timelock and potentially execute functions they are not authorised to execute.
This example illustrates the dangers of misunderstanding Solana’s security model. In essence, mishandling signer accounts can transform a useful delegation mechanism into an exploitable backdoor, where an attacker could chain CPIs to bypass critical authorisation checks. The authority given to signer accounts should be carefully considered, and no single signer account should be used to authorise multiple actions.
Ethereum Developers on Solana: Conclusion
The transition from Ethereum to Solana requires certain security assumptions to be reconsidered. Inadequate account verification and unchecked signer account forwarding can open doors for exploitation. Developers must enforce strict ownership, type checks, relationship validations, and signer handling among accounts to mitigate risks. Embracing Solana’s distinct model calls for a careful and updated approach to program design, ensuring robust protection against vulnerabilities inherent in its architecture.
Brought you by Dedaub, the home of the best EVM bytecode decompiler.