VL005 — Unchecked CPI to unknown program

VL005 is not a detector of cross-program invocations. It reports one thing: a CPI whose program id is taken from an account the caller chose.

What VL005 is not

Until 26 July 2026 this rule flagged every invoke and invoke_signed in a function that did not contain some textual sign of a check. On twelve open-source codebases that produced 324 findings, and essentially none of them were actionable. Both of the results held up as exemplars turned out to be a create_account call from the system-program SDK and one of Anchor's own typed CPI helpers — code with nothing to fix.

The failure was structural, not a matter of tuning. "A CPI with no nearby check" describes healthy and unhealthy code equally well, so the signal carried no information. The phrase "invoke without a program-id check" does not describe this rule, and it should not be used to explain a VL005 finding to anyone.

The rewrite flags the dangerous thing rather than its neighbourhood: a program id the caller controls.

What it catches

An invoke or invoke_signed where both of these hold:

  1. The instruction was built in this same function body. Either an Instruction { program_id: …, … } struct literal, or Instruction::new_with_borsh / new_with_bincode / new_with_bytes, or a builder from the spl_token / spl_token_2022 families — the ones that take the program id as their first argument. One let hop between the construction and the invoke is followed.
  2. The program id is account-derived — the resolved expression reads a key off an account (.key), rather than naming a constant. This is also resolved through one let hop, so let target = *ctx.accounts.t.key; followed by Instruction { program_id: target, … } is caught.

Both conditions are required. Together they say: this transaction's sender decided which program your program is about to call.

Severity

Medium, and it never changes. VL005 does not break CI at the default --fail-on highVL002 is the only rule that can report High.

Medium rather than High because a caller-chosen program id is not automatically a hole: it is one when your program hands that call some authority — a signature over a PDA, an account it owns, tokens it controls. That depends on the surrounding instruction, which the tool cannot judge. The finding says the target is unpinned; you decide what that costs.

Vulnerable code

Vulnerable — the caller picks the program
pub fn forward(ctx: Context<Forward>, data: Vec<u8>) -> Result<()> {
    let ix = Instruction {
        program_id: *ctx.accounts.target_program.key,
        accounts: vec![
            AccountMeta::new(ctx.accounts.vault.key(), false),
            AccountMeta::new_readonly(ctx.accounts.vault_authority.key(), true),
        ],
        data,
    };

    invoke_signed(
        &ix,
        &[
            ctx.accounts.vault.to_account_info(),
            ctx.accounts.vault_authority.to_account_info(),
        ],
        &[&[b"vault-authority", &[ctx.bumps.vault_authority]]],
    )?;
    Ok(())
}

In every finding VL005 currently produces on real code, the account supplying the program id is declared as a raw AccountInfo or UncheckedAccount. Nothing in this function establishes which program it is — and the rule analyses one function at a time. A caller may have already verified the program id with a typed Program<'info, Token> declaration, but that proof does not cross the function boundary, so the finding still appears.

Why this is dangerous

The attacker deploys a program of their own and passes its address as target_program. Your instruction then calls their code — and, critically, calls it with your program's authority. The invoke_signed above signs for a PDA that your program owns; whatever the attacker's program does next, it does as that PDA.

What it does next is drain the vault. Their program receives the same accounts, sees a signed authority, and issues a transfer. Nothing has been broken: your program did exactly what it was told, on behalf of a signer it produced itself.

The plain invoke variant is milder but still real. The attacker's program can return success without doing anything, so an instruction that treats "the CPI succeeded" as "the transfer happened" credits a deposit that never occurred.

This is the class the Sealevel Attacks corpus calls arbitrary CPI, and it is the one where the gap between "the code compiles and the tests pass" and "the program is safe" is widest.

Fixed code

In Anchor, the declarative fix is usually the right one — type the field and there is nothing left to check by hand:

Fixed — Anchor verifies the id at deserialisation
#[derive(Accounts)]
pub struct Forward<'info> {
    pub token_program: Program<'info, Token>,
    // …
}

Program<'info, T> checks the account's key against T::id() before your body runs, so the id cannot be anything else. VL005 recognises this and stays silent.

If the target genuinely has to be dynamic, pin it explicitly before the call:

Fixed — assert the id against a known program
require_keys_eq!(
    ctx.accounts.target_program.key(),
    expected_program::ID,
    ErrorCode::UnknownProgram
);

Where "dynamic" means "one of a set we allow", the set has to be recorded somewhere your program controls — a constant list, or a registry account whose own address is pinned. An id that is merely passed in and compared against another passed-in value proves nothing.

An SDK builder is not safety

This is the most useful thing on the page, because the two calls look alike and differ completely.

Not flagged — program id is a constant inside the builder
let ix = solana_system_interface::instruction::create_account(
    payer.key,
    new_account.key,
    lamports,
    space,
    owner,
);
invoke(&ix, &[payer, new_account, system_program])?;
Flagged — program id is argument zero, and it is an account
let ix = spl_token::instruction::transfer(
    token_program.key,          // ← argument 0 is the program id
    source.key,
    destination.key,
    authority.key,
    &[],
    amount,
)?;
invoke(&ix, &[source, destination, authority, token_program])?;

The first builder compiles the System program's id into itself. There is no id to verify and nothing to fix, so flagging it would be noise. The second takes token_program_id: &Pubkey as its first argument, and passing an unverified account there is the textbook arbitrary-CPI vulnerability verbatim.

This distinction was learned the expensive way. An intermediate version of the rule assumed "built by an SDK builder" implied "program id is a constant", and the finding count dropped from 324 to 8 — which looked like success and was in fact the loss of an entire class of true findings, including confirmed ones in Metaplex's candy-machine and auction-house utilities. A rule that stops reporting owes an account of what it stopped reporting; a falling number is not by itself a result.

The final rule reports 21 findings across twelve codebases, down from 324. All 21 were reviewed by hand and all are true positives.

What the rule does not flag

  • Accounts declared as Program<'info, T>. Anchor checks the id when it deserialises the account, so there is nothing left to verify. This holds whether the field is reached through a Context<S> parameter, through a bare parameter of Program type, or through an Accounts struct passed without a Context wrapper.
  • Functions taking a CpiContext<…> parameter. Those are CPI helpers, not instruction handlers: the caller supplies the accounts and the caller is responsible for them. Without this, the tool shouts at Anchor's own anchor-spl crate — which it did, 28 times, in an earlier round. The match is an exact segment comparison, so a type named MyContext is not silenced by it.
  • Instructions built outside this function. The rule does not track an Instruction value across function boundaries. A builder in one function and the invoke in another is a missed finding, accepted deliberately.
  • The use-shortened form instruction::transfer(…) after use spl_token::instruction;. The call path has no spl_token segment to match. Also accepted deliberately — a missed finding is cheaper than a false one.
  • spl_associated_token_account builders, whose argument zero is the funder rather than a program id. Including them would flag payer.key as an unverified program id.
  • Bodies containing an explicit verification signalrequire_keys_eq!, assert_eq!(, a ::ID reference, or a program_id == comparison.
  • Test, benchmark and fuzz code, which is never scanned. See what gets scanned.

The verification-signal check in that last-but-one item deserves a caveat, since it is the one place where VL005 can be silenced by something weaker than a proof: it is textual and function-wide, so a require_keys_eq! about an unrelated account quiets the whole body. It is a residual heuristic, not the definition of the rule, and it is not what makes a finding appear or disappear in normal code.

Honest limits

Two places where VL005 fires on code that is actually safe:

  • The Program<'info, T> silencer is erased at a helper boundary. When a caller declares token_program: Program<'info, Token>, Anchor verifies the program id before the handler runs. But if the handler passes that account into a helper as a bare &AccountInfo, the type information is gone. The helper receives a token_program: &AccountInfo parameter with no embedded type constraint, and VL005 — which reads one function at a time — sees an account-derived program id with nothing to prove which program it is. The finding appears even though the caller's Program declaration already settled the question. Every VL005 finding on the coral-xyz/anchor corpus (3 of 3) was a false positive, and two of them came from exactly this shape.
  • #[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 a program id verified there, against a stored whitelist for example, silences VL005. Resolution is qualified, so one struct's checker never stands in for another's method of the same name, and it is file-local: a checker imported from another module cannot be found, and the finding stays.

Suppressing a finding

Suppress with a reason
// vaultlint:allow VL005 — target is validated against the registry in `resolve_target`
invoke_signed(&ix, &accounts, &[&seeds])?;

The comment is looked for on the finding's own line first, then upward through the contiguous block of comment lines, #[...] attribute lines 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 VL0051 does not silence VL005, and a bare vaultlint:allow with no id is intentionally not honoured. If you suppress one of these, name the thing that pins the program id — that sentence is what a reviewer will need.

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