Root + Impact
Description
The spec says "Creators can withdraw funds once their campaign succeeds." This implies the goal must be met and the campaign period must be over. The withdraw() function enforces neither. It lets the creator take all raised SOL immediately after the first contribution, no matter how far the campaign is from its goal or deadline. This is a direct rug-pull vector.
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(())
}
Risk
Likelihood:
Any campaign creator can do this. It's just a single function call with no barriers.
Impact:
All contributor funds can be stolen instantly. Combined with H-01 (refunds are broken), contributors have zero recourse — their SOL is gone permanently. This completely destroys the trust model of the platform.
Proof of Concept
The creator simply calls withdraw() whenever they want. There are no checks for campaign success, no time restrictions, nothing.
it("H-03: creator withdraws before goal or deadline", async () => {
const bigGoal = new anchor.BN(1000_000_000_000);
await program.methods
.fundCreate(fundName, description, bigGoal)
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const [contributionPDA] = await PublicKey.findProgramAddress(
[fundPDA.toBuffer(), creator.publicKey.toBuffer()],
program.programId
);
await program.methods
.contribute(new anchor.BN(500000000))
.accounts({
fund: fundPDA,
contributor: creator.publicKey,
contribution: contributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const balanceBefore = await provider.connection.getBalance(creator.publicKey);
await program.methods
.withdraw()
.accounts({
fund: fundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const balanceAfter = await provider.connection.getBalance(creator.publicKey);
expect(balanceAfter).to.be.greaterThan(balanceBefore);
});
Recommended Mitigation
Add goal and deadline validation before allowing withdrawal. The creator should only be able to withdraw after the deadline has passed and only if the campaign reached its goal. Also reset amount_raised to prevent double-withdrawal (see M-01).
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
+ let fund = &ctx.accounts.fund;
+ let now: u64 = Clock::get().unwrap().unix_timestamp.try_into().unwrap();
+
+ if fund.deadline != 0 && fund.deadline > now {
+ return Err(ErrorCode::DeadlineNotReached.into());
+ }
+ if fund.amount_raised < fund.goal {
+ return Err(ErrorCode::GoalNotMet.into());
+ }
+
let amount = ctx.accounts.fund.amount_raised;
// ... transfer logic ...
+ ctx.accounts.fund.amount_raised = 0;
Ok(())
}