How a One-Byte Discriminator Can Silently Break Anchor's Event System
A deep dive into a dispatch hardening gap in the Anchor framework where custom discriminators can shadow the Event CPI sentinel, silently disabling the event system with no compiler warnings.
ExVul Security Research Team
Security Researchers

Affected component: lang/syn/src/codegen/program/dispatch.rs, idl/src/build.rs | Category: Framework hardening (developer footgun) | Versions affected: 0.31.0+ | Tested on: 0.32.1
Introduction
Anchor is the most widely used framework for building Solana programs. At the heart of every Anchor program is a dispatch() function — auto-generated code that routes incoming transaction data to the correct instruction handler based on a discriminator prefix.
By default, Anchor uses an 8-byte discriminator derived from sha256("global:<instruction_name>")[..8]. Since all default discriminators share the same length, the starts_with() matching used in dispatch is unambiguous and safe.
However, since version 0.31.0, Anchor supports custom discriminators via the #[instruction(discriminator = [...])] attribute. This feature introduced a subtle gap: the framework performs no validation on custom discriminator values — no minimum length, no prefix-collision check, and no comparison against internal sentinel bytes. This creates a developer footgun where a carelessly chosen discriminator can silently disable critical framework functionality.
Background: How Anchor Dispatch Works
Every Anchor program compiles down to a dispatch() function that checks each global instruction's discriminator using starts_with(). The key observation: global instructions are checked first, and the Event CPI sentinel is checked last. When all discriminators are 8 bytes, this ordering is fine — no 8-byte sequence can be a prefix of another 8-byte sequence.
fn dispatch(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> Result<()> { // 1. Check each global instruction's discriminator if data.starts_with(instruction::Foo::DISCRIMINATOR) { return __private::__global::foo(program_id, accounts, &data[8..]) } if data.starts_with(instruction::Bar::DISCRIMINATOR) { return __private::__global::bar(program_id, accounts, &data[8..]) } // 2. Check the Event CPI sentinel (checked AFTER all global instructions) if data.starts_with(anchor_lang::event::EVENT_IX_TAG_LE) { return __event_dispatch(program_id, accounts, &data[8..]) } // 3. Fallback Err(ErrorCode::InstructionFallbackNotFound.into())}But what happens when a custom discriminator is shorter than 8 bytes?
The Discovery
The Overrides parser in lang/syn/src/lib.rs:83–104 accepts any arbitrary byte sequence as a custom discriminator with no validation whatsoever:
"discriminator" => { let value = match &arg.value { Expr::Lit(lit) if matches!(lit.lit, Lit::Int(_)) => quote! { &[#lit] }, Expr::Array(arr) => quote! { &#arr }, expr => expr.to_token_stream(), }; attr.discriminator.replace(value) // No validation whatsoever}There is no check for: (1) Minimum length — even an empty [] discriminator is accepted, (2) Cross-instruction prefix collisions — a 1-byte discriminator can shadow an 8-byte one, (3) System sentinel collisions — a discriminator can collide with EVENT_IX_TAG_LE.
Meanwhile, the existing collision check in idl/src/build.rs:372–390 only compares within the same category — accounts vs accounts, events vs events, instructions vs instructions. It never compares instruction discriminators against the system-level EVENT_IX_TAG_LE constant ([0xe4, 0x45, 0xa5, 0x2e, 0x51, 0xcb, 0x9a, 0x1d]).
Root Cause Analysis
1. Dispatch ordering (dispatch.rs:19–85)
The generated dispatch() checks global instruction discriminators first, and the EVENT_IX_TAG_LE sentinel last. When a custom instruction has discriminator [0xe4] — the first byte of EVENT_IX_TAG_LE — the starts_with(&[0xe4]) check matches before starts_with(&[0xe4, 0x45, ...]) ever gets a chance:
// event_hijacker checked firstif data.starts_with(instruction::EventHijacker::DISCRIMINATOR) { // &[0xe4] return event_hijacker(...)}// ... other instructions ... // EVENT_IX_TAG_LE checked later — DEAD CODEif data.starts_with(anchor_lang::event::EVENT_IX_TAG_LE) { // &[0xe4, 0x45, ...] return Err(EventInstructionStub.into());}2. Missing cross-category check (idl/src/build.rs)
The existing collision check (added in PR #3157) does not compare instruction discriminators against internal sentinel values like EVENT_IX_TAG_LE.
3. No constraint on custom discriminators (lang/syn/src/lib.rs)
The Overrides parser accepts any byte sequence without validation. Combined with (1) and (2), there is nothing preventing a developer from accidentally choosing discriminator bytes that shadow system functionality.
Proof of Concept
Minimal program
use anchor_lang::prelude::*; declare_id!("HYFvCMUDQNu2iniPh7wpDYAscUkEs7aTorebutFsYB7Q"); #[program]pub mod discriminator_shadowing { use super::*; // discriminator [0xe4] = first byte of EVENT_IX_TAG_LE // Shadows the event CPI dispatch handler #[instruction(discriminator = [0xe4])] pub fn event_hijacker(ctx: Context<BasicCtx>) -> Result<()> { let log = &mut ctx.accounts.log; log.last_handler = "event_hijacker".to_string(); Ok(()) } pub fn initialize(ctx: Context<InitCtx>) -> Result<()> { ctx.accounts.log.last_handler = String::new(); Ok(()) }}Key test
it("sending EVENT_IX_TAG_LE data dispatches to event_hijacker", async () => { const eventTag = Buffer.from([0xe4, 0x45, 0xa5, 0x2e, 0x51, 0xcb, 0x9a, 0x1d]); const ix = new anchor.web3.TransactionInstruction({ keys: [ { pubkey: provider.wallet.publicKey, isSigner: true, isWritable: false }, { pubkey: logPda, isSigner: false, isWritable: true }, ], programId: program.programId, data: eventTag, }); const tx = new anchor.web3.Transaction().add(ix); await provider.sendAndConfirm(tx); const handler = await getLastHandler(); assert.strictEqual(handler, "event_hijacker"); // ^^^ PROVES: Event CPI tag was intercepted by event_hijacker});Impact Assessment
This issue is not an externally exploitable security vulnerability. Custom discriminators are set at compile time by the program author — an external attacker cannot modify them. The Anchor development team has confirmed this assessment.
However, it is a meaningful developer footgun worth hardening against:
Silent event system breakage
A developer who chooses a short custom discriminator starting with 0xe4 will unknowingly disable their program's entire event system. The program compiles, the IDL builds, and it deploys with zero warnings.
Prefix shadowing between instructions
A short custom discriminator can shadow other instructions. For example, #[instruction(discriminator = [0xb7])] would intercept any instruction whose default sighash starts with 0xb7. The shadowed instruction becomes unreachable dead code.
Deserialization confusion
When a short discriminator shadows a longer one, the dispatch function strips only the short discriminator's bytes before passing data to the handler, causing unpredictable deserialization behavior.
Edge case — empty discriminator
The framework currently accepts discriminator = []. Since [].starts_with(&[]) is always true, an empty discriminator would match every incoming instruction, making all subsequent handlers unreachable.
Suggested Hardening
- Minimum length check: Reject discriminators shorter than a minimum (e.g., 8 bytes) in Overrides::parse, or at least emit a compile-time warning for short discriminators.
- Prefix-collision check at IDL build time: Extend the existing check_discriminator_collision! to detect when one instruction's discriminator is a prefix of another's.
- System sentinel collision check: Compare custom discriminators against known internal sentinels (EVENT_IX_TAG_LE) and reject or warn on prefix collisions.
Timeline
- Discovery: Identified during review of custom discriminator handling introduced in PR #3137
- PoC: Built and verified on Anchor 0.32.1
- Disclosure: Reported via GitHub issue #4272
- Developer response: Acknowledged as worth hardening, not classified as a security issue