Rust Fund

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

Post-withdrawal contributions are permanently locked because amount_raised is never reset after withdrawal or decremented after refund

Normal behavior:
After a creator withdraws raised funds, fund.amount_raised should be reset to 0 to reflect that the funds have been transferred out. After a contributor receives a refund, fund.amount_raised should be decremented by the refund amount to maintain accurate accounting. The amount_raised field should always reflect the net balance of unwithdrawn, unrefunded contributions.

Specific issue:
The withdraw instruction transfers fund.amount_raised lamports from the Fund PDA to the creator but does not reset fund.amount_raised to 0 afterward. The refund instruction deducts lamports from the Fund PDA but does not decrement fund.amount_raised. The amount_raised field is a monotonically increasing counter that accumulates all historical contributions and is never reduced. After a withdrawal, if new contributors deposit SOL, the amount_raised field will exceed the actual fund balance. When the creator attempts a second withdrawal, the instruction tries to transfer the inflated amount_raised, which fails with InsufficientFunds. The new contributors' SOL is permanently locked — the creator cannot withdraw it (InsufficientFunds) and the contributors cannot refund it (RF-02: contribution.amount is 0).

ROOT CAUSE:

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)?;
// @> MISSING: fund.amount_raised = 0; — never reset after withdrawal
Ok(())
}
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// ... lamport transfer logic ...
ctx.accounts.contribution.amount = 0;
// @> MISSING: fund.amount_raised -= amount; — never decremented after refund
Ok(())
}

RISK:

Likelihood:
MEDIUM — This occurs when a creator withdraws funds and then new contributions arrive for the same campaign. The likelihood depends on whether contributors continue to deposit into a campaign that has already been drained. While many contributors would avoid a drained campaign, the fund.amount_raised field (which off-chain dashboards may display) still shows the historical total, potentially misleading new contributors into thinking the campaign has significant backing.

Impact:
MEDIUM — New contributors' SOL is permanently locked in the Fund PDA. Neither the creator (blocked by InsufficientFunds on the inflated amount_raised) nor the contributors (blocked by RF-02's zero contribution.amount) can access the funds. The lamports remain trapped indefinitely. Additionally, the accounting desync means fund.amount_raised is an unreliable indicator of the actual fund balance, which could mislead off-chain indexers and dashboards.

PROOF OF CONCEPT:

// RF-04: Post-withdrawal contributions permanently locked
// Save as: tests/rf04-poc.ts
it("After withdrawal, new contributions are permanently locked", async () => {
// Round 1: victim1 contributes 2 SOL, creator withdraws
await program.methods.fundCreate("LockedFund", "Test", goal)
.accounts({ fund: fundPDA, creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId })
.rpc();
await program.methods.contribute(new anchor.BN(2_000_000_000))
.accounts({ fund: fundPDA, contributor: victim1.publicKey,
contribution: contribPDA1,
systemProgram: anchor.web3.SystemProgram.programId })
.signers([victim1]).rpc();
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId })
.rpc();
// amount_raised NOT reset: still 2 SOL
let fund = await program.account.fund.fetch(fundPDA);
assert(fund.amountRaised.toString() === "2000000000",
"amount_raised should still be 2 SOL (not reset)");
// Round 2: victim2 contributes 1 SOL more
await program.methods.contribute(new anchor.BN(1_000_000_000))
.accounts({ fund: fundPDA, contributor: victim2.publicKey,
contribution: contribPDA2,
systemProgram: anchor.web3.SystemProgram.programId })
.signers([victim2]).rpc();
// amount_raised now = 3 SOL, but fund only has ~1 SOL + rent
fund = await program.account.fund.fetch(fundPDA);
assert(fund.amountRaised.toString() === "3000000000",
"amount_raised inflated to 3 SOL");
// 2nd withdrawal FAILS — victim2's 1 SOL permanently locked
try {
await program.methods.withdraw()
.accounts({ fund: fundPDA, creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId })
.rpc();
assert.fail("Should have thrown InsufficientFunds");
} catch (err) {
assert(err.message.includes("InsufficientFunds"));
}
});

RECOMMENDED MITIGATION:

**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(())
}
// In refund instruction, after the lamport transfer:
ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
Updates

Lead Judging Commences

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

[M-01] Withdrawal doesn't reset amount_raised, leading to locked funds

## Description The `withdraw()` function in the `rustfund` program contains a vulnerability where the `amount_raised` state variable is never reset to zero after a successful withdrawal. This leads to a situation where new contributions after a withdrawal are effectively locked in the contract, as subsequent withdrawal attempts will fail due to insufficient funds. ```rust 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)?; // Missing: fund.amount_raised = 0; Ok(()) } ``` The key issue is that after transferring the funds to its creator, the function does not reset the `amount_raised` variable. This means that if new contributions are made after a withdrawal, the `amount_raised` value will continue to accumulate. When the creator attempts to withdraw again, the contract will try to transfer the entire `amount_raised` value, which will be larger than the actual balance in the fund account, resulting in an `InsufficientFunds` error. ## Impact 1. **Permanently locked funds**: Any contributions made after a successful withdrawal will be permanently locked in the contract, as the creator cannot withdraw them. 2. **Campaign dysfunction**: The crowdfunding mechanism becomes dysfunctional after the first withdrawal, as any new funds contributed cannot be properly managed. ## Proof of Concept (PoC) The following test demonstrates how funds become locked after a withdrawal due to the amount_raised not being reset: ```javascript import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { Rustfund } from "../target/types/rustfund"; import { PublicKey } from '@solana/web3.js'; import { expect } from 'chai'; describe("amount_raised is never reset", () => { const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.Rustfund as Program<Rustfund>; const creator = provider.wallet; const otherUser = anchor.web3.Keypair.generate(); const fundName = "0xWithdrawers Fund04"; const description = "VULN-04"; const goal = new anchor.BN(1000000000); // 1 SOL const contribution = new anchor.BN(1000000000); // 1 SOL let fundPDA: PublicKey; let contributionPDA: PublicKey; before(async () => { // Generate PDA for fund [fundPDA] = await PublicKey.findProgramAddress( [Buffer.from(fundName), creator.publicKey.toBuffer()], program.programId ); // Airdrop some SOL to the other user for testing const airdropSignature = await provider.connection.requestAirdrop( otherUser.publicKey, 2 * anchor.web3.LAMPORTS_PER_SOL ); await provider.connection.confirmTransaction(airdropSignature); }); it("Creates a fund", async () => { await program.methods .fundCreate(fundName, description, goal) .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); }); it("Contributes to fund", async () => { // Generate PDA for contribution [contributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()], program.programId ); // Perform a contribution of 1 SOL await program.methods .contribute(contribution) .accounts({ fund: fundPDA, contributor: provider.wallet.publicKey, contribution: contributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fund = await program.account.fund.fetch(fundPDA); expect(fund.amountRaised.toString()).to.equal(contribution.toString()); }); it("Creator withdraws funds", async () => { const fundBalanceBefore = await provider.connection.getBalance(fundPDA); // Creator withdraws all funds await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); const fundBalanceAfter = await provider.connection.getBalance(fundPDA); expect(fundBalanceAfter).to.be.below(fundBalanceBefore); // VULNERABILITY: amount_raised is not reset to 0 after withdrawal const fundAfterWithdrawal = await program.account.fund.fetch(fundPDA); expect(fundAfterWithdrawal.amountRaised.toString()).to.equal(contribution.toString()); }); it("New contributions are locked after withdrawal due to VULN-04", async () => { // Generate PDA for otherUser's contribution const [otherUserContributionPDA] = await PublicKey.findProgramAddress( [fundPDA.toBuffer(), otherUser.publicKey.toBuffer()], program.programId ); // Make another contribution from a different user const secondContribution = new anchor.BN(500000000); // 0.5 SOL await program.methods .contribute(secondContribution) .accounts({ fund: fundPDA, contributor: otherUser.publicKey, contribution: otherUserContributionPDA, systemProgram: anchor.web3.SystemProgram.programId, }) .signers([otherUser]) .rpc(); // VULNERABILITY: Since the amount_raised wasn't reset, it now includes both contributions const fundAfterSecondContribution = await program.account.fund.fetch(fundPDA); const expectedTotal = contribution.add(secondContribution); expect(fundAfterSecondContribution.amountRaised.toString()).to.equal(expectedTotal.toString()); // Now try to withdraw the second contribution try { await program.methods .withdraw() .accounts({ fund: fundPDA, creator: creator.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }) .rpc(); // If we reach this point, the test has failed expect.fail("Withdrawal should have failed due to insufficient funds"); } catch (error) { // Verify it's the expected error (insufficient funds) expect(error.message).to.include("InsufficientFunds"); } }); }); ``` Save the above test as `tests/04.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 10 SOL project. 2. Contributors donate a total of 10 SOL, reaching the goal. 3. The creator withdraws the 10 SOL (withdrawal succeeds) when goal is reached and deadline past. 4. The `amount_raised` in the contract remains at 10 SOL, though the actual balance is now 0. 5. A new contributor donates 2 SOL to support the ongoing project. 6. The creator tries to withdraw this new contribution. 7. The withdrawal fails with an "InsufficientFunds" error because the contract tries to withdraw 12 SOL (the accumulated `amount_raised`), but only 2 SOL is available in the account. 8. The 2 SOL contribution is now permanently locked in the contract, with no mechanism to withdraw it. ## Recommendation The `withdraw()` function should be modified to reset the `amount_raised` value to zero after a successful withdrawal: ```rust 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)?; // Reset amount_raised to 0 after successful withdrawal ctx.accounts.fund.amount_raised = 0; Ok(()) } ``` This fix ensures that after each withdrawal, the `amount_raised` is reset to zero, allowing new contributions to be properly accounted for and subsequently withdrawn by the creator.

Support

FAQs

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

Give us feedback!