RustFund

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

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.

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:

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:

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:

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.

Updates

Appeal created

bube Lead Judge 5 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.