This is a deliberately vulnerable teaching repo. Your own report looks exactly like this.
Sample report: sealevel-attacks
sealevel-attacks is a public teaching repo that pairs each common Solana vulnerability with an insecure version and a fixed one. It's built to be broken, which makes it a fair test: a good checker should flag the insecure versions and stay quiet on the fixed ones. Below is the full report exactly as a paying user sees it.
Flagged all 11 insecure programs. No findings on the fixed versions.
Ava-Technologies-Org/sealevel-attacks
24555d0solana-anchor v0.2.0
Not launch-ready
1
Critical
10
High
1
Medium
0
Low
Starting from 100: 1 critical finding (−16), 10 high findings (−25), 1 medium finding (−3), giving 56. Capped at 39 because a critical finding is open. A program with an open critical issue is not ready to launch, whatever else is true.
Critical
Arbitrary CPI: authority is not a Signer, token_program is unverified, and source/destination are unconstrained on a value-moving path.
programs/5-arbitrary-cpi/insecure/src/lib.rs:10
The handler invokes a token transfer with caller-supplied accounts: authority need not have signed, token_program can be any program, and source/destination have no owner or relationship checks. A caller can transfer tokens from any account whose authority they can impersonate, or invoke a malicious program with program-derived signers.
Next:Require authority: Signer, token_program: Program<'info, Token>, and typed TokenAccounts (as in the recommended variant); verify spl_token::ID before invoke as in the secure variant.
The account is named and treated as an authority but nothing verifies it signed. Today the handler only logs, so no funds are at risk, but the account struct is reusable and the next handler inheriting it would be unguarded.
SplTokenAccount::unpack on an AccountInfo accepts any look-alike account the caller controls; no owner assertion ties the account to the SPL token program. The read balance can be attacker-chosen.
let token = SplTokenAccount::unpack(&ctx.accounts.token.data.borrow())?;
msg!("Your account balance is: {}", token.amount);
Next:Verify the account owner equals spl_token::ID before unpacking, or use Account<'info, TokenAccount> as in the recommended variant.
Token account deserialised without checking the account is owned by the SPL token program.
programs/2-owner-checks/insecure/src/lib.rs:12
The handler checks the authority field inside the deserialised data but never asserts ctx.accounts.token.owner == spl_token::ID, so a caller can supply a fake account they control whose 'owner' field matches their signer key.
let token = SplTokenAccount::unpack(&ctx.accounts.token.data.borrow())?;
if ctx.accounts.authority.key != &token.owner {
Next:Add the program-owner assertion (as in the secure variant) or use the typed TokenAccount with the authority constraint.
Deserialisation happens before the owner check and there is no account discriminant, allowing type cosplay between User and Metadata accounts.
programs/3-type-cosplay/insecure/src/lib.rs:10
Both User and Metadata are owned by this program, so the owner check does not distinguish them; a Metadata account deserialises as a User with attacker-chosen authority. Also, try_from_slice on unvalidated data with unwrap can panic (DoS). Confidence medium because the deserialise-before-check ordering could be intentional hardening elsewhere, but no discriminant exists in this file.
let user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
if ctx.accounts.user.owner != ctx.program_id {
Next:Add a discriminant check before trusting the data (as in the secure variant) or use Anchor's Account<'info, User> with has_one, and replace unwrap with error propagation.
Initialize handler overwrites the authority field with no init/zero/discriminator check, allowing reinitialisation.
programs/4-initialization/insecure/src/lib.rs:11
The user account is a bare AccountInfo and the handler writes the authority unconditionally; anyone can re-run initialize on an already-initialised account and take ownership of it.
let mut user = User::try_from_slice(&ctx.accounts.user.data.borrow()).unwrap();
user.authority = ctx.accounts.authority.key();
Next:Use #[account(init, payer = authority, ...)] as in the recommended variant, or check a discriminator/zero state before writing.
user_a and user_b are both mutable User accounts with no key inequality check; passing the same account twice makes the second write silently overwrite the first, and any logic assuming distinct accounts breaks.
Multiple bumps can yield valid program addresses for the same seed, so a caller can reach a second, non-canonical Data account for the same logical key and set its value, bypassing whatever the canonical PDA represents.
let address =
Pubkey::create_program_address(&[key.to_le_bytes().as_ref(), &[bump]], ctx.program_id)?;
if address != ctx.accounts.data.key() {
Next:Derive with find_program_address and require the canonical bump (secure variant), or use #[account(seeds = [...], bump)] (recommended variant).
Vault PDA signer seeds built only from the mint, shared across pools, so one pool's PDA can authorise another pool's vault.
programs/8-pda-sharing/insecure/src/lib.rs:10
The seed set (mint, bump) does not uniquely identify the pool or vault; two pools with the same mint share the same PDA authority, letting one pool's withdraw drain another's vault. The pool account is not tied to a PDA derivation in the account struct either.
let seeds = &[ctx.accounts.pool.mint.as_ref(), &[ctx.accounts.pool.bump]];
token::transfer(ctx.accounts.transfer_ctx().with_signer(&[seeds]), amount)
Next:Include a resource-unique seed (e.g. withdraw_destination) and constrain the pool with seeds/bump as in the recommended variant.
Zeroed data is not the same as a closed marker; within a single transaction the account can be refunded and re-initialised (the #[account(zero)] init accepts zeroed accounts) while the program still treats it as live.
let mut data = account.try_borrow_mut_data()?;
for byte in data.deref_mut().iter_mut() {
*byte = 0;
}
Ok(())
Next:Write CLOSED_ACCOUNT_DISCRIMINATOR after zeroing (as in insecure-still-still/secure) or use Anchor's close = constraint.
A caller can pass any account in the rent slot. The current handler only logs the key, so there is no demonstrated path to loss, but the pattern is the concrete unverified-sysvar one.
Next:Use Sysvar<'info, Rent> or require_eq!(rent.key(), sysvar::rent::ID) as in the secure variant.
Fix the following Solana/Anchor security findings in Ava-Technologies-Org/sealevel-attacks.
Reviewed at commit 24555d0448.
Work through them in order — they are sorted by severity, so the most
serious come first. For each one:
- Make the smallest change that fixes the issue. Do not refactor
surrounding code, rename things, or add abstractions.
- If you cannot tell whether the issue is real from the code you can see,
say so rather than guessing. A wrong fix to an access-control bug is
worse than no fix.
- Do not change behaviour beyond closing the hole described.
1. [CRITICAL] Arbitrary CPI: authority is not a Signer, token_program is unverified, and source/destination are unconstrained on a value-moving path.
File: programs/5-arbitrary-cpi/insecure/src/lib.rs:10
Require authority: Signer, token_program: Program<'info, Token>, and typed TokenAccounts (as in the recommended variant); verify spl_token::ID before invoke as in the secure variant.
2. [HIGH] Authority account declared as AccountInfo with no Signer, has_one, or constraint check.
File: programs/0-signer-authorization/insecure/src/lib.rs:14
Confirm no other instruction in the program reuses this struct; change the declaration to Signer<'info> as in the recommended variant.
3. [HIGH] Token account data deserialised from a bare AccountInfo without verifying the owning program.
File: programs/1-account-data-matching/insecure/src/lib.rs:11
Verify the account owner equals spl_token::ID before unpacking, or use Account<'info, TokenAccount> as in the recommended variant.
4. [HIGH] Token account deserialised without checking the account is owned by the SPL token program.
File: programs/2-owner-checks/insecure/src/lib.rs:12
Add the program-owner assertion (as in the secure variant) or use the typed TokenAccount with the authority constraint.
5. [HIGH] Deserialisation happens before the owner check and there is no account discriminant, allowing type cosplay between User and Metadata accounts.
File: programs/3-type-cosplay/insecure/src/lib.rs:10
Add a discriminant check before trusting the data (as in the secure variant) or use Anchor's Account<'info, User> with has_one, and replace unwrap with error propagation.
6. [HIGH] Initialize handler overwrites the authority field with no init/zero/discriminator check, allowing reinitialisation.
File: programs/4-initialization/insecure/src/lib.rs:11
Use #[account(init, payer = authority, ...)] as in the recommended variant, or check a discriminator/zero state before writing.
7. [HIGH] Two mutable accounts of the same type with no constraint preventing the caller from passing the same account twice.
File: programs/6-duplicate-mutable-accounts/insecure/src/lib.rs:19
Add #[account(constraint = user_a.key() != user_b.key())] as in the recommended variant.
8. [HIGH] Caller-supplied bump used with create_program_address instead of the canonical bump from find_program_address.
File: programs/7-bump-seed-canonicalization/insecure/src/lib.rs:9
Derive with find_program_address and require the canonical bump (secure variant), or use #[account(seeds = [...], bump)] (recommended variant).
9. [HIGH] Vault PDA signer seeds built only from the mint, shared across pools, so one pool's PDA can authorise another pool's vault.
File: programs/8-pda-sharing/insecure/src/lib.rs:10
Include a resource-unique seed (e.g. withdraw_destination) and constrain the pool with seeds/bump as in the recommended variant.
10. [HIGH] Close drains lamports and zeroes data but does not write the closed-account discriminator, allowing account revival.
File: programs/9-closing-accounts/insecure-still/src/lib.rs:10
Write CLOSED_ACCOUNT_DISCRIMINATOR after zeroing (as in insecure-still-still/secure) or use Anchor's close = constraint.
11. [HIGH] Close drains lamports but neither zeroes data nor writes the closed-account discriminator, so the account can be revived in the same transaction.
File: programs/9-closing-accounts/insecure/src/lib.rs:9
Use Anchor's close = destination constraint (recommended variant) or zero the data and write CLOSED_ACCOUNT_DISCRIMINATOR (secure variant).
12. [MEDIUM] Rent sysvar accepted as unverified AccountInfo; its key is never compared to the known sysvar address.
File: programs/10-sysvar-address-checking/insecure/src/lib.rs:15
Use Sysvar<'info, Rent> or require_eq!(rent.key(), sysvar::rent::ID) as in the secure variant.
This fixes the code as it stands today. Monitoring checks every commit after it, every week. Monitor this repo
Rather not fix it yourself? Ava does fixed-price remediation, from $1,900. Get a quote
Download score cardA 1200×630 image: the score, the repository, and what was checked. No findings.