Rust Fund

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

Refund Logic Allows Instant Refunds if Deadline Not Set

Root + Impact

Description

  • Logic Flaw: The protocol intends to lock funds until a deadline is reached. However, the refund instruction checks for the deadline expiration using the logic:

    if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get()?.unix_timestamp { ... }
  • Default State: When a fund is created, deadline is initialized to 0.

  • Bypass: If the Creator has not yet called set_deadline, fund.deadline is 0. The condition 0 != 0 evaluates to false, effectively skipping the "Deadline Not Reached" check entirely.

  • Consequence: Users can contribute and immediately refund their tokens before any deadline is set, bypassing the apparent intent of a "locked" crowdfunding campaign. While this doesn't result in theft (users only get their own funds back), it violates the invariant that "funds are committed until the campaign outcome is decided."

Risk

Likelihood:

  • High: This state exists for every new fund from the moment of creation until set_deadline is called.

Impact:

  • Protocol Inconsistency: It allows users to use the program as a temporary holding wallet rather than a committed crowdfunding vehicle.

  • Griefing: A malicious user could spam contributions and immediate refunds, emitting events and clogging metrics without ever committing funds.

Proof of Concept

The following test case demonstrates that a user can successfully call refund() immediately after contributing, provided the Creator has not yet updated the deadline from its default 0 value.

it("Allows instant refund when deadline is 0", async () => {
// 1. Create Fund (Default deadline = 0)
await program.methods
.fundCreate("InstantRefund", "Desc", new anchor.BN(100))
.accounts({...})
.rpc();
// 2. User Contributes 1 SOL
const amount = new anchor.BN(1000000000);
await program.methods
.contribute(amount)
.accounts({...})
.rpc();
// 3. User calls Refund immediately
// Expectation: Should FAIL because campaign is open.
// Reality: SUCCEEDS because deadline check is bypassed.
await program.methods
.refund()
.accounts({...})
.rpc();
// 4. Verify user got funds back
const balance = await provider.connection.getBalance(contributor.publicKey);
console.log("User successfully refunded immediately");
});

Recommended Mitigation

To enforce the invariant that "funds are locked until the deadline," the protocol must treat a 0 deadline as "Campaign Active / Indefinite" rather than "Checks Passed."

1. Implicit Lock:
Modify the check to fail if the deadline is 0 (i.e., not yet set), assuming that no refunds are allowed until a valid deadline has historically passed.

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
// Check if deadline passed
- if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
- return Err(ErrorCode::DeadlineNotReached.into());
- }
+ // Require deadline to be set AND passed
+ if ctx.accounts.fund.deadline == 0 || ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
+ return Err(ErrorCode::DeadlineNotReached.into());
+ }
// ...

2. Explicit Error:
Alternatively, block refunds explicitly if the deadline is not set, forcing the Creator to establish terms before the lifecycle proceeds.

require!(ctx.accounts.fund.deadline != 0, ErrorCode::DeadlineNotSet);
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 10 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[L-01] Refund function allows withdrawals when deadline is not set (deadline = 0)

The `refund()` function in the `rustfund` program contains a vulnerability that allows contributors to withdraw funds at any time when the campaign creator has not set a deadline. The deadline is initialized to 0 during campaign creation, and if the creator never sets a deadline, the refund check condition is bypassed due to a logical flaw in the condition: ```rust pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } // ... remainder of refund code ... } ``` The key issue is that the function only blocks refunds when **both** conditions are true: 1. The deadline is not 0 (deadline != 0) 2. The deadline has not been reached (deadline > current_time) This means that when a deadline is set to 0 (the default value), the first condition fails, the entire check is skipped, and refunds are allowed regardless of goal achievement or time constraints. ### Impact 1. **Premature fund withdrawal**: Contributors can withdraw their funds at any time if no deadline is set, which violates the stated project documentation that refunds should "only be possible if the goal is not reached and the deadline is exceeded." 2. **Campaign destabilization**: Creators who intend to set a deadline later (but haven't yet) may find their campaigns undermined by contributors withdrawing funds prematurely. 3. **Trust model violation**: The platform's trust model is based on rules that ensure funds remain locked until specific conditions are met. This vulnerability allows contributors to bypass these conditions. 4. **Campaign failure**: Active campaigns may fail unexpectedly if contributors choose to withdraw funds due to this vulnerability, even when the project is progressing as expected. ### Proof of Concept (PoC) The following test demonstrates how a contributor can withdraw funds from a campaign that has no deadline set: ```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-03: Refund vulnerability when deadline is not set", () => { // Configure 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 for a fund without a deadline (deadline remains 0) const fundName = "TestFundNoDeadline"; const description = "Testing refund vulnerability when no deadline is set (deadline = 0)"; const goal = new anchor.BN(1000000); let fundPda: anchor.web3.PublicKey; let contributionPda: anchor.web3.PublicKey; // Generate a separate contributor keypair for the first test case const contributor = anchor.web3.Keypair.generate(); // Airdrop SOL to the contributor for the first test case before(async () => { const airdropSig = await provider.connection.requestAirdrop( contributor.publicKey, 2e9 // 2 SOL in lamports ); await provider.connection.confirmTransaction(airdropSig); }); it("Allows refund even when deadline is not set (deadline = 0)", async () => { // Derive the PDA for the fund account using fundName and the creator's public key [fundPda] = await anchor.web3.PublicKey.findProgramAddress( [Buffer.from(fundName), provider.wallet.publicKey.toBuffer()], program.programId ); // Create the fund (deadline is initialized to 0 by fund_create) await program.rpc.fundCreate(fundName, description, goal, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }, }); // Derive the PDA for the contribution account using the fund PDA and the contributor's public key [contributionPda] = await anchor.web3.PublicKey.findProgramAddress( [fundPda.toBuffer(), contributor.publicKey.toBuffer()], program.programId ); // Contributor makes a contribution (e.g., 1 SOL = 1e9 lamports) const contributionAmount = new anchor.BN(1e9); await program.rpc.contribute(contributionAmount, { accounts: { fund: fundPda, contributor: contributor.publicKey, contribution: contributionPda, systemProgram: anchor.web3.SystemProgram.programId, }, signers: [contributor], }); // At this point, the fund's deadline remains 0. // Due to the vulnerability, the refund function does not check for a missing deadline. await program.rpc.refund({ accounts: { fund: fundPda, contributor: contributor.publicKey, contribution: contributionPda, systemProgram: anchor.web3.SystemProgram.programId, }, signers: [contributor], }); // Fetch the contribution account to verify that the refund reset the contribution amount to 0 const contributionAccount = await program.account.contribution.fetch(contributionPda); assert.ok( contributionAccount.amount.eq(new anchor.BN(0)), "Contribution amount should be reset to 0 after refund" ); }); }); ``` Save the above test as `tests/03.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: 1. A creator launches a campaign to fund a 100 SOL project without immediately setting a deadline. 2. The creator plans to finalize and set the deadline once initial interest is confirmed. 3. Contributors begin funding the campaign, reaching 50 SOL. 4. Before the creator sets a deadline, contributors discover they can withdraw their funds at any time. 5. Contributors begin withdrawing funds unexpectedly, causing the campaign balance to drop. 6. The creator is unable to prevent these withdrawals without setting a deadline. 7. Even after setting a deadline, any contributions made before the deadline was set could have already been withdrawn. ### Recommendation The `refund()` function should be modified to enforce the business rules stated in the documentation: ```rust pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; let fund = &ctx.accounts.fund; // First ensure a deadline has been set if fund.deadline == 0 { return Err(ErrorCode::DeadlineNotSet.into()); } // Then ensure the deadline has been reached if fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } // Finally, check if the goal has been met (optional additional check based on documentation) if fund.amount_raised >= fund.goal { return Err(ErrorCode::GoalReached.into()); } // ... remainder of refund code ... Ok(()) } ``` Additionally, a new error code should be added to the `ErrorCode` enum: ```rust #[error_code] pub enum ErrorCode { // ... existing error codes ... #[msg("Deadline not set")] DeadlineNotSet, #[msg("Goal has been reached")] GoalReached, } ``` This fix ensures that refunds are only possible when: 1. A deadline has been set 2. The deadline has been reached 3. The funding goal has not been met

Support

FAQs

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

Give us feedback!