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

MockConfidencePoolModerator has poor access control

Author Revealed upon completion

MockConfidencePoolModerator.flag has no access control and as a result, any address can flag any wired pool's outcome and name itself the good-faith attacker, redirecting the entire pool balance to itself

Description

  • flag (and its wrappers flagSurvived, flagCorruptedGoodFaith, flagCorruptedBadFaith) have no caller restriction whatsoever — msg.sender is only used for the event, never checked. Any address, including an attacker who has actually broken the agreement, can call flagCorruptedGoodFaith(pool, attacker) naming themselves (or an accomplice) as attacker, provided the underlying registry is already CORRUPTED (the only real gate is inside ConfidencePool.flagOutcome, which just checks registry state, not caller legitimacy).


  • The docstring correctly warns this is testnet-only and must not be deployed to mainnet, and ConfidencePool itself still enforces that only outcomeModerator may call flagOutcome — so the vulnerability only manifests if this mock is ever wired up as a pool's (or the factory's defaultOutcomeModerator's) actual moderator address in a real deployment. That is exactly the kind of mistake that happens via a stale deployment script, copy-pasted config, or a staging/production address mix-up, and the consequence if it does happen is total: whoever calls it first after CORRUPTED is observed drains the entire pool (corruptedReserve = snapshotTotalStaked + snapshotTotalBonus) via claimAttackerBounty, ahead of the legitimate whitehat.

  • A comment is not an enforcement mechanism — nothing in the code itself prevents this contract's address from ending up in outcomeModerator on a live network

contract MockConfidencePoolModerator {
function flag(address pool, PoolStates.Outcome outcome, bool goodFaith, address attacker) public {
IConfidencePool(pool).flagOutcome(outcome, goodFaith, attacker);
emit OutcomeFlagged(msg.sender, pool, outcome, goodFaith, attacker);
}
function flagCorruptedGoodFaith(address pool, address attacker) external {
flag(pool, PoolStates.Outcome.CORRUPTED, true, attacker);
}
...
}

Risk

Likelihood:

  • Low as a spontaneous on-chain event (requires a deployment/config error to wire this contract in as a real moderator) but non-trivial in practice — config mistakes involving mock/testnet artifacts leaking into production are a common real-world failure mode, and the blast radius here is total pool drain, not degraded functionality.

Impact:

  • ICritical if the precondition occurs. 100% of a pool's staked funds + bonus are diverted to an attacker-chosen address instead of the legitimate whitehat/attacker.

Proof of Concept

This POC shows the vulnerabilty

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {MockConfidencePoolModerator} from "test/mock/MockConfidencePoolModerator.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
contract MockModeratorNoAccessControlTest is Test {
ConfidencePool pool;
MockConfidencePoolModerator mockModerator;
MockRegistry registry;
address legitimateWhitehat = address(0xA11CE);
address opportunist = address(0xBAD1);
function setUp() public {
mockModerator = new MockConfidencePoolModerator();
// ... deploy + initialize pool with outcomeModerator = address(mockModerator),
// simulating the misconfiguration scenario the docstring warns against.
registry.setState(IAttackRegistry.ContractState.CORRUPTED);
// Pool has staked funds from prior test setup, e.g. 1,000e18.
}
/// @notice An address with no relationship to the actual attack names itself as the
/// good-faith attacker before the legitimate whitehat can, purely because the mock
/// moderator performs no caller check.
function test_anyoneCanNameThemselvesAttacker() public {
vm.prank(opportunist);
mockModerator.flagCorruptedGoodFaith(address(pool), opportunist);
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
assertTrue(pool.goodFaith());
assertEq(pool.attacker(), opportunist);
vm.prank(opportunist);
pool.claimAttackerBounty();
// Opportunist has drained the pool's full entitlement, ahead of legitimateWhitehat,
// despite having no actual claim to the bounty.
assertGt(token.balanceOf(opportunist), 0);
}
}

Recommended Mitigation

Since this contract is explicitly testnet-only, add an on-chain guard so a mainnet deployment fails hard instead of relying on a comment:
Extend the chainid check to cover any other chain IDs the protocol treats as production. Additionally, consider keeping this contract out of any deployment script / address-book path that could feed defaultOutcomeModerator or a pool's outcomeModerator, and add a factory-side or deployment-CI check that rejects known mock addresses in that role.

constructor() {
require(block.chainid != 1, "MockConfidencePoolModerator: mainnet forbidden");
}

Support

FAQs

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

Give us feedback!