Rust Fund

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

withdraw has no goal/deadline/finalization check — creator can drain all contributions at any time

Description

RustFund is sold as trustless escrow crowdfunding: a creator raises SOL toward a goal by a deadline, contributors get refunds if the goal is not met, and the creator withdraws only once the campaign succeeds. withdraw breaks that guarantee — it enforces none of it:

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(())
}

The account context guards only who calls it (has_one = creator + Signer), never when:

#[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump, has_one = creator)]
pub fund: Account<'info, Fund>,

There is no require!(fund.amount_raised >= fund.goal), no check that the deadline has passed, and no dealine_set check. So a creator can call withdraw the instant any SOL is contributed — before the deadline, with the goal unmet — and drain every contribution. A contributor who, under the protocol's own rules, would be entitled to a refund (goal not met at the deadline) is left with an empty fund account and nothing to refund. The function also never zeroes amount_raised, so the campaign's on-chain accounting is left stale after the pull.

Risk

Impact: High. Direct theft of contributor funds in violation of the protocol's central refund guarantee — the "trustless" escrow is fully bypassable by the creator. Every contribution is withdrawable at will.

Likelihood: High. A single instruction call by the campaign creator, with no preconditions beyond one contribution existing.

Proof of Concept

// anchor test (sketch)
it("creator drains contributions before the deadline, goal unmet", async () => {
await program.methods.fundCreate("camp", "desc", new BN(1_000_000_000)) // goal = 1 SOL
.accounts({ fund, creator: creator.publicKey }).signers([creator]).rpc();
// NOTE: no set_deadline call — deadline stays 0
await program.methods.contribute(new BN(300_000_000)) // 0.3 SOL, goal NOT met
.accounts({ fund, contribution, contributor: alice.publicKey }).signers([alice]).rpc();
const before = await connection.getBalance(creator.publicKey);
await program.methods.withdraw()
.accounts({ fund, creator: creator.publicKey }).signers([creator]).rpc(); // succeeds
const after = await connection.getBalance(creator.publicKey);
assert(after - before >= 300_000_000); // creator took the funds early
// alice's later refund now fails: the fund no longer holds her lamports
});

Recommended Mitigation

Gate withdraw on a finalized, successful campaign, and clear the raised total after paying out:

let fund = &mut ctx.accounts.fund;
require!(fund.dealine_set, ErrorCode::DeadlineNotSet);
require!(Clock::get()?.unix_timestamp as u64 >= fund.deadline, ErrorCode::DeadlineNotReached);
require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet);
let amount = fund.amount_raised;
// ... move `amount` lamports fund -> creator ...
fund.amount_raised = 0;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 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!