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:
- The instruction was built in this same function body. Either an
Instruction { program_id: …, … }struct literal, orInstruction::new_with_borsh/new_with_bincode/new_with_bytes, or a builder from thespl_token/spl_token_2022families — the ones that take the program id as their first argument. Onelethop between the construction and theinvokeis followed. - 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 onelethop, solet target = *ctx.accounts.t.key;followed byInstruction { 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 high — VL002 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
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:
#[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:
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.
let ix = solana_system_interface::instruction::create_account(
payer.key,
new_account.key,
lamports,
space,
owner,
);
invoke(&ix, &[payer, new_account, system_program])?;
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 aContext<S>parameter, through a bare parameter ofProgramtype, or through anAccountsstruct passed without aContextwrapper. - 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 ownanchor-splcrate — which it did, 28 times, in an earlier round. The match is an exact segment comparison, so a type namedMyContextis not silenced by it. - Instructions built outside this function. The rule does not track an
Instructionvalue across function boundaries. A builder in one function and theinvokein another is a missed finding, accepted deliberately. - The use-shortened form
instruction::transfer(…)afteruse spl_token::instruction;. The call path has nospl_tokensegment to match. Also accepted deliberately — a missed finding is cheaper than a false one. spl_associated_token_accountbuilders, whose argument zero is the funder rather than a program id. Including them would flagpayer.keyas an unverified program id.- Bodies containing an explicit verification signal —
require_keys_eq!,assert_eq!(, a::IDreference, or aprogram_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 declarestoken_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 atoken_program: &AccountInfoparameter 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'sProgramdeclaration 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 runsf— and lets its error abort the instruction — before the handler body. VaultLint resolves that call and readsf'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
// 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
- Sealevel Attacks — arbitrary CPI, the runnable version of this vulnerability.
- Solana docs — Cross Program Invocation, including how
invoke_signedpasses PDA authority. - Anchor documentation — CPIs and the typed helpers.
- Common Solana Program Vulnerabilities — the class in context.
- vaultlint on GitHub — the rule's source is
src/rules/cpi.rs.
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