RustFund

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

Inconsistent State After `withdraw` (No Reset of `fund.amount_raised`)

Summary

After a campaign creator withdraws funds, the contract does not reset or clear the fund.amount_raised field (nor mark the campaign as closed). The campaign’s state still shows the old total raised amount, even though those funds have been paid out. This inconsistent state can mislead logic and potentially allow the creator to attempt withdrawing again, thinking funds remain. In effect, the contract fails to mark that a payout already happened, inviting duplicate withdrawal calls.

Vulnerability Details

In the withdraw implementation, once the creator’s funds are transferred out, we expect the contract to update its state to reflect that those funds are no longer available. Typically this could involve setting fund.amount_raised = 0 (if the campaign is considered concluded) or marking a status flag like fund.withdrawn = true to prevent further withdrawals. RustFund does neither – the amount_raised is left unchanged.

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(())
}

Because fund.amount_raised still holds the pre-withdrawal total, the contract’s state suggests the funds are still in the campaign. If the withdraw function is callable again (and there is no internal guard to prevent multiple withdrawals), the logic would attempt to transfer the same amount again.

However, after the first withdrawal, the fund’s actual lamport balance will likely only contain the rent-exempt reserve (since all contributed lamports were withdrawn). A second withdrawal attempt would thus try to subtract an amount that is no longer there, leading to a runtime error (insufficient funds in the account). In our testing, we observed that a second withdrawal transaction fails at the Solana runtime level due to this condition, demonstrating the inconsistency. Even if the runtime prevents the actual second payout, the mere fact that the contract tries to do it is problematic. It could, for example, panic the program or waste fees.

Impact Details:

The immediate impact is that the campaign state is confusing and can lead to erroneous actions. A campaign creator, not realizing the bug, might call withdraw twice and encounter a program error or potentially succeed in withdrawing additional funds if new contributions came in. This could cause unexpected program crashes or fund mismanagement.

A more malicious angle: Suppose after a successful withdrawal, the campaign is left open (since nothing was marked closed). If additional contributors (who missed the deadline or didn’t realize the campaign ended) send contributions late (the contract does not explicitly prevent contributions after withdraw), those new contributions will increase amount_raised from whatever its old value was. The creator could then call withdraw again to scoop up the new contributions. Essentially, because the campaign isn’t closed or reset, the creator can treat the campaign as ongoing and keep withdrawing any later contributions as well. This is an edge scenario, but it demonstrates how failing to reset the state breaks the expected one-time payout model. Even without new contributions, leaving stale values can interfere with other checks. For instance, any function that checks amount_raised (perhaps to decide success/failure or calculate some ratio) will be using outdated data post-withdrawal. This inconsistency could complicate off-chain client logic or audits of the campaign’s outcome as well. Overall, while this bug might not directly allow theft beyond the first withdrawal, it compromises the integrity of the contract’s state and can lead to error conditions (like double-withdraw attempts causing exceptions).

Tools Used

  • Manual analysis of the sequence of operations in the withdraw function.

  • Comparison of the actual behavior with the expected state after withdrawal of funds.

Recommendations

  • Reset the fund.amount_raised field after successful withdrawal of funds, or mark the campaign as closed (for example, set the Boolean flag is_withdrawn) to prevent further operations with incorrect data.

  • Add tests that check the consistency of the state after the withdrawal operation.

Updates

Appeal created

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

`amount_raised` is not reset to 0 in `withdraw` function

Support

FAQs

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