A Hitchhiker's Guide to Advance Solana Program Security
Many Solana program-security guides have been written— this, this and this—but as Solana’s popularity keeps climbing, some categories of bugs remain little known or at least little talked about. Today we’ll dig into those overlooked issues. Every example comes from a blend of sources: personal experience, real-world bug reports, and insights shared by fellow Solana security auditors.
1. Duplicate Mutable Account Writes Overwrite Previous State
In Anchor, each mutable account you declare in your Accounts
struct is loaded into memory and, at the end of the instruction, written back in the order you declared them. If you accidentally pass the same account twice (e.g., both account_a
and account_b
point to the same pubkey), Anchor will happily hand you two separate mutable references—you’ll perform two writes, and only the second will stick, overwriting the first.
Consider the following example of a reward vault, where the admin can increase a user’s rewards in two ways: by boosting the regular reward or by adding a bonus.
On the handler side, we pass in two vault accounts to illustrate the concept. Because both accounts are identical, Anchor loads two mutable references into memory; but when it serializes the accounts back, only the bonus is written, and the earlier reward addition is lost.
We’ll end up with a discrepancy in the state at the end of the transaction, even though the transaction will succeed.
2. Token-Agnostic Interfaces Don’t Guarantee Token-Agnostic Transfers
If you want your program to support both the classic SPL Token mints and the newer Token-2022 mints, the first step is to accept the token program generically:
That only solves half the problem. If we then pay fees (or anything else) with …
The call still fails whenever the currency mint is a Token-2022 mint. Why? Because anchor_spl::token::transfer
silently ignores the program you provide and always builds the instruction for the legacy SPL token program instead. here:
Exact bug in the wild
During the Tensor NFT Marketplace contest on Cantina (issue #175) this oversight broke royalty payouts: any sale that tried to pay creator royalties in a Token-2022 mint failed, effectively DoS-ing the marketplace until the call was replaced with anchor_spl::token_interface::transfer_checked
, which respects whichever token program you pass.
3. Vector Length Issue
This bug isn’t specific to Solana at all—it’s a Rust syntax pitfall. Here’s how it can happen: let’s say you want to declare a vector with a length N:
But if you accidentally write it like this—similar to declaring an array in some languages:
How it surfaced in Mayan (reported by OtterSec)
Because items
is only one byte long, writing to indices 1 and 2 triggers an out-of-bounds panic.
4. Lamport Transfer Kill Switch
Imagine a system whose continued operation depends on transferring lamports to a user-supplied address. If that transfer can be blocked, the entire system can be DoS-ed. There are three primary ways to block it:
The user-supplied address is not rent-exempt even after the transfer.
The address is an executable program.
The address is on the reserved list and is demoted from writable to read-only at runtime.
King-of-SOL example (taken as is from Asymmetric Research Blog)
Anyone can become the king by bidding at least 2× the previous bid.
The old king is reimbursed 95 % of their bid.
The remaining 5 % goes into a prize pot.
If the reigning king survives for 10 days without being dethroned, they can claim the entire pot.
Scenario 1: User supplied address is not rent exempt after the transfer.
Every account on Solana must pay rent to exist, even when data.len() == 0
. Consider this scenario: an account needs 1 SOL to be rent-exempt just for the case of understanding, but user Nirlin provides an empty address with 0 lamports. When the next bidder places a bid, 0.8 SOL is supposed to be transferred to that address.
The address will still be below the rent-exempt minimum after the transfer, so the runtime’s checks will reject the lamport move, DoS-ing the program and blocking further bids.
Scenerio 2: Address is an executable program.
If the new_king
provided is the address of an executable program, the lamport transfer will still fail; this is enforced at runtime in the set_lamports
function
Scenerio3: The supplied address is on the reserved list and is demoted from writable to read-only at runtime.
On Solana, accounts marked as writable in a transaction can be silently downgraded to read-only. This occurs during message sanitization—before your program even executes.
The Solana runtime maintains a reserved account list, which includes addresses with special semantics — such as built-in programs, precompiles, and sysvars.
After these are activated they can’t be write locked. Look here
What’s the solution for it than?
If we examine each problem on its own, several work-arounds appear. For the first issue, you can check whether the transfer will leave the account rent-exempt and abort if it won’t. For the second, Anchor lets you verify that the supplied address is not an executable program. For scenario 3, you could maintain a hard-coded list of reserved addresses—but that list can change, so it’s brittle.
A cleaner fix is to hold refund lamports in a PDA vault owned by your program and let the user pull the funds out later—a classic pull-vs-push pattern.
5. Pre-created ATAs Brick init
To create an associated token account (ATA) for a user’s mint in Anchor, use the init
constraint, as in the following example:
At first glance this seems fine, but it isn’t—the instruction that uses it can be easily DoS-ed. First, let’s examine how associated token accounts are created. Under the hood, Anchor calls create_associated_token_account
, which takes four parameters:
funding_address: &Pubkey.
wallet_address: &Pubkey.
token_mint_address: &Pubkey.
token_program_id: &Pubkey.
The wallet address doesn’t have to be the signer—anyone can pay to create the ATA. Suppose there’s a mint for the $LATE token. Nirlin (or anyone else) can permissionlessly create Nirlin’s ATA for that mint. This opens an attack vector: Bob can pre-emptively create Nirlin’s user ATA, and when the program later tries to init
it, the instruction fails.
What’s the solution for init DOS?
Anchor provides an init_if_needed
constraint for associated token accounts. If the ATA already exists, Anchor simply loads it; if it doesn’t, Anchor creates it on the fly. Using init_if_needed
instead of init
prevents the malicious pre-initialization DoS described above.
6. CPI-Signer Pitfalls
On Solana, any account marked as a signer in the top-level instruction keeps its signer privilege for every cross-program invocation (CPI) that follows—no additional signature is required. Any callee program can therefore treat that account exactly as if the user had signed its instruction.
Ethereum works differently: in a nested call, msg.sender
is always the immediate caller, so this signer-forwarding surprise doesn’t exist there.
Below are two simple ways a malicious program can abuse a forwarded signer, along with the defensive code you’ll need.
“Steal the Wallet” – changing the owner of wallet
On Solana, every account—including user wallets and PDAs—is “owned” by a program. By default, that owner is the System Program (11111111111111111111111111111111
), but ownership can be changed with the System assign
instruction. After reassignment, only the new owner program can modify the account’s data or re-assign it, and standard lamport transfers via the System Program will fail because the source account is no longer system-owned.
How an assign attack works
Your application passes a signer account (wallet or PDA) into an untrusted CPI.
The callee calls
system_program::assign
, setting the account’s owner to itself.From that point on:
The user’s private key can no longer re-assign the account, because only the current owner program is allowed to do so.
Ordinary lamport transfers from the account fail, since the account is no longer system-owned.
Any lamports left in—or later sent to—the account are effectively frozen (or can be redirected by the attacker program in a follow-up CPI).
The result is usually a permanent DoS or fund lockup rather than an immediate drain, but the impact is just as severe.
Defense
Immediately after every CPI that receives an external signer, assert that the account’s owner is still the System Program.
Draining a Wallet after a “Trusted” Program Upgrades
On Ethereum, msg.value
makes it explicit how much ETH a call may move. Solana is different: once you pass an account as a signer, every downstream program in the CPI chain can spend every lamport that account holds.
Attack scenario
Your protocol CPIs into a previously audited
YieldFarm
program.You forward the user’s wallet as a signer, along with some SPL-token accounts.
Months later the
YieldFarm
upgrade authority is compromised. The attacker ships a new version that drains lamports from any signer it receives.A user calls your protocol → your protocol CPIs into the now-malicious
YieldFarm
→YieldFarm
transfers out every lamport in the user’s wallet.
Mitigation – balance-in / balance-out guard
Snapshot the signer’s lamport balance before the CPI, perform the call, then assert that the balance after the call has not dropped (or has dropped only by an amount you explicitly allow, e.g., fees).
Conclusion
Security on Solana isn’t about memorising an ever‑growing checklist—it’s about keeping your curiosity one hop ahead of attackers. Every edge‑case above bricked real programs before someone finally yelled “wait… what?”.
Credits: Asymmetric Research, OtterSec, Cantina, Sherlock, and my friend InfectedCrypto for surfacing bugs, running open contests, and sharing relentless edge‑case research.