ExploitReplay CI

A pull request that opens a hole gets a comment with the transaction that went through it.

On every pull request ExploitReplay CI replays known Solana exploit classes against your Anchor program, in an in-process SVM, and posts what got through: the attacker transaction, the balances it moved, the line that let it.

The ExploitReplay CI comment on demo pull request 1: one scenario exploited and introduced by this pull request, missing-signer-authority at programs/vault/src/lib.rs line 78, two scenarios not applicable, then why it is a hole, how to close it, and where.
The comment on demo pull request #1, as GitHub shows it. Open it

exploitreplay 0.2.0+914667c · seed exploitrun The build that wrote the comment, and the seed every attacker key comes from — the same commit replays to the same result.

Introduced by this PR Compared with the base branch. The check fails on what this change brings in; what was already on the base is listed separately and does not fail it.

NOT APPLICABLE Nothing in this program has the shape that scenario attacks. Not a pass.

programs/vault/src/lib.rs:78 Links the line in the pull request’s own head — the accounts field that is missing a signer.

132 s3 programs, ~29 instructions

144 s2 programs, ~8 instructions

From push to comment on the first run of a new pull request that changes the program, with main already warm. The whole job, toolchain restore included.

Introduced by this PR

The pull request and its base are both built and both scanned, in the same job. The check fails only on what this change introduced; an exploit that was already on the base is reported, and does not turn the check red. The default is gate: introduced on purpose — a repository with existing debt that goes red on day one gets the Action deleted on day two.

There is one comment per pull request, and it is updated, not repeated. Push the fix to the same branch and the same comment turns: demo pull request #2 goes from red to green on one comment.

Answers: did this change open it, or was it there before?

Why this is a holeHow to close itWhere

Why this is a hole: The instruction checks that the authority account is the one its state recorded, but never requires that account to sign. Anyone can pass the authority’s address as a plain account and act as the authority.

How to close it: Declare the authority as Signer<'info>, or add signer to its #[account(...)] constraint, so the transaction has to carry the authority’s signature.

Where: programs/vault/src/lib.rs:78 (accounts field)

Quoted from the comment on demo pull request #1.

Answers: why is this a hole, and what do I change? The line links into the pull request’s head only when that file is the same there as in the merge that was scanned; otherwise the comment says it is a line in the merge with base.

The accounts it moved and the attacker transaction

DbtJEt…XMePxN
  lamports   10.000000000 SOL → 10.999995000 SOL
5hLc5F…a4b4MT
  lamports    1.001176240 SOL →  0.001176240 SOL
1 further watched account: unchanged

gained 0.999995000 SOL

The comment carries the attacker transaction itself, in base64, so it can be replayed. It is not printed on this page.

It is in the demo’s comment because the demo vault exists only on localnet and the base branch lists it under proofs.public. For any program not listed — and for a program declared on mainnet, or declared nowhere, even when listed — the comment, the job summary and the log keep the class and the line, and withhold the transaction.

Answers: is it real? A balance moved, inside an SVM, from an account that never signed.

Other scenarios

Scenario What it reproduces Same class as
missing-signer-authority An instruction acts for an authority without requiring the authority’s signature. Cashio, 2022
account-substitution-pda The program acts on an account of its own type without proving it belongs to the signer in front of it. Wormhole, 2022
durable-nonce-replay A one-time release performed a second time, because nothing asks whether it already happened. The DAO, 2016

Every scenario ships with two fixture programs, one vulnerable and one fixed, and a test that requires exploited on the first and blocked on the second. A scenario that cannot tell them apart does not ship.

Answers: what does it try? Three classes today. A scenario that finds nothing to attack says not applicable, and that is not a pass.

build …, replay …

Workspace New pull request, main warm Cold, nothing cached
3 programs, ~29 instructions 132 s 565 s
2 programs, ~8 instructions 144 s 637 s
the demo, 1 program 59 s 370 s

Building the pull request takes 22–31 s of that; replaying the scenarios, 0–3 s. The cold run — installing the toolchain and building everything — is over five minutes, and happens once per toolchain or Cargo.lock change. A push to main builds and caches, so pull requests into main start warm.

About 4–6 runner minutes per pull request: twenty pull requests a week is 340–520 of the 2,000 free minutes a month.

Measured on two public Anchor workspaces live on mainnet, scanned as private copies, and on the demo. Wall-clock of the whole job, toolchain included.

exploitreplay.yml

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  pull-requests: write

jobs:
  exploitreplay:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          persist-credentials: false
      # install solana-cli and anchor-cli at the versions Anchor.toml pins
      - uses: ExploitReplay/ExploitReplayCI/apps/action@v0.3.0

No secrets beyond the token CI already has. It runs on pull_request, never pull_request_target: it builds the pull request’s code, and that event would hand a write token to whoever opened it.

The whole setup. The demo’s workflow, with the toolchain steps in full: exploitreplay.yml

blocked means this attack did not get through. It is not a statement that the program is safe.

Nothing here demonstrates the absence of a vulnerability, only the presence of the ones this library reproduced by executing them. not applicable means the scenario found nothing in this program it applies to, and inconclusive means the run could not decide. Neither of them is a pass.

Not here yet: every verdict is taken on local state — there is no mainnet fork. Anchor IDLs older than 0.30 come back inconclusive, never a guess. Three exploit classes. Linux and macOS runners. It is not an audit, not static analysis, and no language model reads your code.

The comment’s last paragraph, verbatim. Every run ends with it.