Rust Fund

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

Fund state account is also used as the SOL vault, causing rent-reserve withdrawal failures

Fund state account is also used as the SOL vault, causing rent-reserve withdrawal failures

Description

The Fund account stores campaign state and also receives contributed SOL directly. This means the same account balance contains both rent-exempt lamports required to keep the state account alive and contributor funds intended to be withdrawable or refundable.

This design becomes unsafe because withdrawal accounting is based on fund.amount_raised, not on a dedicated vault balance. Since withdraw does not reset amount_raised, later withdrawals can attempt to transfer more lamports than the newly contributed amount. With small later contributions, the attempted transfer would consume the state account's rent reserve, so the Solana runtime rejects the transaction with an insufficient-rent error.

#[account]
#[derive(InitSpace)]
pub struct Fund {
#[max_len(200)]
pub name: String,
#[max_len(5000)]
pub description: String,
pub goal: u64,
pub deadline: u64,
pub creator: Pubkey,
@> pub amount_raised: u64,
pub dealine_set: bool,
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
@> let amount = ctx.accounts.fund.amount_raised;
@> // This subtracts from the Fund state account's lamports,
@> // which also include its rent-exempt reserve.
**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)?;
@> // amount_raised is not reset after withdrawal
Ok(())
}

Risk

Likelihood:

  • This occurs after a successful withdrawal followed by another contribution, because amount_raised still includes already-withdrawn funds.

  • This occurs most clearly with small later contributions, where a later withdrawal would need to consume the fund account's rent reserve to satisfy stale amount_raised.

Impact:

  • Future withdrawals can fail with insufficient funds for rent, leaving newly contributed funds stuck in the fund account.

  • The protocol's state account balance becomes fragile because rent-exempt lamports and user funds are mixed in the same account.

Proof of Concept

const airdropSolAmount = 5;
const airdrop = async (
pubkey: anchor.web3.PublicKey,
sol = airdropSolAmount
) => {
const sig = await provider.connection.requestAirdrop(
pubkey,
sol * anchor.web3.LAMPORTS_PER_SOL
);
const latest = await provider.connection.getLatestBlockhash();
await provider.connection.confirmTransaction(
{
signature: sig,
blockhash: latest.blockhash,
lastValidBlockHeight: latest.lastValidBlockHeight,
},
"confirmed"
);
};
it("fund account is used both as state account and SOL vault", async () => {
const testCreator = provider.wallet;
const testContributor = anchor.web3.Keypair.generate();
const testFundName = `refund-accounting-${Date.now()}`;
const smallContribution = new anchor.BN(1_000_000);
await airdrop(testContributor.publicKey);
const [testFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(testFundName), testCreator.publicKey.toBuffer()],
program.programId
);
const [testContributionPDA] = await PublicKey.findProgramAddress(
[testFundPDA.toBuffer(), testContributor.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(testFundName, description, goal)
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
await program.methods
.contribute(smallContribution)
.accountsPartial({
fund: testFundPDA,
contributor: testContributor.publicKey,
contribution: testContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
await program.methods
.withdraw()
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
await program.methods
.contribute(smallContribution)
.accountsPartial({
fund: testFundPDA,
contributor: testContributor.publicKey,
contribution: testContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([testContributor])
.rpc();
const fundAccountBeforeSecondWithdraw =
await program.account.fund.fetch(testFundPDA);
let failed = false;
try {
await program.methods
.withdraw()
.accountsPartial({
fund: testFundPDA,
creator: testCreator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
expect.fail("withdraw should fail because stale amountRaised would consume rent reserve");
} catch (err) {
failed = true;
expect(String(err)).to.include("insufficient funds for rent");
}
expect(failed).to.eq(true);
expect(fundAccountBeforeSecondWithdraw.amountRaised.toNumber()).to.be.greaterThan(
smallContribution.toNumber()
);
});

The first withdrawal succeeds but leaves amountRaised stale. After a second small contribution, amountRaised is larger than the newly contributed amount. The next withdrawal would consume rent-reserve lamports from the Fund state account, so the runtime rejects the transaction with insufficient funds for rent.

Recommended Mitigation

Separate state from funds by using a dedicated vault account for contributed SOL. Track withdrawable balance separately from the state account's rent-exempt reserve. Also reset or decrement amount_raised after withdrawals.

- pub fund: Account<'info, Fund>,
+ pub fund: Account<'info, Fund>,
+ #[account(
+ mut,
+ seeds = [b"vault", fund.key().as_ref()],
+ bump
+ )]
+ pub vault: SystemAccount<'info>,
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)?;
+ // Transfer from a dedicated vault, not from the state account's rent reserve.
+ // Preserve the fund account's rent-exempt lamports.
**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 23 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!