security: add missing authorization checks in close-account and transfer-sol - #667
Conversation
…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.
Greptile SummaryThe 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.
Confidence Score: 4/5The 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
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.
|
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
left a comment
There was a problem hiding this comment.
thx ! all the same comments, assert on real error instead of just "an error happened"
…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).
|
Hey @dev-jodee — done fixing, all 5 spots now assert on the specific error instead of a generalized failure check:
CI is green. Let me know if anything else needs a look! |
…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.
Summary
Audited every
native/pinocchio(non-Anchor — no macro-generated account-validation safety net) instruction handler underbasics/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-examplesis 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 accountclose_user/process_closetooktarget_accountandpayer, and unconditionally moved nearly all oftarget_account's lamports intopayer, then reassigned it to the System Program. It never checkedpayer.is_signer, and never re-derived the["USER", payer.key]PDA to confirmtarget_accountactually belongs topayer.Exploit: submit
CloseUserwith[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 viagetProgramAccounts.The Anchor version of this same example was already safe —
user: Signer<'info>enforcesis_signer, andseeds = [b"USER", user.key().as_ref()]onuser_accountmakes 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 touchingtarget_account.2.
basics/transfer-sol(anchor + native + pinocchio — all three): anyone can drain any program-owned accounttransfer_sol_with_programdirectly manipulates lamports (**payer.try_borrow_mut_lamports()? -= amount) with no CPI and nois_signercheck 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:
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 checksis_signer. The top-levelbasics/transfer-sol/README.mdpresents 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_signerin all three implementations. For Anchor,payerchanges fromUncheckedAccount<'info>toSigner<'info>(constraints likemut/ownercompose 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 viacargo test— fails before fix, blocked after. 1/1 tests pass.transfer-sol/anchor(litesvm): attacker marks victim's program-owned accountisSigner: 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/kitaccount-role manipulation — fails before fix, blocked after. 4/4 tests pass.transfer-sol/pinocchio: same attack via rawAccountMeta— 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 viacreate_account, so it was actually System-Program-owned and the runtime blocked the debit for an unrelated reason. Recreated the victim properly viacreate_accountwith 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]), sincepayeris nowSigner<'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/surfpoolhangs at startup in this sandbox — unrelated to the change); the identical on-chain program logic is already fully exercised and verified via thelitesvm.test.tssuite 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.