High: nested tx-sender authorization lets an intermediary seize the vault key and keeper role
Bounty: mts7e7jcabac446e3f0e Repository: Rapha-btc/jing-contracts-v3 Audited HEAD: 92ca494a05bf5a3ff63ca08efa60ba69e4588040 Affected: contracts/vault-sbtc-stx-v5.clar
Summary
The vault authenticates OWNER and keeper with tx-sender rather than the immediate contract-caller. In Clarity, tx-sender remains the original transaction signer across nested contract calls. Any untrusted contract that the vault owner calls can therefore call back into the vault while still appearing to be OWNER.
The intermediary can replace both owner-pubkey and keeper in one transaction. It then satisfies both requirements used by verify-and-consume: it is the configured keeper and controls the private key corresponding to owner-pubkey. This grants arbitrary Jing deposit, reprice, direct-swap, and router-swap authority over vault assets. Direct withdraws still pay immutable OWNER, so this is below Critical, but arbitrary trading authority can realize losses through attacker-selected limits and a controlled counterposition/fallback liquidity.
Root cause
Affected owner checks at current HEAD: lines 130, 137, 144, 154, 164, 176. Shared execution gate: lines 485-486.
(asserts! (is-eq tx-sender OWNER) ERR_NOT_OWNER)
(or
(is-eq tx-sender OWNER)
(is-eq (some tx-sender) (var-get keeper))
)
For OWNER -> malicious-proxy -> vault:
tx-sender = OWNER
contract-caller = malicious-proxy
The checks pass even though the immediate caller is attacker-controlled.
Attack sequence
- Attacker deploys a proxy that calls set-owner-pubkey(attacker-key) and set-keeper(some attacker).
- OWNER calls the proxy once through an untrusted dApp/contract interaction.
- Both writes pass because nested calls preserve tx-sender=OWNER.
- Attacker directly calls execution entrypoints as keeper and signs intents with the installed key.
- No original-owner signature is needed after takeover. The owner can recover only after noticing and resetting both values.
Proxy used by the PoC:
(define-public (hijack (pubkey (buff 33)) (new-keeper principal))
(begin
(try! (contract-call? .vault-sbtc-stx-v5 set-owner-pubkey pubkey))
(try! (contract-call? .vault-sbtc-stx-v5 set-keeper (some new-keeper)))
(ok true)))
Deterministic reproduction
A minimal Clarinet/Vitest harness copied the exact OWNER, owner-pubkey, keeper, set-owner-pubkey, and set-keeper fragment from the scoped contract. It deployed the proxy under wallet_1, called hijack with deployer/OWNER as transaction signer, and asserted owner-pubkey == attackerKey and keeper == wallet_1. A control direct set-keeper call from wallet_1 returned err u6001, proving the bypass is specific to nested tx-sender propagation.
Command:
npx vitest run tests/bounty-mts7e7jcabac446e3f0e.test.ts --config vitest.bounty.config.ts
Result:
Test Files 1 passed (1)
Tests 1 passed (1)
Fix
Use immediate caller authority everywhere:
-(is-eq tx-sender OWNER)
+(is-eq contract-caller OWNER)
-(is-eq (some tx-sender) (var-get keeper))
+(is-eq (some contract-caller) (var-get keeper))
Apply this to all six direct owner checks and check-owner-or-keeper. Add a regression test where OWNER calls a proxy that attempts every privileged endpoint; every nested call must return ERR_NOT_OWNER.
Novelty
At verification time the bounty API listed three submissions covering MIN_MARKET bouncing, parked exits/repricing, keeper revocation, canonical replacement, rounding dust, and donation accounting. None reported nested tx-sender authorization or pubkey/keeper takeover.