If the value of lockedToken.remainder is not reduced to zero even when the user has withdrawn all the tokens he locked it can cause a user to get more number of nfts than he is eligible to.
Proof of Concept
Following is the _lock function
function _lock(
address _tokenContract,
uint256 _quantity,
address _tokenOwner,
address _lockRecipient
) private {
(
address _mainAccount,
MunchablesCommonLib.Player memory _player
) = accountManager.getPlayer(_lockRecipient);
if (_mainAccount != _lockRecipient) revert SubAccountCannotLockError();
if (_player.registrationDate == 0) revert AccountNotRegisteredError();
// check approvals and value of tx matches
if (_tokenContract == address(0)) {
if (msg.value != _quantity) revert ETHValueIncorrectError();
} else {
if (msg.value != 0) revert InvalidMessageValueError();
IERC20 token = IERC20(_tokenContract);
uint256 allowance = token.allowance(_tokenOwner, address(this));
if (allowance < _quantity) revert InsufficientAllowanceError();
}
LockedToken storage lockedToken = lockedTokens[_lockRecipient][
_tokenContract
];
ConfiguredToken storage configuredToken = configuredTokens[
_tokenContract
];
// they will receive schnibbles at the new rate since last harvest if not for force harvest
accountManager.forceHarvest(_lockRecipient);
// add remainder from any previous lock
uint256 quantity = _quantity + lockedToken.remainder;
uint256 remainder;
uint256 numberNFTs;
uint32 _lockDuration = playerSettings[_lockRecipient].lockDuration;
if (_lockDuration == 0) {
_lockDuration = lockdrop.minLockDuration;
}
if (
lockdrop.start <= uint32(block.timestamp) &&
lockdrop.end >= uint32(block.timestamp)
) {
if (
_lockDuration < lockdrop.minLockDuration ||
_lockDuration >
uint32(configStorage.getUint(StorageKey.MaxLockDuration))
) revert InvalidLockDurationError();
if (msg.sender != address(migrationManager)) {
// calculate number of nfts
remainder = quantity % configuredToken.nftCost;
numberNFTs = (quantity - remainder) / configuredToken.nftCost;
if (numberNFTs > type(uint16).max) revert TooManyNFTsError();
// Tell nftOverlord that the player has new unopened Munchables
nftOverlord.addReveal(_lockRecipient, uint16(numberNFTs));
}
}
// Transfer erc tokens
if (_tokenContract != address(0)) {
IERC20 token = IERC20(_tokenContract);
token.transferFrom(_tokenOwner, address(this), _quantity);
}
lockedToken.remainder = remainder;
lockedToken.quantity += _quantity;
lockedToken.lastLockTime = uint32(block.timestamp);
lockedToken.unlockTime =
uint32(block.timestamp) +
uint32(_lockDuration);
// set their lock duration in playerSettings
playerSettings[_lockRecipient].lockDuration = _lockDuration;
emit Locked(
_lockRecipient,
_tokenOwner,
_tokenContract,
_quantity,
remainder,
numberNFTs,
_lockDuration
);
}
Remainder is calculated as follows
remainder = quantity % configuredToken.nftCost;
Suppose initially user locked 100 tokens and cost of each nft = 40 therefore the remainder would be equal to 20.
Now the user has 2 new unopened munchables
Now unlock time has passed and the user unlocks all his transferred tokens using the following unlock function
function unlock(
address _tokenContract,
uint256 _quantity
) external notPaused nonReentrant {
LockedToken storage lockedToken = lockedTokens[msg.sender][
_tokenContract
];
if (lockedToken.quantity < _quantity)
revert InsufficientLockAmountError();
if (lockedToken.unlockTime > uint32(block.timestamp))
revert TokenStillLockedError();
// force harvest to make sure that they get the schnibbles that they are entitled to
accountManager.forceHarvest(msg.sender);
lockedToken.quantity -= _quantity;
// send token
if (_tokenContract == address(0)) {
payable(msg.sender).transfer(_quantity);
} else {
IERC20 token = IERC20(_tokenContract);
token.transfer(msg.sender, _quantity);
}
emit Unlocked(msg.sender, _tokenContract, _quantity);
}
LockedToken.quantity was equal to 100. Now he unlocks and withdraws all the 100 tokens and accordingly the lockedToken.quantity is reduced to zero but we can clearly see that the value of lockedToken.remainder is not reduced to zero and it is still equal to 20.
Now the same user once again locks 60 tokens as he had withdrawn all his amount previously he should be only eligible for 60/40 = 1 new unopened munchables but as the remainder value was not reduced to zero therefore now he would be minted 60+20= 80/40 = 2 new unopened munchables.Thus he gets more munchables than he should be eligible to.
Tools Used
Manual review
Recommended Mitigation Steps
Add the following if condition in the unlock function
if (_quantity = lockedToken.quantity ){
locked.remainder = 0}
Lines of code
https://github.com/code-423n4/2024-05-munchables/blob/57dff486c3cd905f21b330c2157fe23da2a4807d/src/managers/LockManager.sol#L401
Vulnerability details
Impact
If the value of lockedToken.remainder is not reduced to zero even when the user has withdrawn all the tokens he locked it can cause a user to get more number of nfts than he is eligible to.
Proof of Concept
Following is the _lock function
Remainder is calculated as follows remainder = quantity % configuredToken.nftCost; Suppose initially user locked 100 tokens and cost of each nft = 40 therefore the remainder would be equal to 20. Now the user has 2 new unopened munchables Now unlock time has passed and the user unlocks all his transferred tokens using the following unlock function
LockedToken.quantity was equal to 100. Now he unlocks and withdraws all the 100 tokens and accordingly the lockedToken.quantity is reduced to zero but we can clearly see that the value of lockedToken.remainder is not reduced to zero and it is still equal to 20.
Now the same user once again locks 60 tokens as he had withdrawn all his amount previously he should be only eligible for 60/40 = 1 new unopened munchables but as the remainder value was not reduced to zero therefore now he would be minted 60+20= 80/40 = 2 new unopened munchables.Thus he gets more munchables than he should be eligible to.
Tools Used
Manual review
Recommended Mitigation Steps
Add the following if condition in the unlock function
Assessed type
Context