Research updated
This monthSolana Program Vulnerabilities: Missing Account Checks and How to Prevent Them
Solana programs are stateless. Every account a program reads or writes is passed in by the caller as part of the transaction, and the runtime does little to guarantee that an account is the one the de…
Solana Program Vulnerabilities: Missing Account Checks and How to Prevent Them
Solana programs are stateless. Every account a program reads or writes is passed in by the caller as part of the transaction, and the runtime does little to guarantee that an account is the one the developer expected. That design shifts a large share of security work onto the program itself: before trusting any account, the program has to prove who owns it, whether it signed, what type of data it holds, and how it relates to the other accounts in the instruction. Most serious Solana smart contract exploits come down to one of those checks being missing.
This guide covers the main vulnerability classes, two well-documented exploits that followed them, and the defensive patterns that address each one. It draws on Neodyme's widely cited audit write-up on common Solana pitfalls, published in 2021, and on post-mortems of the Wormhole and Cashio incidents from 2022.
1. Missing ownership checks
A program that reads data from an account without first checking that the account is owned by the expected program can be fed a forged account. The attacker creates an account under their own control, fills it with whatever field values they need, such as a configuration account naming the attacker as admin, and passes it in place of the real one. Neodyme recommends comparing the account's owner field against the program ID before trusting its contents, and wrapping verified accounts in a distinct type so that unvalidated accounts cannot reach sensitive logic by accident.
Case study: Wormhole. The Wormhole bridge exploit on Solana in early 2022 is the best-known example of this class. Wormhole's signature verification read instruction data through the deprecated load_instruction_at function rather than load_instruction_at_checked, so it never confirmed that the data came from the real sysvar instructions account. According to Ackee Blockchain's analysis, the attacker created a fake account containing crafted secp256k1 instruction data, passed it in place of the sysvar, and got forged guardian approval through verification. That approval was then used to mint 120,000 wrapped ETH on Solana without locking any ETH on Ethereum. The attacker redeemed 93,750 of those tokens for real ETH on Ethereum, and Jump Crypto later replenished the 120,000 ETH. Ackee notes that a fix had been committed to Wormhole's public repository a few hours before the exploit but had not yet been deployed, a reminder that a public patch can reveal a live vulnerability.
2. Missing signer checks
A program may correctly check that an account's key matches the stored authority, such as the current admin, yet never verify that the account actually signed the transaction. Because public keys are public, anyone can pass the real admin's key and pass the comparison. The fix is to require is_signer on every privileged account before acting. In Anchor, declaring the account as Signer enforces this.
3. Account type confusion
Account data on Solana is a byte array. If two account types share an owner and a similar layout, a program may deserialize one as the other, and an attacker can create a legitimate account of one type and pass it where a different type is expected. Neodyme recommends storing a unique type discriminator in every account and validating it on deserialization, plus a version field or new type whenever layouts change.
4. Missing relationship checks between accounts
Even when each account is individually valid, a program must check that the accounts belong together. The Cashio stablecoin exploit shows how this fails. According to Halborn's analysis, Cashio's collateral validation confirmed that the crate_collateral_tokens account matched the saber_swap.arrow account but never validated the mint field inside saber_swap.arrow. The depositor_source account was also left unchecked against the collateral type. The attacker built a chain of fake accounts so that worthless collateral passed every check, then minted real CASH against it, taking more than $52 million and collapsing the token's price. Halborn notes that Cashio had not been audited.
The lesson is that validation must reach all the way down a chain of accounts to a trusted root, such as a known mint address or a program-derived address, rather than stopping at a link the attacker can control.
5. Arbitrary cross-program invocation
When a program calls another program through invoke or invoke_signed, the target program is itself an account passed in by the caller. If the program does not check that account's key, an attacker can substitute a malicious program that pretends to perform a token transfer while the calling program's vault is drained. The defense is to compare the supplied program ID against the expected one, for example the SPL Token program, before every cross-program call. In Anchor, the Program<'info, T> type handles this.
6. Integer overflow and unsafe casts
Rust panics on overflow in debug builds but wraps silently in release builds, which is how on-chain programs are compiled. An attacker-supplied amount large enough to wrap a fee calculation can slip past a balance check. Neodyme recommends checked arithmetic such as checked_add, returning an error on overflow, and try_from instead of as for integer conversions, since as can silently truncate values.
Using Anchor constraints as a checklist
The Anchor framework turns many of these checks into declarations. According to the Anchor constraints reference:
Account<'info, T>checks the account's owner and rejects accounts owned by a different program.Signerenforces that the account signed the transaction.has_one = targetchecks that a field stored in one account matches the key of another account in the instruction. This is exactly the relationship check Cashio was missing.seedsandbumpverify that an account is the expected program-derived address.constraint = exprenforces arbitrary custom conditions.UncheckedAccountskips Anchor's automatic checks, so any account declared this way needs manual validation and a/// CHECK:comment explaining why it is safe.
Anchor reduces the surface for mistakes but does not remove it. A developer can still reach for UncheckedAccount, write a constraint that checks the wrong field, or forget a relationship between two valid accounts. Treat every account in an instruction as attacker-supplied until the code proves otherwise. An independent audit before deployment remains a key defense, along with a fast path from a committed fix to an upgraded program.
Sources
Sponsored
Partners we have relationships with that can improve your security posture
Become a sponsor