Anchor is the dominant framework for Solana smart contract development. It provides something Solana's native runtime doesn't: automatic account validation. When you define an #[account] struct, Anchor verifies ownership, checks discriminators, enforces signer constraints, and validates PDAs โ all before your instruction logic runs.
Most Solana programs rely on this entirely. But some production protocols bypass it on purpose โ adding custom instruction-routing logic that intercepts specific instruction types before Anchor's validation runs. When done carefully this is fine. When done carelessly, it silently reverts the program to native Solana's anything-goes account model.
This is the 0xFF pattern: a custom entrypoint that reads a magic prefix from the instruction data and routes to hand-written handlers that skip Anchor entirely.
How Anchor's Entrypoint Works
A normal Anchor program has one entrypoint, generated by the #[program] macro. It reads the first 8 bytes of instruction data as a discriminator, routes to the matching handler, and then deserializes and validates the accounts via the handler's Context<T> type.
// Generated by #[program] macro:
pub fn entry(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let discriminator = &data[0..8];
match discriminator {
// Each arm validates accounts via Context<T> before calling your fn
DEPOSIT_DISC => handle_deposit(program_id, accounts, &data[8..]),
WITHDRAW_DISC => handle_withdraw(program_id, accounts, &data[8..]),
_ => Err(ProgramError::InvalidInstructionData),
}
}
The key property is that account validation is mandatory โ you can't reach your handler without passing it. Anchor makes it structurally difficult to skip.
The Bypass Pattern
Some programs need to handle high-throughput instructions with lower overhead. Anchor's deserialization and validation add cycles. For a protocol updating oracle prices on every block, those cycles matter.
The common optimization: intercept instructions with a known magic prefix before routing to Anchor. The 0xFF prefix (four bytes: 0xFF 0xFF 0xFF 0xFF) is a convenient choice since it can't be confused with any Anchor discriminator (which is a SHA-256 hash of a namespace string).
pub fn entry(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
// Intercept native handlers BEFORE Anchor sees the instruction
if data.starts_with(&[0xFF, 0xFF, 0xFF, 0xFF]) {
let selector = &data[4..8];
return match selector {
UPDATE_ORACLE_DISC => handle_update_oracle_native(accounts, &data[8..]),
UPDATE_SPREAD_DISC => handle_update_spread_native(accounts, &data[8..]),
_ => Err(ProgramError::InvalidInstructionData),
};
}
// Everything else goes through normal Anchor routing
anchor_lang::solana_program::entrypoint::entry(program_id, accounts, data)
}
The native handlers look straightforward โ just fast writers that update oracle data in place. And they often are. The problem is what they don't check.
What Anchor Checks That You Don't
When you write a native handler, you're working with raw &[AccountInfo]. There's no Account<PerpMarket> wrapper, no #[account] constraint, no PDA verification. You get whatever the caller put in the accounts array.
Three checks are almost always missing:
1. Account ownership
Anchor's Account<T> verifies that account.owner == program_id before deserializing. A raw handler typically doesn't. An attacker can pass an account owned by a different program โ and if you're doing raw byte writes, you're now writing to someone else's account.
2. Account discriminator
Anchor serializes every account with an 8-byte type discriminator. Account<PerpMarket> checks that the first 8 bytes match PerpMarket::discriminator(). A raw handler writing to offset 832 in the account data doesn't know whether it's writing to a PerpMarket, a User, or a State โ it just writes.
3. Sysvar identity
Handlers that read the clock often trust that accounts[N] is actually the Clock sysvar. Without a pubkey check against solana_program::sysvar::clock::ID, a caller can substitute a fake account with crafted timestamp data.
fn handle_update_oracle_native(accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
// โ
Signer check present
let signer = &accounts[1];
require!(signer.is_signer && *signer.key == AUTHORIZED_CRANK, ProgramError::MissingRequiredSignature);
// โ No: require!(accounts[0].owner == program_id)
// โ No: discriminator check
// โ No: require!(accounts[2].key == &clock::ID)
let mut market_data = accounts[0].data.borrow_mut();
let clock_data = accounts[2].data.borrow();
market_data[832..840].copy_from_slice(&clock_data[0..8]); // slot
market_data[912..920].copy_from_slice(&data[0..8]); // oracle price
Ok(())
}
The handler has exactly one security check: the authorized signer. Everything else is trusted implicitly. If the authorized crank key is ever compromised โ or if there's a separate vulnerability that allows the caller to substitute the crank signature โ the handler becomes an arbitrary write primitive targeting any account owned by the program.
The Combined Impact
Missing ownership and discriminator checks together mean an attacker can write oracle data to a User account, overwriting fields like collateral amounts or position sizes at the byte offsets the handler targets. The exact impact depends on what the byte offsets mean in each account type โ which varies by program, and which an attacker would spend time mapping carefully.
Missing sysvar identity check means an attacker can inject arbitrary slot numbers as "current time," which is especially dangerous in protocols that use slots for time-locks, TWAP calculations, or oracle freshness checks.
How to Detect This Pattern During an Audit
Start at the program entrypoint, not the instruction handlers. Look for multi-arm dispatch with conditional branching before the Anchor router:
# Look for custom entrypoint wrappers
data.starts_with(&[0xFF
"native" AND "handle_" # functions named *_native
.data.borrow_mut() # direct mutable borrow without Anchor wrapper
copy_from_slice # raw byte writes
For each native handler you find, ask three questions explicitly:
- Does it verify
account.owner == program_id? - Does it verify the account discriminator against the expected type?
- Does it verify that any sysvar accounts (
clock,rent) match their canonical pubkeys?
If any answer is "no," rate the finding based on what the handler writes and who controls the authorized signer.
The Fix
Three lines โ one for each missing check:
fn handle_update_oracle_native(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let signer = &accounts[1];
require!(signer.is_signer && *signer.key == AUTHORIZED_CRANK, ProgramError::MissingRequiredSignature);
// โ
Ownership check
require!(accounts[0].owner == program_id, ProgramError::IncorrectProgramId);
// โ
Discriminator check
let discriminator = &accounts[0].data.borrow()[..8];
require!(discriminator == PerpMarket::discriminator(), CustomError::InvalidAccountType);
// โ
Sysvar identity check
require!(accounts[2].key == &solana_program::sysvar::clock::ID, CustomError::InvalidClockAccount);
let mut market_data = accounts[0].data.borrow_mut();
let clock_data = accounts[2].data.borrow();
market_data[832..840].copy_from_slice(&clock_data[0..8]);
market_data[912..920].copy_from_slice(&data[0..8]);
Ok(())
}
Why This Pattern Keeps Appearing
The 0xFF pattern is a rational optimization. On-chain oracle updates are high-frequency operations and Anchor's deserialization overhead is real. The engineers implementing it aren't being careless โ they're solving a legitimate performance problem and isolating the fast path.
The issue is that Anchor's safety model is so automatic that teams build intuitions around it, and those intuitions don't transfer cleanly to the native handler code sitting just a few lines away. The mental model of "Anchor checks accounts, I check business logic" breaks the moment you leave the Anchor router. In the native handler, you're responsible for everything.
This is worth checking explicitly in any Anchor program that advertises low-latency oracle updates, keeper paths, or "optimized" instruction variants. The optimization is usually real; the missing checks are usually an oversight.
This post describes a vulnerability class found in multiple production Solana programs. All responsible disclosure has been completed or is in progress. No specific protocol names are disclosed until patches are public.