flagOutcome is missing the nonReentrant modifier, allowing a cross-function reentrancy via ERC-777/ERC-223/ERC-677 hook tokens that can flip the outcome mid-transaction and drain the entire pool.
Every other state-changing function in the contract (stake, withdraw, claimSurvived, claimCorrupted, claimAttackerBounty, sweepUnclaimedCorrupted, sweepUnclaimedBonus, claimExpired) carries the nonReentrant guard. flagOutcome is the only function modifying outcome-critical state without it.
When the stake token is an ERC-777/ERC-223/ERC-677 (admitted through factory governance), sweepUnclaimedBonus triggers tokensReceived on the recoveryAddress contract. If recoveryAddress is moderator-controlled, the callback re-enters flagOutcome(CORRUPTED, true, attacker), flips the outcome from SURVIVED to CORRUPTED, and allows the attacker to drain the remaining pool via claimAttackerBounty().
Likelihood:
The factory owner must allowlist a hook-bearing token (ERC-777/ERC-223/ERC-677) contrary to the documented standard-ERC-20-only policy
The moderator must set recoveryAddress to a contract (or control one that implements tokensReceived), combining two privileged roles
Impact:
The full pool balance can be drained by the attacker via claimAttackerBounty() after the outcome is flipped from SURVIVED to CORRUPTED
Stakers who verified the SURVIVED outcome receive nothing despite the pool being resolved in their favor
The reentrancy requires the pool to use a hook-bearing stake token (ERC-777/ERC-223 — a factory governance error) and a recoveryAddress contract that is also the moderator. The exploit works in six steps:
Step 1 — Deploy MockHookToken (an ERC-20 that calls tokensReceived on the recipient during transfer, simulating ERC-777 hooks).
Step 2 — Deploy MaliciousRecovery implementing IERC777Recipient. Its tokensReceived callback calls flagOutcome(CORRUPTED, true, attacker) — and because flagOutcome lacks nonReentrant, this call succeeds even when triggered from inside a nonReentrant sweep function.
Step 3 — Initialize the pool with MaliciousRecovery as both outcomeModerator and recoveryAddress.
Step 4 — Standard setup: Alice stakes 100 ONE, Carol contributes 50 ONE bonus, registry transits through UNDER_ATTACK to CORRUPTED.
Step 5 — Moderator flags SURVIVED (scope-blind judgement). A 1 ONE donation is sent to the pool to create sweepable surplus above the reserved principal.
Step 6 — The reentrancy trigger:
After the call returns, the attacker calls claimAttackerBounty() and drains the full 150 ONE pool.
Run: forge test --match-test testPoC11 -vv
Adding nonReentrant closes the cross-function reentrancy path from any hook-bearing token transfer (sweepUnclaimedBonus, sweepUnclaimedCorrupted, or future transfer entrypoints) without changing moderator re-flag semantics or requiring a token allowlist policy change.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.