ConfidencePool.initialize() allows protocol-shaped counterfeit pools that bypass factory-enforced trust parameters. Users or integrations that authenticate pools by implementation/interface/registry/agreement can deposit into a pool where the attacker controls resolution and recovery.
The factory's createPool() enforces five security-critical restrictions: allowlisted stake token, agreement owner authorization, DAO-controlled moderator, pool registration, and pinned registry, but initialize() is external with no check that msg.sender is the factory and replicates none of them. Anyone can deploy an EIP-1167 clone of the real ConfidencePool implementation and call initialize() directly with the real registry, a real valid agreement, real in-scope accounts, and a real allowlisted token, while setting themselves as outcomeModerator, owner, and recoveryAddress.
The factory's createPool() enforces these restrictions before deploying and initializing a pool:
However, initialize() itself accepts all of these as caller-supplied parameters with no factory check:
The gap between factory restrictions and initialize() checks:
| Factory restriction | Purpose | initialize() check |
|---|---|---|
allowedStakeToken[stakeToken] |
Prevent fee-on-transfer/rebasing accounting bugs | None |
IAgreement(agreement).owner() == msg.sender |
Only agreement owner creates pools | None |
defaultOutcomeModerator hardcoded |
Ensure DAO-controlled resolution | Caller chooses freely |
address(safeHarborRegistry) hardcoded |
Ensure trusted state oracle | Caller chooses freely |
_poolsByAgreement[agreement].push(pool) |
Canonical pool registry | Not registered |
The initializer Modifier only prevents re-initialization of the same proxy. It does not restrict who can initialize a freshly deployed clone.
The factory also provides no isOfficialPool(address) mapping. The only verification path is getPoolsByAgreement(address) which returns an array requiring O(n) iteration; no constant-time on-chain check exists.
This behavior is not documented as intentional anywhere in docs/DESIGN.md. The README states the moderator is "set by the factory at clone time and immutable per-pool," and the sponsor "creates a pool via the factory", both imply factory-exclusive creation.
The attack works by creating a counterfeit pool that can appear protocol-shaped because it uses the real implementation bytecode, real registry, real agreement, real scope accounts, and a real allowlisted token, while only the trust-critical authority fields differ; outcomeModerator, owner, and recoveryAddress are all set to the attacker.
Since the protocol intentionally supports multiple pools per agreement, users and integrations cannot treat agreement identity as proof of pool legitimacy. A direct-initialized clone can point to the same real agreement and registry as an official pool while replacing only the authority fields (outcomeModerator, owner, and recoveryAddress).
If users deposit into this counterfeit pool, and the agreement reaches CORRUPTED on the real registry:
The attacker calls flagOutcome(CORRUPTED, true, attackerAddress) as the moderator. Every check passes: onlyModerator (attacker IS the moderator), registry returns CORRUPTED ( real registry, real breach). The contract sets bountyEntitlement = snapshotTotalStaked + snapshotTotalBonus,the entire pool.
The attacker calls claimAttackerBounty(). Every check passes: msg.sender == attacker (line 436 — attacker named themselves). stakeToken.safeTransfer(attacker, payout) (line 449) sends all user funds to the attacker.
Add an isOfficialPool(address) → bool mapping to the factory for O(1) on-chain verification by downstream integrations:
This allows any user or integration to verify pool legitimacy in constant time without iterating getPoolsByAgreement().
Alternatively, store the factory address as an immutable in the implementation constructor and restrict initialize() to factory-only calls. Note: this requires the factory address to be known before deploying the implementation, which may introduce a deployment-order dependency depending on the deployment flow.
The PoC deploys a counterfeit clone directly (bypassing the factory), initializes it with the attacker as moderator/owner/recovery using the same real registry and agreement, has users deposit, transitions the agreement to CORRUPTED, and shows the attacker claiming all user deposits via claimAttackerBounty.
Create a file at test/audit/PoC_FactoryBypass.t.sol and paste the code below, then run with:
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.