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

Moderator-controlled attacker address is weakly validated

Author Revealed upon completion

Root + Impact

Description

For a good-faith CORRUPTED outcome, flagOutcome() requires only that attacker_ is nonzero. The moderator can name itself, another address it controls, the pool contract, a burn address, or an address unable to receive the token. claimAttackerBounty() then treats that value as the sole identity proof and makes the entire snapshotted pool balance claimable by it.

The moderator is intentionally trusted to classify outcomes and identify the attacker, so self-dealing is not an unprivileged exploit. However, the same role can unilaterally choose a full-pool payout recipient without independent registry binding, attestation, delay, or basic semantic validation.

Good-faith validation establishes only zero/nonzero shape:

src/ConfidencePool.sol
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_)
external
onlyModerator
{
// ...
if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
if (attacker_ == address(0)) revert InvalidGoodFaithParams(); // @> Any nonzero address is accepted.
}
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
attacker = attacker_; // @> Moderator-provided identity is stored verbatim.
// ...
bountyEntitlement = willBeGoodFaithCorrupted
? snapshotTotalStaked + snapshotTotalBonus // @> Entitlement is the entire pool.
: 0;
// ...
}

The payout function authenticates only by equality with the same unverified value:

function claimAttackerBounty() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (bountyClaimed == bountyEntitlement) revert BountyAlreadyClaimed();
if (!goodFaith) revert InvalidGoodFaithParams();
if (msg.sender != attacker) revert NotAttacker(); // @> No independent identity binding exists.
// ...
stakeToken.safeTransfer(attacker, payout); // @> Full principal and bonus can go to moderator.
}

The PoC deposits 100 tokens of stake and 50 tokens of bonus, moves the registry through an observed attack into CORRUPTED, and has the moderator flag good faith with attacker_ = moderator. The moderator immediately calls claimAttackerBounty() and receives all 150 tokens.

Other accepted values have different failure modes. address(this) can never be an external msg.sender, leaving the bounty unclaimable until the 180-day recovery sweep. Burn or inaccessible addresses permanently lose the bounty if they can execute or receive it, while blacklisted or hook-reverting recipients can block the claim until the deadline. Those cases reinforce that nonzero validation is not sufficient for a value-bearing identity.

Risk

Likelihood:

  • Only the configured outcome moderator can create the designation, and the registry must genuinely report CORRUPTED.

  • Exploitation therefore requires moderator compromise, collusion, or intentional abuse of a broad trusted role.

  • The path is otherwise immediate and requires no approval from the pool owner, stakers, registry moderator, or named attacker.

Impact:

  • A self-appointed moderator or controlled confederate can receive all staked principal and contributed bonus.

  • An unclaimable or destructive address can delay recovery for 180 days or permanently destroy the intended attacker payout.

  • On-chain observers cannot distinguish a legitimate attacker identity from a moderator-selected address because no evidence or registry binding is stored.

Proof of Concept

Create test/audit/CP036ModeratorControlledAttacker.t.sol with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
contract CP036ModeratorControlledAttackerTest is BaseConfidencePoolTest {
function test_ModeratorNamesSelfAndClaimsEntireGoodFaithBounty() external {
_stake(alice, 100 * ONE);
_contributeBonus(carol, 50 * ONE);
_passThroughUnderAttack();
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
uint256 moderatorBefore = token.balanceOf(moderator);
vm.startPrank(moderator);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, moderator);
pool.claimAttackerBounty();
vm.stopPrank();
assertEq(pool.attacker(), moderator);
assertEq(pool.bountyEntitlement(), 150 * ONE);
assertEq(pool.bountyClaimed(), 150 * ONE);
assertEq(token.balanceOf(moderator) - moderatorBefore, 150 * ONE, "moderator captures the full pool");
assertEq(token.balanceOf(address(pool)), 0);
}
}

Run the PoC from the repository root:

  1. Execute forge test --offline --match-path test/audit/CP036ModeratorControlledAttacker.t.sol -vv.

  2. Confirm that the test passes. The moderator is accepted as the good-faith attacker and claims all 150 tokens of principal and bonus in the next call.

Recommended Mitigation

At minimum, reject structurally unsafe and conflicted recipients. These checks prevent direct self-appointment and obvious liveness traps, although they cannot stop a moderator from using a second controlled address.

src/ConfidencePool.sol
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
- if (attacker_ == address(0)) revert InvalidGoodFaithParams();
+ if (
+ attacker_ == address(0)
+ || attacker_ == msg.sender
+ || attacker_ == owner()
+ || attacker_ == address(this)
+ || attacker_ == recoveryAddress
+ || attacker_ == address(stakeToken)
+ ) revert InvalidAttacker();
}
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
}

For meaningful protection against recipient substitution, bind attacker_ to independent incident evidence: for example, a value reported by the AttackRegistry, an EIP-712 designation approved by a separate security council, or a timelocked quorum decision that stakers can inspect before payout. Use a multisig for the moderator role and emit an incident identifier or evidence hash with the designation. If unilateral moderator selection is the intended trust model, document explicitly that the moderator can direct the full pool to any controlled nonzero address; address filtering alone must not be presented as preventing collusion.

Support

FAQs

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

Give us feedback!