Inside a Native Rust Solana Audit: Security Pitfalls Every Builder Should Know

Inside a Native Rust Solana Audit: Security Pitfalls Every Builder Should Know
Share

Nothing like chewing glass in Native Rust on Solana. No framework magic, no hidden constraints macros abstracting it for you. Just you tinkering with the runtime and the raw account model. You see everything and you control everything.

The flip side? You’re responsible for everything.

During a recent audit of Riverboat (prediction market protocol) written entirely in Native Rust, our team at Slot Zero Security uncovered a number of vulnerabilities that ranged from subtle hygiene issues to exploits that allowed an attacker to manipulate the terms of a contract after a user had already locked their prediction and staked real funds.

Let’s walk through them.

Bug 1: Account Type Confusion Across Same-Owner Accounts

This was the most important finding.

The protocol had two different account types owned by the same program. Think of them like this:

struct Contract {
 finalized: bool,
 resolved: bool,
 outcome\_type: OutcomeType,
 // ...
}

struct Terms {
 parent\_contract: Pubkey,
 content: Vec \< u8 \> ,
 // ...
}

vulnerable pattern:

The program flow expected a finalized Contract. The code performed an owner check, then deserialized the account as Contract.

fn create\_wager(accounts: &\[AccountInfo\]) \-\> ProgramResult {
   let contract\_account \= \&accounts\[0\];

   if contract\_account.owner \!= \&CONTRACT\_PROGRAM\_ID {
       return Err(ProgramError::IncorrectProgramId);
   }

   let contract \= Contract::try\_from\_slice(\&contract\_account.data.borrow())?;

   if \!contract.finalized {
       return Err(MyError::ContractNotFinalized.into());
   }

   // create wager against this "contract"
   Ok(())
}

At first glance, this looks normal. The account is owned by the expected program. The bytes deserialize. The contract says it is finalized.

But there is a missing question:

Is this account actually a Contract account?

If both Contract and Terms are owned by the same program, owner checks alone are not enough. A malicious user can pass a Terms account where a Contract is expected. Since the Terms.content is user-controlled, the attacker may shape the bytes so the account deserializes as a valid-looking Contract.

That turns mutable metadata into fake finalized state.

exploit flow:

  1. Attacker creates an unfinalized parent contract.
  2. Attacker creates a mutable terms account.
  3. Attacker writes bytes into terms content that deserialize as:
    • finalized = true
    • resolved = false
  4. Attacker calls create_wager(terms_account_as_contract).
  5. Victim joins and locks funds based on displayed terms.
  6. Attacker mutates the terms after the victim has committed.

The runtime does not save you here. The account is owned by the expected program. The deserializer may returns a valid struct. The bug is the program never checked the account type.

The fix is to add discriminators to Native Rust accounts and check them before deserialization.

safer pattern:

const CONTRACT\_TAG: \[u8; 8\] \= \*b"CONTRACT";
const TERMS\_TAG: \[u8; 8\] \= \*b"TERMS\_\_\_";

fn load\_contract(account: \&AccountInfo) \-\> Result\<Contract, ProgramError\> {
   if account.owner \!= \&CONTRACT\_PROGRAM\_ID {
       return Err(ProgramError::IncorrectProgramId);
   }
   let data \= account.try\_borrow\_data()?;
   if data.len() \< 8 || data\[..8\] \!= CONTRACT\_TAG {
       return Err(ProgramError::InvalidAccountData);
   }
   Contract::try\_from\_slice(\&data\[8..\])
       .map\_err(|\_| ProgramError::InvalidAccountData)
}

This is one of the biggest differences between Anchor and native Rust. Anchor gives every account an 8-byte discriminator by default. In native Rust, you build that guardrail yourself.

Audit takeaway: ownership proves who controls the account data. It does not prove what type of state the data represents.

Bug 2: Missing System Program Identity Validation

Several instructions accepted a system_program account and used it in CPI construction, but did not verify that the caller had actually passed the System Program.

vulnerable pattern:

fn initialize(accounts: &\[AccountInfo\]) \-\> ProgramResult {
   let payer \= \&accounts\[0\];
   let new\_pda \= \&accounts\[1\];
   let system\_program \= \&accounts\[2\];

   invoke(
       \&system\_instruction::create\_account(
           payer.key,
           new\_pda.key,
           rent\_lamports,
           ACCOUNT\_SIZE as u64,
           program\_id,
       ),
       &\[payer.clone(), new\_pda.clone(), system\_program.clone()\],
   )?;
   Ok(())
}

The common pushback is reasonable:

If the caller passes a fake system program, won’t the runtime reject the CPI anyway?

In the above pattern, yes. Since**system_instruction::create_account(...)** hardcodes the CPI target as the real System Program, passing a fake system_program account would usually cause the CPI to fail instead of allowing the caller to redirect execution to a malicious program.

However, the missing validation is still worth fixing because it becomes dangerous if the code is building a CPI using system_program.key as the program_id. In that case, a caller-controlled fake program account could potentially redirect the CPI to an unintended program.

safer pattern:

if system\_program.key \!= \&system\_program::ID {
   return Err(ProgramError::InvalidArgument);
}

That check should happen before CPI construction and before any meaningful state transition.

Audit takeaway: program accounts are fixed accounts — verify the exact key, not just their position in the account list.

Bug 3: Optional Child Accounts During Parent Close

The protocol allowed a parent account to have an associated child account. In the protocol, that child account stored extended terms or metadata.

However, the close instruction treated the child account as optional.

vulnerable pattern:

fn close\_parent(accounts: &\[AccountInfo\]) \-\> ProgramResult {
   let authority \= \&accounts\[0\];
   let parent \= \&accounts\[1\];
   let maybe\_child \= accounts.get(2);
   let parent\_state \= Parent::try\_from\_slice(\&parent.data.borrow())?;
   if let Some(child) \= maybe\_child {
       close\_account(child, authority)?;
   }
   close\_account(parent, authority)?;
   Ok(())
}

At first glance, this looks flexible. In native Rust Solana programs, instructions manually read accounts from the accounts array, so using accounts.get(2) makes it possible for the caller to skip the child account.

That is only safe if the child account is truly optional at the protocol level.

The issue appears when the parent state says a child account exists, but the close instruction still allows the caller to omit it. For example, if parent_state.has_child == true, the instruction should not allow the parent to be closed unless the child account is also provided and closed.

Otherwise, the parent can be closed while the child remains on-chain. This strands the child account: it stays program-owned and rent-funded, but it is no longer reachable through the normal parent lifecycle. Depending on how the protocol uses child accounts, this can cause stale state, broken cleanup, rent leakage, or future account conflicts.

safer pattern:

if parent\_state.has\_child {
   let child \= accounts
       .get(2)
       .ok\_or(ProgramError::NotEnoughAccountKeys)?;
   verify\_child\_pda(child, parent.key)?;
   close\_account(child, authority)?;
}
close\_account(parent, authority)?;

The important part is not just requiring the child. The program should also verify that the child PDA actually belongs to the parent being closed.

Audit takeaway: In native Rust Solana, account requirements are enforced by the program, not automatically by the framework. If an account is passed as optional using accounts.get(index), the program must prove that skipping it is always safe.

Bug 4: Incomplete Native Account Close Sequence

Closing accounts in native Rust is not so easy as compared to anchor.

A common close helper looks like this:

fn close\_account(account: \&AccountInfo, recipient: \&AccountInfo) \-\>
ProgramResult {
   \*\*recipient.lamports.borrow\_mut() \+= account.lamports();
   \*\*account.lamports.borrow\_mut() \= 0;
   account.data.borrow\_mut().fill(0);
   Ok(())
}

That drains lamports and clears bytes. It does not fully return the account to a clean closed state.

A stronger native close helper should:

  1. Transfer lamports to the trusted recipient.
  2. Set the closed account lamports to zero.
  3. Clear account data.
  4. Assign ownership back to the System Program.
  5. Resize account data length to zero.

safer pattern:

fn close\_account(account: \&AccountInfo, recipient: \&AccountInfo) \-\>
ProgramResult {
   let lamports \= account.lamports();
   \*\*recipient.lamports.borrow\_mut() \= recipient
       .lamports()
       .checked\_add(lamports)
       .ok\_or(ProgramError::ArithmeticOverflow)?;
   \*\*account.lamports.borrow\_mut() \= 0;
   account.try\_borrow\_mut\_data()?.fill(0);
   account.assign(\&system\_program::ID);
   account.resize(0)?;
   Ok(())
}

Why does this matter?

A drained and zeroed account can still remain program-owned and allocated. If the account is later re-funded in the same transaction, or through an unusual lifecycle path, it may survive as a zeroed program-owned account instead of becoming a clean system-owned account.

This can create confusing account states, broken lifecycle assumptions, and possible reuse issues if the protocol assumes that draining lamports alone means the account is fully closed.

Audit takeaway: In native Rust Solana programs, closing an account should fully reset its lifecycle: drain lamports, clear data, assign it back to the System Program, and resize it to zero.

Bug 5: Discriminator Checked After Full Deserialization

The codebase had an account loader that deserialized first and checked the discriminator afterward.

vulnerable pattern:

fn from\_account\<T: BorshDeserialize \+ HasDiscriminator\>(
 account: \&AccountInfo,
) \-\> Result\<T, ProgramError\> {
 let value \= T::try\_from\_slice(\&account.data.borrow())?;
 if value.discriminator() \!= T::DISCRIMINATOR {
   return Err(ProgramError::InvalidAccountData);
 }
 Ok(value)
}

This defeats one of the main benefits of discriminators: cheap early rejection.

For fixed-size accounts, this may be mostly a compute inefficiency. For accounts with variable-length fields, user-controlled metadata, or large buffers, it means attacker-shaped bytes reach the deserializer before the program checks whether the account is even the expected type.

safer pattern:

fn from\_account\<T: BorshDeserialize \+ HasDiscriminator\>(
 account: \&AccountInfo,
) \-\> Result\<T, ProgramError\> {
 let data \= account.try\_borrow\_data()?;
 if data.len() \< 8 || data\[..8\] \!= T::DISCRIMINATOR {
   return Err(ProgramError::InvalidAccountData);
 }
 T::try\_from\_slice(\&data\[8..\]).map\_err(|\_| ProgramError::InvalidAccountData)
}

Audit takeaway: treat the discriminator as the account’s first security boundary. Verify it before deserialization so invalid account types are rejected before attacker-controlled bytes reach the parser.

What These Bugs Have in Common

All the bugs look different, but the root issue is the same:

The program trusted something too early.

  1. It trusted account ownership without proving account type.
  2. It trusted CPI failure instead of validating the program account.
  3. It treated required child accounts as optional.
  4. It treated drained lamports as a fully closed account.
  5. It deserialized data before checking the discriminator.

Native Rust Solana gives full control over account handling, CPI calls, and lifecycle rules. That control is powerful, but it also means the program must enforce every boundary clearly.

Closing Thoughts

At Slot Zero Security, we audit Solana programs with a practical, exploit-focused approach. We look beyond generic checklists and focus on what actually protects the protocol: program invariants, PDA safety, CPI validation, account lifecycle risks, and proof-of-concept driven vulnerability review.

If you are building on Solana and preparing for mainnet, do not wait for attackers to test your assumptions.

Need a Rust Solana audit? Reach out to Slot Zero Security before you ship to mainnet.

Community members

[ Our community ]

[ Community hires ]