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

Asymmetric clamping failure allows attackers to artificially inflate time-weighted stakes via micro top-ups during an active risk window.

Author Revealed upon completion

Description

The protocol employs an asymmetric clamping mechanism to ensure fairness when a pool is flagged as UNDER_ATTACK. By design, stakes should be weighted from the riskWindowStart timestamp to prevent users from flooding the pool with capital after an attack has initiated. While the protocol correctly applies this to existing balances via _clampUserSums(msg.sender), it fails to apply the same restriction to new deposits initiated during an active risk window.

As a result, new deposits are valued using the current block.timestamp rather than being clamped to riskWindowStart. This creates an incentive for malicious actors to perform "micro-deposits" after a risk window opens, effectively granting their new capital a higher time-weighting than the capital of honest, long-term stakers.


The vulnerability lies within the stake function where the newEntry variable is calculated for incoming capital:

// ... within stake() function ...
_clampUserSums(msg.sender);
// Pre-risk deposits use wall clock and get promoted to riskWindowStart later via
// `_clampUserSums`; post-risk deposits go in at wall clock (already >= riskWindowStart).
// @> The vulnerability: New deposits are valued at the current block.timestamp
// @> even when the pool is UNDER_ATTACK. This bypasses the asymmetric clamping
// @> applied to previous stakes, allowing attackers to inflate their weight.
uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
if (start != 0 && newEntry < start) newEntry = start;
// @> Consequently, this calculation uses the un-clamped 'newEntry' timestamp.
uint256 contribTime = received * newEntry;
// ...

Risk

Likelihood:

  • Any malicious user can actively monitor the mempool/chain for the protocol shifting into the UNDER_ATTACK state and immediately execute a micro-deposit.

  • The attack requires no special permissions, flashloans, or complex setup; it only requires standard interaction with the stake() function during a known public state.

Impact:

  • Attackers can artificially inflate their userSumStakeTime, allowing them to steal a disproportionate share of yield and rewards meant for honest, long-term stakers.

  • Attackers gain outsized voting weight or dispute resolution power, potentially allowing them to manipulate the outcome of the very attack event that opened the risk window.

Proof of Concept

The following test demonstrates that an attacker depositing 1 ETH after the risk window opens achieves a higher time-weighted score than an honest user who deposited 100 ETH at the start.


function test_AsymmetricClampingExploitPoC() public {
// 1. Initial fair stakes at t=0
_setRegistryState(IAttackRegistry.ContractState.NEW_DEPLOYMENT);
vm.prank(alice);
pool.stake(100 ether); // Alice stakes 100 ETH
vm.prank(attacker);
pool.stake(100 ether); // Attacker stakes 100 ETH
// 2. Risk window opens at t=100
vm.warp(block.timestamp + 100);
_setRegistryState(IAttackRegistry.ContractState.UNDER_ATTACK);
pool.pokeRiskWindow();
// 3. Attacker bypasses clamping with a 1 ETH micro top-up at t=120
vm.warp(block.timestamp + 20);
vm.prank(attacker);
pool.stake(1 ether);
// 4. Verification: Attacker's weight significantly exceeds Alice's
assertGt(pool.userSumStakeTime(attacker), pool.userSumStakeTime(alice));
}

Recommended Mitigation

The protocol should force the newEntry timestamp to align with riskWindowStart whenever the pool is in an active risk window (riskWindowStart != 0 and current time is past that start point).

uint256 newEntry = block.timestamp;
uint256 start = riskWindowStart;
- if (start != 0 && newEntry < start) newEntry = start;
+ // If a risk window is active, force new entries to the start of that window
+ if (start != 0 && newEntry > start) newEntry = start;
+ // If pre-risk window, still promote to start
+ else if (start != 0 && newEntry < start) newEntry = start;

Support

FAQs

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

Give us feedback!