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

`_replaceScope()` missing local `address(0)` guard allows zero address into pool scope when upstream agreement is misconfigured

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, _replaceScope() validates each account by calling IAgreement(agreement).isContractInScope(account) and reverts if the agreement rejects the address. This correctly delegates scope validation to the upstream agreement contract. The pool itself adds no local address(0) guard, relying entirely on the external agreement to reject the zero address.

The specific problem is that if the upstream IAgreement contract is misconfigured, has a default-true fallback, or is later upgraded by the DAO to return true for uninitialized addresses, address(0) will be accepted without any local sanitisation and stored permanently in _scopeAccounts.

// src/ConfidencePool.sol
function _replaceScope(address[] calldata accounts) internal { // calldata, not memory
if (accounts.length == 0) revert EmptyScope();
address[] memory old = _scopeAccounts;
for (uint256 i; i < old.length; ++i) {
isAccountInScope[old[i]] = false;
}
delete _scopeAccounts;
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (isAccountInScope[account]) revert DuplicateAccount(account);
@> // ROOT CAUSE: no local address(0) guard before delegating to external contract
if (!IAgreement(agreement).isContractInScope(account)) {
revert AccountNotInAgreementScope(account); // ← actual error name
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}
emit ScopeUpdated(accounts);
}

Once scopeLocked is set (triggered by the first interaction that observes the registry past NEW_DEPLOYMENT), setPoolScope permanently reverts with ScopePostLockImmutable. Before that transition window closes, the sponsor can call setPoolScope to repair the scope. After it closes, the corrupted state is irreversible.


Risk

Likelihood:

  • A sponsor's agreement contract defaults to true for isContractInScope on uninitialized mappings (a common Solidity default), or the DAO later upgrades the agreement in a way that makes isContractInScope(address(0)) return true.

  • Because _replaceScope is also called from initialize (at pool creation), the error can occur at deployment before the sponsor has any opportunity to inspect the resulting scope state.

Impact:

  • address(0) is permanently stored in _scopeAccounts and isAccountInScope[address(0)] = true. This is an anomalous state not intended by the protocol's design invariants.

  • Off-chain tooling and integrations reading getScopeAccounts() or isAccountInScope(address(0)) will receive misleading data, potentially affecting moderator decisions about whether a breach targeted an in-scope contract.

  • If any future upgrade to the protocol adds scope-membership checks to financial logic paths, the pre-existing address(0) entry would become an exploitable foothold.

  • Note: in the current codebase, isAccountInScope and _scopeAccounts are not read by any financial operation (stake, withdraw, flagOutcome, or claims), so there is no direct fund-loss risk today.


Proof of Concept

Steps to Reproduce:

  1. Create test/ZeroAddressScope.t.sol and paste the code below.

  2. Run: forge test --match-test test_QA_ZeroAddressInScope -vvv

  3. The test passes, confirming address(0) is permanently stored in the pool's scope with no revert.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// Minimal mocks — MockRegistry only needs to satisfy factory validation calls.
// getAgreementState is intentionally omitted: the pool's state machine is never
// triggered in this test, so no registry state observation occurs.
contract MockToken is ERC20 { constructor() ERC20("Mock","MCK") {} }
contract MockRegistry {
function getAttackRegistry() external view returns (address) { return address(this); }
function isAgreementValid(address) external pure returns (bool) { return true; }
}
contract MockAgreement {
address public owner;
constructor(address _owner) { owner = _owner; }
// Simulates a misconfigured agreement that returns true for ALL addresses,
// including address(0). This is the precondition for the vulnerability.
function isContractInScope(address) external pure returns (bool) { return true; }
}
contract ZeroAddressScopeTest is Test {
ConfidencePoolFactory factory;
MockToken token;
address sponsor = address(0x111);
function setUp() public {
token = new MockToken();
MockRegistry registry = new MockRegistry();
ConfidencePool impl = new ConfidencePool();
ConfidencePoolFactory factoryImpl = new ConfidencePoolFactory();
ERC1967Proxy proxy = new ERC1967Proxy(
address(factoryImpl),
abi.encodeWithSelector(
ConfidencePoolFactory.initialize.selector,
address(registry), address(impl), address(this)
)
);
factory = ConfidencePoolFactory(address(proxy));
factory.setStakeTokenAllowed(address(token), true);
}
function test_QA_ZeroAddressInScope() public {
// Sponsor's agreement marks address(0) as in-scope (misconfigured)
vm.prank(sponsor);
MockAgreement agreement = new MockAgreement(sponsor);
// Pass address(0) as a scope account
address[] memory accounts = new address[](1);
accounts[0] = address(0);
vm.prank(sponsor);
address poolAddr = factory.createPool(
address(agreement),
address(token),
block.timestamp + 60 days,
1 ether,
sponsor,
accounts
);
ConfidencePool pool = ConfidencePool(poolAddr);
// BUG: address(0) is now permanently in the pool's scope.
// No revert occurred despite the zero address being stored.
assertTrue(pool.isAccountInScope(address(0)),
"BUG: address(0) stored in scope without local validation");
assertEq(pool.getScopeAccounts()[0], address(0),
"BUG: address(0) appears in enumerable scope array");
}
}

Recommended Mitigation

Add a single local zero-address guard at the top of the per-account loop in _replaceScope. This protects the pool's state independently of whatever the upstream agreement returns:

for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
+ if (account == address(0)) revert ZeroAddress();
if (isAccountInScope[account]) revert DuplicateAccount(account);
if (!IAgreement(agreement).isContractInScope(account)) {
- revert AccountNotInScope(account);
+ revert AccountNotInAgreementScope(account);
}
_scopeAccounts.push(account);
isAccountInScope[account] = true;
}

ZeroAddress() is already defined in IConfidencePool.sol (line 60) and used in initialize(), making this a one-line addition consistent with existing validation patterns across the contract.

Support

FAQs

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

Give us feedback!