#44355 [SC-High] announcer owner can inflate announcers registry entries via mutate and register loop to claim most of rewards
#44355 [SC-High] Announcer Owner Can Inflate Announcers Registry Entries via Mutate and Register Loop to Claim Most of Rewards
Submitted on Apr 22nd 2025 at 07:02:34 UTC by @perseverance for IOP | CircuitDAO
Report ID: #44355
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/CircuitDAO-IoP/tree/main/circuit_puzzles
Impacts:
Theft of unclaimed yield
Description
Short Summary
The announcer owner can repeatedly use the announcer_mutate
operation to change their atom_announcer
coin's INNER_PUZZLE_HASH
and then re-register the announcer using the announcer_register
operation with the new hash in the announcer_registry
. Because the registry uses the mutable inner_puzzle_hash
as the unique identifier instead of the immutable LAUNCHER_ID
, the owner can create multiple distinct entries in the registry, all ultimately controlled by them. This inflates the apparent number of unique announcers, causing the reward distribution mechanism (MINT
operation in the registry) to allocate them a disproportionately large share of the CRT rewards, effectively draining rewards intended for other legitimate participants.
Background Information
The announcer_registry.clsp
puzzle serves as a central registry tracking active and approved Announcers within the Circuit DAO protocol. One of its key functions, triggered by the MINT
operation, is to facilitate the distribution of CRT (Circuit DAO Token) rewards to registered announcers. The intended mechanism involves calculating the total rewards for an interval (crt_credits_per_interval
) and dividing this amount proportionally among the currently registered announcers based on the count of entries in the ANNOUNCER_REGISTRY
list. This aims to fairly compensate active participants.
Reference: https://docs.circuitdao.com/technical-manual/announcer-registry#mint and https://github.com/immunefi-team/CircuitDAO-IoP/blob/d2c3171f08864c29fdd436e25a39c95b371df860/circuit_puzzles/announcer_registry.clsp#L102-L121
The Vulnerability and Vulnerability Details
The vulnerability stems from the interaction between three components: announcer_mutate.clsp
, announcer_register.clsp
, and announcer_registry.clsp
.
Mutable Identifier in Registry: The
announcer_registry.clsp
uses theannouncer_inner_puzzle_hash
provided in theRECEIVE_MESSAGE 0x12
fromannouncer_register
as the unique key to identify and register an announcer. It checks if this specific hash alreadycontains
in theANNOUNCER_REGISTRY
list before adding it.https://github.com/immunefi-team/CircuitDAO-IoP/blob/d2c3171f08864c29fdd436e25a39c95b371df860/circuit_puzzles/announcer_registry.clsp#L133-L134
(assert
(not (contains ANNOUNCER_REGISTRY announcer_inner_puzzle_hash))
Owner Can Mutate "Inner Puzzle Hash": The
announcer_mutate.clsp
puzzle explicitly allows the owner (who can provide the currentinner_puzzle_hash
) to change theINNER_PUZZLE_HASH
of theiratom_announcer
coin to anew_puzzle_hash
. This is a legitimate operation for transferring ownership or updating control logic.
https://github.com/immunefi-team/CircuitDAO-IoP/blob/d2c3171f08864c29fdd436e25a39c95b371df860/circuit_puzzles/programs/announcer_mutate.clsp#L34-L40
(list CREATE_COIN
(curry_hashes MOD_HASH
(sha256 ONE MOD_HASH)
(sha256tree STATUTES_STRUCT)
(sha256 ONE LAUNCHER_ID)
(sha256 ONE (if new_puzzle_hash new_puzzle_hash INNER_PUZZLE_HASH))
(sha256 ONE APPROVED)
Mutation Doesn't Affect Registry: The
announcer_mutate
operation only affects the state of theatom_announcer
coin itself. It does not communicate with theannouncer_registry
to inform it about the change in theinner_puzzle_hash
or to de-register the old hash.
Exploitation
This allows a malicious owner to perform the following loop:
Step 1: Register with `inner_puzzle_hash_A`. Registry contains `inner_puzzle_hash_A`.
Step 2: Use `announcer_mutate` to change the announcer coin's hash to `inner_puzzle_hash_B`.
Step 3: Register again using the mutated coin (now controlled by hash `inner_puzzle_hash_B`). The registry checks for B (not found) and adds it. Registry contains `(B A)`.
Step 4: Use `announcer_mutate` to change the announcer coin's hash to `inner_puzzle_hash_C`.
Step 5: Register again using the mutated coin (now controlled by hash C). The registry checks for C (not found) and adds it. Registry contains `(inner_puzzle_hash_C inner_puzzle_hash_B inner_puzzle_hash_A)`.
...and so on.
Reward Distribution Impact:
When the MINT
operation runs on the announcer_registry
, it calculates announcers_count
by counting the number of entries in ANNOUNCER_REGISTRY
.
Supposed the total of announcers is 3. T
The total reward crt_credits_per_interval
is divided by this count. The generate-offer-assert
function then creates payment conditions for each entry (A, B, and C). Since the owner controls all these inner puzzle hashes, they effectively receive 3 shares of the reward pool instead of the intended 1 share.
Normal operation: Reward for the announcer should be : crt_credits_per_interval /3
After the hack: Reward for the announcer is: crt_credits_per_interval * 3 / 5
that is 60/33.3
= 1.8
times bigger
https://github.com/immunefi-team/CircuitDAO-IoP/blob/d2c3171f08864c29fdd436e25a39c95b371df860/circuit_puzzles/announcer_registry.clsp#L102-L121
(sha256tree
(c
my_coin_id ; nonce
(generate-offer-assert
ANNOUNCER_REGISTRY
(/ crt_credits_per_interval announcers_count)
(if (> change_amount 0)
; we reward the mint spender with remainder if any
(list
(list
change_receiver_hash
change_amount
(list change_receiver_hash)
)
)
()
)
)
)
)
This fundamentally breaks the reward distribution fairness, allowing a single announcer owner to drain rewards intended for the broader set of unique participants.
Severity assessment
Bug Severity: High
Impact category:
Theft of unclaimed yield
Unfair reward distribution.
Theft/Drain of rewards intended for other participants.
Likelihood: High
The attack requires only standard owner operations (
announcer_mutate
,announcer_register
).There is a clear financial incentive for an owner to perform this attack.
Proof of Concept
Proof of concept (Conceptual Steps):
Initial State: Owner controls
AtomAnnouncer_A
(Launcher ID:L1
, Inner Puzzle Hash:IPH_A
).ANNOUNCER_REGISTRY
state is empty.Register A: Owner calls
register
onAtomAnnouncer_A
. Registry receives message withIPH_A
, checks(not (contains reg IPH_A))
(true), addsIPH_A
. ANNOUNCER_REGISTRY state:(IPH_A)
.Mutate A to B: Owner calls
mutate
onAtomAnnouncer_A
, providingnew_puzzle_hash = IPH_B
. A new coinAtomAnnouncer_B
is created (Launcher ID:L1
, Inner Puzzle Hash:IPH_B
). ANNOUNCER_REGISTRY state remains(IPH_A)
.Register B: Owner calls
register
onAtomAnnouncer_B
. Registry receives message withIPH_B
, checks(not (contains reg IPH_B))
(true), addsIPH_B
. Registry state:(IPH_B IPH_A)
.Mutate B to C: Owner calls
mutate
onAtomAnnouncer_B
, providingnew_puzzle_hash = IPH_C
. A new coinAtomAnnouncer_C
is created (Launcher ID:L1
, Inner Puzzle Hash:IPH_C
). ANNOUNCER_REGISTRY state remains(IPH_B IPH_A)
.Register C: Owner calls
register
onAtomAnnouncer_C
. Registry receives message withIPH_C
, checks(not (contains reg IPH_C))
(true), addsIPH_C
. ANNOUNCER_REGISTRY state:(IPH_C IPH_B IPH_A)
.Another announcer
register
withIPH_D
. ANNOUNCER_REGISTRY state:(IPH_D IPH_C IPH_B IPH_A)
.Another announcer
register
withIPH_E
. ANNOUNCER_REGISTRY state:(IPH_E IPH_D IPH_C IPH_B IPH_A)
.Claim Rewards (MINT):
MINT
operation triggers onAnnouncerRegistry
.announcers_count
is calculated as 5.Total rewards
R
are divided by 3 (R/5
).generate-offer-assert
creates payments ofR/5
forIPH_A
,IPH_B
, andIPH_C
.Owner, controlling all three puzzle hashes, claims
3 * (R/5)
=60% R
total rewards, instead of the intendedR / (number of *unique* announcers).
Mermaid Sequence Diagram: or attached png below illustrates the attack sequence.
sequenceDiagram
participant Owner
participant AtomAnnouncer_A as Atom Announcer (IPH_A, L1)
participant AtomAnnouncer_B as Atom Announcer (IPH_B, L1)
participant AtomAnnouncer_C as Atom Announcer (IPH_C, L1)
participant AtomAnnouncer_D as Atom Announcer (IPH_D, L2)
participant AtomAnnouncer_E as Atom Announcer (IPH_E, L3)
participant AnnouncerRegistry as Announcer Registry
participant SettlementPayments as Settlement Payments
Owner->>AtomAnnouncer_A: Call register()
AtomAnnouncer_A->>AnnouncerRegistry: Send Message 0x12 (IPH_A)
AnnouncerRegistry->>AnnouncerRegistry: Add IPH_A to registry [IPH_A]
Owner->>AtomAnnouncer_A: Call mutate(new_puzzle_hash=IPH_B)
Note right of AtomAnnouncer_A: Coin state updated to IPH_B
AtomAnnouncer_A-->>Owner: Mutated Coin (now IPH_B)
Owner->>AtomAnnouncer_B: Call register()
AtomAnnouncer_B->>AnnouncerRegistry: Send Message 0x12 (IPH_B)
AnnouncerRegistry->>AnnouncerRegistry: Add IPH_B to registry [IPH_B, IPH_A]
Owner->>AtomAnnouncer_B: Call mutate(new_puzzle_hash=IPH_C)
Note right of AtomAnnouncer_B: Coin state updated to IPH_C
AtomAnnouncer_B-->>Owner: Mutated Coin (now IPH_C)
Owner->>AtomAnnouncer_C: Call register()
AtomAnnouncer_C->>AnnouncerRegistry: Send Message 0x12 (IPH_C)
AnnouncerRegistry->>AnnouncerRegistry: Add IPH_C to registry [IPH_C, IPH_B, IPH_A]
AtomAnnouncer_D->>AnnouncerRegistry: Call register()
AnnouncerRegistry->>AnnouncerRegistry: Add IPH_D to registry [IPH_D, IPH_C, IPH_B, IPH_A]
AtomAnnouncer_E->>AnnouncerRegistry: Call register()
AnnouncerRegistry->>AnnouncerRegistry: Add IPH_E to registry [IPH_E, IPH_D, IPH_C, IPH_B, IPH_A]
Owner->>AnnouncerRegistry: Call MINT operation
AnnouncerRegistry->>AnnouncerRegistry: Count announcers (count = 5)
AnnouncerRegistry->>AnnouncerRegistry: Divide rewards by 5
AnnouncerRegistry->>SettlementPayments: Create payments for IPH_A, IPH_B, IPH_C (via ASSERT_PUZZLE_ANNOUNCEMENT)
SettlementPayments-->>Owner: Pays reward share for IPH_A
SettlementPayments-->>Owner: Pays reward share for IPH_B
SettlementPayments-->>Owner: Pays reward share for IPH_C
SettlementPayments-->>AtomAnnouncer_D: Pays reward share for IPH_D
SettlementPayments-->>AtomAnnouncer_E: Pays reward share for IPH_E
Was this helpful?