amount_raised above the real balance and keep it there permanentlyamount_raised is what withdraw pays out against, so every lamport that leaves the fund has to be subtracted from it. There are two outflow paths in the program — refund and withdraw — and the counter must fall on both.
refund debits the fund's lamports and clears the contributor's own record, but leaves fund.amount_raised untouched. The counter therefore only ever grows, and after any refund it permanently overstates what the PDA actually holds. Because withdraw demands the counter's value, an inflated counter denies the creator their entire raise.
The counter is written in exactly two places across the whole program, and neither is a decrement:
contribute credits it, refund refunds against it without debiting it. That asymmetry is the entire bug: an attacker can push lamports in, pull the same lamports back out, and leave the increment behind.
| step | actor | PDA lamports | amount_raised |
|---|---|---|---|
| honest campaign reaches its goal | contributors | R + 3 |
3 |
contribute(5 SOL) |
attacker | R + 8 |
8 |
refund() |
attacker | R + 3 — money returned |
8 — increment stays |
withdraw() |
creator | needs 8 from R + 3 |
reverts |
The attacker ends the sequence holding their own 5 SOL again, having paid transaction fees and the rent for their own Contribution account. The creator is left unable to collect a campaign that met its goal. The inflation amount is chosen freely by the attacker and is bounded only by what they can hold momentarily, since they get it straight back.
They share a symptom and nothing else. This one is in refund (line ~85) and is triggered by any contributor; the withdrawal-side omission is in withdraw (line ~104) and is triggered by the creator against themselves. The fixes are different lines in different functions — fund.amount_raised -= amount in refund versus zeroing the counter in withdraw — and applying either one leaves the other fully intact. A patch that only zeroes the counter on withdrawal does nothing whatsoever about the sequence above.
Likelihood:
Fully permissionless. Any address can do it: contribute, then refund. Both instructions are open to any signer, with no allowlist, no minimum, and no timing constraint.
Effectively free. Measured attacker cost in the run below: 0.001457680 SOL, which is transaction fees plus the rent for the attacker's own Contribution PDA. The 5 SOL used to inflate the counter comes straight back in the same sequence.
Repeatable and cumulative. A fresh keypair repeats it, and each round adds to the deficit; the program has no path that ever lowers the counter.
Impact:
The creator cannot collect a campaign that succeeded. withdraw reverts and keeps reverting until someone covers the deficit the attacker created.
The griefer chooses the size of the damage. Inflating by 5 SOL costs the attacker nothing and forces a 5 SOL liquidity requirement onto the creator; inflating by 500 SOL costs the same and forces 500.
The campaign unwinds asymmetrically. In the run below, while the creator's withdraw is reverting, an honest contributor's refund still succeeds and returns their 1 SOL — so the fund drains out through the contributor side while the creator's side is locked.
Stated up front, because they materially bound the severity and I would rather set them out than have a judge find them:
This is dormant in the code as shipped. A separate defect means contribution.amount is only ever assigned 0, so refund currently moves zero lamports and no divergence can occur. I verified this: running the identical attack against the unmodified program leaves amount_raised = 8 SOL against a PDA holding R + 8 SOL — desync of exactly 0 — and the creator's withdraw succeeds. The attacker simply loses their 5 SOL.
It is armed by the obvious fix, not by an exotic one. The natural repair for that defect is a single line, contribution.amount += amount. The PoC below runs against a build where that is the only change; every other line is untouched. That is what turns this from a dormant asymmetry into a live zero-cost griefing primitive, which is why it needs fixing in the same change rather than being discovered afterwards.
It is not theft, and it is recoverable. The attacker gains nothing — this is pure griefing. And the fund PDA is an ordinary address, so anyone can top it up with a plain system transfer to cover the deficit and let withdraw through; the PoC demonstrates that recovery rather than hiding it. The cost asymmetry is the point: the attacker pays ~0.0015 SOL, the creator must front whatever the attacker chose.
These bounds are why I have set Impact to Medium rather than High, and Likelihood to Medium rather than High.
Two builds are used. The original program shows the defect is currently dormant. The patched build differs from it by exactly one line — contribution.amount += amount; added after line 50 — and shows the same attack landing.
Against the patched build — the attack lands:
Against the original build — dormant, exactly as disclosed above:
The two runs differ by one line of Rust. The attacker's net position moves from −5.0015 SOL to −0.0015 SOL, and the creator's withdrawal moves from succeeding to reverting.
Decrement the fund's counter on the refund path so both ledgers move together.
A zero-amount guard at the top of refund is worth adding alongside it, so that a refund which would move nothing fails loudly instead of silently succeeding:
The invariant that should hold after every instruction is fund.to_account_info().lamports() >= rent_minimum + fund.amount_raised. It is worth asserting directly, because it catches this class of divergence on whichever outflow path introduces it.
# \[H-02] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution ## Description The `refund` function does not update `fund.amount_raised`, causing an inconsistency between the fund's actual balance and the recorded raised amount. As a result, when the fund creator tries to withdraw funds, the transaction may fail due to insufficient balance, effectively locking funds in the contract. ## Vulnerability Details The issue arises in the `refund` function, which transfers funds back to the contributor but does not update the `amount_raised` field: ```rust pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } 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.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; Ok(()) } ``` The issue becomes evident when the fund creator attempts to withdraw using the following function: ```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)?; Ok(()) } ``` Since `amount_raised` is never updated when a refund occurs, the creator will attempt to withdraw more than what actually exists in the fund, causing an insufficient funds error and failing the transaction. ## Impact - If any contributor requests a refund, the total balance in the fund decreases. However, `fund.amount_raised` remains unchanged, leading to an overestimated available balance. - When the fund creator calls `withdraw`, they attempt to transfer `fund.amount_raised`, which no longer matches the actual available balance. - This results in a failed transaction, effectively locking funds in the contract since the withdraw function will always fail if refunds have been processed. ## Proof of Concept This issue is not currently caught by tests because the `contribute` function itself has a bug (not updating `contribution.amount`), preventing the refund function from executing properly. Once the contribute function is fixed, the issue will be clearly visible in test cases. ## Recommendations The `refund` function must update `fund.amount_raised` to ensure the contract state reflects the actual balance after refunds. ### Fixed Code: ```diff pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } 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.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; + // Fix: Decrease the fund's recorded amount_raised + let fund = &mut ctx.accounts.fund; + fund.amount_raised = fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.