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.
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.
Creator creates a fund (no deadline set, so deadline == 0).
Alice contributes 5 SOL -> fund holds 5 SOL, amount_raised = 5.
Creator calls withdraw -> receives all 5 SOL from the fund.
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.
Alice's contribution is lost: the creator already took the lamports backing it.
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.
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.
## 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.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.