Rust Fund

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

set_deadline never sets dealine_set, so the creator can overwrite or clear the deadline any number of times and revoke every contributor's refund window at will

Description
FundSetDeadline is meant to be a one-shot action, and the program carries both the flag and the error for that rule:

rust

pub dealine_set: bool, // L190 — declared as the "already set" marker
#[msg("Deadline already set")] DeadlineAlreadySet, // L197-L198 — the error for a second call

pub fn set_deadline(ctx: Context, deadline: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
if fund.dealine_set { // L57 — checked …
return Err(ErrorCode::DeadlineAlreadySet.into());
}
fund.deadline = deadline; // L61 — … but the flag is never set to true
Ok(())
}
dealine_set is written exactly once in the whole program, at fund_create (L20, = false). No instruction ever assigns it true, so the guard at L57 can never trigger: set_deadline is callable an unlimited number of times by the creator.

That matters because deadline is the only thing gating refunds and contributions:

rust

// contribute (L29): contribution allowed while deadline == 0 or deadline >= now
if fund.deadline != 0 && fund.deadline Clock::get().unwrap().unix_timestamp.try_into().unwrap() { ... }
Because deadline == 0 means "no deadline" in both checks, a creator can move the deadline forward after contributors have already paid — pushing every refund window into the future indefinitely — or set it back to 0, which removes the refund path entirely. The repo's own tests use exactly this lever: setDeadline(new anchor.BN(0)) at L242 and L292 is what makes refund() permanently unavailable for those contributors.

Risk
Contributors cannot rely on the deadline they contributed against. A campaign can extend its own deadline after collecting, so "the deadline passed, I'll get my money back" is not a guarantee the contributor can act on — the creator can move the target before anyone calls refund().
The one-shot rule the code advertises does not exist. DeadlineAlreadySet is unreachable dead code, and the flag that would enforce it is never set; anyone reading the program will believe the constraint holds.
Combined with Finding 4, the creator controls who gets paid. Extending or clearing the deadline decides whether refunds are possible at all, while withdraw() has no such gate — so the creator can always act first and take the pool before any contributor's window opens.
Impact is medium (the refund guarantee and campaign terms are unilaterally changeable; funds are not directly drained by this instruction) and likelihood is high: it needs one extra ordinary call from the creator, which the repo's own test suite already makes twice.

Proof of Concept
Overview. fund.dealine_set is checked at L57 but never assigned true anywhere in the program, so the "deadline can only be set once" guard is unreachable and the creator can rewrite or clear fund.deadline repeatedly.

Actors.

Attacker: the creator (the only signer with has_one = creator on FundSetDeadline, L138).
Victim: contributors who contributed against a deadline that the creator later moves to the future or back to 0, removing their refund path.
Protocol: Fund.deadline / Fund.dealine_set (L187, L190) gating contribute (L29) and refund (L69).
Anchor integration test — the repo's own suite already demonstrates repeated set_deadline calls on the same fund:

ts

// tests/rustfund.ts L55-L67: first set_deadline(deadline = now + 10s) succeeds.
// tests/rustfund.ts L242-L247 and L292-L297 (later tests, same fundPDA): a SECOND set_deadline is accepted —
await program.methods.setDeadline(new anchor.BN(0)).accounts({ fund: fundPDA, creator: creator.publicKey }).rpc();
// No DeadlineAlreadySet error is raised even though L57 checks the flag, because the flag was never set to true.

// Consequence for a contributor: with deadline == 0 the refund gate at L69 is skipped entirely and
// the contribute gate at L29 is always open, so the campaign has no refund window at all:
await assert.rejects(() => program.methods.refund().rpc(), /* would need DeadlineNotReached — instead it pays 0 */);
Run with anchor build && anchor test: both setDeadline calls succeed on the same fund. A third call with any future value also succeeds, which is the finding: ConstraintSpace/DeadlineAlreadySet never fires.

Anchor-level arithmetic reproduction (audit_repro.rs) of what the two gates do when the deadline is rewritten to 0:

rust

// contribute gate (L29): reject only if deadline != 0 && deadline now
let refund_allowed = !(deadline != 0 && (deadline as i64) > now);
println!("{label}: contribution_rejected={contribution_rejected} refund_available={refund_allowed}");
}
代码

$ rustc audit_repro.rs -o audit_repro && ./audit_repro
creator moved it forward: contribution_rejected=false refund_available=false
creator cleared it: contribution_rejected=false refund_available=true-but-pays-0
Both rewrites keep contributions open while removing the contributor's ability to be refunded (the second case only "works" because of Finding 2, where the refund pays 0).

Recommended Mitigation

  1. Actually set the flag, and make the deadline strictly in the future.

rust

pub fn set_deadline(ctx: Context, deadline: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
if fund.dealine_set { return Err(ErrorCode::DeadlineAlreadySet.into()); }
let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
if deadline Window) makes it impossible for one path to treat "no deadline" as open while the other treats it as closed.

Test file reference
tests/rustfund.ts L55–L67 (first setDeadline) and L242–L247 / L292–L297 (second and third setDeadline on the same fund, both accepted) + audit_repro.rs. Tool: Anchor 0.30.1 / anchor test + rustc.


FINDING 4 (Medium)
标题 / Title
代码

withdraw has no deadline or goal check and can run before the deadline, so the creator can take the entire pool first and every contributor's refund then finds nothing to return
(166 字符)

范围 / Scope
代码

programs/rustfund/src/lib.rs
影响 → 中等的 可能性 → 高的
Description
withdraw() (L90) is the only instruction in the program with no temporal or goal condition attached. Its account validation (FundWithdraw, L162–L168) checks the PDA seeds and has_one = creator, and nothing else — there is no comparison against fund.deadline, no check against fund.goal, and no state flag marking "campaign ended":

rust

pub fn withdraw(ctx: Context) -> Result<()> { // L90 — no deadline check, no goal check, no one-shot flag
let amount = ctx.accounts.fund.amount_raised; // L91 — the whole accounting total, callable at any time
...
}
Compare with the two contributor-facing instructions, which are both gated:

rust

// contribute (L29): blocked after the deadline
if fund.deadline != 0 && fund.deadline Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(...) }
So before the deadline passes, the creator can call withdraw() and take everything that has been raised, while every contributor's refund() is still rejected with DeadlineNotReached. The README describes the intended rule ("Creators can withdraw funds once their campaign succeeds", "Contributors can get refunds if deadlines are reached and goals aren't met"), but neither half of that rule is enforced: goal is never read (see Finding 6) and the deadline does not gate the creator.

The author's own comment at L66 records the symptom without treating it as a defect: //@audit this function can be frontrun because withdraw can be called before deadline. Combined with Finding 1, the ordering matters permanently: once withdraw() has run, amount_raised and the fund balance no longer agree, so the refunds that arrive after the deadline are capped by whatever the accounting mismatch allows.

Risk
Contributors can be pre-empted out of a failed campaign. A creator who sees the goal will not be met can withdraw before the deadline; contributors then reach their refund window to find the pool drained, and (per Finding 2) the per-contribution record they would be refunded from is 0 anyway.
There is no "campaign succeeded" condition at all. Withdrawal success depends only on who signs first, not on whether the goal was reached — which inverts the intended design where reaching the goal is what unlocks the creator's payout.
The ordering conflict is permanent, not transient. Because withdraw() leaves amount_raised unchanged (L91 comment at L91 acknowledges it) and the fund PDA is never closed, a withdrawn-then-refunded campaign ends in a state where neither path can complete correctly (Finding 1's InsufficientFunds).
Impact is medium (contributors lose access to funds they are promised back; the protocol's stated success condition is not enforced) and likelihood is high: it requires only the creator calling an instruction that has no gate, which any rational creator in a struggling campaign will do.

Proof of Concept
Overview. withdraw() carries no deadline or goal condition (L90–L105, FundWithdraw L162–L168), while both contributor paths are gated on the same fund.deadline, so the creator can drain the pool before any contributor's refund window opens.

Actors.

Attacker: the creator (only signer required; has_one = creator).
Victim: contributors who contributed before the deadline and are then told DeadlineNotReached until it passes — by which time the fund holds nothing but its rent reserve.
Protocol: Fund.amount_raised / fund PDA balance, drained at L93–L96 with no gate.
Anchor integration test (tests/rustfund.ts) — the repo's own sequence withdraws while the deadline is still in the future:

ts

const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 10); // L21: 10 seconds from now
await program.methods.setDeadline(deadline)... // L55-L67
await program.methods.contribute(new anchor.BN(500_000_000))... // L69-L90: contributor pays 0.5 SOL

// Immediately — well before the deadline — the creator withdraws the whole pool with no gate:
await program.methods.withdraw().accounts({ fund: fundPDA, creator: creator.publicKey }).rpc(); // L219-L238
console.log("fundBalanceAfter", await provider.connection.getBalance(fundPDA)); // only the rent reserve remains

// A contributor who now tries to refund is refused: their window has not opened yet.
await assert.rejects(() => program.methods.refund().rpc(), /Deadline not reached/); // L179-L193 asserts exactly this error
Run with anchor build && anchor test: the withdraw() call succeeds before deadline is reached, and the refund path is refused at the same moment — the two facts that make the ordering one-sided.

Anchor-level arithmetic reproduction (audit_repro.rs) of the state left behind for a contributor who tries to refund afterwards:

rust

// withdraw() ran first (no gate): it takes amount_raised and leaves only the rent reserve in the fund.
let fund_rent_reserve: u64 = 3480 * (5273 + 128) * 2; // 37_590_960 lamports
let raised: u64 = 500_000_000; // one contributor's 0.5 SOL
let mut fund_balance = fund_rent_reserve + raised;
fund_balance -= raised; // creator took the raise, before the deadline

// now the deadline passes and the contributor calls refund(): it pays contribution.amount, which is 0 (Finding 2)
let recorded: u64 = 0; // L37
println!("creator took {raised}; fund balance left for refunds: {fund_balance}; refund would pay {recorded}");
代码

$ rustc audit_repro.rs -o audit_repro && ./audit_repro
creator took 500000000; fund balance left for refunds: 37590960; refund would pay 0
Recommended Mitigation

  1. Gate the withdrawal on the campaign actually ending successfully.

rust

pub fn withdraw(ctx: Context) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();

if fund.deadline == 0 { return Err(ErrorCode::DeadlineNotSet.into()); }        // a campaign with no end cannot pay out
if now < fund.deadline { return Err(ErrorCode::DeadlineNotReached.into()); }   // the creator waits like the contributors do
if fund.amount_raised < fund.goal { return Err(ErrorCode::GoalNotReached.into()); }  // "once their campaign succeeds"

...

}
2. Make refunds impossible after a successful withdrawal, and vice versa. A single withdrawn: bool (or state: CampaignState) on Fund, checked in both withdraw() and refund(), removes the race instead of leaving it to transaction ordering: after a successful campaign the contributors' claim is closed by design; after a failed one the creator cannot drain the pool.

  1. If partial outcomes are intended, pay proportionally from real balances — refund each contributor contribution.amount (Finding 2 fix) and let the creator withdraw only amount_raised minus what was refunded (Finding 1 fix 2), so neither path can consume the other's funds.

Test file reference
tests/rustfund.ts (Sets a deadline, Contributes to fund, Withdraws funds, and the asserted /Deadline not reached/ rejection at L179–L193) + audit_repro.rs. Tool: Anchor 0.30.1 / anchor test + rustc.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 2 hours ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] The set_deadline function does not set the dealine_set flag to true

The `set_deadline()` function in the `rustfund` program contains a vulnerability that allows campaign creators to manipulate deadlines indefinitely. While the function correctly checks if `fund.dealine_set` is true before allowing the deadline to be changed, it never sets this flag to true after setting the deadline. ```rust 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; Ok(()) } ``` The function is missing a crucial line to update the flag: `fund.dealine_set = true;` This oversight bypasses a key safeguard intended to prevent creators from manipulating deadlines after they've been set. According to the project documentation, this flag is meant to enforce deadline immutability, which is an essential part of the platform's trust model. ### Impact 1. **Refund evasion**: Creators can prevent users from obtaining refunds by continually extending the deadline whenever it approaches. This directly undermines the project's advertised "Refund Mechanism" which promises that "Contributors can get refunds if deadlines are reached and goals aren't met." 2. **Fund locking**: Contributors' funds can be effectively locked indefinitely, as the refund function is contingent upon the deadline being reached: ```rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` ### Proof of Concept (PoC) The following test demonstrates how a creator can set the deadline multiple times, effectively bypassing the intended deadline immutability: ```javascript import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { Rustfund } from "../target/types/rustfund"; import { assert } from "chai"; describe("VULN-02: set_deadline vulnerability", () => { // Configures the provider to use the local cluster const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.Rustfund as Program<Rustfund>; // Test variables const fundName = "TestFund"; const description = "Testing deadline vulnerability"; const goal = new anchor.BN(1000000); let fundPda: anchor.web3.PublicKey; it("Allows you to modify the deadline several times", async () => { // Derivation of PDA address for financing account [fundPda] = await anchor.web3.PublicKey.findProgramAddress( [Buffer.from(fundName), provider.wallet.publicKey.toBuffer()], program.programId ); // Fund creation await program.rpc.fundCreate(fundName, description, goal, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }, }); // First deadline assignment const deadline1 = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // 1 hour in the future await program.rpc.setDeadline(deadline1, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, }, }); // Second deadline assignment (which should not be possible if the flag is set to true) const deadline2 = new anchor.BN(Math.floor(Date.now() / 1000) + 7200); // 2 hours into the future await program.rpc.setDeadline(deadline2, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, }, }); // Check that the deadline has been updated to the second value const fundAccount = await program.account.fund.fetch(fundPda); assert.ok( fundAccount.deadline.eq(deadline2), "The deadline may have been modified several times, but vulnerability presents" ); }); }); ``` Save the above test as, for example, tests/02.ts in your project's test directory and run the test : ```Solidity anchor test ``` ### Concrete Impact Example To illustrate the real-world impact of this vulnerability, consider this scenario: - A creator launches a campaign to fund a project with a goal of 100 SOL - The creator sets an initial deadline of 30 days - Contributors collectively deposit 80 SOL (below the goal) - As the deadline approaches, the creator realizes they won't reach the goal - Instead of allowing refunds as promised, the creator extends the deadline by another 30 days - This pattern can repeat indefinitely, effectively locking contributor funds - Even if contributors try to request refunds, they'll be rejected with "DeadlineNotReached" errors ### Recommendation The fix for this vulnerability is straightforward. The `set_deadline()` function should be modified to set the `dealine_set` flag to true after setting the deadline: ```rust 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; // Add this line to fix the vulnerability Ok(()) } ```

Support

FAQs

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

Give us feedback!