Submitted by Al-Qa-qa, also found by Infect3d and souilos
When the winner earns his reward he can either claim it himself, or he can let a claimer contract withdraw it on his behalf, and he will pay part of his reward for that. This is because the user will not pay for the gas fees; instead the claimer contract will pay it instead.
The problem here is that the winner can make the claimer pay for the gas of the transaction, without paying the fees that the claimer contract takes.
Claimer contracts are allowed for anyone to use them, transfer prizes to winners, and claim some fees; where the one who fired the transaction is the one who will pay for the fees, so he deserved those fees.
pt-v5-claimer/Claimer.sol#L120-L150
As in the function, the function takes the winners and he passed the fee recipient and his fees (but it should not exceed the maxFees, which is initialized in the constructor).
Now we know that anyone can transfer winners prizes and claim some fees.
Before the prizes are claimed, the winner can initialize a hook before calling the PoolPrize::claimPrize. This is used if the winner wants to initialize another address as the receiver of the reward. The hook parameter is passed by parameters that are used to determine the correct winner (winner address, tier, prizeIndex).
abstract/Claimable.sol#L85-L95
But to prevent OOG the gas is limited to 150K.
Now what the user can do to make the claimer pay for the transaction, and not pay any fees is:
Note: The Claimer claiming function will not revert, as if the prize was already claimed the function will just emit an event and will not revert.
pt-v5-claimer/Claimer.sol#L194-L198
The only check that can prevent this attack is the gas cost of calling beforeClaimPrize hook.
We will call one function Claimer::claimPrizes() by only passing one winner, and without fees. We calculated the gas that can be used by installing protocol contracts (Claimer and PrizePool), then grab a test function that first the function we need, and we get these results:
So the total gas that can be used is $118,124 + 5292 = $123,416 which is smaller than HOOK_GAS by more than 25K, so the function will not revert because of OOG error, and the reentrancy will occur.
Another thing that may lead to a misunderstanding is that the Judger may say if this happens the function will go to beforeClaimPrize hook again leading to infinite loop and the transaction will go OOG. However, making the transaction beforeClaimPrize be fired to make a result and when called again do another logic is an easy task that can be made by implementing a counter or something. However, we did not implement this counter in our test. We just wanted to point out how the attack will work in our POC, but in real interactions, there should be some edge cases to take care of and further configurations to take care off.
We made a simulation of how the function will occur. We found that the testing environment made by the devs is abstracted a little bit compared to the real flow of transactions in the production mainnet, so I made Mock contracts, and simulated the attack with them. Please go for the testing script step by step, and it will work as intended.
Output:
In this test, we first made a reward and withdrew it from our Claimer contract normally (no attack happened). Then, we made another prize reward but by making the attack when withdrawing it, which can be seen in the Logs.
Foundry
We can check the prize state before and after the hook; if it changes from unclaimed to claimed, we can revert the transaction.
Claimable.sol:
Note: We were writing this issue 30 minutes before ending of the audit - the mitigation review may not be the best, or may not work (we did not test it). Devs should keep this in mind when mitigating this issue.
Reentrancy
raymondfam (lookout) commented:
hansfriese (judge) decreased severity to Low
Al-Qa-qa (warden) commented:
hansfriese (judge) increased severity to Medium and commented:
trmid (PoolTogether) confirmed and commented:
