Skip to content

security: add missing authorization checks in close-account and transfer-sol - #667

Merged
dev-jodee merged 3 commits into
solana-foundation:mainfrom
NikkiAung:fix/missing-authorization-checks
Aug 5, 2026
Merged

security: add missing authorization checks in close-account and transfer-sol#667
dev-jodee merged 3 commits into
solana-foundation:mainfrom
NikkiAung:fix/missing-authorization-checks

Conversation

@NikkiAung

Copy link
Copy Markdown
Contributor

Summary

Audited every native/pinocchio (non-Anchor — no macro-generated account-validation safety net) instruction handler under basics/ for the classic Solana vulnerability classes: missing signer checks, missing owner checks before deserialization, missing PDA re-derivation, arbitrary CPI, duplicate-account aliasing. Found two real, currently-shipping vulnerabilities. Both are invisible to the existing test suites — every existing test only exercises the honest path (the same signer creates and later acts on their own account), so green CI gives false confidence.

This matters more here than in most repos: program-examples is code thousands of learners copy-paste directly into real projects, so a vulnerability in a teaching example teaches the vulnerability to everyone who copies it. Both are the textbook "missing signer authorization" pattern — the most common real-world Solana exploit class.

1. basics/close-account (native + pinocchio): anyone can close and drain anyone else's account

close_user / process_close took target_account and payer, and unconditionally moved nearly all of target_account's lamports into payer, then reassigned it to the System Program. It never checked payer.is_signer, and never re-derived the ["USER", payer.key] PDA to confirm target_account actually belongs to payer.

Exploit: submit CloseUser with [victim's User PDA, attacker's own pubkey, System Program]. The attacker doesn't need the victim's key at all — they sign with their own key (satisfying "some signer exists"), pass the victim's PDA as the account to close, and drain it. PDAs are discoverable via getProgramAccounts.

The Anchor version of this same example was already safe — user: Signer<'info> enforces is_signer, and seeds = [b"USER", user.key().as_ref()] on user_account makes Anchor auto-verify the PDA. Only the native/pinocchio ports dropped both checks when translating away from the framework.

Fix: require payer.is_signer, and re-derive + compare the expected PDA before touching target_account.

2. basics/transfer-sol (anchor + native + pinocchio — all three): anyone can drain any program-owned account

transfer_sol_with_program directly manipulates lamports (**payer.try_borrow_mut_lamports()? -= amount) with no CPI and no is_signer check anywhere. Since the Solana runtime only requires that the owning program initiate a lamport debit (no signature required by the runtime itself), any account owned by this program can be drained by anyone who knows its pubkey.

This is the one case where the bug is not an Anchor-vs-native gap — the Anchor version has it too:

/// CHECK: Use owner constraint to check account is owned by our program
#[account(mut, owner = id())]
payer: UncheckedAccount<'info>,

owner = id() confirms the account is program-owned — which the runtime requires anyway for the debit to succeed at all, so it doesn't add real protection — but never checks is_signer. The top-level basics/transfer-sol/README.md presents this as a working pattern with zero caveat about needing an authorization check, which is exactly the shape of bug that has caused real fund-loss incidents in production "vault" programs holding user funds in program-owned accounts.

Fix: require payer.is_signer in all three implementations. For Anchor, payer changes from UncheckedAccount<'info> to Signer<'info> (constraints like mut/owner compose with either account type, so this is a minimal, idiomatic change — not adding a redundant owner check to native/pinocchio since the runtime already refuses the debit unless the executing program owns the account).

Test plan

Every fix ships with a new adversarial test proving the exploit is blocked. Each one was verified to fail against the unpatched code first (confirming it actually exercises the bug, not a false negative), then verified to pass after the fix, with every pre-existing legitimate-flow test still passing unchanged:

  • close-account/native: attacker (own signer, victim's PDA as target) — fails before fix, blocked after. 3/3 tests pass.
  • close-account/pinocchio: same attack in Rust via cargo test — fails before fix, blocked after. 1/1 tests pass.
  • transfer-sol/anchor (litesvm): attacker marks victim's program-owned account isSigner: false, only signs with their own unrelated fee-payer key — fails before fix, blocked after. 3/3 tests pass.
  • transfer-sol/native: same attack via raw @solana/kit account-role manipulation — fails before fix, blocked after. 4/4 tests pass.
  • transfer-sol/pinocchio: same attack via raw AccountMeta — fails before fix, blocked after. 1/1 tests pass. (Caught and fixed a flaw in my first draft of this test: the initial victim account had only ever received lamports rather than being created via create_account, so it was actually System-Program-owned and the runtime blocked the debit for an unrelated reason. Recreated the victim properly via create_account with this program as owner so the test exercises the real vulnerability.)
  • transfer-sol/anchor/tests/test.ts (the validator-based suite, not litesvm): updated the existing "Transfer SOL with Program" test to add .signers([payerAccount]), since payer is now Signer<'info> and must actually sign — this is a necessary, expected update to the legitimate flow, not a new issue. Could not execute this specific file locally (solana-test-validator/surfpool hangs at startup in this sandbox — unrelated to the change); the identical on-chain program logic is already fully exercised and verified via the litesvm.test.ts suite above, which hits the same compiled program through a full transaction-processing path.

All programs build clean (cargo check / anchor build / cargo build-sbf) with no new warnings beyond pre-existing ones.

…fer-sol

Audited every native/pinocchio (non-Anchor) instruction handler under
basics/ for the classic Solana vulnerability classes: missing signer
checks, missing owner checks, missing PDA re-derivation, arbitrary
CPI, duplicate-account aliasing. Found two real, currently-shipping
vulnerabilities, both invisible to the existing test suites (every
test only exercises the same-signer-creates-and-acts-on-their-own-
account happy path).

1. close-account (native + pinocchio): close_user/process_close took
   target_account and payer and unconditionally moved target_account's
   lamports to payer, then reassigned it to the System Program - with
   no check that payer.is_signer, and no re-derivation of the
   ["USER", payer.key] PDA to confirm target_account actually belongs
   to payer. Anyone could close and drain any other user's account by
   signing with their own key and passing the victim's PDA as the
   target. Fix: require payer.is_signer, and verify target_account
   matches payer's derived PDA before touching it. The Anchor version
   of this example was already safe (Signer<'info> + seeds constraint
   provide both checks automatically) - only the native/pinocchio
   ports dropped them.

2. transfer-sol (anchor + native + pinocchio, all three): transfer_sol
   _with_program directly manipulated lamports with no is_signer
   check. Since the Solana runtime only requires that the *owning*
   program initiate a lamport debit (no signature required by the
   runtime itself), any account owned by this program could be
   drained by anyone who knew its pubkey. The Anchor version's
   `owner = id()` constraint on an UncheckedAccount confirms
   ownership (which the runtime enforces anyway) but never checked
   is_signer either. Fix: require payer.is_signer in all three
   implementations; for Anchor, change payer from UncheckedAccount to
   Signer (constraints compose with either type, so this is a minimal
   change).

Each fix ships with a new adversarial test proving the exploit is
blocked - every one was verified to fail against the unpatched code
first (confirming it actually exercises the bug), then pass after the
fix, with all pre-existing legitimate-flow tests still passing
unchanged. transfer-sol/anchor/tests/test.ts also needed
.signers([payerAccount]) added to its existing "Transfer SOL with
Program" test, since payer is now a Signer and must actually sign.
@NikkiAung
NikkiAung requested a review from dev-jodee as a code owner August 5, 2026 00:58
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds signer and PDA-binding authorization checks to the close-account and transfer-sol examples, with adversarial regression tests across native, Pinocchio, and Anchor implementations.

  • Validates close-account targets against the signing payer’s derived User PDA.
  • Requires authorization before directly debiting program-owned SOL accounts.
  • Adds tests that assert the specific authorization errors returned for attempted attacks.
  • Adds test-only Solana error dependencies needed for typed error assertions.

Confidence Score: 4/5

The code changes appear sound, but the PR must not be merged until its three unsigned commits are replaced with verified commits.

No blocking code failure remains, but every commit in the PR reports “No signature,” which violates the repository’s verified-commit requirement.

Important Files Changed

Filename Overview
basics/close-account/native/program/src/instructions/close_user.rs Adds payer signature validation and binds the target account to the payer-derived User PDA before closing it.
basics/close-account/pinocchio/program/src/lib.rs Mirrors the native close-account authorization checks in the Pinocchio implementation.
basics/transfer-sol/anchor/programs/transfer-sol/src/lib.rs Replaces the unchecked program-transfer payer with a signer while retaining the program-owner constraint.
basics/transfer-sol/native/program/src/instruction.rs Rejects direct lamport debits unless the payer account authorized the transaction.
basics/transfer-sol/pinocchio/program/src/lib.rs Adds the corresponding payer signature requirement to the Pinocchio direct-transfer path.
Cargo.toml Adds workspace test dependencies for matching concrete instruction and transaction errors.
Cargo.lock Records new dependency edges for existing Solana error packages.

Reviews (3): Last reviewed commit: "test(#667): assert on the specific error..." | Re-trigger Greptile

No logic changes - just formatting per the repo's rustfmt.toml and
.prettierrc.json.
@NikkiAung

Copy link
Copy Markdown
Contributor Author

Hey @dev-jodee — bumping this for review when you get a chance. CI is green and Greptile came back clean (5/5, no blocking findings). Thanks!

@dev-jodee dev-jodee left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx ! all the same comments, assert on real error instead of just "an error happened"

Comment thread basics/transfer-sol/pinocchio/program/tests/test.rs Outdated
Comment thread basics/close-account/native/tests/close-account.test.ts
Comment thread basics/transfer-sol/anchor/tests/litesvm.test.ts
Comment thread basics/transfer-sol/native/tests/test.ts
Comment thread basics/close-account/pinocchio/program/tests/tests.rs
…it failed"

dev-jodee's review pointed out that the 5 adversarial tests added in
this PR only checked that the attacker's transaction failed somehow,
not why - a regression elsewhere that made the transaction fail for
an unrelated reason would pass these just as easily as the intended
authorization check firing.

Verified each test's actual failure empirically (rather than assuming
from reading the source) before asserting on it, since two of them
turned out not to be what a first guess would suggest: the close-
account tests fail on the PDA-ownership check (IncorrectProgramId),
since their attacker signs for themselves but requests the wrong
target account, not because they're an unsigned participant like in
the transfer-sol tests (MissingRequiredSignature, or Anchor's custom
AccountNotSigner/3010 for the anchor variant).

The two Rust litesvm tests assert on the real solana-transaction-error/
solana-instruction-error enum variants; the three TS ones match on the
error's descriptive string, since litesvm's napi bindings don't expose
InstructionErrorFieldless as a usable value at runtime despite the
.d.ts claiming otherwise (confirmed empirically - it resolves to a
placeholder function, not a value map).
@NikkiAung

Copy link
Copy Markdown
Contributor Author

Hey @dev-jodee — done fixing, all 5 spots now assert on the specific error instead of a generalized failure check:

  • close-account (native + pinocchio): IncorrectProgramId
  • transfer-sol native + pinocchio: MissingRequiredSignature
  • transfer-sol anchor: Anchor's AccountNotSigner (code 3010)

CI is green. Let me know if anything else needs a look!

@dev-jodee
dev-jodee merged commit 9e4ac05 into solana-foundation:main Aug 5, 2026
29 checks passed
dev-jodee pushed a commit that referenced this pull request Aug 12, 2026
…nstruction (#668)

* security: fix broken take_offer invariant + add missing RefundOffer instruction

Escalated the same security-audit methodology from PR #667 (missing
signer checks in basics/) to tokens/escrow - native, pinocchio, and
anchor. Escrow/vault programs are the highest-value target for this
kind of audit: they hold other people's funds in program custody, and
this repo's version is what learners copy into real token-swap
escrows.

Bug (native only): take_offer's post-transfer invariant check
compared the maker's token-B balance against the wrong variable -
`taker_amount_a_before_transfer` instead of
`maker_amount_b_before_transfer` (a copy-paste of the line above,
half-edited). The real SPL Token CPI always moves the correct amount,
so the assert only happened to pass when the maker's token-B account
balance was exactly 0 before the trade. That's not guaranteed, and is
trivially breakable by a third party: anyone can create the maker's
token-B ATA (permissionless) and send it 1 base unit (also
permissionless, no signature required from the recipient) before a
take_offer call. From that point on, every take_offer for that offer
panics and reverts.

Gap (all three implementations): there was no way for a maker to
reclaim a deposited offer - not in native, not in pinocchio, not even
in the anchor reference version. Only MakeOffer/TakeOffer existed.
This is what turns the assert bug from "annoying" into "permanent
fund loss": once an offer is bricked (or its counterparty simply
never shows up, the ordinary non-adversarial case), the maker's
tokens were locked in the vault with no recovery path.

Fixes:
- One-line fix to the comparison in native's take_offer.rs.
- New RefundOffer instruction in all three implementations: only the
  maker (verified against the offer's stored `maker` field) can call
  it. Returns the vault's current token-A balance to the maker's own
  account, closes the vault and offer accounts, rent to the maker.
  Structurally mirrors each implementation's existing take_offer
  vault-drain-and-close logic (same seeds, same CPI shape), just
  redirecting the destination and dropping the token-B leg.

Every new test was verified to fail before its corresponding fix
(confirming it actually exercises the bug, not a false negative) and
pass after:
- native: regression test pre-funds the maker's token-B account
  before Take Offer to reproduce the exact condition that broke the
  old assert; RefundOffer happy-path and non-maker-cannot-refund
  tests.
- pinocchio: same two RefundOffer tests (never had the assert bug).
- anchor: same two RefundOffer tests added to litesvm.test.ts.

Also extended native's and pinocchio's test `createValues()` helper
to actually respect all the overridable defaults its own type
signature (TestValuesDefaults) already promised - it previously only
read `programId`/`id` from the passed defaults and silently
regenerated maker/taker/mint keypairs regardless, which made it
impossible to create a second offer against the same already-funded
accounts and already-deployed program.

* fix(#668): fix CI typecheck failures in new escrow tests

Two separate tsc --noEmit errors, both only caught by CI's typecheck
step (tsx strips types without checking, so the local mocha run
passed regardless):

- native: svm.getAccount() returns a union type where .data only
  exists on the exists:true variant. Four new call sites decoded
  .data without narrowing first - added the same
  assert(x.exists, ...) guard the rest of this file already uses
  before every decode.
- anchor: the non-maker-refund test intentionally passes mismatched
  accounts (that's what it's proving the program rejects), which
  trips TypeScript's excess-property check against the IDL-derived
  account type. Split it into a separately-typed
  Record<string, PublicKey> and pass it through an explicit `as any`,
  since strict typing doesn't apply to a deliberately-invalid input.

* fix(#668): address Greptile review findings

Four real issues, all verified before/after:

1. native/pinocchio refund_offer.rs (P1, security): neither checked
   that `vault` is actually the offer's canonical ATA. A substitute
   token-A account with owner = the offer PDA (creatable by anyone,
   no cooperation needed from the PDA - owner is just a stored field,
   never requires the owner to sign at account-creation time) could
   be passed instead, draining that decoy and then closing the real
   offer anyway - permanently stranding the genuine vault's funds.
   Added the same assert_is_associated_token_account check make_offer
   already does at vault creation time. New adversarial test (native)
   creates exactly such a decoy and confirms refund now rejects it -
   verified it fails without the fix, passes with it.

2. native refund_offer.rs (P1, security): the caller-supplied
   token_program was used directly as the CPI target with no check
   that it's the real SPL Token program. A substituted fake program
   could report success on both the transfer and close without
   moving anything, after which the offer gets destroyed anyway.
   Added spl_token_interface::check_program_account(), the same
   function the SPL crate's own instruction builders call internally
   - now enforced explicitly up front instead of implicitly deep in
   the CPI. (Pinocchio's Transfer/CloseAccount builders already
   hardcode the real program ID rather than trusting the passed
   account, so this class of bug doesn't apply there.)

3. pinocchio/tests/utils.ts expectRevert (P2): the re-throw meant to
   signal "the operation unexpectedly succeeded" happened inside the
   same try block as the operation itself, so its own catch swallowed
   it - the helper returned normally regardless of whether the
   promise resolved or rejected. This made "Refund Offer rejects a
   non-maker signer" pass vacuously (verified: it kept passing even
   before this fix, silently proving nothing). Restructured per
   Greptile's suggestion so success is tracked in a flag checked after
   the try/catch, outside where the operation's own rejection is
   caught. The non-maker test's underlying security check was never
   actually broken - just its ability to prove that.

4. native/pinocchio tests/utils.ts createValues (P2): my earlier fix
   for making defaults overridable had a bug of its own - if the
   caller supplied only one of mintAKeypair/mintBKeypair and the
   ordering check needed a redo, it could regenerate and discard the
   one the caller DID supply. Rewrote so only the mint not present in
   the original defaults is ever regenerated; if both were supplied
   and are genuinely out of order, throws instead of silently picking
   one to discard.

* fix(#668): address dev-jodee review - vault/destination substitution in take_offer and refund_offer

dev-jodee's review found the same vault-substitution class fixed in
refund_offer.rs during the prior review round was never applied to
take_offer.rs (native + pinocchio), and that pinocchio's
refund_offer.rs only validated the vault, not the maker's own refund
destination account.

- native take_offer.rs: add assert_is_associated_token_account for
  vault before it's used as the transfer source and CPI-signing
  authority, matching the check already present in refund_offer.rs.
- pinocchio take_offer.rs: add the equivalent find_program_address
  canonical-ATA check for vault, matching refund_offer.rs's existing
  pattern.
- pinocchio refund_offer.rs: add the same canonical-ATA check for
  maker_token_account_a, so a substitute destination account can't be
  passed for the refund.

Added adversarial tests for all three (plus a "Refund Offer rejects a
substitute vault account" test for pinocchio, which turned out to
have zero regression coverage for that already-existing check).
Verified each new test fails against the pre-fix code (temporarily
reverted via a captured patch, not a blind revert) and passes after -
confirmed the two brand-new checks (take_offer's vault, refund's
maker_token_account_a) each catch a real gap, while the pre-existing
refund vault check predictably passes either way since it was already
fixed in an earlier commit on this branch.

Anchor's take_offer.rs was already safe: its `vault` field carries
`associated_token::mint/authority/token_program` constraints, so
Anchor validates the canonical ATA at the type level before the
instruction body runs - no equivalent gap there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants