The README says creators "can withdraw funds once their campaign succeeds" and "Withdraws raised funds after successful campaigns", and it calls the platform trustless. A campaign should pay its creator only after the deadline and only when amount_raised >= goal. A failed campaign's SOL is meant to go back to contributors as refunds.
withdraw() reads fund.amount_raised (L91) and moves that many lamports from the fund to the creator with no condition. Its account constraints (L163) check only who may call: the PDA seeds, has_one = creator and the creator's signature. Nothing reads the clock, the deadline or the goal. fund.goal is written in fund_create (L16) and never read anywhere in the program. Anyone can become a creator by calling fund_create, so this power belongs to an untrusted actor.
Likelihood:
It needs the creator to act against the contributors. The creator role is open to anyone through fund_create, and the README does not make creators trusted toward contributors.
The attack is a single withdraw() transaction that can be sent at any moment, before or after the deadline, with no setup or timing.
Impact:
A creator can take all SOL contributed to a campaign before the deadline with the goal unmet. A creator can also take it after the deadline when the campaign has failed, which is exactly when contributors are promised refunds. In the PoC the creator takes 50 SOL on day 1 of a 30-day campaign that has reached 5% of its goal.
The withdrawal leaves the fund holding only its rent-exempt reserve (37,590,960 lamports for the 5,273-byte account). Nothing is left for contributors, even after refund accounting is fixed.
The first test creates a campaign with a 1,000 SOL goal and a 30-day deadline. Alice contributes 30 SOL and Bob 20 SOL.
On day 1 (5% of the goal, deadline 29 days away), a stranger's withdraw() is rejected with Anchor's ConstraintSeeds (2006), so the identity check works.
The creator's withdraw() then succeeds. The creator gains exactly 50 SOL and the fund drops to exactly its rent-exempt minimum. amount_raised still reads 50 SOL.
A repeat withdraw() reverts with InsufficientFunds, so the loss is bounded to the contributed SOL.
After the deadline passes with the goal missed, the fund holds nothing above its rent reserve.
The second test covers two more cases.
After a failed deadline, contribute() is correctly rejected with DeadlineReached (6001), yet the creator still withdraws all 50 SOL.
On a campaign with no deadline set and goal = u64::MAX, the creator withdraws the 10 SOL contributed immediately.
Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-02.rs before running the last line:
Result: test result: ok. 2 passed; 0 failed. Key log lines:
Allow withdrawal only after the deadline and only when the goal has been met. Refunds then need the opposite goal condition (see the refund-gate finding), which makes the two outcomes mutually exclusive.
## 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.