Skip to content

CRITICAL FINDING (F-9): deposit-as-repayment reentrancy on live v2 pools - #83

Merged
mattglory merged 4 commits into
mainfrom
security-lead/f9-v2-pool-deposit-reentrancy
Oct 1, 2026
Merged

mattglory merged 4 commits into
mainfrom
security-lead/f9-v2-pool-deposit-reentrancy

Conversation

@unixwhisperer

Copy link
Copy Markdown
Collaborator

Summary

flashstack-stx-pool-v2 and flashstack-sbtc-pool-v2 have no reentrancy lock. A flash-loan receiver can "repay" by calling the pool's own deposit() (wrapped in as-contract, so the deposit is funded from the loan itself) instead of a plain transfer. That satisfies the balance-delta repayment check but also mints the caller LP shares, priced against the pool's balance as depressed by the same loan's own outbound transfer — a far cheaper share price than any honest depositor gets, diluting every existing LP. Same bug class as pv3-F1, never ported to these two pools, which predate pool-v3 and are live with real funds.

  • Proven in simnet against a faithful localization of the live deployed flashstack-stx-pool-v2 contract (SPR9PQAN…).
  • Independently re-verified confirmed dormant: the admin wallet's complete transaction history has zero add-approved-receiver calls against either v2 pool — nobody is whitelisted to trigger it today.
  • Independently reproduced from scratch by @mattglory before this PR was shared — numbers match.
  • Exact minimum attacker capital: 49,500 µSTX (the real 0.05% fee on a 99 STX loan; one µSTX less and it fails). Receiver ends with 9,904,940,194,109,305 of 10,004,940,194,109,305 total shares, worth 99,049,499 µSTX — a ~2,001x return at that minimum. Honest LP's 100,000,000 µSTX deposit drops to 1,000,000 µSTX — a 99% loss, extracted in one transaction.
  • Mitigation already executed on-chain: set-paused true on both live v2 pools (confirmed via fresh get-stats reads — paused=true, balances unchanged, flash-loan blocked, deposit/withdraw correctly still open).

Full detail in docs/security/FINDINGS_REGISTER.md (F-9) and Flashstack-ajv.4.8.

What's in this PR

  • tests/stx-pool-v2-deposit-reentrancy.test.ts + contracts/test/test-pool-v2-receiver-deposit-reentrant.clar: the simnet PoC, corrected to use as-contract (shares land on the receiver contract, funded by the loan itself — matching the economically realistic attack, and @mattglory's independent repro).
  • docs/security/FINDINGS_REGISTER.md: F-9 entry with the corrected, independently-verified numbers.

Test plan

  • npx vitest run tests/stx-pool-v2-deposit-reentrancy.test.ts passes
  • Boundary-tested the exact minimum attacker capital (49,499 µSTX fails, 49,500 succeeds)
  • Cross-checked share-mint numbers by hand against the pool's own formula, independent of the test harness
  • @mattglory to fold in verify-f9-independently if useful

Next steps (tracked separately, not in this PR)

No fix is possible in place — these are immutable deployed contracts. A real fix mirrors pv3-F1's per-asset lock and needs a v3-equivalent successor for these two pools, same posture as BC1.

🤖 Generated with Claude Code

flashstack-stx-pool-v2 and flashstack-sbtc-pool-v2 have no reentrancy lock.
A flash-loan receiver can repay by calling the pool's own deposit() instead
of transferring funds back. That satisfies the balance-delta repayment
check (the pool's balance genuinely grows by amount+fee) but also mints
the caller LP shares, priced against the pool's balance as depressed by
this same loan's own outbound transfer -- a far cheaper price than any
honest depositor gets. Shares land on tx-sender (the original caller), not
the receiver contract. Same bug class as pv3-F1, never checked against the
two pools that predate pool-v3 and are actually live with real funds.

Proven in simnet (tests/stx-pool-v2-deposit-reentrancy.test.ts, receiver
contracts/test/test-pool-v2-receiver-deposit-reentrant.clar) against the
localized copy of the live flashstack-stx-pool-v2 contract (SPR9PQAN...,
1.000000 STX, paused=false): honest LP deposits 100 STX; attacker fronts
only 0.01 STX (fee-sized capital), borrows 99 STX, and ends up owning
effectively 100% of the pool's shares. The honest LP's 100 STX position is
left worth 1 STX -- 99% extracted in a single transaction.

flashstack-sbtc-pool-v2 was read directly (contracts/flashstack-sbtc-pool-v2.clar:84-163)
and has an identical deposit/flash-loan structure with no lock -- same
vector, 44,990 sats live, not yet independently simnet-proven. The two
cores (flashstack-stx-core, flashstack-sbtc-core) are NOT exposed: their
deposit-reserve equivalent is tx-sender==admin gated, which a receiver
callback cannot satisfy.

Bounded by the receiver-approval whitelist (flash-loan checks the receiver
principal, not tx-sender, so any already-approved contract can be invoked
by anyone) -- but nothing in the current review process distinguishes
"repays via transfer" from "repays via deposit." Immediate, zero-code
mitigation: set-paused true on both live v2 pools blocks flash-loan
entirely (the only entry point for this vector); deposit/withdraw
correctly stay open. No in-place fix is possible on these immutable
contracts -- a real fix needs a v3-equivalent successor with a per-asset
lock, mirroring pv3-F1 and the BC1 pattern.

Full write-up in docs/security/FINDINGS_REGISTER.md (F-9). Tracked in
Flashstack-ajv.4.8. Full suite: 260 passed, 3 skipped / 263 total across
26 files -- no regressions.
Independent re-verification against live chain state before escalating:
fresh Hiro fetch confirms the PoC ran against the real deployed
flashstack-stx-pool-v2 source (diffs only in the expected address
localization); both live pools read paused=false, total-loans=0 right
now; the admin wallet's complete transaction history (24/24 txs) shows
zero add-approved-receiver calls against either v2 pool. No receiver has
ever been whitelisted, so the vector is real but unreachable today -- not
mitigated, just not yet opened. A control run with an honest receiver
(repay by plain transfer) credits zero shares, isolating the effect to
the deposit-reentrancy mechanism rather than a test-harness artifact.
… repro

The original PoC's receiver called deposit() without as-contract, so
tx-sender stayed the attacker throughout the nested call. That meant
deposit's transfer silently pulled amount+fee from the attacker's own
wallet (masked by Clarinet's 100M STX default simnet balance) instead of
from the loan the receiver was holding, and credited shares to the
attacker's EOA. Wrapping the deposit call in as-contract fixes both: the
transfer is funded by the loan itself, and shares land on the receiver
contract -- the economically realistic construction, and the one Matt's
own from-scratch reproduction used.

Corrected numbers (independently re-derived three ways: hand arithmetic
against the share-mint formula, a fresh on-chain-equivalent read, and a
boundary test pinning the exact minimum capital at 49,500 uSTX): receiver
ends with 9,904,940,194,109,305 of 10,004,940,194,109,305 total shares
worth 99,049,499 uSTX, a ~1,981x return on a 50,000 uSTX buffer. Honest
LP's 99% loss is unchanged. FINDINGS_REGISTER.md F-9 updated to match,
with a note on what was wrong and why.
@vercel

vercel Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
web Ready Ready Preview Oct 1, 2026 2:40am UTC

Request Review

tests/mainnet-plan-guard.test.ts pins the exact set of contracts/test/
files clarinet's mainnet deploy plan would publish (D6, Flashstack-ajv.6.6).
The new F-9 receiver, test-pool-v2-receiver-deposit-reentrant.clar, is
test-only infrastructure and belongs on that list like its siblings.

@mattglory mattglory left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independently re-verified the core claims rather than reviewing the diff on its own:

  • Construction: as-contract-wrapped deposit(amount + fee), fee pulled live via get-fee-basis-points, matches my own from-scratch repro of this vector.
  • Numbers: hand-verified the fee math — 99,000,000 µSTX loan × 5bp = 49,500 µSTX, and 49,500 × 2001 = 99,049,500, consistent with the "~2,001× at the exact minimum" figure and the 99,049,499 µSTX result (1 µSTX off from redemption rounding at that boundary, as documented).
  • Share-count correction: the 1-unit discrepancy between our two repros is explained by Number() precision loss above MAX_SAFE_INTEGER on a ~9.9 quadrillion-unit value. Your figures (ending …109,305) are correct; mine weren't — thanks for tracking that down.
  • Dormancy claim: re-checked independently, not taken on the PR description — confirmed.
  • CI: Build, CodeQL ×2, Dependency Audit, Test Smart Contracts, Vercel all green. Clarinet.toml and mainnet-plan-guard.test.ts entries correctly placed and alphabetized.

Not folding in verify-f9-independently as a separate PoC — this PR's register entry already documents the independent cross-check and the as-contract correction, so a second near-duplicate fixture (and another mainnet-plan-guard entry) wouldn't add verification value, just surface area.

Approved.

@mattglory
mattglory merged commit 0dcdc84 into main Oct 1, 2026
7 checks passed
@mattglory
mattglory deleted the security-lead/f9-v2-pool-deposit-reentrancy branch October 1, 2026 04:09

This branch was successfully deployed

1 active deployment
Preview — df13fbd8 Deployed Oct 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants