Rust Fund

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

Root cause: withdraw checks neither the funding goal nor the deadline. Impact: a creator drains every contribution at any moment, while contributors cannot refund because the campaign is still running

Root cause: withdraw checks neither the funding goal nor the deadline. Impact: a creator drains every contribution at any moment, while contributors cannot refund because the campaign is still running

Description

The README states that creators withdraw "once their campaign succeeds", and the program stores a goal and a deadline on every fund for exactly that purpose. A withdrawal should therefore require that the goal has been reached and the campaign is over.

withdraw performs no such check. It reads amount_raised and moves that many lamports to the creator, with nothing gating when this may happen.

// programs/rustfund/src/lib.rs
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised; // no goal check, no deadline check
**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 goal field is written once at line 16 and never read anywhere else in the program, so it can never influence any decision. The deadline field is enforced elsewhere, in contribute at line 29 and refund at line 69, which shows the check was understood and simply omitted here. The only guard on the instruction is has_one = creator, which constrains who calls it, not when.

Risk

Likelihood:

  • The creator can do this in any block from the first contribution onward, with no precondition, no timing and no special tooling.

  • It needs only the one signature the creator already holds, so every campaign on the platform is exposed by default.

Impact:

  • Every lamport contributed is taken by the creator before the campaign concludes, which is the exact rug pull the goal and deadline fields exist to prevent.

  • Contributors have no recourse: refund rejects them with DeadlineNotReached while the campaign is still live, and by the time the deadline arrives the account is already empty.

Proof of Concept

The attacker is the campaign creator, who needs nothing beyond their own signature. The victims are contributors, whose SOL is taken before the campaign ends. Run with anchor test.

it("H-1 creator withdraws before the goal is met and with no deadline", async () => {
const fund = await createFund("h1rug"); // goal is 10 SOL
const alice = await fundedKeypair();
await contributeFrom(fund, alice, CONTRIB); // alice contributes 2 SOL
const state = await program.account.fund.fetch(fund);
expect(state.goal.gt(state.amountRaised)).to.be.true; // goal NOT reached
expect(state.deadline.toNumber()).to.equal(0); // no deadline at all
const before = await provider.connection.getBalance(creator.publicKey);
await program.methods // creator simply calls withdraw
.withdraw()
.accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.rpc();
const after = await provider.connection.getBalance(creator.publicKey);
expect(after).to.be.greaterThan(before); // alice's SOL is now the creator's
});

Output:

creator gained lamports: 1999995008
✔ H-1 creator withdraws before the goal is met and with no deadline

Two further tests close the obvious objections. With a deadline a full year in the future and the goal unmet, the same call drained the identical 1,999,995,008 lamports, because withdraw never reads fund.deadline. A contributor trying to rescue their SOL afterwards is rejected with DeadlineNotReached, the campaign being formally still live, so neither side has a recovery path.

I also confirmed the existing guard works: a stranger calling withdraw on someone else's fund is rejected by has_one = creator. This is about when a withdrawal is allowed, not who may call it.

Recommended Mitigation

Gate the withdrawal on the two conditions the program already stores, and reset the counter so the funds cannot be claimed twice:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
require!(fund.dealine_set, ErrorCode::DeadlineNotReached);
let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
require!(now >= fund.deadline, ErrorCode::DeadlineNotReached);
require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
let amount = fund.amount_raised;
fund.amount_raised = 0; // prevent a second withdrawal
// ... move `amount` lamports to the creator ...
Ok(())
}

Add a GoalNotReached variant to ErrorCode. A campaign that misses its goal should leave the lamports in the fund so contributors can refund instead.

Updates

Lead Judging Commences

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