Rust Fund

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

`contribute` and `refund` deadline checks short-circuit when `deadline == 0`

rustfund::lib::contribute, rustfund::lib::refund · Confidence: 75 (capped from 78) · Severity: Medium

Validation summary

Stage Verdict Note
2 — Audit candidate at Medium, conf 90 Execution Trace, Invariant, Periphery, Vector Scan (V34)
3 — PoC ADVANCE Tier 2 (TS test demonstrating two contributes succeed while deadline=0)
4 — Adversarial Pass D calibrated Medium (CONFIRMED) Challenge 2 (refund-side currently inert via F-02) bounds but does not invalidate
5 — Platform ADVANCE at Medium, score 78/100 aligns with CodeHawks "Medium" (indirect impact, specific condition)
6 — Program in-scope same scope chain
7 — Duplication no hard duplicates; risk of judge-side merge with F-03 noted

Root cause

Both deadline guards (programs/rustfund/src/lib.rs:29 in contribute and lib.rs:69 in refund) use if fund.deadline != 0 && <time-comparison> — treating 0 as a sentinel for "no deadline." Since fund_create initializes fund.deadline = 0, the post-init pre-set_deadline window is exactly the state in which both guards short-circuit to false.

Location

  • rustfund::lib::contributeprograms/rustfund/src/lib.rs:29

  • rustfund::lib::refundprograms/rustfund/src/lib.rs:69

Exploit path

  1. Creator calls fund_create(...)fund.deadline = 0.

  2. Creator delays calling set_deadline (or never calls it).

  3. Any contributor calls contribute(amount) — guard at lib.rs:29 evaluates fund.deadline != 0 to false, short-circuits the &&, and skips the deadline check. Contribution accepted.

  4. Repeat indefinitely — fund.amount_raised grows without bound while deadline remains 0.

  5. (Refund branch — currently inert because of F-02; live once F-02 is fixed): contributor calls refund — guard at lib.rs:69 short-circuits the same way; refund executes regardless of whether any deadline has been reached.

Proof of concept

  • tier: 2 (TS test)

  • file: argus/20260504T004143Z/3-poc/F-04/poc.ts

  • reproduction: argus/20260504T004143Z/3-poc/F-04/repro.md

  • proven impact: With deadline=0, two successive contribute calls succeed; fund.amount_raised grows by 0.3 SOL across the calls without the deadline guard ever firing.

F-03 reproduction

// PoC for F-03 — `set_deadline` never sets `dealine_set = true`,
// so the creator can call it any number of times and arbitrarily move the deadline.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Rustfund } from "../target/types/rustfund";
import { PublicKey, LAMPORTS_PER_SOL, SystemProgram } from "@solana/web3.js";
import { expect } from "chai";
describe("F-03 set_deadline can be called repeatedly (one-shot guard broken)", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Rustfund as Program<Rustfund>;
const creator = provider.wallet;
const fundName = "F-03-deadline-mutable-fund";
const description = "F-03 demonstrates dealine_set is never set true";
const goal = new anchor.BN(LAMPORTS_PER_SOL);
const firstDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 60); // +60s
const secondDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 9999999); // far future
const thirdDeadline = new anchor.BN(Math.floor(Date.now() / 1000) - 1); // in the past
let fundPDA: PublicKey;
before(async () => {
[fundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
});
it("creator can change deadline 3 times — dealine_set is never flipped", async () => {
await program.methods
.fundCreate(fundName, description, goal)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: SystemProgram.programId,
})
.rpc();
let fund = await program.account.fund.fetch(fundPDA);
expect(fund.dealineSet).to.equal(false);
expect(fund.deadline.toNumber()).to.equal(0);
// First set_deadline call.
await program.methods.setDeadline(firstDeadline).accounts({
fund: fundPDA,
creator: creator.publicKey,
}).rpc();
fund = await program.account.fund.fetch(fundPDA);
expect(fund.deadline.toNumber()).to.equal(firstDeadline.toNumber());
// BUG: even though set_deadline checks `if fund.dealine_set { return Err }`,
// the function never assigns `dealine_set = true`. So the flag stays false.
expect(fund.dealineSet).to.equal(false);
// Second call — should fail with DeadlineAlreadySet, but succeeds because the flag is still false.
await program.methods.setDeadline(secondDeadline).accounts({
fund: fundPDA,
creator: creator.publicKey,
}).rpc();
fund = await program.account.fund.fetch(fundPDA);
expect(fund.deadline.toNumber()).to.equal(secondDeadline.toNumber());
expect(fund.dealineSet).to.equal(false);
// Third call — also succeeds, this time setting deadline in the past.
await program.methods.setDeadline(thirdDeadline).accounts({
fund: fundPDA,
creator: creator.publicKey,
}).rpc();
fund = await program.account.fund.fetch(fundPDA);
expect(fund.deadline.toNumber()).to.equal(thirdDeadline.toNumber());
});
});

Setup

Same as F-01.

Steps

  1. Copy argus/20260504T004143Z/3-poc/F-03/poc.ts to tests/poc-F-03.ts.

  2. Run anchor test.

  3. Observe the it block PASSES — proving the deadline can be re-set repeatedly.

Expected output (buggy code)

F-03 set_deadline can be called repeatedly (one-shot guard broken)
✔ creator can change deadline 3 times — dealine_set is never flipped

Expected output (with suggested fix applied)

Add fund.dealine_set = true; after the fund.deadline = deadline; assignment in set_deadline (programs/rustfund/src/lib.rs:61):

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; // <-- added
Ok(())
}

After the fix, the second call to setDeadline errors with DeadlineAlreadySet and the test FAILS at the second setDeadline call.

Impact

Indirect fund impact: amplifies F-01's drainage pool by accepting contributions indefinitely; post-F-02-fix, also enables refund without any deadline-reached precondition (contradicting README's "if deadlines are reached and goals aren't met"). Maps to CodeHawks "Medium": "Indirect impact on the funds or the protocol's functionality. The attack path isn't straightforward and needs specific conditions."

Recommendation

Replace the short-circuit checks with explicit dealine_set validation (chain with the F-03 fix that flips dealine_set = true):

// In contribute:
- if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
- return Err(ErrorCode::DeadlineReached.into());
- }
+ require!(fund.dealine_set, ErrorCode::DeadlineNotSet);
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().map_err(|_| ErrorCode::CalculationOverflow)?;
+ require!(now < fund.deadline, ErrorCode::DeadlineReached);
// In refund:
- if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
- return Err(ErrorCode::DeadlineNotReached.into());
- }
+ require!(ctx.accounts.fund.dealine_set, ErrorCode::DeadlineNotSet);
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().map_err(|_| ErrorCode::CalculationOverflow)?;
+ require!(now >= ctx.accounts.fund.deadline, ErrorCode::DeadlineNotReached);

Add DeadlineNotSet to ErrorCode. Note: this fix only works once F-03's fix is applied (so dealine_set reliably becomes true after set_deadline).

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 8 days 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!