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

Corrupted-claim deadline anchors on the first good-faith flag, stranding a re-named whitehat

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Explain the specific issue or problem in one or more sentences

`corruptedClaimDeadline` is always recomputed from `_firstGoodFaithCorruptedAt`, which is set once on the first good-faith CORRUPTED flag and never reset:
```solidity
if (willBeGoodFaithCorrupted) {
if (_firstGoodFaithCorruptedAt == 0) {
// forge-lint: disable-next-line(unsafe-typecast)
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
// Reuses the original window on re-entry — which may already be in the past, leaving
// nothing to claim. Intended: the deadline must never be extendable.
// Sum stays in uint32 unless flagged within 180 days of the 2106 ceiling; out of scope.
// forge-lint: disable-next-line(unsafe-typecast)
corruptedClaimDeadline = uint32(_firstGoodFaithCorruptedAt + CORRUPTED_CLAIM_WINDOW);
} else {
corruptedClaimDeadline = 0;
}
```
The good-faith re-flag is the documented §4 correction mechanism. It exists to fix a wrong attacker name before any claim locks the outcome, and is gated only on `!claimsStarted` with no timing bound. So the moderator naming the wrong attacker first, then correcting to the true whitehat, is normal documented operation, not misoperation.
The bug: the correction re-names to a different attacker but reuses the original anchor. The freshly-named whitehat inherits the first nominee's already-running clock. If the correction lands late in, or past, the 180-day `CORRUPTED_CLAIM_WINDOW`, that whitehat's first `claimAttackerBounty` reverts:
```solidity
if (block.timestamp > corruptedClaimDeadline) revert ClaimWindowExpired();
```
`sweepUnclaimedCorrupted` is then callable and routes the entire bounty to `recoveryAddress`:
```solidity
if (block.timestamp <= corruptedClaimDeadline) revert ClaimWindowNotExpired();
```
```solidity
stakeToken.safeTransfer(recoveryAddress, amount);
```
DESIGN.md §12 promises the named good-faith whitehat the whole pool with a full 180-day window. The stale anchor silently breaks that promise for a re-named whitehat. The non-extendability comment above covers a same-attacker toggle, not a different-attacker re-name, so this case is uncovered rather than accepted.
## Example
Good-faith CORRUPTED pool where the whole 120e18 pool is the whitehat's bounty (§12).
1. A real in-scope breach occurs and the moderator flags good-faith CORRUPTED, honestly naming the wrong address at day 0
2. `_firstGoodFaithCorruptedAt` is set to day 0, so `corruptedClaimDeadline` is day 180
3. The wrong-named party never claims, so `claimsStarted` stays false and the pool sits
4. The true attacker's identity is contested off-chain and resolves months later
5. At day 185 the moderator issues the documented §4 correction, re-flagging good-faith CORRUPTED naming the correct whitehat
6. `_firstGoodFaithCorruptedAt` is not reset, so `corruptedClaimDeadline` recomputes to the already-past day 180
7. The whitehat calls `claimAttackerBounty` and it reverts `ClaimWindowExpired`
8. Anyone calls `sweepUnclaimedCorrupted`, routing the full 120e18 to `recoveryAddress`
9. The correct whitehat receives 0 of the bounty they earned
A correction inside the window is harmed too. A day-170 correction silently hands the whitehat a 10-day window instead of 180.

Risk

Likelihood:

  • Reason 1 // Describe WHEN this will occur (avoid using "if" statements)

  • Reason 2

Impact:

  • Impact 1

  • Impact 2

Proof of Concept

Recommended Mitigation

Reset the anchor when the named attacker changes, so a corrected different whitehat gets a full window. A same-attacker re-flag leaves the anchor untouched, preserving non-extendability.
```diff
bool willBeGoodFaithCorrupted = newOutcome == PoolStates.Outcome.CORRUPTED && goodFaith_;
+ address priorAttacker = attacker;
outcome = newOutcome;
goodFaith = goodFaith_;
attacker = attacker_;
```
```diff
if (willBeGoodFaithCorrupted) {
- if (_firstGoodFaithCorruptedAt == 0) {
+ // A different named attacker is a fresh entitlement, not an extension: reset the anchor
+ // so the corrected whitehat gets a full window. Same-attacker re-flag keeps the anchor.
+ if (_firstGoodFaithCorruptedAt == 0 || attacker_ != priorAttacker) {
// forge-lint: disable-next-line(unsafe-typecast)
_firstGoodFaithCorruptedAt = uint32(block.timestamp);
}
```
To also block extension via an attacker A to B to A toggle, track the first-flag timestamp per attacker in a mapping instead of a single anchor.

Support

FAQs

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

Give us feedback!