FoundrySolidityLayer 2
7.25 ETH
Submission Details
Impact: high
Likelihood: medium

Incorrect Contract Check Allows for Active Counterfeit Pools

Author Revealed upon completion

Incorrect Contract Check Allows for Active Counterfeit Pools

Root + Impact

An incorrect account authorization check in ConfidencePoolFactory, combined with AttackRegistry manipulations, allows anyone to create a live confidence pool for accounts they never control. This leads to counterfeit pools where the controller has asymmetric power to steer settlement to a profitable outcome for himself.

The manipulation is unlikely to be detected. Even when it is, there is no correct resolution. With a bad faith corruption, the moderation must either reward the deployer of a counterfeit pool or reward stakers even though a legitimate corruption occurred. The good-faith case is even worse since both stakers and the white hat hacker have legitimate reasons to lay claim to the pools funds.

Description

A Confidence Pool is supposed represent accounts controlled by its Agreement owner with its committed scope aligned with the accounts that AttackRegistry authorizes for that Agreement. This lets stakers and the pool moderator rely on the published account set throughout activation and settlement.

An incorrect authorization check allows an Agreement owner to create a pool for accounts they do not control and then activate that counterfeit pool by manipulating the Agreement's scope around AttackRegistry registration:

  1. ConfidencePool._replaceScope checks only that the supplied Agreement declares an account. It does not check that AttackRegistry identifies that Agreement as the account's Binding Agreement, even though IAgreement.isContractInScope explicitly warns callers not to use it as binding proof.

  2. After creating the counterfeit pool, the attacker can remove every account they do not control from the Agreement and call requestUnderAttack. AttackRegistry validates ownership only for the Agreement's current account list; after moderator approval, the Agreement enters UNDER_ATTACK while the pool still advertises its original scope.

  3. After the Agreement becomes PRODUCTION or CORRUPTED, registerContractForExistingAgreement returns before account-ownership and Binding Agreement validation. The Agreement owner can therefore re-add unrelated accounts before the pool moderator evaluates the outcome.

  4. Confidence Pool settlement reads only the aggregate state of its stored Agreement. It does not identify the breached account or verify that every committed pool account remains bound to that Agreement.

This creates an asymmetric settlement option over incidents affecting accounts that Bob never controlled:

  • Alice owns account A and its legitimate Agreement. Bob owns account B, but creates another Agreement and Confidence Pool declaring both A and B.

  • Bob removes A before registration. AttackRegistry authorizes and places Bob's Agreement UNDER_ATTACK using only B, while the pool's locked scope continues to advertise A+B.

  • If A is later corrupted, the registry transition belongs to Alice's Binding Agreement. Bob's Agreement remains UNDER_ATTACK, so the pool moderator cannot flag either a good- or bad-faith CORRUPTED outcome: flagOutcome requires the pool's stored Agreement itself to be CORRUPTED.

  • For a bad-faith corruption of A, Bob can opt into the profitable branch. He marks his own Agreement CORRUPTED, re-adds A through the terminal-state validation bypass, and presents matching Agreement and pool scopes. If the pool moderator does not detect the laundering, a bad-faith resolution transfers all principal and bonus to Bob's recoveryAddress.

  • For a good-faith corruption of A, Bob can refuse to mark his Agreement CORRUPTED. The pool moderator then has no callable path to award the bounty. If Bob's Agreement remains UNDER_ATTACK through expiry, claimExpired resolves the pool as EXPIRED, returning principal and bonus to stakers while the whitehat receives nothing.

  • If neither takes place, Bob can activate a hidden bug in account B and obtain the stakers' funds this way — a last resort, since it is the only clearly illegal path.

The missing binding check therefore lets Bob advertise coverage over A while controlling whether an incident involving A is reflected in the only Agreement state the pool can settle against. Discovery does not provide an invalid-pool or principal-only cancellation outcome. After Bob mirrors a bad-faith incident, CORRUPTED pays the counterfeit pool owner while SURVIVED rewards stakers despite a published in-scope corruption. For a good-faith incident, the moderator cannot force Bob's Agreement into the state required to pay the whitehat. Lastly, if Bob exploits his own account through a hidden bug, it is extremely difficult to ascertain whether or not this was done by an independent blackhat or Bob masquerading as one.

// 1. ConfidencePool accepts declarative membership as authority.
// src/ConfidencePool.sol::_replaceScope
@> if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account);
}
// lib/battlechain-safe-harbor-contracts/src/interface/IAgreement.sol::isContractInScope
@> /// A `true` return does NOT establish that this agreement is the Binding
/// Agreement for the contract; multiple agreements may include it.
// 2. Agreement scope can diverge from the pool's stored scope.
// lib/battlechain-safe-harbor-contracts/src/Agreement.sol::removeAccounts
@> if (block.timestamp < s_cantChangeUntil) {
revert Agreement__CannotReduceScopeDuringCommitment();
}
...
@> _removeFromBattleChainScope(accountAddresses[i]);
// AttackRegistry validates only the Agreement's current scope at activation.
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol::_validateAndPrepareAgreement / requestUnderAttack
@> contracts = agreement.getBattleChainScopeAddresses();
...
@> if (s_authorizedOwner[contracts[i]] != agreementOwner) {
revert AttackRegistry__AgreementOwnerNotAuthorized(
contracts[i], agreementOwner
);
}
// 3. Re-adding accounts after corruption skips binding and ownership checks.
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol::registerContractForExistingAgreement
ContractState state = _getAgreementState(msg.sender);
@> if (state == ContractState.PRODUCTION || state == ContractState.CORRUPTED) {
return;
}
_revertIfLinkedToActiveAgreement(contractAddress);
_validateAndLinkContract(
contractAddress,
msg.sender,
IAgreement(msg.sender).owner()
);
// Bob, not the pool moderator, controls whether Bob's Agreement becomes CORRUPTED.
// lib/battlechain-safe-harbor-contracts/src/AttackRegistry.sol::markCorrupted
@> function markCorrupted(address agreementAddress)
external
onlyAttackModerator(agreementAddress)
{
...
s_agreementInfo[agreementAddress].corrupted = true;
}
// 4. Every pool account follows one aggregate Agreement state, and the moderator
// cannot flag CORRUPTED until that stored Agreement is itself CORRUPTED.
// src/ConfidencePool.sol::_getAgreementState / flagOutcome
@> return IAttackRegistry(attackRegistry).getAgreementState(agreement);
...
@> if (state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
// An unresolved pool whose Agreement remains UNDER_ATTACK expires in favor of stakers.
// src/ConfidencePool.sol::claimExpired
@> if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
} else {
outcome = PoolStates.Outcome.EXPIRED;
}

Risk

Likelihood:

The only preconditions are a credible pool and a bonus large enough to attract stakers. No authority over the advertised account A is needed, and because Bob owns account B he can always force his own Agreement to CORRUPTED, so the theft never depends on A actually being breached. Low barrier to entry and a clear profit motive put the likelihood at least at Medium.

Impact:

  • On a bad-faith corruption of A, Bob mirrors it into his Agreement and sweeps 100% of staker principal and bonus to his recoveryAddress.

  • On a good-faith corruption of A, Bob either withholds the transition so the whitehat is never paid, or allows it and funds the whitehat's bounty entirely from staker principal. A blameless staker and a blameless whitehat can both be denied, and the moderator has no correct outcome.

  • Failing any corruption of A, Bob can hack his own account B to force the same sweep, indistinguishable on-chain from a real breach. Staker theft is therefore always available to the pool creator.

The impact is High: complete, unrecoverable loss of staker funds, with no resolution that leaves every honest party whole.

Proof of Concept

Runnable tests: poc/UnauthorizedPoolScopePoc.t.sol. ConfidencePool is the production contract; the registry double manually stages the same agreement-state and per-account binding results produced by the upstream lifecycle because this repository pins solc 0.8.26 while the concrete upstream contracts require 0.8.34. The tests cover both branches: Bob mirrors a bad-faith corruption of A and sweeps the pool after restoring A, while refusing to mirror a good-faith corruption leaves the moderator unable to flag CORRUPTED.

function test_poc_badFaithAIncidentCanBeMirroredIntoCounterfeitPayout() external {
(MockAgreement bobAgreement, ConfidencePool pool) =
_createActiveCounterfeitPool(); // pool A+B; live Agreement B; A remains Alice-bound
// A suffers an unrelated bad-faith corruption. Bob mirrors it into his Agreement.
vm.prank(bob);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
bobAgreement.setContractInScope(aliceProtocolAccount, true); // terminal validation bypass
assertEq(
attackRegistry.getAgreementForContract(aliceProtocolAccount),
aliceAgreement
);
vm.prank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
uint256 recoveryBefore = token.balanceOf(recovery);
pool.claimCorrupted();
assertEq(token.balanceOf(recovery) - recoveryBefore, STAKE + BONUS);
assertEq(token.balanceOf(address(pool)), 0);
}
function test_poc_goodFaithAIncidentCannotBeFlaggedWithoutBobTransition() external {
(, ConfidencePool pool) = _createActiveCounterfeitPool();
// A's whitehat cannot be paid while Bob withholds his Agreement transition.
vm.prank(moderator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
uint256 stakerBefore = token.balanceOf(staker);
vm.prank(staker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(staker) - stakerBefore, STAKE + BONUS);
assertEq(token.balanceOf(whitehat), 0);
}

Recommended Mitigation

Authorize scope against the Binding Agreement (getAgreementForContract), not the declarative isContractInScope. The check cannot go in _replaceScope, since bindings are still zero when the pool is created. Validate the committed scope once, when the pool first observes a registered state, and latch the result. Do not re-check live at settlement: DESIGN.md §8 lets a sponsor legitimately narrow the Agreement after lock, so a live re-check would block honest CORRUPTED resolutions. Scope cannot grow after lock, so the one-time latch is enough.

On failure, enter a staker-safe mode instead of reverting the observation (which would brick withdraw): block new deposits, keep principal withdrawal open, and forbid every CORRUPTED resolution.

+bool public bindingFailed;
+function _scopeBindingsValid() internal view returns (bool) {
+ IAttackRegistry ar = IAttackRegistry(safeHarborRegistry.getAttackRegistry());
+ for (uint256 i; i < _scopeAccounts.length; ++i) {
+ if (ar.getAgreementForContract(_scopeAccounts[i]) != agreement) return false;
+ }
+ return true;
+}
// _observePoolState, at the scope-lock transition:
scopeLocked = true;
+ if (!_scopeBindingsValid()) bindingFailed = true;

Then gate on the latch: stake / contributeBonus revert when bindingFailed; withdraw is allowed when bindingFailed (bypassing the risk-window lock); the CORRUPTED branch of flagOutcome and the auto-CORRUPTED path in claimExpired revert or are skipped when bindingFailed.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!