Rust Fund

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

withdraw Never Resets amount_raised — Creator Can Repeatedly Drain New SOL Deposits Forever

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

### Summary
The `withdraw` instruction transfers `fund.amount_raised` lamports to the creator but fails to update or zero out the `amount_raised` state variable post-execution. This allows the campaign creator to repeatedly invoke the withdrawal sequence and drain any subsequent or residual SOL deposited into the Program Derived Address (PDA).
### Vulnerability Detail
The `withdraw` function processes asset transfers by referencing the persistent `fund.amount_raised` metric. However, it lacks a terminal state update to clear this value once assets are cleared from the account balance.
If unexpected, residual, or accidental SOL deposits occur inside the Fund PDA after an initial successful withdrawal, the creator can trigger the instruction a second time. Because `fund.amount_raised` remains statically populated with its initial value, the runtime attempts to execute another transfer based on the stale state data rather than reflecting the real-time operational balance changes.
### Impact
* **Persistent Funds Theft**: Any late, accidental, or newly routed user contributions sent to the PDA after campaign conclusion can be permanently drained by the campaign owner.
* **Protocol Account Ruin**: Broken balance state updates prevent safe account tracking, state recycling, or protocol upgrading, turning the campaign account into a permanent security drain.
### Proof of Concept
The exploit path is entirely deterministic. The `withdraw` handler computes and subtracts lamport balances using the persistent `fund.amount_raised` value but never performs an assignment to clear out that tracker. Consequently, if the PDA balance changes after a withdrawal, the contract remains blind to the change, using stale records to allow additional extractions.
The automated execution sequence initializes a funding campaign, satisfies the target parameters, executes a standard initial extraction, routes an accidental secondary deposit to the address, and proves that the owner successfully drains those extra assets:
```typescript
it("amount_raised never reset - creator re-drains new SOL", async () => {
// Step 1: Create fund with 100 SOL goal using the program's account configuration
await program.methods.createFund(new anchor.BN(100 * LAMPORTS_PER_SOL))
.accounts({ creator: creator.publicKey, fund: fundPDA })
.signers([creator]).rpc();
// Step 2: Contributor deposits the required 100 SOL to hit target benchmarks
await program.methods.contribute(new anchor.BN(100 * LAMPORTS_PER_SOL))
.accounts({ contributor: contributor.publicKey, fund: fundPDA })
.signers([contributor]).rpc();
// Step 3: Fast-forward the campaign window by shifting the deadline to a past value
await program.methods.setDeadline(new anchor.BN(Date.now() / 1000 - 100))
.accounts({ creator: creator.publicKey, fund: fundPDA })
.signers([creator]).rpc();
// Step 4: First withdrawal execution - creator successfully drains initial 100 SOL pool
const before1 = await connection.getBalance(creator.publicKey);
await program.methods.withdraw()
.accounts({ creator: creator.publicKey, fund: fundPDA })
.signers([creator]).rpc();
const after1 = await connection.getBalance(creator.publicKey);
assert(after1 > before1 + 99 * LAMPORTS_PER_SOL);
// Step 5: A user accidentally transfers an additional 10 SOL directly to the fund PDA address
await connection.sendTransaction(
new Transaction().add(
SystemProgram.transfer({
fromPubkey: someUser.publicKey,
toPubkey: fundPDA,
lamports: 10 * LAMPORTS_PER_SOL,
})
),
[someUser]
);
// Step 6: Second withdrawal execution - creator leverages stale state to drain the accidental 10 SOL
const before2 = await connection.getBalance(creator.publicKey);
await program.methods.withdraw()
.accounts({ creator: creator.publicKey, fund: fundPDA })
.signers([creator]).rpc();
const after2 = await connection.getBalance(creator.publicKey);
// Assert that the transaction passes and the creator successfully stole the unallocated secondary assets
assert(after2 > before2 + 9 * LAMPORTS_PER_SOL);
});
```
### Mitigation
Reset the `amount_raised` tracking value to zero within the active execution scope immediately following a successful asset disbursement. This keeps the internal state variable completely synced with real-time account balances.
Modify the instruction handler implementation as shown below:
```rust
pub fn withdraw(ctx: Context<Withdraw>) -> Result<()> {
let fund = &mut ctx.accounts.fund;
// ... existing deadline/goal validation checks remain here ...
let amount = fund.amount_raised;
// Perform manual lamport modifications safely
**ctx.accounts.fund.to_account_info().lamports.borrow_mut() -= amount;
**ctx.accounts.creator.to_account_info().lamports.borrow_mut() += amount;
// Fix: Explicitly zero out state tracking metrics post-transfer
fund.amount_raised = 0;
Ok(())
```
// Root cause in the codebase with @> marks to highlight the relevant section

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

Recommended Mitigation

- remove this code
+ add this code
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days 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!