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.
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 none → is-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.
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:
signal-config-change(jing-mm-safe.clar:218-229) —(try! (is-authorized none)), i.e. plainis-admin-calling tx-sender. Nosig-authparameter exists on this function at all; a passkey can never be supplied. Setsconfig-signaled-at = burn-block-height.- Wait
effective-config-cooldownblocks, whereeffective-config-cooldown = min(current cooldown-period, MAX-CONFIG-COOLDOWN)(jing-mm-safe.clar:239-243,MAX-CONFIG-COOLDOWN = u4032at line 43 — about 28 days at ~10 min/block). At the contract's own default (cooldown-period: u144at line 1461, ≈1 day), the wait is ~1 day, not 28. set-wallet-config(jing-mm-safe.clar:231-260) — again(try! (is-authorized none)), admin-only, nosig-authparameter exists on this function either. Setswallet-configto{ ..., cooldown-period: new-cooldown-period, ... }with no lower bound check onnew-cooldown-period. Call it withnew-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.
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 tx1→tx2 are a one-time setup cost (~1 day at default config, ≤28 days regardless of the deployed cooldown-period); tx3→tx4 then repeat for unlimited over-threshold drains with zero further delay and zero passkey involvement.
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.
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.
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.
execute-pending-*-now(jing-mm-safe.clar:482-533and sbtc equivalents): correctly rejectspasskey-createdops (err-forbiddenu4003) and requires a valid passkey signature bound to the specificop-id/auth-idviamm-safe-auth-helpers-v1.build-execute-now-hash, which itself domain-binds tocontract-caller(the calling safe contract) — no cross-wallet replay.verify-signaturerp-id whitelist (jing-mm-safe.clar:1000-1037): only the five documented domains pass; any otherrp-idhash failserr-invalid-signature.confirm-transfer-wallet(jing-mm-safe.clar:951-998):propose-transfer-walletis admin-only but only stages a pending value; the actual admin swap requires a valid passkey signature against the current admin's registered pubkey viaverify-signature→is-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 emittedlog-propose-transfer-walletevent).
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).