A malicious user can game the system to increase his chances of winning a round
Summary
A missing value check in the depositETHIntoMultipleRounds() function allows a malicious user to deposit 0 amount for a round and thus have the same currentEntryIndex as the previous non malicious user who entered a round. Due to the implementation of the findUpperBound() function in the Arrays.sol contract, which doesn't expect repeated elements in the provided array. A malicious user can game the system and either increase his chances of winning the round or guarantee that he is the round winner if certain conditions(described in the below section) are met.
Vulnerability Detail
In the scenario where a round starts, and all users increment their currentEntryIndex with 1 with each deposit, meaning that they call the deposit() function with value equal to the valuePerEntry for the specified round, the system will create only one entrieCount for them. If we have four user do that, the first user currentEntryIndex will be equal to 1, the second to 2, the third to 3, and the fourth to 4. The problem arises if a malicious user calls depositETHIntoMultipleRounds(), after each single user deposit, with a 0 value, creating a round deposit entry with the same currentEntryIndex, paying nothing but gas. When fulfillRandomWords() is called back from the VRF contract and a randomWord is supplied the contract will first create the following currentEntryIndexArray
uint256[] memory currentEntryIndexArray = new uint256[](count);
for (uint256 i; i < count; ++i) {
currentEntryIndexArray[i] = uint256(round.deposits[i].currentEntryIndex);
}
which will contain the following elements [1, 1, 2, 2, 3, 3, 4, 4] , with each second equal element referring to an attacker deposit.
The function then takes the last element of the array as it is the biggest one
The findUpperBound() function will always return the second equal entry, for example if the winningEntry is 1, the findUpperBound() will return 1, corresponding to the index of the attacker deposit. The attacker then has to make sure that he wins the next round by filling the maximum number of depositors value with addresses controlled by him. In the scenario where the entry indexes are not incremented by one the attacker still has a better chance than most to win because if a number that is equal to an arbitrary currentEntryIndex existing in the currentEntryIndexArray, the attacker will still win the round. A protocol fee is withheld from the winner of each round. but if paid in LOOKS tokens according to the parameters set in the deployment script there is a 50% discount. The protocol fee in the deployment script is set to 500 which is equal to 5%. The parameters in the deploy script expect 50 participants per round, so if we get 25 * 0.01 ETH(which is the value per entry) we get 250000000000000000 = 0,25 ETH which will be the profit for the attacker. He will have to pay fees for winning two rounds. The fee for the first round where there are only 25 real deposits is 12500000000000000 = 0,0125 ETH If paying the fees in LOOKs tokens we get the following
(0,0125 ETH + 0.025 ETH ) / 2 = 0.01875 ETH. So the attacker can win 0.25ETH - 0.01875 ETH = 0.23125ETH. The attack becomes profitable if there are at least two users in the round that the attacker can game. This way he will have to pay 0.0125 ETH in LOOKs for the second round that he has to win and 0.001ETH / 2 = 0.0005ETH for the round where he games users in order to win. But he will win 20000000000000000 = 0,02ETH
NOTE: The parameters described in this report have nothing to do with the actual root of the issue, they are used to clarify the attack and the profitability of it. An arbitrary set of different parameters will result in the same issue. If the protocol fee becomes some astronomical % then the attack may not be profitable. However very few people will use the protocol if the protocol fee is 50%.
Proof of Concept
Gist
After following the steps in the above gist add the following to the AuditorsTest.t.sol
Logs:
Protocol fee owed: 2000000000000000
Alice entry index: 1
Attacker entry index: 1
Bob entry index: 2
Attacker1 entry index: 2
Tom entry index: 3
Attacker2 entry index: 3
John entry index: 4
Attacker3 entry index: 4
Protocol fee owed for the second round: 40000000000000
To run the test use: forge test -vvv --mt test_MaliciousUserCanWinAllRounds
Impact
If the conditions specified above (each users increments the entries by one) the attacker can be guaranteed to win each round, If not all entries are incremented by one the attacker still has a better chance than most to win, because if a number that is equal to an arbitrary currentEntryIndex existing in the currentEntryIndexArray, the attacker will still win the round.
In depositETHIntoMultipleRounds() function add the following check, to ensure that each amount is bigger than 0, and thus the minimum roundValuePerEntry
dimulski
medium
A malicious user can game the system to increase his chances of winning a round
Summary
A missing value check in the
depositETHIntoMultipleRounds()
function allows a malicious user to deposit 0 amount for a round and thus have the samecurrentEntryIndex
as the previous non malicious user who entered a round. Due to the implementation of thefindUpperBound()
function in theArrays.sol
contract, which doesn't expect repeated elements in the provided array. A malicious user can game the system and either increase his chances of winning the round or guarantee that he is the round winner if certain conditions(described in the below section) are met.Vulnerability Detail
In the scenario where a round starts, and all users increment their
currentEntryIndex
with 1 with each deposit, meaning that they call thedeposit()
function with value equal to thevaluePerEntry
for the specified round, the system will create only oneentrieCount
for them. If we have four user do that, the first usercurrentEntryIndex
will be equal to 1, the second to 2, the third to 3, and the fourth to 4. The problem arises if a malicious user callsdepositETHIntoMultipleRounds()
, after each single user deposit, with a 0 value, creating a round deposit entry with the samecurrentEntryIndex
, paying nothing but gas. WhenfulfillRandomWords()
is called back from the VRF contract and a randomWord is supplied the contract will first create the followingcurrentEntryIndexArray
which will contain the following elements
[1, 1, 2, 2, 3, 3, 4, 4]
, with each second equal element referring to an attacker deposit. The function then takes the last element of the array as it is the biggest oneand uses it to determine the
winningEntry
From the above example there are four values that we can expect to have for the
winningEntry
: 1,2,3,4. The max value is currentEntryIndex.The
findUpperBound()
function will always return the second equal entry, for example if thewinningEntry
is 1, thefindUpperBound()
will return 1, corresponding to the index of the attacker deposit. The attacker then has to make sure that he wins the next round by filling the maximum number of depositors value with addresses controlled by him. In the scenario where the entry indexes are not incremented by one the attacker still has a better chance than most to win because if a number that is equal to an arbitrarycurrentEntryIndex
existing in thecurrentEntryIndexArray
, the attacker will still win the round. A protocol fee is withheld from the winner of each round. but if paid in LOOKS tokens according to the parameters set in the deployment script there is a 50% discount. The protocol fee in the deployment script is set to500
which is equal to5%
. The parameters in the deploy script expect 50 participants per round, so if we get 25 * 0.01 ETH(which is the value per entry) we get250000000000000000
=0,25 ETH
which will be the profit for the attacker. He will have to pay fees for winning two rounds. The fee for the first round where there are only 25 real deposits is12500000000000000
=0,0125 ETH
If paying the fees in LOOKs tokens we get the following (0,0125 ETH
+0.025 ETH
) / 2 =0.01875 ETH
. So the attacker can win0.25ETH
-0.01875 ETH
=0.23125ETH
. The attack becomes profitable if there are at least two users in the round that the attacker can game. This way he will have to pay0.0125 ETH
in LOOKs for the second round that he has to win and0.001ETH
/ 2 =0.0005ETH
for the round where he games users in order to win. But he will win20000000000000000
=0,02ETH
NOTE: The parameters described in this report have nothing to do with the actual root of the issue, they are used to clarify the attack and the profitability of it. An arbitrary set of different parameters will result in the same issue. If the protocol fee becomes some astronomical % then the attack may not be profitable. However very few people will use the protocol if the protocol fee is 50%.
Proof of Concept
Gist After following the steps in the above gist add the following to the
AuditorsTest.t.sol
To run the test use:
forge test -vvv --mt test_MaliciousUserCanWinAllRounds
Impact
If the conditions specified above (each users increments the entries by one) the attacker can be guaranteed to win each round, If not all entries are incremented by one the attacker still has a better chance than most to win, because if a number that is equal to an arbitrary
currentEntryIndex
existing in thecurrentEntryIndexArray
, the attacker will still win the round.Code Snippet
https://github.com/sherlock-audit/2024-01-looksrare/blob/main/contracts-yolo/contracts/YoloV2.sol#L312-L362
Tool used
Manual Review & Foundry
Recommendation
In
depositETHIntoMultipleRounds()
function add the following check, to ensure that each amount is bigger than 0, and thus the minimumroundValuePerEntry
Duplicate of #18