Confused-deputy owner takeover in fakfun-smart-router-registry

Finding

High: every owner-only entry checks tx-sender, including propose-owner at line 79. In Clarity, tx-sender remains the originating account across nested contract calls. A contract called by the legitimate registry owner can therefore call propose-owner(attacker) and pass the owner check even though the immediate contract-caller is untrusted.

After the 144-block cooldown, the attacker calls accept-owner directly. That function requires only that tx-sender equal the pending owner, so the attacker becomes registry owner without another transaction or confirmation from the legitimate owner.

The same ambient-authority check appears in propose-router (line 39), confirm-router (line 53), and revoke-pending (line 67).

Deployed-source verification

On 2026-09-09, the source returned by Hiro for SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22.fakfun-smart-router-registry matched contracts/fakfun-smart-router-registry.clar byte for byte after only normalizing CRLF to LF. Both normalized sources have SHA-256 c2d62d7a565f22c52fc90795ac48de82e85d7aa2d7ed6cb518dcb2b049668a7a.

Source endpoint: https://api.hiro.so/v2/contracts/source/SPV9K21TBFAK4KNRJXF5DFP8N7W46G4V9RCJDC22/fakfun-smart-router-registry?proof=0

Stacks documents tx-sender as the original transaction sender and contract-caller as the immediate caller, which changes on contract-call?: https://docs.stacks.co/reference/clarity/keywords

Impact

The new registry is the global allowlist authority for every smart-buy-* and smart-sell-* entry in fakfun-wallet-v18. After taking ownership, the attacker can stage and confirm an attacker-controlled router, and approvals are intentionally permanent. Once approved, that router passes the registry gate for every v18 wallet. A wallet trade still needs its own admin or passkey authorization, so registry takeover alone does not move wallet funds.

The attack needs one transaction in which the registry owner originates a call to an attacker-controlled or compromised contract. A direct attacker call to propose-owner correctly fails with u7001; the nested call succeeds because authorization follows ambient tx-sender authority.

Reproduction

Run against the exact repository contract:

npx vitest run tests/fakfun-smart-router-registry-confused-deputy.test.ts -- --manifest tests/cl-v18-registry/Clarinet.toml

The test performs this sequence:

  1. An attacker directly calls propose-owner(attacker) and receives u7001.
  2. The legitimate owner calls only zz-owner-proxy.stage-owner-takeover.
  3. The proxy calls the registry's propose-owner(attacker); it succeeds because tx-sender is still the legitimate owner.
  4. After 144 burn blocks, the attacker calls accept-owner and becomes owner.

The second test rewrites only the four owner checks to use contract-caller in memory. The nested proposal then fails with u7001, and pending-owner remains none.

Files:

Correction

Use contract-caller for direct authority checks in propose-owner, propose-router, confirm-router, and revoke-pending. The owner acceptance check should likewise authenticate the immediate caller so a contract owner can act only through its own code. Add a regression test where the owner calls an intermediary and the intermediary attempts each owner-only operation.

This finding is independent of the existing submissions about token-lock, unbound sell-token principals, permanent approvals, stale signatures, the swap extension entry point, and max-steps.