This post covers a system-level DoS vector on Solana: if your program initializes an account via the System Program’s create_account, a third party may be able to pre-fund the destination address and cause the initialization to fail permanently.
TL;DR#
create_accountrejects the destination if it already has non-zero lamports (AccountAlreadyInUse).- Attackers can often precompute addresses you will initialize (especially PDAs) and pre-fund them with the minimum lamports to keep the account rent-exempt, making the DoS persistent.
- Fix: don’t rely on
create_accountwhen the destination might be pre-funded; use the safertransfer+allocate+assignflow (or let Anchor handle it).
Account Creation#
In Solana, account creation is commonly performed by invoking the System Program’s create_account instruction from within a program.
This pattern shows up most often when initializing program-derived addresses (PDAs) with invoke_signed.
Below is a simplified example:
fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
let accounts_iter = &mut accounts.iter();
let payer = next_account_info(accounts_iter)?;
let new_account = next_account_info(accounts_iter)?;
let system_program = next_account_info(accounts_iter)?;
// Typical vulnerable pattern: initializing a PDA using create_account.
// If `new_account` was pre-funded (lamports > 0), this will fail with AccountAlreadyInUse.
let space: u64 = 8 + 8;
let lamports: u64 = Rent::get()?.minimum_balance(space as usize);
let seeds: &[&[u8]] = &[b"data", payer.key.as_ref() /*, bump */];
invoke_signed(
&solana_program::system_instruction::create_account(
payer.key,
new_account.key,
lamports,
space,
program_id,
),
&[payer.clone(), new_account.clone(), system_program.clone()],
&[seeds],
)?;
msg!("Account created successfully.");
Ok(())
}This code assumes that new_account has never been created before.
The DoS Attack#
At first glance, calling create_account appears safe. However, to understand the risk, we need to look at how the instruction behaves internally.
Why pre-funding is possible#
On Solana, it’s possible to transfer lamports to an address even if it has never been explicitly “created” by your program. In practice, the attacker can include the target pubkey as a writable account meta and execute a System Program transfer. The result is a (system-owned) account with:
lamports > 0data_len == 0owner == System Program
That’s enough to trip the create_account guard.
According to the documentation in system_instruction.rs:
//! Account creation typically involves three steps: [`allocate`] space,
//! [`transfer`] lamports for rent, [`assign`] to its owning program. The
//! [`create_account`] function does all three at once. All new accounts must
//! contain enough lamports to be [rent exempt], or else the creation
//! instruction will fail.Conceptually, create_account is a convenience wrapper around:
allocatetransferassign
Runtime Behavior#
The actual runtime logic lives in system_processor.rs
#[allow(clippy::too_many_arguments)]
fn create_account(
from_account_index: IndexOfAccount,
to_account_index: IndexOfAccount,
to_address: &Address,
lamports: u64,
space: u64,
owner: &Pubkey,
signers: &HashSet<Pubkey>,
invoke_context: &InvokeContext,
transaction_context: &TransactionContext,
instruction_context: &InstructionContext,
) -> Result<(), InstructionError> {
// if it looks like the `to` account is already in use, bail
{
let mut to = instruction_context
.try_borrow_instruction_account(transaction_context, to_account_index)?;
if to.get_lamports() > 0 {
ic_msg!(
invoke_context,
"Create Account: account {:?} already in use",
to_address
);
return Err(SystemError::AccountAlreadyInUse.into());
}
allocate_and_assign(&mut to, to_address, space, owner, signers, invoke_context)?;
}
transfer(
from_account_index,
to_account_index,
lamports,
invoke_context,
transaction_context,
instruction_context,
)
}The key observation is the following check:
if to.get_lamports() > 0 {
ic_msg!(
invoke_context,
"Create Account: account {:?} already in use",
to_address
);
return Err(SystemError::AccountAlreadyInUse.into());
}Any account with a non-zero lamport balance is considered “already in use.”
Note: although the docs describe “allocate → transfer → assign” conceptually, the runtime implementation performs the “already in use” check first, then allocate_and_assign, and finally transfer. The DoS is specifically about that early lamports > 0 guard.
Pre-funding DoS Vector#
If the destination account address can be precomputed (for example, a PDA derived from predictable seeds), an attacker can:
- Precompute the target address
- Transfer the minimum lamports needed to keep it rent-exempt (commonly the minimum balance for a system-owned, 0-data account)
- Cause
create_accountto revert withAccountAlreadyInUse, blocking the entire logic.
This results in a persistent DoS, as subsequent attempts to initialize the account will fail. While brute-forcing arbitrary addresses is impractical, this attack becomes realistic when PDA seeds are simple or predictable (e.g., user_pubkey, mint, or static identifiers).
When are you vulnerable?#
You’re in the danger zone if all of these are true:
- Your program (or client) derives a destination address that an attacker can predict (commonly a PDA).
- Your initialization path uses System Program
create_account. - You don’t have a fallback path when
lamports > 0.
Mitigations#
There are two commonly used mitigation strategies:
- Drain pre-funded lamports
- If you can sign for the address (e.g., it’s your PDA, so you can
invoke_signed), you can transfer the lamports out first so the balance is zero, then proceed. - This is not always available (e.g., if the destination is not a PDA you control).
- If you can sign for the address (e.g., it’s your PDA, so you can
- Avoid
create_accountentirely- Manually split the process into:
transferallocateassign
- This works because
allocateonly rejects accounts with non-empty data or a non-system-program owner — it does not check for non-zero lamports. So a pre-funded, system-owned, zero-data account passesallocatejust fine.
- Manually split the process into:
Practical mitigation pattern (manual)#
If you control the destination (most commonly because it’s a PDA), you can branch on the current lamports and safely recover even when the address is pre-funded.
The key is: allocate and assign are allowed on a system-owned, zero-data account, but they require the account itself to sign (so for PDAs you must use invoke_signed).
let current_lamports = new_account.lamports();
if current_lamports == 0 {
// Safe to use create_account
invoke_signed(
&system_instruction::create_account(
payer.key,
new_account.key,
rent.minimum_balance(space as usize),
space,
program_id,
),
&[payer.clone(), new_account.clone(), system_program.clone()],
&[seeds],
)?;
} else {
// 1) top up to rent-exempt if needed
let required = rent
.minimum_balance(space as usize)
.saturating_sub(current_lamports);
if required > 0 {
invoke(
&system_instruction::transfer(payer.key, new_account.key, required),
&[payer.clone(), new_account.clone(), system_program.clone()],
)?;
}
// 2) allocate space (requires signature)
invoke_signed(
&system_instruction::allocate(new_account.key, space),
&[new_account.clone(), system_program.clone()],
&[seeds],
)?;
// 3) assign owner (requires signature)
invoke_signed(
&system_instruction::assign(new_account.key, program_id),
&[new_account.clone(), system_program.clone()],
&[seeds],
)?;
}How Anchor Handles It#
Anchor’s #[account(init)] and #[account(init_if_needed)] constraints explicitly address this issue by generating code that:
- Checks
field.lamports()at runtime - Uses
create_accountonly whenlamports == 0 - Otherwise falls back to the safer
transfer+allocate+assignflow
Example user code:
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(mut)]
pub signer: Signer<'info>,
#[account(
init,
payer = signer,
space = 8 + 8
)]
pub new_account: Account<'info, DataAccount>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct DataAccount {
data: u64,
}In generate_constraint_init_group, the owner is checked and generate_create_account is called.
// Define the owner of the account being created. If not specified,
// default to the currently executing program.
let (owner, owner_optional_check) = match owner {
None => (
quote! {
__program_id
},
quote! {},
),
Some(o) => {
// We clone the `check_scope` here to avoid collisions with the
// `payer_optional_check`, which is in a separate scope
let owner_optional_check = check_scope.clone().generate_check(o);
(
quote! {
&#o
},
owner_optional_check,
)
}
}; // CPI to the system program to create the account.
let create_account = generate_create_account(
field,
quote! {space},
owner.clone(),
quote! {#payer},
seeds_with_bump,
);Internally, anchor-generated code checks whether the account already holds lamports. If so, it avoids create_account and falls back to a safer flow.
fn generate_create_account(
field: &Ident,
space: proc_macro2::TokenStream,
owner: proc_macro2::TokenStream,
payer: proc_macro2::TokenStream,
seeds_with_nonce: proc_macro2::TokenStream,
) -> proc_macro2::TokenStream {
// Field, payer, and system program are already validated to not be an Option at this point
quote! {
// If the account being initialized already has lamports, then
// return them all back to the payer so that the account has
// zero lamports when the system program's create instruction
// is eventually called.
let __current_lamports = #field.lamports();
if __current_lamports == 0 {
// Create the token account with right amount of lamports and space, and the correct owner.
let space = #space;
let lamports = __anchor_rent.minimum_balance(space);
let cpi_accounts = anchor_lang::system_program::CreateAccount {
from: #payer.to_account_info(),
to: #field.to_account_info()
};
let cpi_context = anchor_lang::context::CpiContext::new(system_program.key(), cpi_accounts);
anchor_lang::system_program::create_account(cpi_context.with_signer(&[#seeds_with_nonce]), lamports, space as u64, #owner)?;
} else {
require_keys_neq!(#payer.key(), #field.key(), anchor_lang::error::ErrorCode::TryingToInitPayerAsProgramAccount);
// Fund the account for rent exemption.
let required_lamports = __anchor_rent
.minimum_balance(#space)
.max(1)
.saturating_sub(__current_lamports);
if required_lamports > 0 {
let cpi_accounts = anchor_lang::system_program::Transfer {
from: #payer.to_account_info(),
to: #field.to_account_info(),
};
let cpi_context = anchor_lang::context::CpiContext::new(system_program.key(), cpi_accounts);
anchor_lang::system_program::transfer(cpi_context, required_lamports)?;
}
// Allocate space.
let cpi_accounts = anchor_lang::system_program::Allocate {
account_to_allocate: #field.to_account_info()
};
let cpi_context = anchor_lang::context::CpiContext::new(system_program.key(), cpi_accounts);
anchor_lang::system_program::allocate(cpi_context.with_signer(&[#seeds_with_nonce]), #space as u64)?;
// Assign to the spl token program.
let cpi_accounts = anchor_lang::system_program::Assign {
account_to_assign: #field.to_account_info()
};
let cpi_context = anchor_lang::context::CpiContext::new(system_program.key(), cpi_accounts);
anchor_lang::system_program::assign(cpi_context.with_signer(&[#seeds_with_nonce]), #owner)?;
}
}
}Important clarifications when reading this generated code:
- The comment “return them all back to the payer … so that the account has zero lamports” is misleading in this excerpt: the mitigation here is primarily avoiding
create_accountwhen__current_lamports > 0. - The comment “Assign to the spl token program” is also misleading: the generated code assigns the account to
#owner(the owner passed into the constraint), which is not necessarily the SPL Token program. with_signer(&[#seeds_with_nonce])is what makes this work for PDAs: it supplies the PDA signature required byallocateandassign.
Key logic (simplified):
let current_lamports = field.lamports();
if current_lamports == 0 {
// create_account
} else {
// transfer (rent top-up)
// allocate
// assign (to the intended program owner)
}By doing so, Anchor effectively mitigates the pre-funding DoS vector.
References#
Worth sharing?
Send this analysis to another reviewer.
