Rust Fund

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

Creator Can Withdraw Before Goal Is Met, Stealing Funds

Root + Impact

Description

The withdraw() function allows the creator to claim all raised funds without verifying that the fundraising goal was
actually met. While the contract defines a goal field and enforces it during campaign creation, no check is performed at
withdrawal time. This allows creators to steal funds from failed campaigns where the goal was not reached, violating the
core contract promise that funds are only released on successful campaigns.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> NO VALIDATION: Missing check if amount_raised >= goal
// @> Creator can withdraw ANY amount, regardless of goal
**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(())
}
#[account]
pub struct Fund {
#[max_len(200)]
pub name: String,
#[max_len(5000)]
pub description: String,
pub goal: u64, // @> Goal is defined here
pub deadline: u64,
pub creator: Pubkey,
pub amount_raised: u64, // @> Only amount_raised is checked
pub dealine_set: bool,
}
The Problem:
Line 91 (let amount = ctx.accounts.fund.amount_raised;) reads the total raised, but there's NO comparison to
ctx.accounts.fund.goal.

Risk

Likelihood:

  • Creators have immediate economic incentive to call withdraw() as soon as contributions arrive, and the function succeeds
    on the first call regardless of whether the goal is met — there is no technical barrier or time delay preventing this
    exploitation

  • The vulnerability activates the moment any contribution is received, since the fund account has positive lamports and
    the function transfers all of them without checking the goal value

Impact:

  • All contributor funds in campaigns that fail to meet their goal are stolen by the creator — if 300 SOL is raised toward
    a 1000 SOL goal, the creator successfully withdraws all 300 SOL that should be refundable

  • Contributors cannot recover their funds through the refund mechanism because the fund account has been emptied, causing
    refund() calls to fail with InsufficientFunds errors

  • The entire crowdfunding contract promise is violated — funds are supposed to be locked until the goal is met, but
    instead they can be claimed immediately by the creator

Proof of Concept

This test creates a campaign with a 1000 SOL goal but only receives 300 SOL in contributions (goal not met). The creator
then immediately calls withdraw(), which succeeds and transfers all 300 SOL to the creator's wallet. This proves the
vulnerability: the contract allows withdrawal without checking if the goal was actually achieved.

it("creator can withdraw before goal is met", async () => {
const creator = provider.wallet;
const goal = new anchor.BN(1000e9); // 1000 SOL goal
const contribution = new anchor.BN(300e9); // Only 300 SOL raised
const [fundPDA] = await findFundPDA("F02Fund", creator.publicKey);
const [contributionPDA] = await findContributionPDA(fundPDA, creator.publicKey);
// Create fund with 1000 SOL goal
await program.methods.fundCreate("F02Fund", "Test fund", goal)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Set deadline to now (so we can immediately test withdrawal)
const deadline = new anchor.BN(Math.floor(Date.now() / 1000));
await program.methods.setDeadline(deadline)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
})
.rpc();
// Only 300 SOL contributed (goal is 1000 SOL - GOAL NOT MET)
await program.methods.contribute(contribution)
.accounts({
fund: fundPDA,
contributor: creator.publicKey,
contribution: contributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Verify campaign state
const fundData = await program.account.fund.fetch(fundPDA);
console.log("Campaign State:");
console.log(" Goal: " + (fundData.goal.toNumber() / 1e9) + " SOL");
console.log(" Raised: " + (fundData.amountRaised.toNumber() / 1e9) + " SOL");
console.log(" Goal met? " + (fundData.amountRaised.gte(fundData.goal) ? "YES" : "NO"));
// Wait for deadline to pass
await new Promise(resolve => setTimeout(resolve, 1000));
// Creator withdraws (SHOULD FAIL but doesn't!)
const 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);
const fundBalanceAfter = await provider.connection.getBalance(fundPDA);
console.log("\nWithdrawal Result:");
console.log(" Creator received: " + ((creatorBalanceAfter - creatorBalanceBefore) / 1e9) + " SOL");
console.log(" Fund balance after: " + fundBalanceAfter + " lamports");
console.log(" ❌ BUG: Withdrawal succeeded despite goal not being met!");
// Verify the bug
expect(fundBalanceAfter).to.be.lte(creatorBalanceBefore);
});

Recommended Mitigation

What the fix does:

The require! macro validates that the total amount raised (amount) is greater than or equal to the campaign goal
(fund.goal). If this condition is false, the function immediately reverts with an error, preventing any lamport transfers.
This creates an atomic gate that only permits withdrawal on successful campaigns.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = ctx.accounts.fund;
let amount = fund.amount_raised;
// ADD THIS CHECK:
require!(
amount >= fund.goal,
ErrorCode::GoalNotMet
);
// ... rest of function continues unchanged ...
**fund.to_account_info().try_borrow_mut_lamports()? = ...
}
Exact change as diff:
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = ctx.accounts.fund;
let amount = fund.amount_raised;
+ require!(amount >= fund.goal, ErrorCode::GoalNotMet);
**fund.to_account_info().try_borrow_mut_lamports()? =
Also Add:
If GoalNotMet doesn't exist in the ErrorCode enum, add it:
#[error_code]
pub enum ErrorCode {
#[msg("Deadline already set")]
DeadlineAlreadySet,
#[msg("Deadline reached")]
DeadlineReached,
#[msg("Deadline not reached")]
DeadlineNotReached,
#[msg("Unauthorized access")]
UnauthorizedAccess,
#[msg("Calculation overflow occurred")]
CalculationOverflow,
#[msg("Goal not met")] // ADD THIS
GoalNotMet, // ADD THIS
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[H-02] H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals

# H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals **Severity:** High\ **Category:** Fund Management / Economic Security Violation ## Description The `withdraw` function in the RustFund contract allows creators to prematurely withdraw funds without verifying if the campaign goal was successfully met. ## Vulnerability Details In the current RustFund implementation (`lib.rs`), the `withdraw` instruction lacks logic to verify that the campaign's `amount_raised` is equal to or greater than the `goal`. Consequently, creators can freely withdraw user-contributed funds even when fundraising objectives haven't been met, undermining the core economic guarantees of the platform. **Vulnerable Component:** - File: `lib.rs` - Function: `withdraw` - Struct: `Fund` ## Impact - Creators can prematurely drain user-contributed funds. - Contributors permanently lose the ability to receive refunds if the creator withdraws early. - Severely damages user trust and undermines the economic integrity of the RustFund platform. ## Proof of Concept (PoC) ```js // Create fund with 5 SOL goal await program.methods .fundCreate(FUND_NAME, "Test fund", new anchor.BN(5 * LAMPORTS_PER_SOL)) .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); // Contribute only 2 SOL (below goal) await program.methods .contribute(new anchor.BN(2 * LAMPORTS_PER_SOL)) .accounts({ fund, contributor: contributor.publicKey, contribution, systemProgram: SystemProgram.programId, }) .signers([contributor]) .rpc(); // Set deadline to past await program.methods .setDeadline(new anchor.BN(Math.floor(Date.now() / 1000) - 86400)) .accounts({ fund, creator: creator.publicKey }) .signers([creator]) .rpc(); // Attempt withdrawal (should fail but succeeds) await program.methods .withdraw() .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); /* OUTPUT: Fund goal: 5 SOL Contributed amount: 2 SOL Withdrawal succeeded despite not meeting goal Fund balance after withdrawal: 0.00089088 SOL (rent only) */ ``` ## Recommendations Add conditional logic to the `withdraw` function to ensure the campaign has reached its fundraising goal before allowing withdrawals: ```diff pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let fund = &mut ctx.accounts.fund; + require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet); let amount = 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(()) } ``` Also define the new error clearly: ```diff #[error_code] pub enum ErrorCode { // existing errors... + #[msg("Campaign goal not met")] + GoalNotMet, } ```

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!