Rust Fund

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

Missing campaign-state validation allows creators to withdraw all contributed SOL

Root + Impact

Description

  • Normally, a campaign creator can withdraw raised SOL only after the campaign deadline has elapsed and the funding goal has been reached.

  • The `withdraw` instruction authenticates the creator but never validates the deadline, goal, campaign outcome, prior withdrawal, or outstanding refund liabilities. Any campaign creator can therefore withdraw the entire recorded balance immediately after contributors fund the campaign.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
// @> Uses the full raised amount without checking deadline or goal.
let amount = ctx.accounts.fund.amount_raised;
// @> Transfers contributor funds directly to the creator.
**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(())
}

Risk

Likelihood:

  • This occurs whenever a creator calls withdraw after at least one contribution, but the handler contains no campaign-state guard.

  • Creating a campaign is permissionless, and the creator signer requirement is satisfied by the same wallet that created the campaign

Impact:

  • The cretor can take 100% of contributor funds before the deadline or with an unmet goal.

  • Contributors lose the refund protection advertised by the protocol.

Proof of Concept

The scenario below creates a campaign with a 1 SOL goal, contributes only 0.5 SOL, and then calls withdraw as the legitimate creator. The expected result is GoalNotReached; the observed result is a successful transfer of the entire 0.5 SOL balance to the creator, proving that creator authentication is the only effective gate.

// State reproduced by the existing local-validator test:
let goal = 1_000_000_000; // 1 SOL
let contributed = 500_000_000; // 0.5 SOL
assert!(contributed < goal);
// No deadline/goal predicate runs inside withdraw().
withdraw_as_creator()?;
assert_eq!(creator_received, contributed);
assert_eq!(fund_balance_after, fund_rent_reserve);

Recommended Mitigation

Enforce the campaign's success conditions independently from creator authentication, then consume the one-time withdrawal claim before moving lamports. Because Solana rolls back all account changes when the transfer fails, writing the terminal state before the transfer provides checks-effects-interactions ordering without leaving partial state committed.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let now = Clock::get()?.unix_timestamp;
+ require!(fund.deadline != 0, ErrorCode::DeadlineNotSet);
+ require!(now >= i64::try_from(fund.deadline)
+ .map_err(|_| ErrorCode::InvalidDeadline)?, ErrorCode::DeadlineNotReached);
+ require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
+ require!(!fund.withdrawn, ErrorCode::AlreadyWithdrawn);
let amount = fund.amount_raised;
+ fund.withdrawn = true;
+ fund.amount_withdrawn = amount;
// Perform the checked lamport transfer after consuming the claim.
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!