Rust Fund

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

set_deadline`'s one-shot guard is dead code — `dealine_set` is never assigned `true`, so the creator can rewrite the campaign deadline without limit

The dealine_set flag is written once as false and never again, making the DeadlineAlreadySet check unreachable

Description

  • The program deliberately encodes a one-shot rule for the campaign deadline: a dedicated boolean on the Fund account, a guard that rejects a second call, and a dedicated error variant for it. Once a deadline is announced, contributors should be able to rely on it — it defines when contributions close and when refunds open.

  • The flag is initialised to false and never set to true by anything. set_deadline writes the new deadline and returns, leaving the flag exactly as it found it. The guard therefore never fires on any input, ErrorCode::DeadlineAlreadySet is unreachable code, and the deadline is freely rewritable for the entire life of the campaign.

// src/lib.rs:55-63
pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
@> if fund.dealine_set { // @> always false - branch is unreachable
return Err(ErrorCode::DeadlineAlreadySet.into());
}
fund.deadline = deadline;
@> // @> MISSING: fund.dealine_set = true; <- the flag the guard depends on is never raised
@> // @> MISSING: a check that `deadline` is in the future
Ok(())
}

The flag appears three times in the entire program, and exactly one of those is a write:

// src/lib.rs:20 (fund_create) - the ONLY write, and it writes false
fund.dealine_set = false;
// src/lib.rs:57 (set_deadline) - read
if fund.dealine_set {
// src/lib.rs:190 (struct Fund) - declaration
pub dealine_set: bool,

Both gates that depend on the deadline therefore sit under the creator's unilateral, repeatable control:

// src/lib.rs:29-31 - move the deadline into the PAST and contributions stop
if fund.deadline != 0 && fund.deadline < Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
return Err(ErrorCode::DeadlineReached.into());
}
// src/lib.rs:69-71 - move it into the FUTURE and the refund window never opens
if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap()... {
return Err(ErrorCode::DeadlineNotReached.into());
}

Two adjacent omissions in the same function compound it, and both survive the obvious fix of simply latching the flag:

  • No bound on the value. set_deadline accepts any u64, including u64::MAX and any timestamp already in the past. There is no maximum campaign duration, so a deadline can be set that will never be reached.

  • No ordering constraint. Nothing requires the deadline to be fixed before money arrives. The creator can let contributions accumulate while deadline == 0 — a state in which the fund advertises as maximally permissive, since line 29 admits every contribution and line 69 admits every refund — and only then call set_deadline(u64::MAX), closing the refund window on contributors who are already in. That sequence uses a single, first, entirely "legitimate" call, so latching the flag does not prevent it.

I am reporting these together with the dead guard rather than separately because they are one function, one intent, and one patch: the whole of set_deadline's validation is missing, not one line of it.

Why this is a defect and not a creator privilege

Ordinarily "the creator changes a parameter on their own campaign" is designed behaviour and not a finding. That defence does not apply here, because the developer explicitly attempted to forbid it: the dealine_set field exists solely for this restriction, the guard at line 57 exists solely to enforce it, and DeadlineAlreadySet at line 198 exists solely to report it. All three are present and none of them work. This is an invariant the program tries to enforce and fails to, which is a broken check rather than an intentional permission.

fund_create is also permissionless (lines 12-22) — any signer can be a creator — so the actor holding this unintended power is an arbitrary stranger, not a vetted operator.

Risk

Likelihood:

  • The guard fails on 100% of calls. There is no input, ordering or state that makes it fire, because the value it tests can never become true.

  • Exploiting it needs one instruction, no cost beyond the fee, no timing and no cooperation. It is available for the entire lifetime of every fund ever created.

Impact:

  • The deadline — the only public commitment a campaign makes about its own lifecycle — carries no guarantee. A contributor who reads fund.deadline before contributing has read a number that can be changed the moment they act on it.

  • Live consequence: a creator can set a past timestamp and retroactively close their campaign to new contributions, then reopen it, arbitrarily and repeatedly. contribute (line 29) rejects with DeadlineReached for as long as the past value stands.

  • The refund window is equally under creator control: a deadline pushed to u64::MAX means refund (line 69) rejects with DeadlineNotReached indefinitely.

Honest limitation

I set Impact to Low deliberately, and want to be explicit about why rather than let a judge discover it. In this program a separate defect means contribution.amount is only ever assigned 0, so refund moves zero lamports regardless. That makes the refund-blocking consequence above inert today — blocking refund currently blocks a no-op, and no contributor lamports are actually protected by it. The consequence that is genuinely live is the contribution-blocking one, which is a creator griefing their own fundraiser.

So: Likelihood High, because the guard is unconditionally broken; Impact Low, because today's reachable harm is limited. The reason this still matters is that it is a latent hazard sitting directly under the refund mechanism — the moment amounts are recorded correctly, this flag becomes the switch that decides whether contributors can ever exit, and it is currently held by the one party with an interest in it staying off.

Proof of Concept

Three consecutive set_deadline calls, all accepted, with the flag still false afterwards.

cargo build-sbf
solana-test-validator --reset --bpf-program 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h target/deploy/rustfund.so
npm install @solana/web3.js && node poc.mjs
import {
Connection, Keypair, PublicKey, SystemProgram, Transaction,
TransactionInstruction, LAMPORTS_PER_SOL, sendAndConfirmTransaction,
} from "@solana/web3.js";
import { createHash } from "crypto";
const PROGRAM_ID = new PublicKey("6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h");
const conn = new Connection("http://127.0.0.1:8899", "confirmed");
const SYS = SystemProgram.programId;
const disc = (n) => createHash("sha256").update(`global:${n}`).digest().subarray(0, 8);
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
const str = (s) => { const d = Buffer.from(s); const l = Buffer.alloc(4); l.writeUInt32LE(d.length); return Buffer.concat([l, d]); };
const M = (pubkey, isSigner, isWritable) => ({ pubkey, isSigner, isWritable });
async function air(kp) {
const sig = await conn.requestAirdrop(kp.publicKey, 50 * LAMPORTS_PER_SOL);
await conn.confirmTransaction({ signature: sig, ...(await conn.getLatestBlockhash()) }, "confirmed");
}
const send = (ixs, s) => sendAndConfirmTransaction(conn, new Transaction().add(...ixs), s, { commitment: "confirmed" });
async function trySend(ixs, s) {
try { await send(ixs, s); return { okd: true }; }
catch (e) { return { okd: false, why: ((e.logs || []).join("\n").match(/Error Message: [^\n]+/) || ["reverted"])[0] }; }
}
// Fund layout: 8 disc | 4+name | 4+desc | goal u64 | deadline u64 | creator 32 | amount_raised u64 | dealine_set bool
async function readFund(pda) {
const d = (await conn.getAccountInfo(pda, "confirmed")).data;
let o = 8; const nl = d.readUInt32LE(o); o += 4 + nl;
const dl = d.readUInt32LE(o); o += 4 + dl;
o += 8; // goal
const deadline = d.readBigUInt64LE(o); o += 8 + 32 + 8;
return { deadline, dealineSet: d[o] === 1 };
}
const ixSetDeadline = (f, c, dl) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true)], data: Buffer.concat([disc("set_deadline"), u64(dl)]) });
(async () => {
const creator = Keypair.generate(), alice = Keypair.generate();
for (const k of [creator, alice]) await air(k);
const name = "poc-" + Math.floor(Date.now() / 1000);
const [f] = PublicKey.findProgramAddressSync([Buffer.from(name), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(creator.publicKey, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(LAMPORTS_PER_SOL)]) })], [creator]);
const now = () => Math.floor(Date.now() / 1000);
for (const [label, dl] of [["#1 future", now() + 100], ["#2 further future", now() + 200], ["#3 the PAST", now() - 1000]]) {
const r = await trySend([ixSetDeadline(f, creator.publicKey, dl)], [creator]);
console.log(`set_deadline ${label.padEnd(18)} -> ${r.okd ? "ACCEPTED" : "rejected: " + r.why}`);
}
console.log("dealine_set after three successful calls =", (await readFund(f)).dealineSet);
// the deadline now sits in the past, so contributions are retroactively closed
const [cp] = PublicKey.findProgramAddressSync([f.toBuffer(), alice.publicKey.toBuffer()], PROGRAM_ID);
const rc = await trySend([new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(alice.publicKey, true, true), M(cp, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(LAMPORTS_PER_SOL)]) })], [alice]);
console.log(`alice contributes -> ${rc.okd ? "accepted" : "REJECTED: " + rc.why}`);
// and it can be pushed to the maximum, closing the refund window indefinitely
await send([ixSetDeadline(f, creator.publicKey, "18446744073709551615")], [creator]);
console.log("deadline is now", (await readFund(f)).deadline.toString(), "(u64::MAX)");
})();

Output:

set_deadline #1 future -> ACCEPTED
set_deadline #2 further future -> ACCEPTED
set_deadline #3 the PAST -> ACCEPTED
dealine_set after three successful calls = false
alice contributes -> REJECTED: Error Message: Deadline reached.
deadline is now 18446744073709551615 (u64::MAX)

Note on the project's own test suite

tests/rustfund.ts calls setDeadline exactly once and asserts nothing (every check is a console.log), so the suite never exercises a second call and cannot observe that the guard is inert.

Recommended Mitigation

Raise the flag the guard depends on, bound the value, and forbid changing the terms after contributors have committed money.

+const MAX_CAMPAIGN_DURATION: u64 = 180 * 24 * 60 * 60; // 180 days
+
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());
}
+
+ // the terms cannot change once contributors are exposed
+ if fund.amount_raised > 0 {
+ return Err(ErrorCode::DeadlineAlreadySet.into());
+ }
+
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+ if deadline <= now || deadline > now + MAX_CAMPAIGN_DURATION {
+ return Err(ErrorCode::InvalidDeadline.into());
+ }
fund.deadline = deadline;
+ fund.dealine_set = true;
Ok(())
}
pub enum ErrorCode {
...
+ #[msg("Deadline must be in the future and within the maximum campaign duration")]
+ InvalidDeadline,
}

Taking the deadline as a parameter of fund_create instead would remove the window entirely and make the field immutable by construction — a fund would then never exist in the deadline == 0 state. Note also that the field name is misspelled (dealine_set); renaming it to deadline_set is worth doing in the same change, since the typo is what makes the missing assignment easy to overlook.

Updates

Lead Judging Commences

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