try/catch in Claimer._claim() allows users to steal gas from claimer bots
Summary
Claimer.claimPrizes() does not revert when an attempt to claim a prize fails, making it possible for claimer bots to pay gas but not receive any fees in return.
Vulnerability Detail
When claimer bots call Claimer.claimPrizes(), they provide a _minFeePerClaim parameter, which determines the minimum fee they should receive for each successful claim:
If the fee received per claim is less than _minFeePerClaim, Claimer.claimPrizes() reverts to ensure that claimer bots never lose funds from gas costs.
However, this is not sufficient to protect against the scenarios where calling PrizeVault.claimPrize() fails. In Claimer._claim(), calling PrizeVault.claimPrize() is wrapped in a try/catch:
This means that Claimer._claim() will not revert when attempting to claim a prize fails. The issue is that calling PrizeVault.claimPrize() costs gas even when it reverts. As such, Claimer bots are not reimbursed for gas costs due to failed calls to PrizeVault.claimPrize().
As seen from above, the function could revert for a number of reasons, such as:
The beforeClaimPrize or afterClaimPrize hook implemented by the winner reverts.
PrizePool.claimPrize() reverts as the prize has already been claimed, or there insufficient liquidity in the pool.
To maximize the claimer bot's loss from calling PrizeVault.claimPrize(), a winner could do the following:
In the beforeClaimPrize consume as much gas as possible.
In the afterClaimPrize hook, consume as much gas as possible and revert.
Assume a claimer bot calls Claimer.claimPrizes() to claim a prize for such a winner:
In Claimer._claim(), PrizeVault.claimPrize() is called:
150,000 gas is spent on the beforeClaimPrize hook.
More gas is spent calling PrizePool.claimPrize().
150,000 gas is spent on the afterClaimPrize hook.
When afterClaimPrize reverts, all state changes in PrizeVault.claimPrize() also revert. This causes the fees sent to the claimer bot in PrizePool.claimPrize() to be reverted.
Since PrizeVault.claimPrize() is wrapped in a try/catch, Claimer.claimPrizes() does not revert and the claimer bot pays for all the gas used.
As seen from above, the claimer bot pays for more than 300,000 gas, but receives no fees in return.
Note that on Blast L2 (which is one of the supported chains in the contest's README), contracts can claim a portion of the gas that was spent. This incentivizes winners to perform the attack described above, since they can claim part of the gas spent in the beforeClaimPrize and afterClaimPrize hook.
Impact
Since claimer bots pay gas but do not receive any fees in return when a call to PrizeVault.claimPrize() fails, they can lose funds. On Blast L2, winners can steal gas from claimer bots by intentionally spending gas in custom hooks, which maximizes the loss for claimer bots.
Whenever _vault.claimPrize() is called in Claimer.claim(), PrizePool.claimPrize() must be executed successfully to guarantee that the claimer bot will receive fees in return for paying gas. This can be achieved by doing the following:
In Claimer.claim(), check PrizePool.wasClaimed() before calling _vault.claimPrize(). This ensures gas is never spent to try to claim a prize that was already claimed (eg. another bot front-runs and claims the prize).
In _vault.claimPrize(), wrap the beforeClaimPrize and afterClaimPrize hooks in a try/catch. This ensures the _winner address cannot intentionally revert the call.
Both of these mitigations will always ensure that _vault.claimPrize() and PrizePool.claimPrize() will never revert when called, provided there is sufficient liquidity in the pool to payout prizes.
MiloTruck
medium
try/catch
inClaimer._claim()
allows users to steal gas from claimer botsSummary
Claimer.claimPrizes()
does not revert when an attempt to claim a prize fails, making it possible for claimer bots to pay gas but not receive any fees in return.Vulnerability Detail
When claimer bots call
Claimer.claimPrizes()
, they provide a_minFeePerClaim
parameter, which determines the minimum fee they should receive for each successful claim:Claimer.sol#L113-L116
If the fee received per claim is less than
_minFeePerClaim
,Claimer.claimPrizes()
reverts to ensure that claimer bots never lose funds from gas costs.However, this is not sufficient to protect against the scenarios where calling
PrizeVault.claimPrize()
fails. InClaimer._claim()
, callingPrizeVault.claimPrize()
is wrapped in atry/catch
:Claimer.sol#L161-L167
This means that
Claimer._claim()
will not revert when attempting to claim a prize fails. The issue is that callingPrizeVault.claimPrize()
costs gas even when it reverts. As such, Claimer bots are not reimbursed for gas costs due to failed calls toPrizeVault.claimPrize()
.The logic of
PrizeVault.claimPrize()
is as shown:Claimable.sol#L86-L118
As seen from above, the function could revert for a number of reasons, such as:
beforeClaimPrize
orafterClaimPrize
hook implemented by the winner reverts.PrizePool.claimPrize()
reverts as the prize has already been claimed, or there insufficient liquidity in the pool.To maximize the claimer bot's loss from calling
PrizeVault.claimPrize()
, a winner could do the following:beforeClaimPrize
consume as much gas as possible.afterClaimPrize
hook, consume as much gas as possible and revert.Assume a claimer bot calls
Claimer.claimPrizes()
to claim a prize for such a winner:Claimer._claim()
,PrizeVault.claimPrize()
is called:beforeClaimPrize
hook.PrizePool.claimPrize()
.afterClaimPrize
hook.afterClaimPrize
reverts, all state changes inPrizeVault.claimPrize()
also revert. This causes the fees sent to the claimer bot inPrizePool.claimPrize()
to be reverted.PrizeVault.claimPrize()
is wrapped in atry/catch
,Claimer.claimPrizes()
does not revert and the claimer bot pays for all the gas used.As seen from above, the claimer bot pays for more than 300,000 gas, but receives no fees in return.
Note that on Blast L2 (which is one of the supported chains in the contest's README), contracts can claim a portion of the gas that was spent. This incentivizes winners to perform the attack described above, since they can claim part of the gas spent in the
beforeClaimPrize
andafterClaimPrize
hook.Impact
Since claimer bots pay gas but do not receive any fees in return when a call to
PrizeVault.claimPrize()
fails, they can lose funds. On Blast L2, winners can steal gas from claimer bots by intentionally spending gas in custom hooks, which maximizes the loss for claimer bots.Code Snippet
https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-claimer/src/Claimer.sol#L113-L116
https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-claimer/src/Claimer.sol#L161-L167
https://github.com/sherlock-audit/2024-05-pooltogether/blob/1aa1b8c028b659585e4c7a6b9b652fb075f86db3/pt-v5-vault/src/abstract/Claimable.sol#L86-L118
Tool used
Manual Review
Recommendation
Whenever
_vault.claimPrize()
is called inClaimer.claim()
,PrizePool.claimPrize()
must be executed successfully to guarantee that the claimer bot will receive fees in return for paying gas. This can be achieved by doing the following:Claimer.claim()
, checkPrizePool.wasClaimed()
before calling_vault.claimPrize()
. This ensures gas is never spent to try to claim a prize that was already claimed (eg. another bot front-runs and claims the prize)._vault.claimPrize()
, wrap thebeforeClaimPrize
andafterClaimPrize
hooks in atry/catch
. This ensures the_winner
address cannot intentionally revert the call.Both of these mitigations will always ensure that
_vault.claimPrize()
andPrizePool.claimPrize()
will never revert when called, provided there is sufficient liquidity in the pool to payout prizes.Duplicate of #163