DEV Community

ohmygod
ohmygod

Posted on

Writing Custom Semgrep Rules to Catch Solana Anchor Vulnerabilities Before Auditors Do

Writing Custom Semgrep Rules to Catch Solana Anchor Vulnerabilities Before Auditors Do

You've written your Anchor program. It compiles. Tests pass. You're ready to deploy.

But buried in your instruction handlers are patterns that every experienced auditor knows to look for — and that automated tools regularly miss. Missing signer checks. Unconstrained account deserialization. Authority fields that nobody validates.

Here's the thing: you don't need to wait for a $50K audit to find these. With Semgrep — a lightweight static analysis tool — you can write custom rules that catch the most common Anchor vulnerabilities in seconds.

This guide walks through building a practical Semgrep ruleset for Solana Anchor programs. Every rule targets a real vulnerability class that has led to actual exploits.


Why Semgrep for Solana?

The Solana security tooling landscape in 2026 looks like this:

Tool Strength Weakness
cargo-audit Known CVEs in dependencies Zero coverage of logic bugs
Soteria Solana-specific checks Abandoned/limited maintenance
Anchor's built-in checks Automatic with proper types Only works if you use them correctly
Semgrep Custom rules, fast, CI-friendly You have to write the rules yourself

Semgrep fills a critical gap: it lets you encode project-specific security invariants as rules that run in CI. Unlike generic linters, you're matching actual code patterns — not just style violations.

Setup

pip install semgrep
mkdir -p .semgrep/
Enter fullscreen mode Exit fullscreen mode

That's it. No Rust toolchain dependency, no compilation step. Rules are YAML files.


Rule 1: Missing Signer Check on Authority Accounts

The vulnerability: An instruction accepts an authority account but doesn't verify it actually signed the transaction. An attacker can pass any public key as the authority.

Real-world impact: The Wormhole exploit ($320M) stemmed from missing signature verification.

# .semgrep/missing-signer-check.yaml
rules:
  - id: anchor-missing-signer-on-authority
    patterns:
      - pattern: |
          pub $FIELD: AccountInfo<'info>
      - metavariable-regex:
          metavariable: $FIELD
          regex: "(authority|admin|owner|signer|payer|creator)"
      - pattern-not: |
          #[account(signer)]
          pub $FIELD: AccountInfo<'info>
      - pattern-not: |
          pub $FIELD: Signer<'info>
    message: >
      Account '$FIELD' looks like an authority but has no signer constraint.
      Use `Signer<'info>` type or add `#[account(signer)]` constraint.
      Missing signer checks allow anyone to impersonate the authority.
    severity: ERROR
    languages: [rust]
    metadata:
      category: security
      impact: CRITICAL
      cwe: "CWE-862: Missing Authorization"
      references:
        - https://secure-contracts.com/not-so-smart-contracts/solana/signer_check/
Enter fullscreen mode Exit fullscreen mode

What it catches:

// ❌ VULNERABLE — no signer verification
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    pub authority: AccountInfo<'info>,  // Semgrep flags this
}

// ✅ SAFE — Signer type enforces signature check
#[derive(Accounts)]
pub struct WithdrawFunds<'info> {
    #[account(mut)]
    pub vault: Account<'info, Vault>,
    pub authority: Signer<'info>,
}
Enter fullscreen mode Exit fullscreen mode

Rule 2: Unchecked Account Ownership

The vulnerability: Using AccountInfo instead of Account<'info, T> means Anchor skips owner and discriminator validation. An attacker can pass a fake account with crafted data.

# .semgrep/unchecked-account-deserialization.yaml
rules:
  - id: anchor-unchecked-account-info-deserialization
    patterns:
      - pattern: |
          let $DATA = $ACCOUNT.try_borrow_data()?;
      - pattern-inside: |
          pub fn $FUNC(ctx: Context<$ACCOUNTS>, ...) -> Result<()> {
              ...
          }
      - pattern-not-inside: |
          if $ACCOUNT.owner != $PROGRAM_ID { ... }
    message: >
      Raw deserialization of AccountInfo data without owner check.
      If '$ACCOUNT' is an AccountInfo (not Account<T>), an attacker can
      pass a fake account owned by any program. Verify account.owner
      or use Account<'info, T> for automatic validation.
    severity: WARNING
    languages: [rust]
    metadata:
      category: security
      impact: HIGH
      cwe: "CWE-345: Insufficient Verification of Data Authenticity"
Enter fullscreen mode Exit fullscreen mode

Rule 3: PDA Seeds Without Bump Validation

The vulnerability: Creating or validating PDAs without specifying the canonical bump allows attackers to use non-canonical bumps, potentially creating duplicate accounts.

# .semgrep/pda-missing-bump.yaml
rules:
  - id: anchor-pda-missing-bump-constraint
    patterns:
      - pattern: |
          #[account(
              seeds = [$...SEEDS],
              $...REST
          )]
      - pattern-not: |
          #[account(
              seeds = [$...SEEDS],
              bump = $BUMP,
              $...REST
          )]
      - pattern-not: |
          #[account(
              seeds = [$...SEEDS],
              bump,
              $...REST
          )]
    message: >
      PDA account uses seeds constraint without explicit bump.
      Always include `bump` (or `bump = field_name`) to enforce
      the canonical bump. Without this, an attacker might find a
      valid non-canonical bump to bypass account derivation checks.
    severity: WARNING
    languages: [rust]
    metadata:
      category: security
      impact: MEDIUM
      cwe: "CWE-330: Use of Insufficiently Random Values"
Enter fullscreen mode Exit fullscreen mode

Rule 4: Arithmetic Without Overflow Protection

The vulnerability: Using +, -, * operators on token amounts without checked_ math. In release builds without overflow checks, this wraps silently.

# .semgrep/unchecked-arithmetic.yaml
rules:
  - id: anchor-unchecked-arithmetic-on-amounts
    patterns:
      - pattern-either:
          - pattern: $A + $B
          - pattern: $A - $B
          - pattern: $A * $B
      - metavariable-regex:
          metavariable: $A
          regex: ".*(amount|balance|supply|deposit|withdraw|fee|reward|lamports).*"
      - pattern-not-inside: |
          $A.checked_add($B)
      - pattern-not-inside: |
          $A.checked_sub($B)
      - pattern-not-inside: |
          $A.checked_mul($B)
    message: >
      Arithmetic operation on what looks like a financial amount
      without using checked_add/checked_sub/checked_mul. This can
      silently overflow in release builds. Use checked math or
      Anchor's require!() to validate bounds.
    severity: WARNING
    languages: [rust]
    metadata:
      category: security
      impact: HIGH
      cwe: "CWE-190: Integer Overflow or Wraparound"
Enter fullscreen mode Exit fullscreen mode

Rule 5: Missing close Constraint Leaves Lamports Locked

The vulnerability: Accounts that should be closeable don't have the close constraint, leaving lamports permanently locked in the account.

# .semgrep/missing-close-constraint.yaml
rules:
  - id: anchor-missing-close-on-ephemeral-account
    patterns:
      - pattern: |
          #[account(
              init,
              $...REST
          )]
          pub $FIELD: Account<'info, $TYPE>
      - metavariable-regex:
          metavariable: $TYPE
          regex: "(Escrow|Order|Bid|Offer|Request|Ticket|Receipt)"
    message: >
      Account type '$TYPE' looks ephemeral but is initialized without
      a corresponding close instruction. Consider adding a close
      instruction with `#[account(mut, close = destination)]` to
      allow reclaiming lamports when the account is no longer needed.
    severity: INFO
    languages: [rust]
    metadata:
      category: security
      impact: LOW
Enter fullscreen mode Exit fullscreen mode

Putting It All Together: CI Integration

Create a .semgrep.yml config at your project root:

# .semgrep.yml
rules:
  - .semgrep/
Enter fullscreen mode Exit fullscreen mode

Then add to your CI pipeline:

# .github/workflows/security.yml
name: Solana Security Scan
on: [push, pull_request]

jobs:
  semgrep:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: .semgrep/
Enter fullscreen mode Exit fullscreen mode

Every PR now gets checked against your custom security rules before any human reviews it.


What This Doesn't Catch

Let's be honest about limitations:

  • Cross-instruction logic bugs — Semgrep is intra-file; it can't trace state across CPIs
  • Economic exploits — Flash loan attacks, oracle manipulation, MEV — these need runtime simulation
  • Timing/ordering issues — Race conditions in concurrent transaction processing

For these, you need dynamic analysis tools like Trident (fuzzing) or Guardrail (runtime monitoring). Static analysis is your first line of defense, not your only one.


The Takeaway

The best time to catch a vulnerability is before deployment. The second best time is during code review. Both are cheaper than a post-exploit audit.

These five Semgrep rules won't replace a professional audit — but they'll catch the low-hanging fruit that accounts for ~40% of Solana exploits. And unlike a one-time audit, they run on every commit, forever.

The full ruleset is available as a GitHub Gist — fork it, customize it for your project, and make it part of your CI pipeline.


Building security tooling for Solana? I write weekly about DeFi security engineering, audit methodologies, and vulnerability research. Follow for more.

Top comments (0)