Rust Fund

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

Root + Impact: [OP-003] Рассинхронизация состояния баланса (State Desync DoS)

Root + Impact: [OP-003] Рассинхронизация состояния баланса (State Desync DoS)

Description

  • Ожидаемое поведение: Функция refund позволяет пользователю вернуть свой вклад, физически переводя lamports с PDA-аккаунта фонда обратно пользователю. Функция withdraw позволяет создателю вывести все собранные средства, используя переменную fund.amount_raised как источник правды о сумме доступных к выводу средств.

  • Конкретная проблема: Функция refund напрямую вычитает lamports из баланса PDA-аккаунта fund, но не декрементирует логическую переменную fund.amount_raised. Это приводит к рассинхронизации (State Desync): переменная fund.amount_raised становится строго больше фактического баланса lamports на PDA-аккаунте. Когда создатель вызывает withdraw, код пытается вычесть завышенное значение amount_raised из реального (меньшего) баланса PDA, что приводит к ошибке ProgramError::InsufficientFunds и permanenty DoS функции вывода.

// programs/rustfund/src/lib.rs
pub fn refund(ctx: Context<FundRefund>) -> Result<()> {
let amount = ctx.accounts.contribution.amount;
// ... проверки дедлайна ...
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount)
.ok_or(ProgramError::InsufficientFunds)?;
**ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.contributor.to_account_info().lamports()
.checked_add(amount)
.ok_or(ErrorCode::CalculationOverflow)?;
// Reset contribution amount after refund
ctx.accounts.contribution.amount = 0;
// @> ОТСУТСТВУЕТ: ctx.accounts.fund.amount_raised -= amount;
// @> Состояние amount_raised не синхронизируется с физическим балансом
Ok(())
}
pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> {
// @> amount берется из устаревшей (завышенной) переменной состояния
let amount = ctx.accounts.fund.amount_raised;
**ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? =
ctx.accounts.fund.to_account_info().lamports()
.checked_sub(amount) // @> Revert: InsufficientFunds (физический баланс PDA меньше amount)
.ok_or(ProgramError::InsufficientFunds)?;
// ...

Risk

Likelihood: High

  • Reason 1: Уязвимость срабатывает при выполнении легитимного сценария использования протокола. Достаточно, чтобы хотя бы один пользователь внес средства и успешно запросил refund (что является нормальным поведением, если дедлайн не был установлен или время пришло). Никакой злонамеренной манипуляции от пользователя не требуется.

  • Reason 2: Влияние детерминировано. Как только происходит первый refund, переменная fund.amount_raised навсегда расходится с реальным балансом lamports в меньшую сторону. Любая последующая попытка вызова withdraw математически обречена на провал.

Impact: Medium

  • Impact 1: Permanent DoS (Отказ в обслуживании) функции withdraw. Создатель теряет возможность вывести оставшиеся средства из фонда, даже если на PDA-аккаунте все еще есть lamports от других вкладчиков, не запросивших рефанд.

  • Impact 2: Зависание средств. Оставшиеся средства вкладчиков на балансе PDA могут оказаться навсегда заблокированными, если баланс PDA фонда опустеет ниже rent-exempt минимума, или если они попытаются вернуть средства одновременно (хотя рефанды могут проходить, пока физически есть lamports, логическая несогласованность делает контракт ненадежным и сломанным).

Proof of Concept

// 1. Пользователь A вносит 1 SOL (1_000_000_000 lamports).
// Состояние: fund.lamports = 1 SOL, fund.amount_raised = 1 SOL.
// 2. Пользователь A вызывает refund() (например, deadline == 0 или прошёл).
// Состояние: fund.lamports = 0, fund.amount_raised = 1 SOL (рассинхрон!).
// 3. Пользователь B вносит 1 SOL.
// Состояние: fund.lamports = 1 SOL, fund.amount_raised = 2 SOL.
// 4. Создатель вызывает withdraw().
// - amount = fund.amount_raised (2 SOL).
// - Пытается: 1 SOL (физический баланс) - 2 SOL (amount) -> checked_sub возвращает None.
// - Транзакция отклоняется с ProgramError::InsufficientFunds.
// Создатель не может вывести 1 SOL, которые фактически лежат на контракте.

Recommended Mitigation

// Reset contribution amount after refund
ctx.accounts.contribution.amount = 0;
+ ctx.accounts.fund.amount_raised = ctx.accounts.fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?;
Ok(())

Сводка:

  • Файл: programs\rustfund\src\lib.rs (функция refund и функция withdraw)

  • Likelihood: High

  • Impact: Medium

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 1 hour ago
Submission Judgement Published
Validated
Assigned finding tags:

[M-03] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution

# \[H-02] Fund Creator Can't Withdraw If Someone Has Refunded Their Contribution ## Description The `refund` function does not update `fund.amount_raised`, causing an inconsistency between the fund's actual balance and the recorded raised amount. As a result, when the fund creator tries to withdraw funds, the transaction may fail due to insufficient balance, effectively locking funds in the contract. ## Vulnerability Details The issue arises in the `refund` function, which transfers funds back to the contributor but does not update the `amount_raised` field: ```rust pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; Ok(()) } ``` The issue becomes evident when the fund creator attempts to withdraw using the following function: ```rust pub fn withdraw(ctx: Context<FundWithdraw>) -> Result<()> { let amount = ctx.accounts.fund.amount_raised; ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.creator.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.creator.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ``` Since `amount_raised` is never updated when a refund occurs, the creator will attempt to withdraw more than what actually exists in the fund, causing an insufficient funds error and failing the transaction. ## Impact - If any contributor requests a refund, the total balance in the fund decreases. However, `fund.amount_raised` remains unchanged, leading to an overestimated available balance. - When the fund creator calls `withdraw`, they attempt to transfer `fund.amount_raised`, which no longer matches the actual available balance. - This results in a failed transaction, effectively locking funds in the contract since the withdraw function will always fail if refunds have been processed. ## Proof of Concept This issue is not currently caught by tests because the `contribute` function itself has a bug (not updating `contribution.amount`), preventing the refund function from executing properly. Once the contribute function is fixed, the issue will be clearly visible in test cases. ## Recommendations The `refund` function must update `fund.amount_raised` to ensure the contract state reflects the actual balance after refunds. ### Fixed Code: ```diff pub fn refund(ctx: Context<FundRefund>) -> Result<()> { let amount = ctx.accounts.contribution.amount; if ctx.accounts.fund.deadline != 0 && ctx.accounts.fund.deadline > Clock::get().unwrap().unix_timestamp.try_into().unwrap() { return Err(ErrorCode::DeadlineNotReached.into()); } ctx.accounts.fund.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.fund.to_account_info().lamports() .checked_sub(amount) .ok_or(ProgramError::InsufficientFunds)?; ctx.accounts.contributor.to_account_info().try_borrow_mut_lamports()? = ctx.accounts.contributor.to_account_info().lamports() .checked_add(amount) .ok_or(ErrorCode::CalculationOverflow)?; // Reset contribution amount after refund ctx.accounts.contribution.amount = 0; + // Fix: Decrease the fund's recorded amount_raised + let fund = &mut ctx.accounts.fund; + fund.amount_raised = fund.amount_raised.checked_sub(amount).ok_or(ErrorCode::CalculationOverflow)?; Ok(()) } ```

Support

FAQs

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

Give us feedback!