Description:
Per the protocol documentation:
Secure Withdrawals: Creators can withdraw funds once their campaign succeeds
This establishes withdrawal as conditional on campaign success — implying, at minimum, that the fundraising goal must have been met (and typically that the deadline has been reached, mirroring the same lifecycle gating refund() is supposed to enforce in the opposite direction).
The actual withdraw implementation contains no such gating:
There is no reference to Clock::get(), no comparison against fund.deadline, and no comparison against fund.goal anywhere in the function. The only checks performed happen earlier, during account validation in FundWithdraw (PDA re-derivation and has_one = creator) — these confirm the caller is the legitimate creator of this specific fund, but say nothing about whether the campaign has actually succeeded, or even started collecting contributions.
Root Cause:
withdraw() unconditionally sweeps fund.amount_raised to the creator with no check that:
The deadline has been reached, and
The fundraising goal (fund.goal) has actually been met by fund.amount_raised.
Impact:
A fund's creator can call withdraw() immediately after any contribution lands in the fund — even a single, partial contribution far below fund.goal, and even before any deadline has been set or reached. This directly contradicts the "Creators can withdraw funds once their campaign succeeds" guarantee contributors rely on when deciding to contribute.
This is a classic rug-pull primitive: a malicious or opportunistic creator can create a fund, wait for any contributions to arrive, and immediately withdraw them — well before the campaign deadline, and without ever reaching the stated goal — leaving contributors with a failed campaign, no product/outcome, and (compounding with [H-1]) no way to recover their contribution via refund() either, since refund()'s accounting is already broken independent of this bug.
Because fund.amount_raised is also never decremented or reset after a withdrawal, a creator could in principle call withdraw() again after further contributions accumulate, repeatedly draining new deposits as they arrive, with no lifecycle restriction at any point in the fund's existence.
Proof of Concept:
Add the following to rustfund.ts:
Expected output:
The withdraw() call succeeds and the creator's balance increases by the full contributed amount, despite amount_raised (0.1 SOL) being far below goal (1 SOL) and the deadline still being an hour away — confirming withdrawal is not actually gated on campaign success.
Recommended Mitigation:
Add both a deadline check and a goal check before allowing withdrawal, mirroring (and inverse to) the intended refund() logic:
A new GoalNotMet variant should be added to ErrorCode. Resetting fund.amount_raised to 0 after a successful withdrawal is also recommended to keep the fund's bookkeeping accurate and prevent any future double-withdrawal path if further contributions are (intentionally or otherwise) accepted after a withdrawal.
# 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, } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.