PROBUS
PROBUS

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
39/ 100
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.

pub struct Cpi<'info> {
    source: AccountInfo<'info>,
    destination: AccountInfo<'info>,
    authority: AccountInfo<'info>,
    token_program: AccountInfo<'info>,
}

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.

High

Authority account declared as AccountInfo with no Signer, has_one, or constraint check.

programs/0-signer-authorization/insecure/src/lib.rs:14

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.

pub struct LogMessage<'info> {
    authority: AccountInfo<'info>,
}

Next: Confirm no other instruction in the program reuses this struct; change the declaration to Signer<'info> as in the recommended variant.

High

Token account data deserialised from a bare AccountInfo without verifying the owning program.

programs/1-account-data-matching/insecure/src/lib.rs:11

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.

High

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.

Highmedium confidence

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.

High

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.

High

Two mutable accounts of the same type with no constraint preventing the caller from passing the same account twice.

programs/6-duplicate-mutable-accounts/insecure/src/lib.rs:19

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.

pub struct Update<'info> {
    user_a: Account<'info, User>,
    user_b: Account<'info, User>,
}

Next: Add #[account(constraint = user_a.key() != user_b.key())] as in the recommended variant.

High

Caller-supplied bump used with create_program_address instead of the canonical bump from find_program_address.

programs/7-bump-seed-canonicalization/insecure/src/lib.rs:9

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).

High

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.

High

Close drains lamports and zeroes data but does not write the closed-account discriminator, allowing account revival.

programs/9-closing-accounts/insecure-still/src/lib.rs:10

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.

High

Close drains lamports but neither zeroes data nor writes the closed-account discriminator, so the account can be revived in the same transaction.

programs/9-closing-accounts/insecure/src/lib.rs:9

After closing, the account still holds valid Data state and can be refunded and reused while the program treats it as live, enabling revival attacks.

**ctx.accounts.account.to_account_info().lamports.borrow_mut() = 0;

Ok(())

Next: Use Anchor's close = destination constraint (recommended variant) or zero the data and write CLOSED_ACCOUNT_DISCRIMINATOR (secure variant).

Medium

Rent sysvar accepted as unverified AccountInfo; its key is never compared to the known sysvar address.

programs/10-sysvar-address-checking/insecure/src/lib.rs:15

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.

pub struct CheckSysvarAddress<'info> {
    rent: AccountInfo<'info>,
}

Next: Use Sysvar<'info, Rent> or require_eq!(rent.key(), sysvar::rent::ID) as in the secure variant.

Download score cardA 1200×630 image: the score, the repository, and what was checked. No findings.

Get a free scan of your own repo

Same checks, your code, in minutes.

Get a free scan

No automated tool catches everything. This result reflects the code at one commit.