Rust Fund

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

withdraw() never resets amount_raised and the Fund has no withdrawn state, so a paid-out campaign keeps accepting SOL and every later withdraw() of that SOL fails

Root + Impact

Description

  • After the creator withdraws, the campaign should be settled. Either the fund is marked as paid out and stops taking deposits, or amount_raised reflects what is still escrowed, so later deposits can be withdrawn too.

  • withdraw() pays out the cumulative fund.amount_raised (L91). It then returns (L104) without resetting it or recording that the fund was paid out; the Fund struct has no field for that.

    • contribute() is gated only by the deadline (L29). A fund whose deadline is 0 or still in the future keeps accepting SOL after a payout, and L50 keeps adding it to the stale total.

    • After a first payout W, the fund holds R (its rent-exempt minimum, 37,590,960 lamports), but amount_raised still says W. Every later contribution X raises both by X, so each later withdraw() tries to take W + X from R + X.

    • When W > R, that withdraw reverts with InsufficientFunds. When W < R, it would leave the account below the rent-exempt minimum, so the runtime rejects the transaction (InsufficientFundsForRent). Only W == R exactly succeeds.

    Contributors who deposit after a payout also get 0 from refund() today. That is caused by the separate contribution.amount bug, not by this one.

// programs/rustfund/src/lib.rs L29: contribute(): the only gate is the deadline, so a paid-out fund still takes deposits
if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
​
// programs/rustfund/src/lib.rs L50: contribute(): later deposits are added to the stale total
fund.amount_raised += amount;
​
// programs/rustfund/src/lib.rs L90-L91 (@> L91): withdraw(): pays out the cumulative total ...
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
​
// programs/rustfund/src/lib.rs L98-L104 (@> L104): withdraw(): ... and returns without resetting it or marking the fund as paid out
**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(())
​
// programs/rustfund/src/lib.rs L179-L191: Fund: no withdrawn or closed field
#[account]
#[derive(InitSpace)]
pub struct Fund {
#[max_len(200)]
pub name: String,
#[max_len(5000)]
pub description: String,
pub goal: u64,
pub deadline: u64,
pub creator: Pubkey,
pub amount_raised: u64,
pub dealine_set: bool,
}

Risk

Likelihood:

  • Campaigns start without a deadline (L17), and withdraw() can be called at any time. A creator withdrawing once the goal is reached while the campaign is still open is therefore an ordinary sequence.

  • After the payout, amount_raised still reports the withdrawn SOL as raised, so the campaign looks live and funded and keeps attracting deposits.

Impact:

  • SOL deposited after a payout cannot be withdrawn by the normal call. In the PoC, the shortfall stays at W − R = 962,409,040 lamports however much more is contributed. The creator can unlock it only through an undocumented workaround: send the earlier payout W back into the fund with a plain System transfer, in the same transaction as withdraw(). The PoC shows this nets exactly X, while sending W − R + 1 fails the rent check. No SOL is permanently lost, so the impact is Low.

  • amount_raised misreports the escrow after any payout, and a paid-out campaign keeps accepting deposits.

Proof of Concept

Test 1 runs the scenario twice: on a fund without a deadline, and on a fund with a deadline 30 days out.

  • Alice contributes 1 SOL and the creator withdraws it (baseline). amount_raised still reads 1 SOL, while the fund holds only its rent reserve.

  • Bob then contributes 0.5 SOL, which is accepted. The creator's second withdraw() reverts with InsufficientFunds.

  • After Bob adds 2 SOL more, a third withdraw() reverts the same way, with the shortfall unchanged.

  • Bob's refund() on the fund without a deadline moves 0 lamports. That last step comes from the separate contribution.amount bug.

Test 2 covers the other two cases.

  • W < R: the payout is 0.01 SOL, and the runtime rejects the second withdraw() with InsufficientFundsForRent.

  • W == R: the second withdraw() succeeds and drains the fund to 0.

Test 3 shows how far the impact goes. Each attempt is one transaction: send lamports back into the fund, then call withdraw().

  • Sending W − R + 1 lamports back fails the rent check.

  • Sending W back succeeds, and the creator nets exactly the 0.5 SOL deposited after the payout.

// PoC RF-05: withdraw() pays out fund.amount_raised but never resets it, and nothing marks the fund
// as withdrawn/closed, so the campaign keeps taking SOL after a payout and every later withdraw fails.
//
// lib.rs:91 let amount = ctx.accounts.fund.amount_raised; <- read, never written back
// lib.rs:93-101 lamports moved fund -> creator
// lib.rs:29 contribute's only gate is the deadline (open when 0 or in the future)
// lib.rs:50 fund.amount_raised += amount; <- only post-create write
// lib.rs:181-191 Fund has no withdrawn/closed field
//
// After a first payout W, (lamports - amount_raised) is stuck at R - W (R = rent-exempt minimum).
// Every later contribution X raises both sides by X, so withdraw tries to take W + X from R + X:
// W > R -> checked_sub fails -> InsufficientFunds
// W < R -> leaves R - W lamports (RentPaying) -> runtime rejects the tx (InsufficientFundsForRent)
// W == R -> drains to exactly 0 -> allowed
//
// Tests:
// 1. main scenario (W = 1 SOL > R) on an open-ended fund (deadline 0) and on a dated fund
// (deadline now+30d): payout OK, later deposits accepted, second withdraw reverts forever,
// amount_raised over-reports the escrow, refund moves 0 (RF-01 dependency).
// 2. the W < R branch (rent check) and the W == R edge.
// 3. recovery: the creator can unlock by re-injecting the earlier payout with a plain System
// transfer in the same tx (net +X), which bounds the impact (no permanent loss for the creator).
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
account::Account as SdkAccount,
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
system_instruction,
transaction::{Transaction, TransactionError},
};
​
const 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)
}
​
// ---------------------------------------------------------------- helpers
​
fn new_pt() -> ProgramTest {
ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction))
}
​
fn give_sol(pt: &mut ProgramTest, who: &Pubkey, sol: u64) {
pt.add_account(*who, SdkAccount::new(sol * SOL, 0, &solana_sdk::system_program::id()));
}
​
fn fund_pda(name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], &rustfund::ID).0
}
​
fn contribution_pda(fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], &rustfund::ID).0
}
​
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 fund_rent_min(ctx: &mut ProgramTestContext, fund: &Pubkey) -> u64 {
let acc = ctx.banks_client.get_account(*fund).await.unwrap().unwrap();
ctx.banks_client.get_rent().await.unwrap().minimum_balance(acc.data.len())
}
​
async fn now(ctx: &mut ProgramTestContext) -> i64 {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap().unix_timestamp
}
​
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: "creator withdraws once the goal is met".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 {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund,
contributor,
contribution: contribution_pda(&fund, &contributor),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
}
}
​
fn refund_ix(fund: Pubkey, contributor: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundRefund {
fund,
contribution: contribution_pda(&fund, &contributor),
contributor,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.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(),
}
}
​
// ---------------------------------------------------------------- 1. main scenario, W = 1 SOL > R
​
#[tokio::test]
async fn rf05_after_first_payout_later_deposits_are_accepted_but_unwithdrawable() {
let creator = Keypair::new();
let (alice, bob) = (Keypair::new(), Keypair::new());
let open = fund_pda("open", &creator.pubkey());
let dated = fund_pda("dated", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 10);
give_sol(&mut pt, &bob.pubkey(), 10);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
​
send(&mut ctx, &[create_ix(open, creator.pubkey(), "open", SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[create_ix(dated, creator.pubkey(), "dated", SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(dated, creator.pubkey(), (t0 + 30 * DAY) as u64)], &[&creator]).await.unwrap();
​
for (fund, label) in [(open, "open (deadline 0)"), (dated, "dated (deadline now+30d)")] {
let r = fund_rent_min(&mut ctx, &fund).await;
println!("---- fund {}: rent-exempt minimum R = {} lamports", label, r);
assert_eq!(lamports(&mut ctx, &fund).await, r);
​
// Baseline: Alice contributes 1 SOL (= goal), creator withdraws it. This works as intended.
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), SOL)], &[&alice]).await.unwrap();
let f = fund_state(&mut ctx, &fund).await;
assert_eq!(lamports(&mut ctx, &fund).await, r + SOL);
assert_eq!(f.amount_raised, SOL);
let c0 = lamports(&mut ctx, &creator.pubkey()).await;
let res = send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await;
let c1 = lamports(&mut ctx, &creator.pubkey()).await;
let f = fund_state(&mut ctx, &fund).await;
let fl = lamports(&mut ctx, &fund).await;
println!("[1] Alice contribute 1 SOL, withdraw #1 -> {:?}; creator +{} lamports; fund lamports={} amount_raised={}",
res, c1 - c0, fl, f.amount_raised);
assert!(res.is_ok(), "first withdraw succeeds (baseline)");
assert_eq!(c1 - c0, SOL, "creator received W = 1 SOL");
assert_eq!(fl, r, "escrow is empty: only the rent reserve remains");
// BUG limb 3: counter not reset, so the paid-out campaign still reports 1 SOL raised.
assert_eq!(f.amount_raised, SOL, "amount_raised still reports the withdrawn 1 SOL");
​
// BUG limb 4: the paid-out campaign keeps accepting deposits.
let res = send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), SOL / 2)], &[&bob]).await;
let f = fund_state(&mut ctx, &fund).await;
let fl = lamports(&mut ctx, &fund).await;
println!("[2] after payout, Bob contribute 0.5 SOL -> {:?}; fund lamports={} amount_raised={}",
res, fl, f.amount_raised);
assert!(res.is_ok(), "contribute accepted after the campaign was paid out");
assert_eq!(fl, r + SOL / 2);
assert_eq!(f.amount_raised, SOL + SOL / 2);
​
// BUG limb 1: second withdraw tries to take W + X = 1.5 SOL from R + X and reverts.
let c0 = lamports(&mut ctx, &creator.pubkey()).await;
let err = send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("[3] withdraw #2 -> {:?}", err);
assert!(matches!(
err,
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::InsufficientFunds))
));
assert_eq!(lamports(&mut ctx, &creator.pubkey()).await, c0, "creator got nothing");
assert_eq!(lamports(&mut ctx, &fund).await, r + SOL / 2, "Bob's 0.5 SOL stays in the fund");
​
// More deposits never close the gap: both sides rise by the same X.
send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), 2 * SOL)], &[&bob]).await.unwrap();
let f = fund_state(&mut ctx, &fund).await;
let fl = lamports(&mut ctx, &fund).await;
let err = send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("[4] Bob +2 SOL more: fund lamports={} amount_raised={} shortfall(amount_raised - lamports)={}; withdraw #3 -> {:?}",
fl, f.amount_raised, f.amount_raised - fl, err);
assert!(matches!(
err,
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::InsufficientFunds))
));
assert_eq!(f.amount_raised - fl, SOL - r, "shortfall is constant at W - R");
assert_eq!(fl, r + SOL / 2 + 2 * SOL);
}
​
// Contributor side (open fund, deadline 0 so refund is allowed): refund moves 0 lamports.
// NOTE: this limb is caused by RF-01 (contribution.amount never recorded), not by RF-05.
let f0 = lamports(&mut ctx, &open).await;
let b0 = lamports(&mut ctx, &bob.pubkey()).await;
let res = send(&mut ctx, &[refund_ix(open, bob.pubkey())], &[&bob]).await;
let moved = lamports(&mut ctx, &bob.pubkey()).await as i128 - b0 as i128;
println!("[5] Bob refund on `open` -> {:?}; Bob balance change = {} lamports; fund keeps {} lamports",
res, moved, lamports(&mut ctx, &open).await);
assert!(res.is_ok());
assert_eq!(moved, 0);
assert_eq!(lamports(&mut ctx, &open).await, f0, "2.5 SOL of post-payout deposits stay locked in the fund");
}
​
// ---------------------------------------------------------------- 2. W < R (rent rejection) and W == R (edge)
​
#[tokio::test]
async fn rf05_small_payout_second_withdraw_rejected_by_rent_rule_and_exact_rent_edge() {
let creator = Keypair::new();
let alice = Keypair::new();
let small = fund_pda("small", &creator.pubkey());
let exact = fund_pda("exact", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 10);
let mut ctx = pt.start_with_context().await;
​
// W < R: W = 0.01 SOL.
send(&mut ctx, &[create_ix(small, creator.pubkey(), "small", SOL / 100)], &[&creator]).await.unwrap();
let r = fund_rent_min(&mut ctx, &small).await;
let w = SOL / 100;
assert!(w < r);
send(&mut ctx, &[contribute_ix(small, alice.pubkey(), w)], &[&alice]).await.unwrap();
send(&mut ctx, &[withdraw_ix(small, creator.pubkey())], &[&creator]).await.unwrap();
assert_eq!(lamports(&mut ctx, &small).await, r);
send(&mut ctx, &[contribute_ix(small, alice.pubkey(), SOL / 2)], &[&alice]).await.unwrap();
let err = send(&mut ctx, &[withdraw_ix(small, creator.pubkey())], &[&creator]).await.unwrap_err();
println!("[W<R] R={} W={}: payout #1 OK; +0.5 SOL; withdraw #2 would leave R-W={} (RentPaying) -> {:?}",
r, w, r - w, err);
assert!(matches!(err, BanksClientError::TransactionError(TransactionError::InsufficientFundsForRent { .. })));
assert_eq!(lamports(&mut ctx, &small).await, r + SOL / 2, "0.5 SOL stays in the fund");
​
// W == R exactly: second withdraw drains the account to 0 and is allowed (the only passing case).
send(&mut ctx, &[create_ix(exact, creator.pubkey(), "exact", r)], &[&creator]).await.unwrap();
assert_eq!(fund_rent_min(&mut ctx, &exact).await, r);
send(&mut ctx, &[contribute_ix(exact, alice.pubkey(), r)], &[&alice]).await.unwrap();
send(&mut ctx, &[withdraw_ix(exact, creator.pubkey())], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(exact, alice.pubkey(), SOL / 2)], &[&alice]).await.unwrap();
let res = send(&mut ctx, &[withdraw_ix(exact, creator.pubkey())], &[&creator]).await;
println!("[W==R] W=R={}: withdraw #2 -> {:?}; fund lamports after = {}", r, res, lamports(&mut ctx, &exact).await);
assert!(res.is_ok());
assert_eq!(lamports(&mut ctx, &exact).await, 0, "account drained to 0 (closed by runtime)");
}
​
// ---------------------------------------------------------------- 3. recovery bounds the impact
​
#[tokio::test]
async fn rf05_creator_can_unlock_by_reinjecting_earlier_payout_in_one_tx() {
let creator = Keypair::new();
let (alice, bob) = (Keypair::new(), Keypair::new());
let fund = fund_pda("recover", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 10);
give_sol(&mut pt, &bob.pubkey(), 10);
let mut ctx = pt.start_with_context().await;
​
send(&mut ctx, &[create_ix(fund, creator.pubkey(), "recover", SOL)], &[&creator]).await.unwrap();
let r = fund_rent_min(&mut ctx, &fund).await;
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), SOL)], &[&alice]).await.unwrap();
send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.unwrap(); // creator now holds W
send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), SOL / 2)], &[&bob]).await.unwrap();
assert!(send(&mut ctx, &[withdraw_ix(fund, creator.pubkey())], &[&creator]).await.is_err(), "locked as in test 1");
​
// Partial re-injection (between W-R and W, not equal to either) still fails the rent rule.
let c0 = lamports(&mut ctx, &creator.pubkey()).await;
let partial = SOL - r + 1;
let err = send(
&mut ctx,
&[system_instruction::transfer(&creator.pubkey(), &fund, partial), withdraw_ix(fund, creator.pubkey())],
&[&creator],
)
.await
.unwrap_err();
println!("[recover] re-inject W-R+1={} then withdraw (one tx) -> {:?}", partial, err);
assert!(matches!(err, BanksClientError::TransactionError(TransactionError::InsufficientFundsForRent { .. })));
​
// Re-injecting the earlier payout W with a plain System transfer, then withdraw, in ONE tx: succeeds.
let res = send(
&mut ctx,
&[system_instruction::transfer(&creator.pubkey(), &fund, SOL), withdraw_ix(fund, creator.pubkey())],
&[&creator],
)
.await;
let c1 = lamports(&mut ctx, &creator.pubkey()).await;
let f = fund_state(&mut ctx, &fund).await;
println!("[recover] re-inject W=1 SOL then withdraw (one tx) -> {:?}; creator net change = +{} lamports; fund lamports={} amount_raised={} (still stale)",
res, c1 as i128 - c0 as i128, lamports(&mut ctx, &fund).await, f.amount_raised);
assert!(res.is_ok());
assert_eq!(c1 - c0, SOL / 2, "creator nets exactly X: funds were locked, not lost");
assert_eq!(lamports(&mut ctx, &fund).await, r);
assert_eq!(f.amount_raised, SOL + SOL / 2, "counter still never reset");
}

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-05.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-05 -- --nocapture

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

[1] Alice contribute 1 SOL, withdraw #1 -> Ok(()); creator +1000000000 lamports; fund lamports=37590960 amount_raised=1000000000
[2] after payout, Bob contribute 0.5 SOL -> Ok(()); fund lamports=537590960 amount_raised=1500000000
[3] withdraw #2 -> TransactionError(InstructionError(0, InsufficientFunds))
[4] Bob +2 SOL more: fund lamports=2537590960 amount_raised=3500000000 shortfall(amount_raised - lamports)=962409040; withdraw #3 -> TransactionError(InstructionError(0, InsufficientFunds))
[W<R] R=37590960 W=10000000: payout #1 OK; +0.5 SOL; withdraw #2 would leave R-W=27590960 (RentPaying) -> TransactionError(InsufficientFundsForRent { account_index: 2 })
[recover] re-inject W-R+1=962409041 then withdraw (one tx) -> TransactionError(InsufficientFundsForRent { account_index: 2 })
[recover] re-inject W=1 SOL then withdraw (one tx) -> Ok(()); creator net change = +500000000 lamports; fund lamports=37590960 amount_raised=1500000000 (still stale)

Recommended Mitigation

Record that the fund has been paid out. Then refuse a second payout and refuse deposits after it.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -29,4 +29,7 @@
if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
+ if fund.withdrawn {
+ return Err(ErrorCode::AlreadyWithdrawn.into());
+ }
@@ -90,2 +93,5 @@
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ if ctx.accounts.fund.withdrawn {
+ return Err(ErrorCode::AlreadyWithdrawn.into());
+ }
let amount = ctx.accounts.fund.amount_raised;
@@ -101,5 +107,6 @@
.ok_or(ErrorCode::CalculationOverflow)?;
+ ctx.accounts.fund.withdrawn = true;
Ok(())
}
@@ -189,3 +196,4 @@
pub amount_raised: u64,
pub dealine_set: bool,
+ pub withdrawn: bool,
}
@@ -205,3 +213,5 @@
#[msg("Calculation overflow occurred")]
CalculationOverflow,
+ #[msg("Funds already withdrawn")]
+ AlreadyWithdrawn,
}
Updates

Lead Judging Commences

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

[M-01] Withdrawal doesn't reset amount_raised, leading to locked funds

## Description The `withdraw()` function in the `rustfund` program contains a vulnerability where the `amount_raised` state variable is never reset to zero after a successful withdrawal. This leads to a situation where new contributions after a withdrawal are effectively locked in the contract, as subsequent withdrawal attempts will fail due to insufficient 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)?; // Missing: fund.amount_raised = 0; Ok(()) } ``` The key issue is that after transferring the funds to its creator, the function does not reset the `amount_raised` variable. This means that if new contributions are made after a withdrawal, the `amount_raised` value will continue to accumulate. When the creator attempts to withdraw again, the contract will try to transfer the entire `amount_raised` value, which will be larger than the actual balance in the fund account, resulting in an `InsufficientFunds` error. ## Impact 1. **Permanently locked funds**: Any contributions made after a successful withdrawal will be permanently locked in the contract, as the creator cannot withdraw them. 2. **Campaign dysfunction**: The crowdfunding mechanism becomes dysfunctional after the first withdrawal, as any new funds contributed cannot be properly managed. ## Proof of Concept (PoC) The following test demonstrates how funds become locked after a withdrawal due to the amount_raised not being reset: ```javascript 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("amount_raised is never reset", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.Rustfund as Program<Rustfund>; const creator = provider.wallet; const otherUser = anchor.web3.Keypair.generate(); const fundName = "0xWithdrawers Fund04"; const description = "VULN-04"; const goal = new anchor.BN(1000000000); // 1 SOL const contribution = new anchor.BN(1000000000); // 1 SOL let fundPDA: PublicKey; let contributionPDA: PublicKey; before(async () => { // Generate PDA for fund [fundPDA] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Airdrop some SOL to the other user for testing const airdropSignature = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL ); await provider.connection.confirmTransaction(airdropSignature); }); it("Creates a fund", async () => { await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); }); it("Contributes to fund", async () => { // Generate PDA for contribution [contributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()], program.programId ); // Perform a contribution of 1 SOL await program.methods .contribute(contribution) .accounts({ fund: fundPDA, contributor: provider.wallet.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fund = await program.account.fund.fetch(fundPDA); expect(fund.amountRaised.toString()).to.equal(contribution.toString()); }); it("Creator withdraws funds", async () => { const fundBalanceBefore = await provider.connection.getBalance(fundPDA); // Creator withdraws all funds await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fundBalanceAfter = await provider.connection.getBalance(fundPDA); expect(fundBalanceAfter).to.be.below(fundBalanceBefore); // VULNERABILITY: amount_raised is not reset to 0 after withdrawal const fundAfterWithdrawal = await program.account.fund.fetch(fundPDA); expect(fundAfterWithdrawal.amountRaised.toString()).to.equal(contribution.toString()); }); it("New contributions are locked after withdrawal due to VULN-04", async () => { // Generate PDA for otherUser's contribution const [otherUserContributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // Make another contribution from a different user const secondContribution = new anchor.BN(500000000); // 0.5 SOL await program.methods .contribute(secondContribution) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: otherUserContributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // VULNERABILITY: Since the amount_raised wasn't reset, it now includes both contributions const fundAfterSecondContribution = await program.account.fund.fetch(fundPDA); const expectedTotal = contribution.add(secondContribution); expect(fundAfterSecondContribution.amountRaised.toString()).to.equal(expectedTotal.toString()); // Now try to withdraw the second contribution try { await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // If we reach this point, the test has failed expect.fail("Withdrawal should have failed due to insufficient funds"); } catch (error) { // Verify it's the expected error (insufficient funds) expect(error.message).to.include("InsufficientFunds"); } }); }); ``` Save the above test as `tests/04.ts` in your project's test directory and run the test: ```Solidity 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. 2. Contributors donate a total of 10 SOL, reaching the goal. 3. The creator withdraws the 10 SOL (withdrawal succeeds) when goal is reached and deadline past. 4. The `amount_raised` in the contract remains at 10 SOL, though the actual balance is now 0. 5. A new contributor donates 2 SOL to support the ongoing project. 6. The creator tries to withdraw this new contribution. 7. The withdrawal fails with an "InsufficientFunds" error because the contract tries to withdraw 12 SOL (the accumulated `amount_raised`), but only 2 SOL is available in the account. 8. The 2 SOL contribution is now permanently locked in the contract, with no mechanism to withdraw it. ## Recommendation The `withdraw()` function should be modified to reset the `amount_raised` value to zero after a successful withdrawal: ```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)?; // Reset amount_raised to 0 after successful withdrawal ctx.accounts.fund.amount_raised = 0; Ok(()) } ``` This fix ensures that after each withdrawal, the `amount_raised` is reset to zero, allowing new contributions to be properly accounted for and subsequently withdrawn by the creator.

Support

FAQs

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

Give us feedback!