Rust Fund

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

Missing goal and deadline validation in withdraw lets the campaign creator drain all contributed SOL from a campaign that never succeeded

Root + Impact

Description

In a trustless crowdfunding program the creator may withdraw the raised SOL only after the campaign has succeeded, that is, only once amount_raised has reached goal.

The withdraw instruction transfers the entire amount_raised to the creator without ever comparing amount_raised against goal, and without checking deadline or any finalized flag, so the creator can withdraw at any moment from a campaign that has not met its goal.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
@> // no `require!(amount_raised >= goal)`, no deadline check, no finalized flag
**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:

  • When a creator opens a campaign, receives contributions, and calls withdraw before the goal is reached, the program pays out all raised SOL.

  • When a creator calls withdraw before the deadline on a still-live campaign, no check rejects the call.

Impact:

  • The creator takes 100 percent of the contributors' SOL from a campaign that has not succeeded, which is a direct rug pull.

  • Contributors cannot recover their deposits afterward, because the drained PDA can no longer satisfy the refund transfer.

Proof of Concept

This Anchor test launches a 100 SOL-goal campaign with a deadline one hour in the future, has a contributor send 2 SOL (goal unmet, campaign live), then has the creator withdraw. It passes: the creator gains 2 SOL and the fund loses exactly amount_raised.

it("creator drains an unsuccessful campaign", async () => {
await program.methods.fundCreate("poc-h01", "cause", new anchor.BN(100 * LAMPORTS_PER_SOL))
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.signers([creator]).rpc();
const future = new anchor.BN(Math.floor(Date.now() / 1000) + 3600);
await program.methods.setDeadline(future)
.accounts({ fund: fundPDA, creator: creator.publicKey }).signers([creator]).rpc();
await program.methods.contribute(new anchor.BN(2 * LAMPORTS_PER_SOL))
.accounts({ fund: fundPDA, contributor: contributor.publicKey, contribution: contributionPDA, systemProgram: SystemProgram.programId })
.signers([contributor]).rpc();
const f = await program.account.fund.fetch(fundPDA);
assert.isTrue(f.amountRaised.lt(f.goal)); // goal NOT met
assert.isTrue(f.deadline.gtn(Math.floor(Date.now() / 1000))); // deadline NOT reached
const creatorBefore = await connection.getBalance(creator.publicKey);
const fundBefore = await connection.getBalance(fundPDA);
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.signers([creator]).rpc();
const gained = (await connection.getBalance(creator.publicKey)) - creatorBefore;
const fundDelta = fundBefore - (await connection.getBalance(fundPDA));
assert.strictEqual(fundDelta, 2 * LAMPORTS_PER_SOL); // fund lost exactly amount_raised
assert.isAbove(gained, 1.99 * LAMPORTS_PER_SOL); // creator pocketed it
});

Executed result:

Add a withdrawn: bool field to the Fund struct and GoalNotMet and AlreadyWithdrawn variants to ErrorCode.

creator gained 2 SOL; fund lost 2 SOL on an unsuccessful campaign
✔ lets the creator drain all contributions although the goal was never met an

Recommended Mitigation

Gate withdraw on the success condition and finalize the campaign. The require!(amount_raised >= goal) enforces that funds can leave only after the campaign has actually met its goal, which restores the trustless guarantee that a creator cannot take funds from a campaign that has not succeeded. Setting a withdrawn flag and zeroing amount_raised finalizes the campaign so a second withdraw cannot replay the transfer and so the recorded total stays consistent with the PDA's real lamport balance. If withdrawal should only happen after the campaign closes, also require the deadline to have passed.

Add a withdrawn: bool field to the Fund struct and GoalNotMet and AlreadyWithdrawn variants to ErrorCode.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ require!(ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal, ErrorCode::GoalNotMet);
+ require!(!ctx.accounts.fund.withdrawn, ErrorCode::AlreadyWithdrawn);
let amount = ctx.accounts.fund.amount_raised;
+ ctx.accounts.fund.withdrawn = true; // finalize so withdraw cannot be replayed
+ ctx.accounts.fund.amount_raised = 0; // keep accounting consistent with the balance
**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(())
}
Updates

Lead Judging Commences

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