When there is a tie, players are refunded their entrance ether minus the fee. If one of the refunds reverts, both refunds fail and the ether is stuck in the contract. Furthermore, there is no other way to withdraw them.
The RockPaperScissors
handles scenarios in games created by createGameWithEth
where there may be a tie by calling RockPaperScissors::_handleTie
which refunds both players - after deducting the 10% protocol fee.
The Code Issue: Root
Due to the function logic requiring both refund transactions to be successful, if one of the refunds fails, then they both fail and the ether is stuck in the contract. Further more, there is no way to withdraw any ether out ofthe contract - regardless of player or owner. When the owner withdraws the fees, the balance is removed from the accumulatedFees
variable and not from the contract balance.
This root issue also creates two other issues: protocol looses fees and failed event emission
The affected area is in RockPaperScissors::_handleTie#517
:
For example, if a game is started with a minBet
of 1 ether, on a tie where one refund fails, then the total funds locked in the contract is 2 ether.
Note: This does not necessarily need to be a maliciousPlayer who exploits this to cause loss to others. A normal player may join using a smart contract where they forgot to implement a recieve()
function. There may also be other reasons why a transaction may fail.
Escalation 1: Lost Admin Fees
When the refund transaction reverts, the entire _handleTie
function fails resulting in the fee changes made to accumulatedFees
variable to be rolled back. This means that no fees are collected for the game played. Therefore the protocols logic to still collect fees on games that end in a tie fails. The protocol is now loosing fees and has locked ether.
Escalation 2: Failed Event Emission
The event RockPaperScissors::GameFinished
is expected to be emitted once a game has finished. Due to refund reverting, this event is never emitted and disrupts logging.
The impact of this vulnerability is High as it results in direct loss of user funds. The likelihood is Medium as it only happens if one of the transactions fails.
Lost funds: direct loss of user funds. Users have their funds locked in the contract with no way to withdraw them. Furthermore, even the protocol cannot utilize the funds as there is no way to withdraw them out.
Loss in Protocol trust: Users who have their funds lost, loose trust in the protocol as it appears to be a scam/way to steal money. This will result in bad reputation and fewer game participation.
Protocol loses fees: The protocol expects to take a 10% fee on all games played. This is no longer the case when a refund fails meaning the protocol is loosing fees.
Disrupted logging: any logging mechanism relying on the GameFinished
event is disrupted. Even though a game has technically finished, it is not being correctly announced.
Manual Review
Foundry
To prove the existence of the vulnerability, I have provided a test suite.
Description
Modifier:
accumulateFees
: This starts a game in order to accumulate fees for the owner to withdraw
Test function:
2 players join game using contracts and play against each other (MaliciousPlayer does not have a receive() function)
Game ends in a draw
RockPaperScissors
contract tries to refund but transfer to MaliciousPlayer
fails causing both to fail
Owner calls withdrawFees
to take out the accumulated fees from the first game (played via modifer)
Fees from the game that ended in tie are not included and owner withdraws less
Console logging of balances for the Contract, owner and players are shown.
Contract balance still has the bets placed by the players.
Owner has received less than the expected fee
Code
Run with: forge test --mt testRefundFailuresCausesLosses -vvv
To mitigate this vulnerability, the refund logic needs to be handled differently.
When a refund fails, instead of reverting, an event can be emitted noting the failure.
Alternatively, the contract should not use the "push" method. In the event a tie happens, the amount each player should be refunded should be recorded. A new function should be created allowing users to individually "pull" out their funds. Essentially using the "pull" over the "push" method.
Result: The _handleTie
function now executes from start to finish successfully preventing the admin from loosing fees via rolled back changes on reverts. Furthermore, this will allow the GameFinished
event to be correctly emitted.
ETH sent directly to the contract via the receive function or after a canceled game becomes permanently locked
ETH sent directly to the contract via the receive function or after a canceled game becomes permanently locked
Malicious player wins a game using a contract that intentionally reverts when receiving ETH, the entire transaction will fail
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.