code-423n4 / 2024-05-munchables-findings

3 stars 1 forks source link

Players can steal funds from the `lockManager` preventing other users form unlocking the full amount of their withdrawals #438

Closed howlbot-integration[bot] closed 6 months ago

howlbot-integration[bot] commented 6 months ago

Lines of code

https://github.com/code-423n4/2024-05-munchables/blob/57dff486c3cd905f21b330c2157fe23da2a4807d/src/managers/LockManager.sol#L344-L366 https://github.com/code-423n4/2024-05-munchables/blob/57dff486c3cd905f21b330c2157fe23da2a4807d/src/managers/LockManager.sol#L416

Vulnerability details

Impact

Players can

Proof of Concept

During locking in a lockdrop event, the amount of tokens that do not contribute to the nft release owing to the nftCost of locking the token is saved in the lockedToken.remainder variable. For every time a player locks a token, the lockedToken.remainder is added to the amount the user intends to lock as shown in L344 below.

However, when the user is unlocking his tokens the lockedToken.remainder is not updated and the player can only withdraw his total locked amount per time leaving no crumbs. This leads to a situation where the previously cached lockedToken.remainder is added to the players balance when they intend to lock another time as shown in L341 below thereby over inflating the players locked token amount and by extension more NFTs as shown on L364 .

POC Summary

For simplicity, assume there are 3 tokenContract A, B and C, MaxLockDuration = 90days, MinLockDuration = 20 days and time t started at day 0

    struct ConfiguredToken {
        uint256 usdPrice; // 
        uint256 nftCost = 6000e18; 
        uint8 decimals; 
        bool active;
    }

    struct LockedToken {
        uint256 quantity = 20_000e18;
        uint256 remainder;
        uint32 lastLockTime;
        uint32 unlockTime;
    }

    uint256 quantity = _quantity + lockedToken.remainder;

    uint256 remainder = quantity % configuredToken.nftCost;
    uint256 numberNFTs = (quantity - remainder) / configuredToken.nftCost;

File: LockManager.sol
311:     function _lock(
312:         address _tokenContract,
313:         uint256 _quantity,
314:         address _tokenOwner,
315:         address _lockRecipient
316:     ) private {

...

343:         // add remainder from any previous lock
344: @>      uint256 quantity = _quantity + lockedToken.remainder;
345:         uint256 remainder;
346:         uint256 numberNFTs;
347:         uint32 _lockDuration = playerSettings[_lockRecipient].lockDuration;
348: 
349:         if (_lockDuration == 0) {
350:             _lockDuration = lockdrop.minLockDuration;
351:         }
352:         if (
353:             lockdrop.start <= uint32(block.timestamp) &&
354:             lockdrop.end >= uint32(block.timestamp)
355:         ) {
...
361:             if (msg.sender != address(migrationManager)) {
362:                 // calculate number of nfts
363: @>              remainder = quantity % configuredToken.nftCost;
364: @>              numberNFTs = (quantity - remainder) / configuredToken.nftCost;
365: 
...
366:             }
367:         }
...
378: 
379: @>      lockedToken.remainder = remainder;
380: @>      lockedToken.quantity += _quantity;
381:         lockedToken.lastLockTime = uint32(block.timestamp);
382         lockedToken.unlockTime =
383:             uint32(block.timestamp) +
384:             uint32(_lockDuration);
385: 
...
398:     }

401:     function unlock(
402:         address _tokenContract,
403:         uint256 _quantity
404:     ) external notPaused nonReentrant {
405:         LockedToken storage lockedToken = lockedTokens[msg.sender][
406:             _tokenContract
407:         ];
408:         if (lockedToken.quantity < _quantity)
409:             revert InsufficientLockAmountError();
410:         if (lockedToken.unlockTime > uint32(block.timestamp))
411:             revert TokenStillLockedError();
412: 
413:         // force harvest to make sure that they get the schnibbles that they are entitled to
414:         accountManager.forceHarvest(msg.sender);
415: 
416:  @>     lockedToken.quantity -= _quantity;
417: 
418:         // send token
...
425: 
426:         emit Unlocked(msg.sender, _tokenContract, _quantity);
427:     }

The goal here was to show how step by step a player can steal from the LockManager preventing others from withdrawing,

As a matter of fact notice that if Alice reats this for the next lockdrop event with token A she would make an extra 4_000e18 token A bringing her total theft to 6_000e18 token A

Tools Used

Manual review

Recommended Mitigation Steps

Moidify the unlock(...) function to update the lockedToken.remainder for the player whenever he unlocks as shown below

401:     function unlock(
402:         address _tokenContract,
403:         uint256 _quantity
404:     ) external notPaused nonReentrant {
405:         LockedToken storage lockedToken = lockedTokens[msg.sender][
406:             _tokenContract
407:         ];
408:         if (lockedToken.quantity < _quantity)
409:             revert InsufficientLockAmountError();
410:         if (lockedToken.unlockTime > uint32(block.timestamp))
411:             revert TokenStillLockedError();
412: 
413:         // force harvest to make sure that they get the schnibbles that they are entitled to
414:         accountManager.forceHarvest(msg.sender);
415: 
416:  @>     lockedToken.quantity -= _quantity;

417:  +      lockedToken.remainder = 0 

418:         // send token
419:         if (_tokenContract == address(0)) {
420:             payable(msg.sender).transfer(_quantity);
421:         } else {
422:             IERC20 token = IERC20(_tokenContract);
423:             token.transfer(msg.sender, _quantity);
424:         }
425: 
426:         emit Unlocked(msg.sender, _tokenContract, _quantity);
427:     }

Assessed type

Math

c4-judge commented 6 months ago

alex-ppg marked the issue as satisfactory

c4-judge commented 6 months ago

alex-ppg changed the severity to 2 (Med Risk)