Rust Fund

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

Root cause: refund never checks the funding goal, and its deadline guard is skipped whenever no deadline is set. Impact: refunds are accepted on live campaigns and on campaigns that already succeeded, contrary to the stated rules

Root cause: refund never checks the funding goal, and its deadline guard is skipped whenever no deadline is set. Impact: refunds are accepted on live campaigns and on campaigns that already succeeded, contrary to the stated rules

Description

The README states the rule plainly: contributors "can get refunds if deadlines are reached and goals aren't met", and the Contributor section repeats that a refund applies when "the campaign fails to meet the goal and the deadline is reached". Two conditions, both required.

refund enforces neither properly. It checks only the deadline, and that check is short circuited away when deadline is still zero, which is the state every campaign starts in because fund_create sets fund.deadline = 0.

// programs/rustfund/src/lib.rs
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()... {
return Err(ErrorCode::DeadlineNotReached.into());
}
@> // fund.goal is never read here at all

Two separate consequences follow. While deadline is zero the left side of the && is false, so the guard never runs and a refund is accepted on a campaign that has only just opened. And because goal is never consulted, a campaign that reached its target still hands money back.

Risk

Likelihood:

  • Every campaign begins with deadline = 0, and setting a deadline is a separate optional instruction, so the unguarded window exists by default on every fund from creation onward.

  • Neither case needs an attacker or unusual timing: an ordinary contributor calling the ordinary instruction reaches both.

Impact:

  • A successful campaign can be drained back out by its contributors after it met its goal, which puts the creator and the contributors in a race over the same lamports, since withdraw and refund both pay from the same account balance with no shared accounting.

  • The refund rule the platform advertises is not the rule it enforces, so neither side can rely on the stated terms when deciding to create or fund a campaign.

Proof of Concept

No attacker is required. The scenarios below are an ordinary contributor calling refund in two states where the documented rules forbid it. Run with anchor test.

it("G2 refund is accepted even after the goal was reached", async () => {
const smallGoal = new BN(1 * LAMPORTS_PER_SOL); // goal is only 1 SOL
await program.methods
.fundCreate("g2met", "poc", smallGoal)
.accounts({ fund: pda, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.rpc();
const alice = await fundedKeypair();
await contributeFrom(pda, alice, CONTRIB); // 2 SOL in, goal exceeded
await program.methods
.setDeadline(new BN(1)) // deadline in the past
.accounts({ fund: pda, creator: creator.publicKey })
.rpc();
const st = await program.account.fund.fetch(pda);
expect(st.amountRaised.gte(st.goal)).to.be.true; // the campaign SUCCEEDED
await program.methods // and the refund still goes through
.refund()
.accounts({
fund: pda,
contribution: contribPda(pda, alice.publicKey),
contributor: alice.publicKey,
systemProgram: SystemProgram.programId,
})
.signers([alice])
.rpc();
});

Output:

refund accepted on a campaign that met its goal
✔ G2 refund is accepted even after the goal was reached
refund accepted with deadline unset and campaign live
✔ G1 refund is open immediately when no deadline is set

The companion test covers the other half: on a fund whose deadline is still 0, a contributor refunds immediately while the campaign is live, because the guard is short circuited.

Being precise about the present impact: in the code as shipped these refunds move zero lamports, since contribution.amount is never written. The conditions themselves are wrong regardless, and once that recording is fixed these paths pay out real SOL in states where the documented rules forbid it.

Recommended Mitigation

Enforce both documented conditions, and require that a deadline exists at all:

let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
require!(fund.dealine_set, ErrorCode::DeadlineNotReached);
require!(now >= fund.deadline, ErrorCode::DeadlineNotReached);
require!(fund.amount_raised < fund.goal, ErrorCode::GoalReached);

Add a GoalReached variant to ErrorCode. With these in place a refund is possible only on a campaign that actually failed, which is what both the README and the withdrawal side assume.

Updates

Lead Judging Commences

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

[H-04] Inadequate Refund Conditions

## Description the refund mechanism only verifies that the current time has passed the campaign deadline, without checking whether the campaign has failed to meet its funding goal.This oversight may result in refunds being issued even if the campaign was, in principle, successful, potentially undermining the trust and financial integrity of the platform. &#x20; ## Vulnerability Details The refund function in the contract is designed to return funds to contributors if a campaign fails. However, it only checks whether the campaign deadline has been reached (or passed) before allowing a refund, without verifying if the campaign's funding goal was met. In other words, the function solely relies on a time-based condition and does not incorporate the additional logic required to determine if a campaign has been unsuccessful. **Code Analysis:**\ The refund function contains the following check: ```Rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` This condition ensures that refunds are only triggered after the deadline has passed. However, there is no subsequent verification that compares `fund.amount_raised` to the `fund.goal` to determine whether the campaign failed to meet its funding target. As a result, even if the campaign has met or exceeded its goal, contributors could potentially request refunds simply because the deadline has passed. ## proof Of Concept ```typescript it("Allows refund on a successful campaign due to missing goal check", async () => { // Define campaign parameters with a near-future deadline (5 seconds from now) const fundName = "refund flaw"; const description = "Test for refund vulnerability on a successful campaign"; const goal = new anchor.BN(1000000000); // 1 SOL goal // Set deadline to 5 seconds from now const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 5); // Generate PDA for the fund using the campaign name and creator's public key let [fundPDA, fundBump] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Create the fund campaign await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // Set the campaign deadline await program.methods .setDeadline(deadline) .accounts({ fund: fundPDA, creator: creator.publicKey, }) .rpc(); // Airdrop lamports to otherUser so they can contribute const airdropSig = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL // e.g., 2 SOL ); await provider.connection.confirmTransaction(airdropSig); // Generate PDA for the contribution account using fund's PDA and otherUser's public key let [contributionPDA, contributionBump] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // otherUser contributes 1 SOL, meeting the campaign goal const contributionAmount = new anchor.BN(1000000000); // 1 SOL await program.methods .contribute(contributionAmount) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // Verify the campaign is successful by checking that amountRaised >= goal let fundBeforeDeadline = await program.account.fund.fetch(fundPDA); expect(fundBeforeDeadline.amountRaised.gte(goal)).to.be.true; // Wait until after the deadline has passed await new Promise((resolve) => setTimeout(resolve, 6000)); // otherUser calls refund despite the campaign being successful // (a correct implementation should disallow this refund) let refundTxSucceeded = true; try { await program.methods .refund() .accounts({ fund: fundPDA, contribution: contributionPDA, contributor: otherUser.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); } catch (err) { refundTxSucceeded = false; } // The vulnerability: refund call is erroneously allowed even though the campaign met its goal. expect(refundTxSucceeded).to.be.true; // check the contributor's balance change to further demonstrate the refund was processed. const balanceAfterRefund = await provider.connection.getBalance( otherUser.publicKey ); console.log("Contributor balance after refund:", balanceAfterRefund); }); ``` ## Impact - **Financial Discrepancies:**\ The improper refund mechanism result in successful campaigns losing funds that were meant to be retained by the campaign creator, leading to financial imbalances within the contract. - **Erosion of Trust:**\ Contributors and creators rely on the refund logic to be fair and accurate. The absence of a funding goal check in the refund function erode trust in the platform, as users could experience unexpected fund reversals or disputes over campaign success. - **Operational Risks:**\ Campaigns that meet their funding goals still be subject to refund requests, creating operational inefficiencies and potential disputes between creators and contributors. This undermines the intended crowdfunding model and could deter future participation. &#x20; ## Recommendations Update the refund function to include a check that verifies whether the campaign's funding goal has been met. Refunds should only be processed if both the deadline has passed and the `amount_raised` is below the `goal`.&#x20; &#x20; ```Solidity if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } if ctx.accounts.fund.amount_raised >= ctx.accounts.fund.goal { return Err(ErrorCode::CampaignSuccessful.into()); } ```

Support

FAQs

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

Give us feedback!