RustFund

First Flight #36
Beginner FriendlyRust
100 EXP
View results
Submission Details
Severity: high
Valid

Critical Vulnerabilities in Crowdfunding Refund Mechanism

Summary

The refund function contains serious flaws that can lead to incorrect refunds, fund loss, and contract bricking. It fails to check if the fundraising goal was met before issuing refunds, incorrectly manipulates account balances, and does not prevent multiple refunds per user.

Vulnerability Details

1️⃣ Incorrect Refund Condition (Does Not Check If Goal Was Met).

The function only checks if the deadline has passed before issuing a refund:

if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {return Err(ErrorCode::DeadlineNotReached.into());}

However, it does not check if the funding goal was met.

  • If the goal was met, refunds should not be issued.

  • If the goal was not met, contributors should be refunded.

Impact

  • Potential loss of funds → If the goal was reached, refunds should not be processed.

  • Violation of crowdfunding logic → Refunds should depend on whether the goal was met, not just the deadline.

PoC Exploit:

// Exploit: Refund is allowed even if the goal was reachedawait program.methods.refund().accounts({ fund: fundPDA, contributor: user.publicKey }).signers([user]).rpc();
// ❌ Bug: User gets refunded even when the campaign was successful.// Exploit: Refund is allowed even if the goal was reached
await program.methods
.refund()
.accounts({ fund: fundPDA, contributor: user.publicKey })
.signers([user])
.rpc();
// ❌ Bug: User gets refunded even when the campaign was successful.

Recommendations

Modify refund to only allow refunds if the goal was NOT met:

if fund.amount_raised >= fund.goal {
return Err(ErrorCode::GoalMetNoRefund.into());
}

Vulnerability Details

2️⃣ Unsafe Manual Lamport Manipulation (Fund & Contributor Balance Updates)

The function directly modifies lamport balances:

**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.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;

This approach is unsafe because:

  • Bypasses Solana’s runtime security checks.

  • Can lead to account state corruption.

  • If an error occurs mid-transaction, funds can be lost.

Impact

  • Contract bricking risk if an invalid balance update occurs.

  • Loss of funds due to direct manipuation of balances.

  • No proper transfer execution via invoke_signed().

Recommendations

Replace direct lamport updates with invoke_signed for secure fund transfers:

let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;

Vulnerability Details

3️⃣ No Protection Against Multiple Refunds (Double Spending Issue)

The function does not mark contributions as refunded before transferring funds:

// ❌ Resets contribution amount AFTER the refund
ctx.accounts.contribution.amount = 0;

Since the refund is issued before setting amount = 0, an attacker can call refund() multiple times before the transaction finalizes, draining the entire fund.

Impact:

  • Attackers can double-spend and drain funds through repeated refunds.

  • Legitimate contributors might not receive their rightful refunds.

PoC Exploit:

// Exploit: User rapidly calls refund() before the first transaction finalizes
await Promise.all([
program.methods.refund().accounts({ fund: fundPDA, contributor: user.publicKey }).signers([user]).rpc(),
program.methods.refund().accounts({ fund: fundPDA, contributor: user.publicKey }).signers([user]).rpc(),
]);
// ❌ Bug: User receives double refund, draining funds.

Recommendations

Mark the contribution as refunded before processing the refund:

// ✅ Secure refund by setting amount to zero before transferring
let refund_amount = contribution.amount;
contribution.amount = 0;
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
refund_amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;

Final Comprehensive Fixed Code

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
let contributor = &mut ctx.accounts.contributor;
// ✅ Ensure refunds are only allowed if the goal was NOT met
if fund.amount_raised >= fund.goal {
return Err(ErrorCode::GoalMetNoRefund.into());
}
// ✅ Secure timestamp retrieval and prevent conversion errors
let current_time = Clock::get()
.map_err(|_| ErrorCode::CalculationOverflow)?
.unix_timestamp
.try_into()
.map_err(|_| ErrorCode::CalculationOverflow)?;
// ✅ Ensure the deadline has actually passed before allowing refunds
if fund.deadline != 0 && fund.deadline > current_time {
return Err(ErrorCode::DeadlineNotReached.into());
}
// ✅ Prevent double refunds by setting contribution amount to zero before transfer
let refund_amount = contribution.amount;
contribution.amount = 0;
// ✅ Secure refund transfer using invoke_signed
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.fund.to_account_info().key,
&ctx.accounts.contributor.to_account_info().key,
refund_amount,
);
invoke_signed(
&transfer_instruction,
&[
ctx.accounts.fund.to_account_info(),
ctx.accounts.contributor.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
&[&[fund.key().as_ref(), contributor.key().as_ref(), &[ctx.bumps.contribution]]],
)?;
Ok(())
}
Updates

Appeal created

bube Lead Judge 6 months ago
Submission Judgement Published
Validated
Assigned finding tags:

There is no check for goal achievement in `refund` function

Unsafe direct lamport manipulation

Support

FAQs

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