Rust Fund

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

withdraw() drains all funds without accounting for pending refunds, causing insolvency

Root + Impact

Description

Contributors can get their SOL back via refund(), and the creator can collect the raised SOL via withdraw(). Both pull lamports out of the same fund account, so the two paths must be coordinated: the creator should not be able to take the lamports that back contributors' refunds. The problem is that withdraw() transfers the entire amount_raised to the creator with no reservation for outstanding contributions, and refund() independently pays contributors from the same fund account. There is no shared state preventing the creator from withdrawing everything while contributions are still refundable. Once the creator withdraws, the fund no longer holds the lamports needed to honor refunds.

// withdraw(): takes ALL raised funds, no reservation for refunds
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised; // @> takes everything
// ... transfer `amount` lamports fund -> creator ...
Ok(())
}
// refund(): independently pays a contributor from the same fund account
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// ... transfer `amount` lamports fund -> contributor ...
// @> no coordination with withdraw(); fund may already be drained
Ok(())
}

Risk

Likelihood:

  • Occurs whenever the creator withdraws while any contribution is still refundable (deadline is 0 or not yet reached at withdraw time).

  • Neither instruction reserves or tracks funds owed to the other path.

Impact:

  • After the creator withdraws all amount_raised, the fund lacks lamports to honor refund(), so contributors' refunds fail — their SOL is effectively stolen.

  • Conversely, refunds paid before a withdrawal reduce the fund below amount_raised, so withdraw()'s checked_sub can fail or the accounting diverges from reality.

  • Net effect: the same lamports are claimable by two parties, leading to fund insolvency.

Proof of Concept

  1. Creator creates a fund (no deadline set, so deadline == 0).

  2. Alice contributes 5 SOL -> fund holds 5 SOL, amount_raised = 5.

  3. Creator calls withdraw -> receives all 5 SOL from the fund.

  4. Alice calls refund (deadline == 0 passes the check) -> the fund no longer
    holds her 5 SOL, so the lamport checked_sub fails / she cannot recover funds.

  5. Alice's contribution is lost: the creator already took the lamports backing it.

it("creator can withdraw funds owed to refundable contributors", async () => {
// Alice contributes 5 SOL (no deadline set -> refundable)
await program.methods.contribute(new BN(5 * LAMPORTS_PER_SOL))
.accounts({ fund, contributor: alice.publicKey, /* ... */ })
.signers([alice]).rpc();
// Creator withdraws everything
await program.methods.withdraw()
.accounts({ fund, creator: creator.publicKey, /* ... */ })
.signers([creator]).rpc();
// Alice tries to refund -> fails, fund was already drained by the creator
let failed = false;
try {
await program.methods.refund()
.accounts({ fund, contribution, contributor: alice.publicKey, /* ... */ })
.signers([alice]).rpc();
} catch (e) {
failed = true; // refund cannot be honored: insolvency
}
assert.isTrue(failed);
});

Note: demonstrates that withdraw and refund both draw from the same fund with no coordination. Running requires the Anchor test setup; the logic flaw is evident from the source.

Recommended Mitigation

Coordinate the two withdrawal paths. For example: only allow withdraw() after the deadline (so the refund window has closed), require the goal to be met, and reset amount_raised. Contributors get refunds only if the campaign fails; the creator withdraws only if it succeeds — never both against the same lamports.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
+ // Only allow withdrawal once the fund is final (e.g., deadline reached and
+ // goal met), and prevent refunds afterward, so the same lamports can't be
+ // claimed by both the creator and contributors.
+ require!(ctx.accounts.fund.deadline != 0, ErrorCode::DeadlineNotSet);
+ require!(
+ ctx.accounts.fund.deadline <= Clock::get()?.unix_timestamp as u64,
+ ErrorCode::DeadlineNotReached
+ );
// ... transfer lamports ...
+ ctx.accounts.fund.amount_raised = 0;
Ok(())
}
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!