Platform: Code4rena
Start Date: 04/01/2022
Pot Size: $25,000 USDC
Total HM: 3
Participants: 40
Period: 3 days
Judge: Ivo Georgiev
Total Solo HM: 1
Id: 75
League: ETH
Rank: 9/40
Findings: 2
Award: $806.34
π Selected for report: 2
π Solo Findings: 0
768.967 USDC - $768.97
cccz
There is a reentrancy vulnerability in the _safeMint function
function _safeMint( address to, uint256 tokenId, bytes memory _data ) internal virtual { _mint(to, tokenId); require( _checkOnERC721Received(address(0), to, tokenId, _data), "ERC721: transfer to non ERC721Receiver implementer" ); } ... function _checkOnERC721Received( address from, address to, uint256 tokenId, bytes memory _data ) private returns (bool) { if (to.isContract()) { try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) { return retval == IERC721Receiver.onERC721Received.selector;
The lock function changes the totalDepositedXDEFI variable after calling the _safeMint function
function lock(uint256 amount_, uint256 duration_, address destination_) external noReenter returns (uint256 tokenId_) { // Lock the XDEFI in the contract. SafeERC20.safeTransferFrom(IERC20(XDEFI), msg.sender, address(this), amount_); // Handle the lock position creation and get the tokenId of the locked position. return _lock(amount_, duration_, destination_); } ... function _lock(uint256 amount_, uint256 duration_, address destination_) internal returns (uint256 tokenId_) { // Prevent locking 0 amount in order generate many score-less NFTs, even if it is inefficient, and such NFTs would be ignored. require(amount_ != uint256(0) && amount_ <= MAX_TOTAL_XDEFI_SUPPLY, "INVALID_AMOUNT"); // Get bonus multiplier and check that it is not zero (which validates the duration). uint8 bonusMultiplier = bonusMultiplierOf[duration_]; require(bonusMultiplier != uint8(0), "INVALID_DURATION"); // Mint a locked staked position NFT to the destination. _safeMint(destination_, tokenId_ = _generateNewTokenId(_getPoints(amount_, duration_))); // Track deposits. totalDepositedXDEFI += amount_;
Since the updateDistribution function does not use the noReenter modifier, the attacker can re-enter the updateDistribution function in the _safeMint function. Since the value of totalDepositedXDEFI is not updated at this time, the _pointsPerUnit variable will become abnormally large.
function updateDistribution() external { uint256 totalUnitsCached = totalUnits; require(totalUnitsCached> uint256(0), "NO_UNIT_SUPPLY"); uint256 newXDEFI = _toUint256Safe(_updateXDEFIBalance()); if (newXDEFI == uint256(0)) return; _pointsPerUnit += ((newXDEFI * _pointsMultiplier) / totalUnitsCached); emit DistributionUpdated(msg.sender, newXDEFI); } ... function _updateXDEFIBalance() internal returns (int256 newFundsTokenBalance_) { uint256 previousDistributableXDEFI = distributableXDEFI; uint256 currentDistributableXDEFI = distributableXDEFI = IERC20(XDEFI).balanceOf(address(this))-totalDepositedXDEFI; return _toInt256Safe(currentDistributableXDEFI)-_toInt256Safe(previousDistributableXDEFI); }
If the attacker calls the lock function to get the NFT before exploiting the reentrance vulnerability, then the unlock function can be called to steal a lot of rewards, and the assets deposited by the user using the reentrance vulnerability can also be redeemed by calling the unlock function. Since the unlock function calls the _updateXDEFIBalance function, the attacker cannot steal the assets deposited by the user
function unlock(uint256 tokenId_, address destination_) external noReenter returns (uint256 amountUnlocked_) { // Handle the unlock and get the amount of XDEFI eligible to withdraw. amountUnlocked_ = _unlock(msg.sender, tokenId_); // Send the the unlocked XDEFI to the destination. SafeERC20.safeTransfer(IERC20(XDEFI), destination_, amountUnlocked_); // NOTE: This needs to be done after updating `totalDepositedXDEFI` (which happens in `_unlock`) and transferring out. _updateXDEFIBalance(); } ... function _unlock(address account_, uint256 tokenId_) internal returns (uint256 amountUnlocked_) { // Check that the account is the position NFT owner. require(ownerOf(tokenId_) == account_, "NOT_OWNER"); // Fetch position. Position storage position = positionOf[tokenId_]; uint96 units = position.units; uint88 depositedXDEFI = position.depositedXDEFI; uint32 expiry = position.expiry; // Check that enough time has elapsed in order to unlock. require(expiry != uint32(0), "NO_LOCKED_POSITION"); require(block.timestamp >= uint256(expiry), "CANNOT_UNLOCK"); // Get the withdrawable amount of XDEFI for the position. amountUnlocked_ = _withdrawableGiven(units, depositedXDEFI, position.pointsCorrection); // Track deposits. totalDepositedXDEFI -= uint256(depositedXDEFI); // Burn FDT Position. totalUnits -= units; delete positionOf[tokenId_]; emit LockPositionWithdrawn(tokenId_, account_, amountUnlocked_); } ... function _withdrawableGiven(uint96 units_, uint88 depositedXDEFI_, int256 pointsCorrection_) internal view returns (uint256 withdrawableXDEFI_) { return ( _toUint256Safe( _toInt256Safe(_pointsPerUnit * uint256(units_)) + pointsCorrection_ ) / _pointsMultiplier ) + uint256(depositedXDEFI_); }
Manual analysis
- function updateDistribution() external { + function updateDistribution() external noReenter {
#0 - deluca-mike
2022-01-09T05:26:41Z
Valid and a big issue. However, due to other recommendations, I will not solve it this way. Instead, updateDistribution()
will be called at the start of every lock/unlock function (so it can't have a noReenter
modifier), and the _safeMint
calls will be moved to the end of their respective operations to prevent the effect of the re-entrancy (i.e. position will created with a _pointsPerUnit
before a re-entering from _safeMint
can affect it). Tests will be added to show this is not longer possible.
#1 - deluca-mike
2022-01-14T04:52:39Z
In our release candidate contract, as mentioned above, updateDistribution()
is called before each locking and unlocking function, via a updatePointsPerUnitAtStart
modifier, and thus, updateDistribution()
is now a public fucntion, and since it is used by other functions, cannot be behind a noReenter
.
See:
Also, a test was written to ensure that this is no longer exploitable, and that the contract behaves properly if a re-entrancy call updateDistribution()
.
#2 - Ivshti
2022-01-16T00:37:52Z
Agreed with the severity.
Resolution of reordering the calls seems to be adequate
37.3718 USDC - $37.37
cccz
In the setLockPeriods function, there is no verification of the multipliers parameter, multipliers[i] may be 0, and the length of multipliers may not be equal to the length of durations_.
function setLockPeriods(uint256[] memory durations_, uint8[] memory multipliers) external onlyOwner { uint256 count = durations_.length; for (uint256 i; i < count; ++i) { uint256 duration = durations_[i]; require(duration <= uint256(18250 days), "INVALID_DURATION"); emit LockPeriodSet(duration, bonusMultiplierOf[duration] = multipliers[i]); } }
Manual analysis
function setLockPeriods(uint256[] memory durations_, uint8[] memory multipliers) external onlyOwner { + require(durations_.length == multipliers.length); uint256 count = durations_.length; for (uint256 i; i < count; ++i) { uint256 duration = durations_[i]; + require(multipliers[i] != 0); require(duration <= uint256(18250 days), "INVALID_DURATION"); emit LockPeriodSet(duration, bonusMultiplierOf[duration] = multipliers[i]); } }
#0 - deluca-mike
2022-01-05T08:32:47Z
If there are more multipliers
than durations
, then the extra multipliers
are ignored. If there are more durations
than multipliers
, then the function will revert anyway. Further, if the admin did make a mistake, they can just call the function again. This is not a risk at all, though.
However, what we agree on is a check that durations_.length > 0
to ensure that the function doesn't succeed and do nothing but waste gas, without the wallet picking it up.
#1 - deluca-mike
2022-01-13T21:52:25Z
Custom error reversion when array is empty, in release candidate contract.