VL002 — Missing owner check

A function deserialises an account's raw bytes while nothing in it proves which program owns that account. Any account with the right byte layout will parse, whoever created it.

Severity: the only High, and when it is not

VL002 is the only rule in VaultLint that can report High, which makes it the only one that fails a build at the default --fail-on high. The other four report at Medium or Low and let the run pass.

That has a direct consequence for how the rule is written: a false positive here costs more than a false positive anywhere else, because it stops a pipeline rather than adding a line to a report. So VL002 is deliberately the most conservative rule in the set. Several shapes that a stricter tool would flag are silenced on purpose, and each one is listed below rather than left for you to discover.

Severity is never a guess at how much money a given instruction has at risk — the tool has no notion of how exposed one is, and inventing a score would be guesswork. It varies on exactly one axis, and that axis is evidence.

When the account being read is one the function itself holds — an Anchor context field, a local, an entry in accounts[] — the absence of an owner check in that function is the whole story, and the finding is High.

When the account arrives as a helper's bare &AccountInfo parameter, it is not. VaultLint reads one function at a time, so whatever the caller proved is invisible to it. The finding is still made — see honest limits for why — but at Medium, and your build does not fail on a question the tool has just admitted it cannot answer. The message says so in as many words:

Reported, but not build-failing
⚠ MED  missing owner check
        src/utils.rs:301
        `metadata_info` is a bare `AccountInfo` parameter, deserialised here
        without verifying the account owner. VaultLint reads one function at a
        time, so a check performed by the caller is invisible to it.

Vulnerable code

Two spellings, both real. The first is the textbook one:

Vulnerable — inline raw read
pub fn read_config(ctx: Context<ReadConfig>) -> Result<()> {
    let account = &ctx.accounts.config;
    let config = Config::try_from_slice(&account.data.borrow())?;
    apply_fee(config.fee_bps);
    Ok(())
}

The second dominates in production code, and until 26 July 2026 the rule could not see it at all. The read and the deserialisation are separate statements, joined by a local:

Vulnerable — the common two-statement form
pub fn read_vault(vault_info: &AccountInfo) -> Result<Vault> {
    let data = vault_info.try_borrow_data()?;
    let vault = Vault::try_from_slice(&data)?;
    Ok(vault)
}

VL002 recognises four raw-read spellings — .data.borrow(), .data.borrow_mut(), .try_borrow_data() and .try_borrow_mut_data() — written inline in the deserialiser's argument or reached through one let hop. It reports the deserialising expression, not the borrow.

Why this is dangerous

On Solana an account is a labelled byte array. The label — AccountInfo::owner — names the program allowed to modify it, and it is the only thing that ties data to the code that produced it. Bytes carry no provenance. Borsh will happily parse anything of the right length and shape.

So the attack does not require breaking anything. The attacker deploys their own program, creates an account owned by it, and writes into it whatever bytes a Config would have — with fee_bps set to zero, an admin field set to their own key, a paused flag cleared. They then call your instruction and pass that account where config is expected. Your program reads it, parses it, believes it, and acts on it.

The same shape produces type confusion within a single program: two of your own account types that happen to share a prefix layout are interchangeable to a raw try_from_slice, so an account of one type can be presented where the other is expected. This is what Anchor's 8-byte discriminator exists to prevent — and a hand-rolled raw read skips it.

This class has been exploited on mainnet. The historic Metaplex vector was exactly this: a helper that took a bare &AccountInfo, deserialised it, and trusted the result, with the ownership question left to a caller that did not always ask it.

Fixed code

In Anchor, the fix is usually to stop reading raw bytes at all. Account<'info, T> checks the owning program and the discriminator before your body runs:

Fixed — let Anchor do it
#[derive(Accounts)]
pub struct ReadConfig<'info> {
    pub config: Account<'info, Config>,
}

pub fn read_config(ctx: Context<ReadConfig>) -> Result<()> {
    apply_fee(ctx.accounts.config.fee_bps);
    Ok(())
}

When you genuinely must read raw bytes — a foreign account layout, a dynamic account list, a native program — assert the owner before you parse:

Fixed — explicit owner assertion
pub fn read_vault(vault_info: &AccountInfo) -> Result<Vault> {
    require_keys_eq!(*vault_info.owner, crate::ID);
    let data = vault_info.try_borrow_data()?;
    let vault = Vault::try_from_slice(&data)?;
    Ok(vault)
}

For an account owned by another program, compare against that program's id instead — anchor_spl::token::ID for an SPL token account, and so on. What matters is that the comparison exists and names a program id you control the meaning of.

Two different things called owner

This is the single most common confusion in Solana code, and it is worth being precise about, because one of the two is a security boundary and the other is application data.

  • AccountInfo::owner — the program that owns the account. Set by the runtime, changeable only by the current owner, and the thing this rule is about.
  • The owner field of a deserialised SPL token account — the wallet that holds the tokens. Ordinary application data, sitting inside bytes you have already parsed.

They are unrelated concepts with the same name, and in application code the second appears more often than the first. The practical consequence for this rule: let holder = token_account.owner; is a read, not a check, and VL002 does not treat it as one. Until 26 July 2026 it did — any mention of .owner anywhere in a function silenced the whole function — and the rule was consequently near-inert, producing 8 findings across twelve codebases, 7 of them in a teaching repository.

What counts as a check is .owner in a checking position: one side of an == or !=, inside an assertion macro such as require_keys_eq! or assert_eq!, or passed to any function or method call whose final name segment contains owner or owned — so assert_owned_by, check_account_owner and is_owned_by all silence the finding, but so does get_owner() or set_owner(), which may prove nothing about the account. If your code calls a function with owner in its name for an unrelated reason, that call will silence the rule even when it should not.

An address check is an owner check

If a body proves the account sits at an address this program derived, VL002 stays silent — and this is not leniency, it is the same guarantee reached by a different route. Only a program can sign for its own PDAs. An account holding data at an address derived from [seeds, this program's id] was therefore created and written by this program, which is exactly what an owner check establishes.

Three forms are silent for this reason. In the handler body:

Silent — derived and compared in the body
let (expected, _bump) = Pubkey::find_program_address(
    &[b"vault", user.key.as_ref()],
    program_id,
);
require_keys_eq!(expected, vault.key());

Or declaratively, where Anchor checks the derivation before your body is entered:

Silent — Anchor verifies the seeds
#[account(seeds = [b"vault", user.key().as_ref()], bump)]
pub vault: UncheckedAccount<'info>,

Or with the address pinned to a constant, which fixes the account completely:

Silent — address fixed by constant
#[account(address = PYTH_LAZER_STORAGE_ID)]
pub storage: UncheckedAccount<'info>,

The boundary matters as much as the rule. The proof must name the account being read. Deriving a PDA for one account and then raw-reading a different one is not a check — it is "verified something, therefore verified everything", and it is the exact failure the first version of VL005 was built on. So VL002 requires both halves: a derivation call somewhere in the body, and the read's own account named inside a comparison, an assertion, or the derivation's arguments.

Not silent — the proof names a different account
assert_derivation(program_id, collection_metadata, &collection_seeds)?;
// …
if ctx.accounts.metadata.data_len() == 0 { return err!(ErrorCode::Empty); }
let md = Metadata::try_from_slice(&ctx.accounts.metadata.data.borrow())?;

The derivation covers collection_metadata. The read is of metadata. A length test proves nothing about ownership, and the finding stands.

Account references are matched on their trailing identifier, so ctx.accounts.collection_metadata is tied to collection_metadata and not to the shared ctx prefix. Matching the leading identifier would have degenerated to "the body contains any comparison mentioning ctx", which is true almost everywhere.

What the rule does not flag

  • Typed Anchor accounts. Account<'info, T>, Program, Signer, SystemAccount and friends are checked by the framework. There is no raw read to flag.
  • The method form account.try_deserialize(&mut data). Only the path-call form (T::try_from_slice(…), T::try_deserialize(…), T::deserialize(…)) is matched. The method form is what Account<'info, T> does internally, after Anchor has already verified the owner — flagging it would flag the correct pattern.
  • Bodies that check any account's owner. The silencer is function-wide: one owner check in the function quiets every read in it. That is a knowingly generous setting for the tool's only build-failing rule.
  • Reads covered by an address check, in any of the three forms above.
  • Reading account bytes without deserialising them — a length test, a discriminator peek, logging. The finding is anchored to the deserialiser, not to the borrow.
  • Test, benchmark and fuzz code, which is never scanned. A fuzzer deserialising unvalidated account bytes is doing its job. See what gets scanned.

Honest limits

Four places where VL002 is knowingly imprecise. Two make it quieter than a strict reading would, two make it louder.

  • Order is not required. A check that appears after the read still silences the finding. The justification is that such a check still halts the program before it acts on bad data; the cost is that a deserialise-then-verify body goes quiet even though the parse itself ran on untrusted bytes.
  • The silencer is textual and function-wide. A comparison naming .owner quiets the whole function, and the rule cannot tell an AccountInfo::owner comparison from a comparison of an SPL token account's holder field. The compared-against side is not required to look like a program id either — demanding that would produce false positives on legitimate checks against a pubkey held in state, which is not a trade this rule makes while it is the only one that can fail a build.
  • Helpers taking a bare &AccountInfo are flagged even when every caller checks. VL002 works within one function and does not follow call sites. The finding stays: it is precisely the shape by which Metaplex was historically exploited, and a helper that parses whatever it is handed is a hazard regardless of today's callers. But it is reported at Medium rather than High, because the missing proof is in another function and the rule should not fail your build over something it cannot see.
  • #[access_control(...)] is followed, but only within one file. Anchor's #[access_control(f(ctx))] attribute runs f — and lets its error abort the instruction — before the handler body. VaultLint resolves that call and reads f's body as part of the handler's evidence, so an owner check placed there silences VL002. Resolution is qualified, so CreateCheck::accounts never matches a different struct's method of the same name, and it is file-local: a checker imported from another module cannot be found, and the finding stays.

For context on how those settings landed: across twelve open-source Solana codebases the rule went 8 → 44 → 32 → 31 → 24 findings. The first step restored recall (the two-statement raw read and the .owner-in-checking-position distinction), the two middle steps bought precision, and the last removed test and fuzz code. Of the 24 survivors, 17 are accounts with nothing whatsoever establishing what they are. The remaining 7 are helper-shaped — the Metaplex form above — and were kept on purpose.

Suppressing a finding

If a read is genuinely safe for a reason the tool cannot see, record the reason next to the code:

Suppress with a reason
// vaultlint:allow VL002 — caller asserts ownership; see assert_vault_owner
let vault = Vault::try_from_slice(&data)?;

The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines (including multi-line ones) and blank lines above it. The walk stops at the first line that is none of those.

The rule id is matched on a word boundary, so vaultlint:allow VL0021 does not silence VL002, and a bare vaultlint:allow with no id is intentionally not honoured. Because VL002 is the rule that fails builds, prefer fixing over suppressing — and if you suppress, write down what proves ownership so the next reader does not have to reconstruct it.

References

Run this check on every PR.

VaultLint is an offline security linter for Solana and Anchor programs. Five narrow rules, hand-written, no telemetry.

See all five rules