Platform: Code4rena
Start Date: 14/06/2022
Pot Size: $50,000 USDC
Total HM: 19
Participants: 99
Period: 5 days
Judge: HardlyDifficult
Total Solo HM: 4
Id: 136
League: ETH
Rank: 5/99
Findings: 4
Award: $2,443.71
🌟 Selected for report: 0
🚀 Solo Findings: 0
276.9248 USDC - $276.92
Depending on the amounts and timeline of user stakes, the user can lose locked value upon unstaking. The unstake()
function firstly checks the vested balance of the user for each duration and then calls _updateUserStakedAmounts()
. This function steps through each duration linearly, setting the duration stake amount to 0 if the requested amount is greater than the vested amount. This will cause loss of funds in certain circumstances. A detailed example is outlined below.
Imagine the scenario where a user stakes an amount 100
for each duration, but at specific times. The user stakes for Duration.TWELVE_MONTH
12 months ago, so it is fully vested. The user then stakes in Duration.NONE
, Duration.THREE_MONTHS
, and Duration.SIX_MONTH
right this instant. The user's stake balance and vested balance will looks as follows:
Duration | Stake | Vested NONE | 100 | 100 3 MO | 100 | 0 6 MO | 100 | 0 12 MO | 100 | 100
The user attempts to unstake 200
. This will call _updateUserStakedAmounts()
as such:
Definition w/ params:
_updateUserStakedAmounts(msg.sender, amount, noVesting, vestedThreeMonths, vestedsixMonths, vestedTwelveMonths);
Actual call:
_updateUserStakedAmounts(msg.sender, 200, 100, 0, 0, 100);
When these values are passed into _updateUserStakedAmounts()
, the function will set the user's THREE_MONTH and SIX_MONTH stake balances to 0. The function is a multiple-nested if statement, but the logic in question is this portion:
if (amount > noVesting) { userstakedAmounts[user][Duration.NONE].amount = 0; userstakedAmounts[user][Duration.NONE].timestamp = 0; amount = amount - noVesting; if (amount > vestedThreeMonths) { userstakedAmounts[user][Duration.THREE_MONTHS].amount = 0; userstakedAmounts[user][Duration.THREE_MONTHS].timestamp = 0; amount = amount - vestedThreeMonths; if (amount > vestedSixMonths) { userstakedAmounts[user][Duration.SIX_MONTHS].amount = 0; userstakedAmounts[user][Duration.SIX_MONTHS].timestamp = 0; amount = amount - vestedSixMonths; if (amount > vestedTwelveMonths) { userstakedAmounts[user][Duration.TWELVE_MONTHS].amount = 0; userstakedAmounts[user][Duration.TWELVE_MONTHS].timestamp = 0; } else { userstakedAmounts[user][Duration.TWELVE_MONTHS].amount -= amount;
amount > noVesting
-> (200 > 100)
, so userstakedAmounts[user][Duration.NONE].amount
is correctly set to 0
.amount
is reduced to 100
.amount > vestedThreeMonths
-> (100 > 0)
, so userstakedAmounts[user][Duration.THREE_MONTHS].amount
is INCORRECTLY set to 0
.amount > vestedSixMonths
-> (100 > 0)
, so userstakedAmounts[user][Duration.SIX_MONTHS].amount
is INCORRECTLY set to 0
.else
statement executes and correctly decrements userstakedAmounts[user][Duration.TWELVE_MONTHS].amount -= amount;
The user has lost the value of their 3 and 6 months stakes.
Manual review.
Instead of stepping through these linearly based on duration, step through based on total vested amount of each duration. In the above example, the function should execute in the following order:
NONE -> TWELVE_MONTHS -> THREE_MONTHS -> SIX_MONTHS
#0 - nneverlander
2022-06-22T12:40:52Z
#1 - nneverlander
2022-07-05T11:29:43Z
#2 - HardlyDifficult
2022-07-10T14:57:15Z
2084.3311 USDC - $2,084.33
Due to transfer control on the ERC1155 transfer, a buyer is able to reenter the contract and take the sell order again, effectively doubling the amount that the seller ordered to sell. Specific conditions need to be met for this to be true.
matchOneToManyOrders()
function called by the MATCH_EXECUTOR
The issue stems from the fact that the original sell order is only marked as true
after the many buy orders have completed. The attacker is able to reenter and call takeOrders()
in the middle of the for loop of the original matched buy orders.
MATCH_EXECUTOR
attempts to match the sell order and multiple buy orders by calling matchOneToManyOrders()
.matchOneToManyOrders()
function checks that the orders are valid (not completed or cancelled) and initiates the for loop to iterate through the buy orders.safeBatchTransferFrom()
which internally calls _doSafeBatchTransferAcceptanceCheck()
which makes an external call to the receiver, transferring control of execution.NFTs transferred: 1 NFTs remaining: 4
takeOrders()
and take the entire sell order. Since the sell order has not been marked as completed yet, the call is valid. The attacker receives all 5 qty from the original sell order.IMPORTANT: the match...
functions do not contain the nonReentrant
modifier. Only the takeOrders()
function does. Therefore, this exploit does not get mitigated by reentrancy guard.
NFTs transferred: 6 NFTs remaining: 4
matchOneToManyOrders()
complete, transferring 4 more NFTs to the attacker.NFTs transferred: 10 NFTs remaining: 0
The attacker has now bought double the NFTs than the owner listed.
Manual review.
Multiple options:
isUserOrderNonceExecutedOrCancelled[makerOrder.signer][makerOrder.constraints[5]] = true;
line above the for loop so that the order is marked as completed prior to control transfer.I recommend option 2. Option 1 will add a bit of gas to the match calls.
#0 - nneverlander
2022-06-23T12:25:44Z
We removed support for ERC1155 and added reentrancy guard to match* functions
#1 - HardlyDifficult
2022-07-10T21:42:35Z
🌟 Selected for report: joestakey
Also found by: 0x1f8b, 0x29A, 0x52, 0xDjango, 0xNazgul, 0xNineDec, 0xf15ers, 0xkowloon, 0xmint, 8olidity, BowTiedWardens, Chom, Cityscape, Czar102, ElKu, FSchmoede, Funen, GimelSec, GreyArt, IllIllI, KIntern, Kaiziron, Kenshin, Lambda, MadWookie, MiloTruck, PPrieditis, Picodes, Ruhum, Sm4rty, StErMi, TerrierLover, TomJ, Treasure-Seeker, VAD37, WatchPug, Wayne, _Adam, a12jmx, abhinavmir, antonttc, apostle0x01, asutorufos, berndartmueller, cccz, cloudjunky, codexploder, cryptphi, csanuragjain, defsec, delfin454000, fatherOfBlocks, georgypetrov, hake, hansfriese, horsefacts, hyh, k, kenta, nxrblsrpr, oyc_109, peritoflores, rajatbeladiya, reassor, rfa, robee, sach1r0, saian, samruna, shenwilly, simon135, sorrynotsorry, sseefried, throttle, unforgiven, wagmi, zzzitron
51.2388 USDC - $51.24
https://github.com/code-423n4/2022-06-infinity/blob/765376fa238bbccd8b1e2e12897c91098c7e5ac6/contracts/core/InfinityExchange.sol#L59-L63 https://github.com/code-423n4/2022-06-infinity/blob/765376fa238bbccd8b1e2e12897c91098c7e5ac6/contracts/staking/InfinityStaker.sol#L27 https://github.com/code-423n4/2022-06-infinity/blob/765376fa238bbccd8b1e2e12897c91098c7e5ac6/contracts/staking/InfinityStaker.sol#L33-L42
#0 - HardlyDifficult
2022-07-10T21:56:33Z
#1 - HardlyDifficult
2022-07-12T00:00:19Z
#2 - HardlyDifficult
2022-07-12T00:02:49Z
#3 - HardlyDifficult
2022-07-12T00:05:32Z
🌟 Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0x29A, 0xAsm0d3us, 0xDjango, 0xKitsune, 0xNazgul, 0xf15ers, 0xkatana, 0xkowloon, BowTiedWardens, Chom, ElKu, FSchmoede, Funen, GimelSec, Kaiziron, Kenshin, Lambda, MadWookie, MiloTruck, PPrieditis, Picodes, PwnedNoMore, StErMi, Tadashi, TerrierLover, TomJ, Tomio, Wayne, Waze, _Adam, antonttc, apostle0x01, asutorufos, c3phas, codexploder, defsec, delfin454000, fatherOfBlocks, hake, hansfriese, hyh, joestakey, k, kenta, oyc_109, peritoflores, reassor, rfa, robee, sach1r0, simon135, slywaters, zer0dot
31.2172 USDC - $31.22
No setter function for INFINITY_TOKEN
other than constructor.