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

initialize() allows external execution against a partially initialized pool

Author Revealed upon completion

Root + Impact

Description

  • ConfidencePoolFactory.createPool() deploys a deterministic clone and invokes initialize() exactly once. The expected lifecycle is that initialization completes before the pool becomes externally usable.

  • However, initialize() performs an external call through _replaceScope() before initialization has completed. The function is protected by OpenZeppelin's initializer modifier but not nonReentrant. These two mechanisms are independent: initializer only prevents re-entering another initializer/reinitializer function, while it does not engage ReentrancyGuard's lock.

  • Before _replaceScope() executes, the pool has already initialized state required by user-facing entrypoints, including stakeToken, minStake, expiry, and outcome. A malicious agreement implementation can therefore reenter the pool during _replaceScope() and successfully invoke externally callable functions such as stake() or contributeBonus() while initialization is still in progress.

function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
...
stakeToken = IERC20(stakeToken_);
expiry = uint32(expiry_);
minStake = minStake_;
outcome = PoolStates.Outcome.UNRESOLVED;
// @> External interaction while initialization is still executing
_replaceScope(accounts);
// @> Initialization only completes after this point
_transferOwnership(owner_);
}

Risk

Likelihood:

  • initialize() performs an external call before initialization completes.

  • Because agreement is supplied by the pool creator, a malicious implementation can execute arbitrary logic during isContractInScope(), including reentering the pool.

Impact:

  • Reentrant execution into stake() and contributeBonus() is possible while the pool is still initializing.

  • exposing externally callable state-changing functions before initialization completes unnecessarily expands the contract's attack surface and deviates from the otherwise consistent defensive pattern (nonReentrant) used across the contract's value-moving functions.


Proof of Concept

The following call sequence demonstrates successful reentrant execution during initialization:

ConfidencePoolFactory.createPool()
Deploy clone
initialize()
stakeToken / expiry / minStake / outcome initialized
_replaceScope(accounts)
agreement.isContractInScope(...)
(reentrant)
pool.stake(...)
stake() succeeds
initialize() resumes
Initialization completes

An illustrative Foundry test:

contract MaliciousAgreement {
address public sponsor;
ConfidencePool public pool;
IERC20 public token;
bool internal reentered;
constructor(address _sponsor, IERC20 _token) {
sponsor = _sponsor;
token = _token;
}
function owner() external view returns (address) {
return sponsor;
}
function setPool(ConfidencePool _pool) external {
pool = _pool;
}
function isContractInScope(address) external returns (bool) {
if (!reentered) {
reentered = true;
pool.stake(1e18);
}
return true;
}
}

Recommended Mitigation

Prevent external execution while initialization is still in progress. One straightforward approach is to apply the same ReentrancyGuard protection already used by the contract's other state-changing functions.

function initialize(
...
) external initializer nonReentrant {
...
}

Support

FAQs

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

Give us feedback!