Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Severity: medium
Valid

`withdraw` pays out `amount_raised` but never decrements it, so every subsequent withdrawal reverts and later contributions are stranded in the fund PDA

fund.amount_raised is a monotonically increasing counter that survives its own payout, permanently desynchronising the accounting from the PDA's real balance

Description

  • amount_raised is the fund's ledger of how much it is holding. A payout must reduce it, exactly as a bank balance falls when money is taken out. Otherwise the field stops describing the account and starts describing history.

  • withdraw reads amount_raised, transfers that many lamports to the creator, and exits without touching the field. From that moment the counter and the balance are permanently offset by the amount already paid out, and every later withdrawal asks the PDA for lamports that were paid out once already.

// src/lib.rs:90-105
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
@> .checked_sub(amount) // @> lamports really do leave
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
@> // @> MISSING: ctx.accounts.fund.amount_raised = 0;
Ok(())
}

Across the whole program amount_raised is written in exactly two places — = 0 at creation (line 19) and += in contribute (line 50). There is no decrement anywhere in the file.

// src/lib.rs:19 and :50 - the only two writes in the program
fund.amount_raised = 0; // fund_create
...
fund.amount_raised += amount; // contribute - and that is all

The resulting state machine

Let R be the fund PDA's rent-exempt reserve (measured: 0.037590960 SOL for the 5273-byte Fund account), X the balance at the first withdrawal, Y anything contributed afterwards.

step PDA lamports amount_raised
campaign running R + X X
after withdraw #1 R Xstale, still claims X
contribution Y arrives R + Y X + Y
withdraw #2 demands X + Y against R + Y available

Withdrawal #2 therefore fails for every non-trivial X, by one of two different mechanisms:

  • X > R — the program's own checked_sub at line 95 underflows and returns InsufficientFunds.

  • X ≤ R — the arithmetic succeeds, the program logs success, and the runtime then rejects the whole transaction because the account would be left below its rent-exempt minimum: Transaction results in an account (1) with insufficient funds for rent.

There is no value of X for which the second withdrawal works. The two error paths matter because the first one is visible in the program's logs and the second one is not — a creator debugging this sees their instruction report success and the transaction disappear anyway.

Risk

Likelihood:

  • Nothing in the program closes a campaign to new contributions after a withdrawal. contribute (lines 25-52) checks only the deadline, so contributions keep being accepted into a fund whose counter is already stale, and each one deepens the desync.

  • Withdrawal is not a once-at-the-end event in this program. It is callable from the moment the fund exists, so there is no natural single-shot moment that would keep a campaign inside the one-withdrawal window where the bug stays invisible.

  • The failure is deterministic, not probabilistic: given any second withdrawal attempt, it always reverts. No attacker, no race, no timing.

  • No project test covers a second withdrawal — tests/rustfund.ts calls withdraw exactly once, at the very end, and asserts nothing.

Impact:

  • The creator's collection path is denied. Any funds contributed after the first withdrawal cannot be collected through the program at all, and the fund's own accounting is the thing blocking it.

  • Contributions that arrive after a withdrawal sit in the PDA unreachable by the normal flow. Measured in the PoC below: 1.000000000 SOL stranded in a fund whose creator can no longer call withdraw.

  • The corruption is not self-healing and gets strictly worse: because contribute keeps incrementing the counter, the deficit the creator must cover grows with every new contributor.

Honest limitations

Stated explicitly so the severity is judged on facts rather than on my framing. I tested each of these on a live validator rather than assuming them:

  • This is not permanent, and it is not theft. The fund PDA is an ordinary address and anyone can send lamports to it with a plain SystemProgram.transfer, outside the program entirely. Topping the PDA up by the deficit makes withdraw succeed again. My PoC demonstrates this recovery rather than hiding it.

  • The creator can self-rescue, and the rescue is a round-trip out of money they already hold: the deficit is X - R, and they withdrew X in step one. So the practical cost is a temporary liquidity requirement, not a loss.

  • checked_sub prevents over-drainage. The inflated counter never lets the creator extract more than the PDA actually holds, so there is no path to stealing the rent reserve or another campaign's funds through this defect.

This is why I have set Impact to Medium rather than High. Likelihood is High because the guard fails on every second withdrawal without exception. A judge who weighs the external-top-up recovery more heavily than I do would land this at Medium overall, and that is a reasonable reading.

Proof of Concept

Save as poc.mjs and run against a validator with the program loaded:

cargo build-sbf
solana-test-validator --reset --bpf-program 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h target/deploy/rustfund.so
npm install @solana/web3.js && node poc.mjs
import {
Connection, Keypair, PublicKey, SystemProgram, Transaction,
TransactionInstruction, LAMPORTS_PER_SOL, sendAndConfirmTransaction,
} from "@solana/web3.js";
import { createHash } from "crypto";
const PROGRAM_ID = new PublicKey("6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h");
const conn = new Connection("http://127.0.0.1:8899", "confirmed");
const SYS = SystemProgram.programId;
const disc = (n) => createHash("sha256").update(`global:${n}`).digest().subarray(0, 8);
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
const str = (s) => { const d = Buffer.from(s); const l = Buffer.alloc(4); l.writeUInt32LE(d.length); return Buffer.concat([l, d]); };
const M = (pubkey, isSigner, isWritable) => ({ pubkey, isSigner, isWritable });
const sol = (l) => (l / LAMPORTS_PER_SOL).toFixed(9);
async function air(kp) {
const sig = await conn.requestAirdrop(kp.publicKey, 50 * LAMPORTS_PER_SOL);
await conn.confirmTransaction({ signature: sig, ...(await conn.getLatestBlockhash()) }, "confirmed");
}
const send = (ixs, s) => sendAndConfirmTransaction(conn, new Transaction().add(...ixs), s, { commitment: "confirmed" });
async function trySend(ixs, s) {
try { await send(ixs, s); return { okd: true }; }
catch (e) { return { okd: false, why: ((e.logs || []).join("\n").match(/Error Message: [^\n]+/) || [e.message.split("\n")[1] || e.message])[0] }; }
}
const lamports = async (pk) => (await conn.getAccountInfo(pk, "confirmed"))?.lamports ?? 0;
async function amountRaised(pda) {
const d = (await conn.getAccountInfo(pda, "confirmed")).data;
let o = 8; const nl = d.readUInt32LE(o); o += 4 + nl;
const dl = d.readUInt32LE(o); o += 4 + dl;
return d.readBigUInt64LE(o + 8 + 8 + 32);
}
const ixCreate = (f, c, name) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(100 * LAMPORTS_PER_SOL)]) });
const ixContribute = (f, w, cp, amt) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(w, true, true), M(cp, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(amt)]) });
const ixWithdraw = (f, c) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true), M(SYS, false, false)], data: disc("withdraw") });
const cPda = (f, w) => PublicKey.findProgramAddressSync([f.toBuffer(), w.toBuffer()], PROGRAM_ID)[0];
(async () => {
const creator = Keypair.generate(), alice = Keypair.generate(), bob = Keypair.generate();
for (const k of [creator, alice, bob]) await air(k);
const name = "poc-" + Math.floor(Date.now() / 1000);
const [f] = PublicKey.findProgramAddressSync([Buffer.from(name), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([ixCreate(f, creator.publicKey, name)], [creator]);
const R = await lamports(f);
console.log(`rent-exempt reserve R = ${sol(R)} SOL`);
await send([ixContribute(f, alice.publicKey, cPda(f, alice.publicKey), 1 * LAMPORTS_PER_SOL)], [alice]);
await send([ixWithdraw(f, creator.publicKey)], [creator]);
console.log(`after withdraw #1: PDA = ${sol(await lamports(f))} SOL, amount_raised = ${sol(Number(await amountRaised(f)))} SOL <-- STALE`);
// an honest contributor arrives after the creator already withdrew
await send([ixContribute(f, bob.publicKey, cPda(f, bob.publicKey), 1 * LAMPORTS_PER_SOL)], [bob]);
console.log(`bob contributes 1 SOL: PDA = ${sol(await lamports(f))} SOL, amount_raised = ${sol(Number(await amountRaised(f)))} SOL`);
const r = await trySend([ixWithdraw(f, creator.publicKey)], [creator]);
console.log(`withdraw #2 -> ${r.okd ? "SUCCEEDED" : "REVERTED: " + r.why}`);
console.log(`STRANDED: ${sol((await lamports(f)) - R)} SOL sits in the PDA, uncollectable through the program`);
// --- disclosed limitation: an external top-up outside the program restores it ---
const need = Number(await amountRaised(f)) - (await lamports(f));
const donor = Keypair.generate(); await air(donor);
await send([SystemProgram.transfer({ fromPubkey: donor.publicKey, toPubkey: f, lamports: need })], [donor]);
const r2 = await trySend([ixWithdraw(f, creator.publicKey)], [creator]);
console.log(`after an external top-up of ${sol(need)} SOL, withdraw -> ${r2.okd ? "SUCCEEDED" : "REVERTED"} (recovery is possible, see Honest limitations)`);
})();

Output:

rent-exempt reserve R = 0.037590960 SOL
after withdraw #1: PDA = 0.037590960 SOL, amount_raised = 1.000000000 SOL <-- STALE
bob contributes 1 SOL: PDA = 1.037590960 SOL, amount_raised = 2.000000000 SOL
withdraw #2 -> REVERTED: Error Message: An account's balance was too small to complete the instruction.
STRANDED: 1.000000000 SOL sits in the PDA, uncollectable through the program
after an external top-up of 0.962409040 SOL, withdraw -> SUCCEEDED (recovery is possible, see Honest limitations)

The X ≤ R variant, which fails through the runtime instead of the program, is reproduced by changing Alice's contribution to 0.01 * LAMPORTS_PER_SOL. The instruction then logs Program ... success and the transaction is still thrown out with Transaction results in an account (1) with insufficient funds for rent.

Recommended Mitigation

Zero the counter in the same instruction that pays it out, so the field always describes the account rather than its history.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
+ ctx.accounts.fund.amount_raised = 0;
Ok(())
}

refund (lines 66-88) has the identical omission on the other outflow path — it moves lamports out of the fund without reducing amount_raised — so the same decrement belongs there for the invariant to hold end to end:

ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;

The invariant worth asserting after either instruction is fund.to_account_info().lamports() >= rent_minimum + fund.amount_raised.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 13 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-01] Withdrawal doesn't reset amount_raised, leading to locked funds

## Description The `withdraw()` function in the `rustfund` program contains a vulnerability where the `amount_raised` state variable is never reset to zero after a successful withdrawal. This leads to a situation where new contributions after a withdrawal are effectively locked in the contract, as subsequent withdrawal attempts will fail due to insufficient funds. ```rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let amount = ctx.accounts.fund.amount_raised; **ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; **ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Missing: fund.amount_raised = 0; Ok(()) } ``` The key issue is that after transferring the funds to its creator, the function does not reset the `amount_raised` variable. This means that if new contributions are made after a withdrawal, the `amount_raised` value will continue to accumulate. When the creator attempts to withdraw again, the contract will try to transfer the entire `amount_raised` value, which will be larger than the actual balance in the fund account, resulting in an `InsufficientFunds` error. ## Impact 1. **Permanently locked funds**: Any contributions made after a successful withdrawal will be permanently locked in the contract, as the creator cannot withdraw them. 2. **Campaign dysfunction**: The crowdfunding mechanism becomes dysfunctional after the first withdrawal, as any new funds contributed cannot be properly managed. ## Proof of Concept (PoC) The following test demonstrates how funds become locked after a withdrawal due to the amount_raised not being reset: ```javascript import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { Rustfund } from "../target/types/rustfund"; import { PublicKey } from '@solana/web3.js'; import { expect } from 'chai'; describe("amount_raised is never reset", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.Rustfund as Program<Rustfund>; const creator = provider.wallet; const otherUser = anchor.web3.Keypair.generate(); const fundName = "0xWithdrawers Fund04"; const description = "VULN-04"; const goal = new anchor.BN(1000000000); // 1 SOL const contribution = new anchor.BN(1000000000); // 1 SOL let fundPDA: PublicKey; let contributionPDA: PublicKey; before(async () => { // Generate PDA for fund [fundPDA] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Airdrop some SOL to the other user for testing const airdropSignature = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL ); await provider.connection.confirmTransaction(airdropSignature); }); it("Creates a fund", async () => { await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); }); it("Contributes to fund", async () => { // Generate PDA for contribution [contributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()], program.programId ); // Perform a contribution of 1 SOL await program.methods .contribute(contribution) .accounts({ fund: fundPDA, contributor: provider.wallet.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fund = await program.account.fund.fetch(fundPDA); expect(fund.amountRaised.toString()).to.equal(contribution.toString()); }); it("Creator withdraws funds", async () => { const fundBalanceBefore = await provider.connection.getBalance(fundPDA); // Creator withdraws all funds await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fundBalanceAfter = await provider.connection.getBalance(fundPDA); expect(fundBalanceAfter).to.be.below(fundBalanceBefore); // VULNERABILITY: amount_raised is not reset to 0 after withdrawal const fundAfterWithdrawal = await program.account.fund.fetch(fundPDA); expect(fundAfterWithdrawal.amountRaised.toString()).to.equal(contribution.toString()); }); it("New contributions are locked after withdrawal due to VULN-04", async () => { // Generate PDA for otherUser's contribution const [otherUserContributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // Make another contribution from a different user const secondContribution = new anchor.BN(500000000); // 0.5 SOL await program.methods .contribute(secondContribution) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: otherUserContributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // VULNERABILITY: Since the amount_raised wasn't reset, it now includes both contributions const fundAfterSecondContribution = await program.account.fund.fetch(fundPDA); const expectedTotal = contribution.add(secondContribution); expect(fundAfterSecondContribution.amountRaised.toString()).to.equal(expectedTotal.toString()); // Now try to withdraw the second contribution try { await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // If we reach this point, the test has failed expect.fail("Withdrawal should have failed due to insufficient funds"); } catch (error) { // Verify it's the expected error (insufficient funds) expect(error.message).to.include("InsufficientFunds"); } }); }); ``` Save the above test as `tests/04.ts` in your project's test directory and run the test: ```Solidity anchor test ``` ## Concrete Impact Example To illustrate the real-world impact of this vulnerability, consider this scenario: 1. A creator launches a campaign to fund a 10 SOL project. 2. Contributors donate a total of 10 SOL, reaching the goal. 3. The creator withdraws the 10 SOL (withdrawal succeeds) when goal is reached and deadline past. 4. The `amount_raised` in the contract remains at 10 SOL, though the actual balance is now 0. 5. A new contributor donates 2 SOL to support the ongoing project. 6. The creator tries to withdraw this new contribution. 7. The withdrawal fails with an "InsufficientFunds" error because the contract tries to withdraw 12 SOL (the accumulated `amount_raised`), but only 2 SOL is available in the account. 8. The 2 SOL contribution is now permanently locked in the contract, with no mechanism to withdraw it. ## Recommendation The `withdraw()` function should be modified to reset the `amount_raised` value to zero after a successful withdrawal: ```rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let amount = ctx.accounts.fund.amount_raised; **ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; **ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset amount_raised to 0 after successful withdrawal ctx.accounts.fund.amount_raised = 0; Ok(()) } ``` This fix ensures that after each withdrawal, the `amount_raised` is reset to zero, allowing new contributions to be properly accounted for and subsequently withdrawn by the creator.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!