Rust Fund

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

Deadline can only be set once” guard is dead; creator can change deadline indefinitely

Root + Impact

Description

  • Normal behavior: once a campaign’s deadline is set, subsequent changes should be rejected (or tightly constrained).

  • Actual behavior: the boolean guard is checked but never flipped true, so the creator can call set_deadline repeatedly.

// programs/rustfund/src/lib.rs
if fund.dealine_set { return Err(...); } // @ programs/rustfund/src/lib.rs:57
fund.deadline = deadline; // @ programs/rustfund/src/lib.rs:61
// missing: fund.dealine_set = true;

Risk

Likelihood:

  • Occurs whenever a creator calls set_deadline a second time; it succeeds unconditionally.

  • Occurs for all funds because dealine_set is initialized false and never updated.


Impact:

  • Time-based trust assumptions break; creators can extend/shorten after users rely on a posted deadline.

  • Enables deadline-based manipulation of contribution/refund availability.

Proof of Concept

Run: cargo test -p rustfund --test poc_finding3_lamport_injection_stuck -- --nocapture

use anchor_lang::{prelude::Pubkey, AccountDeserialize, InstructionData, ToAccountMetas};
use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult};
use solana_program_test::{processor, BanksClientError, ProgramTest, ProgramTestContext};
use solana_sdk::{
clock::Clock,
signature::Signer,
system_program,
transaction::Transaction,
};
fn process_rustfund_instruction<'a, 'b, 'c, 'd>(
program_id: &'a Pubkey,
accounts: &'b [AccountInfo<'c>],
instruction_data: &'d [u8],
) -> ProgramResult {
let accounts: &'c [AccountInfo<'c>] = unsafe { std::mem::transmute(accounts) };
rustfund::entry(program_id, accounts, instruction_data)
}
fn derive_fund_pda(program_id: &Pubkey, name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], program_id).0
}
async fn send_tx(
ctx: &mut ProgramTestContext,
instructions: Vec<solana_sdk::instruction::Instruction>,
) -> Result<(), BanksClientError> {
let recent_blockhash = ctx.banks_client.get_latest_blockhash().await.unwrap();
let tx = Transaction::new_signed_with_payer(
&instructions,
Some(&ctx.payer.pubkey()),
&[&ctx.payer],
recent_blockhash,
);
ctx.banks_client.process_transaction(tx).await
}
async fn get_clock(ctx: &mut ProgramTestContext) -> Clock {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap()
}
async fn fetch_fund(ctx: &mut ProgramTestContext, fund: Pubkey) -> rustfund::Fund {
let account = ctx
.banks_client
.get_account(fund)
.await
.unwrap()
.expect("fund account should exist");
rustfund::Fund::try_deserialize(&mut account.data.as_ref()).unwrap()
}
#[tokio::test]
async fn poc_finding4_deadline_can_be_changed_unlimited_times() {
let program_id = rustfund::id();
let mut pt = ProgramTest::new("rustfund", program_id, processor!(process_rustfund_instruction));
let mut ctx = pt.start_with_context().await;
let creator = ctx.payer.pubkey();
let name = "deadline-mutable";
let description = "deadline mutability PoC";
let goal = 1u64;
let fund = derive_fund_pda(&program_id, name, &creator);
// Create fund.
let ix_create = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundCreate {
fund,
creator,
system_program: system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: description.to_string(),
goal,
}
.data(),
};
send_tx(&mut ctx, vec![ix_create]).await.unwrap();
// Set deadline twice. The second call should fail if `dealine_set` were ever flipped true.
let clock = get_clock(&mut ctx).await;
let deadline1 = clock.unix_timestamp.saturating_add(60).max(1) as u64;
let deadline2 = clock.unix_timestamp.saturating_add(120).max(2) as u64;
let ix_set_1 = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline: deadline1 }.data(),
};
send_tx(&mut ctx, vec![ix_set_1]).await.unwrap();
let ix_set_2 = solana_sdk::instruction::Instruction {
program_id,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline: deadline2 }.data(),
};
send_tx(&mut ctx, vec![ix_set_2]).await.unwrap();
let fund_state = fetch_fund(&mut ctx, fund).await;
println!(
"fund.deadline={} fund.dealine_set={}",
fund_state.deadline, fund_state.dealine_set
);
assert_eq!(fund_state.deadline, deadline2);
assert_eq!(fund_state.dealine_set, false);
}

Recommended Mitigation

fund.deadline = deadline;
+ fund.dealine_set = true;
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge 2 days ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-02] The set_deadline function does not set the dealine_set flag to true

The `set_deadline()` function in the `rustfund` program contains a vulnerability that allows campaign creators to manipulate deadlines indefinitely. While the function correctly checks if `fund.dealine_set` is true before allowing the deadline to be changed, it never sets this flag to true after setting the deadline. ```rust pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> { let fund = &mut ctx.accounts.fund; if fund.dealine_set { return Err(ErrorCode::DeadlineAlreadySet.into()); } fund.deadline = deadline; Ok(()) } ``` The function is missing a crucial line to update the flag: `fund.dealine_set = true;` This oversight bypasses a key safeguard intended to prevent creators from manipulating deadlines after they've been set. According to the project documentation, this flag is meant to enforce deadline immutability, which is an essential part of the platform's trust model. ### Impact 1. **Refund evasion**: Creators can prevent users from obtaining refunds by continually extending the deadline whenever it approaches. This directly undermines the project's advertised "Refund Mechanism" which promises that "Contributors can get refunds if deadlines are reached and goals aren't met." 2. **Fund locking**: Contributors' funds can be effectively locked indefinitely, as the refund function is contingent upon the deadline being reached: ```rust if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ``` ### Proof of Concept (PoC) The following test demonstrates how a creator can set the deadline multiple times, effectively bypassing the intended deadline immutability: ```javascript import * as anchor from "@coral-xyz/anchor"; import { Program } from "@coral-xyz/anchor"; import { Rustfund } from "../target/types/rustfund"; import { assert } from "chai"; describe("VULN-02: set_deadline vulnerability", () => { // Configures the provider to use the local cluster const provider = anchor.AnchorProvider.env(); anchor.setProvider(provider); const program = anchor.workspace.Rustfund as Program<Rustfund>; // Test variables const fundName = "TestFund"; const description = "Testing deadline vulnerability"; const goal = new anchor.BN(1000000); let fundPda: anchor.web3.PublicKey; it("Allows you to modify the deadline several times", async () => { // Derivation of PDA address for financing account [fundPda] = await anchor.web3.PublicKey.findProgramAddress( [Buffer.from(fundName), provider.wallet.publicKey.toBuffer()], program.programId ); // Fund creation await program.rpc.fundCreate(fundName, description, goal, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, systemProgram: anchor.web3.SystemProgram.programId, }, }); // First deadline assignment const deadline1 = new anchor.BN(Math.floor(Date.now() / 1000) + 3600); // 1 hour in the future await program.rpc.setDeadline(deadline1, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, }, }); // Second deadline assignment (which should not be possible if the flag is set to true) const deadline2 = new anchor.BN(Math.floor(Date.now() / 1000) + 7200); // 2 hours into the future await program.rpc.setDeadline(deadline2, { accounts: { fund: fundPda, creator: provider.wallet.publicKey, }, }); // Check that the deadline has been updated to the second value const fundAccount = await program.account.fund.fetch(fundPda); assert.ok( fundAccount.deadline.eq(deadline2), "The deadline may have been modified several times, but vulnerability presents" ); }); }); ``` Save the above test as, for example, tests/02.ts in your project's test directory and run the test : ```Solidity anchor test ``` ### Concrete Impact Example To illustrate the real-world impact of this vulnerability, consider this scenario: - A creator launches a campaign to fund a project with a goal of 100 SOL - The creator sets an initial deadline of 30 days - Contributors collectively deposit 80 SOL (below the goal) - As the deadline approaches, the creator realizes they won't reach the goal - Instead of allowing refunds as promised, the creator extends the deadline by another 30 days - This pattern can repeat indefinitely, effectively locking contributor funds - Even if contributors try to request refunds, they'll be rejected with "DeadlineNotReached" errors ### Recommendation The fix for this vulnerability is straightforward. The `set_deadline()` function should be modified to set the `dealine_set` flag to true after setting the deadline: ```rust pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: u64) -> Result<()> { let fund = &mut ctx.accounts.fund; if fund.dealine_set { return Err(ErrorCode::DeadlineAlreadySet.into()); } fund.deadline = deadline; fund.dealine_set = true; // Add this line to fix the vulnerability Ok(()) } ```

Support

FAQs

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

Give us feedback!