Skip to content

Instantly share code, notes, and snippets.

@sonic-mast
Created July 9, 2026 06:13
Show Gist options
  • Select an option

  • Save sonic-mast/b06a08274df7250ed0aba54cb5c61bce to your computer and use it in GitHub Desktop.

Select an option

Save sonic-mast/b06a08274df7250ed0aba54cb5c61bce to your computer and use it in GitHub Desktop.
pillar-safe-v2 / jing-mm-safe stress-test: admin-key-only cooldown-zeroing bypasses 2FA spend-threshold protection

Finding: Admin key alone can permanently zero the spend-threshold cooldown, fully defeating the propose→veto 2FA guarantee

Bounty: mrczwu00937221e6b7df — Stress-test 5k: pillar-safe-v2 + jing-mm-safe passkey smart wallets Contracts: SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.pillar-safe-v2, SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.jing-mm-safe (identical vulnerable pattern in both — jing-mm-safe line numbers cited below, pillar-safe-v2 equivalents noted) Severity: Medium — full, permanent bypass of the documented threat model ("a compromised admin key alone cannot drain over-threshold funds"), but requires sustained admin-key control across a wait window and emits an on-chain event that a monitoring party could act on.

Threat model claim being tested

The bounty brief states the design goal directly: "Confirm a compromised admin key alone cannot drain over-threshold funds or strip the escape [hatch]." The wallet enforces this via a propose→cooldown→veto pattern: over-threshold spends by the admin key alone create a pending operation with execute-after = burn-block-height + cooldown-period; during that window a passkey holder can call veto-operation to cancel it. execute-pending-*-transfer only re-checks executed, vetoed, and burn-block-height >= execute-after — it does not require a passkey (is-authorized noneis-admin-calling tx-sender, admin-only, jing-mm-safe.clar:468 / pillar-safe-v2.clar:453). So the cooldown window is the entire protection against a stolen admin key: no passkey is required to execute a pending op once the wait has passed.

The bug

cooldown-period is a field of wallet-config, and wallet-config can be changed to any uint, including u0, through a path that is single-factor (admin-key-only) end to end:

  1. signal-config-change (jing-mm-safe.clar:218-229) — (try! (is-authorized none)), i.e. plain is-admin-calling tx-sender. No sig-auth parameter exists on this function at all; a passkey can never be supplied. Sets config-signaled-at = burn-block-height.
  2. Wait effective-config-cooldown blocks, where effective-config-cooldown = min(current cooldown-period, MAX-CONFIG-COOLDOWN) (jing-mm-safe.clar:239-243, MAX-CONFIG-COOLDOWN = u4032 at line 43 — about 28 days at ~10 min/block). At the contract's own default (cooldown-period: u144 at line 1461, ≈1 day), the wait is ~1 day, not 28.
  3. set-wallet-config (jing-mm-safe.clar:231-260) — again (try! (is-authorized none)), admin-only, no sig-auth parameter exists on this function either. Sets wallet-config to { ..., cooldown-period: new-cooldown-period, ... } with no lower bound check on new-cooldown-period. Call it with new-cooldown-period = u0.

After step 3, create-pending-operation (jing-mm-safe.clar:264-...) sets every future pending op's execute-after: (+ burn-block-height (get cooldown-period config)) = burn-block-height + 0 = the current block. execute-pending-stx-transfer / execute-pending-sbtc-transfer / execute-pending-sbtc-withdrawal only require burn-block-height >= execute-after, which is true immediately (and can be satisfied by a second transaction in the very same block).

Net effect: an attacker holding only the STX admin key — never the passkey — can, after a one-time wait bounded by the wallet's own cooldown-period (default ~1 day, hard-capped at 28 days regardless of how long the legitimate cooldown was configured), permanently collapse the propose→veto window to zero blocks. From that point on, every over-threshold stx-transfer / sip010-transfer / sbtc-initiate-withdrawal call the attacker makes is followed one transaction later (same or next block) by a successful execute-pending-* call, admin-key only, with no passkey signature ever produced or required. This is a full bypass of the documented "compromised admin key alone cannot drain over-threshold funds" guarantee — it just costs the attacker a wait, not a passkey.

Both pillar-safe-v2.clar and jing-mm-safe.clar share the identical signal-config-change / set-wallet-config implementation (verified: pillar-safe-v2.clar:207-260, same MAX-CONFIG-COOLDOWN u4032 at line 34, same unbounded new-cooldown-period field, same default cooldown-period: u144 at line 1190). pillar-safe-v2 doesn't even have a -now fast path — the propose→cooldown→veto window is its only over-threshold protection, so zeroing it is equally complete there.

Reproduction (transaction sequence)

Given attacker controls only the admin STX key on a wallet with default config (cooldown-period: u144, stx-threshold: <T>):

tx1 (attacker, admin key): signal-config-change()
    -> wallet-config.config-signaled-at = burn-block-height H

... wait 144 blocks (~24h) ...

tx2 (attacker, admin key, block >= H+144):
    set-wallet-config(new-stx-threshold: <T>, new-sbtc-threshold: <same>, new-cooldown-period: u0)
    -> wallet-config.cooldown-period = 0

tx3 (attacker, admin key, sig-auth=none): stx-transfer(amount > T, attacker-address, none, none, none)
    -> exceeds threshold -> create-pending-operation(...)
    -> pending-operations[op-id].execute-after = burn-block-height (tx3's block, cooldown=0)

tx4 (attacker, admin key, same or next block): execute-pending-stx-transfer(op-id, none)
    -> asserts pass: not executed, not vetoed, burn-block-height >= execute-after (true)
    -> is-authorized none -> is-admin-calling tx-sender -> passes (attacker IS admin)
    -> funds sent, no passkey signature ever presented

Steps tx1tx2 are a one-time setup cost (~1 day at default config, ≤28 days regardless of the deployed cooldown-period); tx3tx4 then repeat for unlimited over-threshold drains with zero further delay and zero passkey involvement.

Why this survives the existing test/fuzz coverage

The bounty notes 25/25 and 39/39 deterministic sim passes plus 4 Rendezvous invariants at 200 runs. Threshold/cooldown/veto invariants framed as "does a pending op execute before its cooldown, and can it execute if vetoed" will not catch this, because both properties still hold relative to the currently configured cooldown-period — the bug is that cooldown-period itself is a single-factor-mutable variable with no floor, so the invariant is checked against a value the attacker can drive to zero using only the factor the invariant assumes is insufficient on its own.

Suggested fix

Either (a) require a passkey signature (via the same sig-auth/is-authorized (some ...) pattern used elsewhere) on set-wallet-config specifically when new-cooldown-period < (get cooldown-period config) i.e. any decrease, or simpler, (b) enforce a protocol-level floor on cooldown-period (e.g. asserts! (>= new-cooldown-period MIN-COOLDOWN) err-cooldown-too-short) so it can never reach a value that makes the propose→veto window meaningless, even to the admin key alone.

Scope check against the bounty's listed attack surface

This isn't literally the "execute-pending-*-now" bypass (that path is correctly guarded — see below), the rp-id whitelist (correctly enforced, jing-mm-safe.clar:1008-1024), or the RFQ desk. It's the same underlying question the brief poses for the transfer escape hatch — "confirm a compromised admin key alone cannot drain over-threshold funds" — applied to the config path that governs the threshold protection itself, which the brief's explicit bullet list doesn't separately cover but the stated security model clearly intends to guarantee.

Also checked, found correctly implemented (no finding)

  • execute-pending-*-now (jing-mm-safe.clar:482-533 and sbtc equivalents): correctly rejects passkey-created ops (err-forbidden u4003) and requires a valid passkey signature bound to the specific op-id/auth-id via mm-safe-auth-helpers-v1.build-execute-now-hash, which itself domain-binds to contract-caller (the calling safe contract) — no cross-wallet replay.
  • verify-signature rp-id whitelist (jing-mm-safe.clar:1000-1037): only the five documented domains pass; any other rp-id hash fails err-invalid-signature.
  • confirm-transfer-wallet (jing-mm-safe.clar:951-998): propose-transfer-wallet is admin-only but only stages a pending value; the actual admin swap requires a valid passkey signature against the current admin's registered pubkey via verify-signatureis-admin-pubkey. A compromised admin key alone cannot complete a wallet takeover through this path (it can only propose, which a monitoring passkey holder would see via the emitted log-propose-transfer-wallet event).

Disclosure

No live user funds identified at these specific mainnet addresses beyond bounty test/dev usage; filing directly per bounty terms (no separate private-disclosure step indicated in the bounty spec, unlike code-bounty findings on live-TVL protocols).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment