Rust Fund

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

withdraw() transfers all raised funds without checking campaign deadline, enabling creator to drain before goal window closes

Root + Impact

Description

  • withdraw() (line 90) reads amount_raised and transfers the full balance to the creator with no check that the campaign deadline has passed or been reached. Any creator can call withdraw() the moment any contribution arrives, regardless of whether the fundraising window is still active.

  • A malicious creator deploys a campaign with an attractive goal, waits for contributions to accumulate, then calls withdraw() immediately — before the deadline expires — to drain all contributor funds. Contributors have no recourse because the on-chain state shows amount_raised reset to zero through the lamport transfer, yet the campaign deadline may still be days away.

// lib.rs lines 90–105
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised; // line 91
**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(())
// No deadline check anywhere in this function
}

Risk

Likelihood:

  • Any campaign creator can trigger this at will — the call requires only a valid FundWithdraw context signed by the creator, which the creator always controls.

  • No deadline needs to be set for the exploit to work: when fund.deadline == 0 the intended semantics are "no deadline configured," yet withdraw() still succeeds unconditionally.

  • The attack requires zero special conditions beyond having at least one contributor.

Impact:

  • All lamports held by the fund PDA are transferred to the creator before the deadline expires, permanently depriving contributors of their funds.

  • Contributors cannot recover their contributions because amount_raised tracks the running total but the lamports are already gone; refund logic (line 73–81) would underflow on the checked subtraction.

  • A rug-pull is executable in a single transaction, leaving no on-chain evidence of premature withdrawal since the program emits no events.

Proof of Concept

grep -n "pub fn withdraw\|deadline\|amount_raised" programs/rustfund/src/lib.rs
12: pub fn fund_create(ctx: Context<FundCreate>, name: String, description: String, goal: u64) -> Result<()> {
17: fund.deadline = 0;
19: fund.amount_raised = 0;
29: if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
50: fund.amount_raised += amount;
55: pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> {
61: fund.deadline = deadline;
90: pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
91: let amount = ctx.accounts.fund.amount_raised;
186: pub goal: u64,
187: pub deadline: u64,
189: pub amount_raised: u64,

withdraw() at line 90 reads amount_raised on line 91 and executes the full transfer with no conditional on fund.deadline or Clock::get(), while contribute() at line 29 does enforce deadline expiry. The asymmetry means contributions are deadline-gated but withdrawals are not.

Recommended Mitigation

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let fund = &ctx.accounts.fund;
+ // Deadline must be set and must have passed
+ require!(
+ fund.deadline != 0
+ && Clock::get().unwrap().unix_timestamp as u64 >= fund.deadline,
+ ErrorCode::DeadlineNotReached
+ );
+ // Goal must have been reached for creator to withdraw
+ require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet);
+
let amount = ctx.accounts.fund.amount_raised;

The fix adds two guards that mirror the existing logic in contribute():

Deadline check (fund.deadline != 0 && Clock::get()... >= fund.deadline): contribute() already rejects contributions after the deadline (line 29); withdraw() must enforce the inverse — the deadline must be set and must have elapsed before the creator can pull funds. The != 0 guard handles campaigns where set_deadline was never called, preventing an uninitialized deadline from being treated as "immediately expired."

Goal check (fund.amount_raised >= fund.goal): Crowdfunding semantics imply an all-or-nothing model — contributors pledge against a stated goal with the implicit expectation that funds are only disbursed if the goal is met. Without this check, a creator can withdraw a partial raise even if the campaign falls short. If the goal is not reached, contributors should be able to reclaim their lamports via the refund path; this guard ensures withdraw() does not race the refund window.

Together, these two require! gates make withdraw() consistent with the protocol's stated intent: funds flow to the creator only after the fundraising window closes and the goal was achieved. Any other outcome routes back to contributors through the existing refund logic.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 22 days 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!