ExVul
Back to Blogresearch

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

ExVul Security Research Team

Security Researchers

March 3, 202612 min
#Solana#Anchor#Rust#Smart Contract#Framework Security
How a One-Byte Discriminator Can Silently Break Anchor's Event System

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.

dispatch.rs
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:

lang/syn/src/lib.rs
"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:

Generated dispatch()
// event_hijacker checked first
if data.starts_with(instruction::EventHijacker::DISCRIMINATOR) { // &[0xe4]
return event_hijacker(...)
}
// ... other instructions ...
// EVENT_IX_TAG_LE checked later — DEAD CODE
if 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

lib.rs
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

test.ts
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

Related Articles

Continue reading about blockchain security