Sol.College

Full lesson

ExploitReplayCI

Replays known Solana exploit classes against your Anchor program in an in-process SVM and hands back a proof — the attacker transaction, the account deltas, the line that allowed it — not a warning.

What it does

From the project README:

Replays a library of known Solana exploit classes against your Anchor program inside an in-process SVM. It does not warn that a line looks suspicious — it hands you the transaction that drained the vault, the account deltas that prove the gain, and the line that allowed it.
Original
ExploitReplay/ExploitReplayCI
SuperStack fork
Blockchains/ExploitReplayCI @ 7943894
Stars
0
Last push (original)
2026-10-25
Languages
TypeScript, Rust, HTML, Shell, CSS
Detected stack
@solana/kit
Categories
GamingInfrastructure & Tooling
Licence
MIT
Homepage
https://exploitreplay.github.io/ExploitReplayCI/

Architecture

105 source files. By extension: .ts ×105.

Top-level directorySource files
packages72
apps33

Dapp: devnet deploy pending

⏳ Devnet deploy pending — goes live when the devnet wallet is topped up. This fork's own dapp is not deployed yet, so there is no program address or transaction to show. It is queued in dapp-queue.txt; once devnet SOL is available it is deployed, exercised with real devnet transactions, and this section switches to the live dapp at /dapps/exploitreplayci/. Until then, the lesson and step-by-step guide below are complete, and you can run it locally with the commands below.

Key code, explained

Excerpts chosen automatically where the code uses the most Solana concepts, pinned to the fork's commit.

ExploitReplay/ExploitReplayCI · packages/scenarios/src/missing-signer.ts L277–300 @ 7943894 · MIT · original
    // the attacker's own lamports and the oracle would read it as a gain.    return account.signer === true ? victim.address : undefined  })  if (!accounts.ok) return accounts   const data = encodeInstructionData(idl, opener.name, args.args)  if (!data.ok) return failed(`\`${opener.name}\`: ${data.reason}`)   const call = await buildCall(svm, victim, programAddress, accounts.accounts, data.data)  const sent = svm.sendTransaction(call)  if (sent instanceof FailedTransactionMetadata) {    return failed(`\`${opener.name}\` would not open the target to attack: ${sent.toString()}`)  }   return { ok: true }} /** * The attack: call the guarded instruction with the real authority's address in * the authority slot and **no signature from it**, paying the fee from the * attacker's own account and pointing the destination at the attacker. * * Passing the attacker's own address as the authority would be refused by both * halves of the pair — `has_one` compares the key, and that comparison is not
  1. Line 286 const sent = svm.sendTransaction(call): Submits the signed transaction to the cluster.
  2. Line 300 * halves of the pair — `has_one` compares the key, and that comparison is not: Checks that a stored pubkey field matches the passed account.
ExploitReplay/ExploitReplayCI · packages/scenarios/src/account-substitution.ts L25–48 @ 7943894 · MIT · original
  TxOutcome,} from './types.js' /** * A program that takes one of its own accounts and acts on the account's data * without proving the account belongs to the signer in front of it. * * The signature is genuine — the attacker signs as themselves, which is exactly * what separates this class from `missing-signer`. What is missing is the link * between the signer and the account they hand in: the handler is given a * `Ledger` this program owns, and never checks it is *this signer's* ledger. So * the attacker signs their own transaction and passes the victim's account of * the same type, and the handler moves lamports it was never entitled to move. * * **This binds on the shape of the instruction, never on the check that closes * the hole** — the rule T021 found and T028 confirmed on this very pair, whose * two halves emit byte-identical IDLs apart from the program address. The fix * is a `require_keys_eq!` inside the handler; it adds no constraint, no * `has_one`, no seeds, nothing an IDL carries. Binding on the fix would be * impossible here — there is nothing in the IDL to read — and everywhere else * it would make the fixed half of a pair come back `not-applicable` where * FR-008 asks for `blocked`. So both halves bind, both are attacked, the SVM * decides. *
  1. Line 42 * is a `require_keys_eq!` inside the handler; it adds no constraint, no: An explicit validation that aborts the instruction with an error.
  2. Line 43 * `has_one`, no seeds, nothing an IDL carries. Binding on the fix would be: Checks that a stored pubkey field matches the passed account.

How to run, build and test

These commands come from the repo's own manifests (package.json scripts, Anchor.toml, Cargo, go.mod, Makefile). Check the README for prerequisites and environment variables.

DirectoryCommandRuns
.npm run devturbo run dev
.npm run buildturbo run build
.npm run testturbo run test --concurrency=1
.npm run lintbiome check .
apps/clinpm run buildnode build.ts
apps/clinpm run testvitest run --passWithNoTests
apps/actionnpm run buildesbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --external:litesvm '--banner:js=import{createR
apps/actionnpm run testvitest run --passWithNoTests
packages/idlnpm run testvitest run --passWithNoTests
packages/scenariosnpm run testvitest run --passWithNoTests
packages/sharednpm run testvitest run --passWithNoTests
packages/locatenpm run testvitest run --passWithNoTests
packages/artifactnpm run testvitest run --passWithNoTests
packages/corenpm run testvitest run --passWithNoTests
packages/discovernpm run testvitest run --passWithNoTests
packages/reportnpm run testvitest run --passWithNoTests

What you could build from it

Used in lessons