Platform: Code4rena
Start Date: 21/06/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 96
Period: 3 days
Judge: HardlyDifficult
Total Solo HM: 5
Id: 140
League: ETH
Rank: 1/96
Findings: 4
Award: $3,657.41
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: xiaoming90
Calling NibblVault.updateTWAP
function will change the state of the system. It will cause the TWAP to be updated and buyout to be rejected in certain condition.
When the system is in Pause
mode, the system state should be frozen. However, it was possible someone to call the NibblVault.updateTWAP
function during the Pause
mode, thus making changes to the system state.
/// @notice Updates the TWAV when in buyout /// @dev TWAV can be updated only in buyout state function updateTWAV() external override { require(status == Status.buyout, "NibblVault: Status!=Buyout"); uint32 _blockTimestamp = uint32(block.timestamp % 2**32); if (_blockTimestamp != lastBlockTimeStamp) { _updateTWAV(getCurrentValuation(), _blockTimestamp); _rejectBuyout(); //For the case when TWAV goes up when updated externally } }
Ensure that the NibblVault.updateVault
function cannot be called when the system is in Pause
mode.
Add the whenNotPaused
modifier to the function.
/// @notice Updates the TWAV when in buyout /// @dev TWAV can be updated only in buyout state function updateTWAV() external override whenNotPaused { require(status == Status.buyout, "NibblVault: Status!=Buyout"); uint32 _blockTimestamp = uint32(block.timestamp % 2**32); if (_blockTimestamp != lastBlockTimeStamp) { _updateTWAV(getCurrentValuation(), _blockTimestamp); _rejectBuyout(); //For the case when TWAV goes up when updated externally } }
#0 - mundhrakeshav
2022-06-26T11:42:30Z
#56
#1 - HardlyDifficult
2022-07-04T14:16:26Z
#56
It's not clear to me how this is a dupe of #56
This is a valid concern and potentially a change worth making.
It will cause the TWAP to be updated and buyout to be rejected
This makes me think Medium risk is correct here. In this scenario a buyout could be rejected without allowing other users to challenge that -- seemingly breaking one of the benefits behind using Twap for this logic.
🌟 Selected for report: xiaoming90
Also found by: hyh
1052.705 USDC - $1,052.70
The current TWAV implementation consists of an array of 4 observations/valuations called twavObservations
. Whenever, the new valuation is updated, the new cumulative valuation will be appended to the twavObservations
array and the oldest observation/valuation will be removed from the twavObservations
array.
Description of current TWAV implementation can be found at https://github.com/NibblNFT/nibbl-smartcontracts#twavsol
- Time-weighted average valuation
- Uses an array of length 4 which stores cumulative valuation and timestamp.
- TWAV is calculated between the most and least recent observations recorded in the array.
- TWAV array is updated only when the system is in buyout state. In case of buyout rejection, the array is reset.
/// @notice current index of twavObservations index uint8 public twavObservationsIndex; uint8 private constant TWAV_BLOCK_NUMBERS = 4; //TWAV of last 4 Blocks uint32 public lastBlockTimeStamp; /// @notice record of TWAV TwavObservation[TWAV_BLOCK_NUMBERS] public twavObservations; /// @notice updates twavObservations array /// @param _blockTimestamp timestamp of the block /// @param _valuation current valuation function _updateTWAV(uint256 _valuation, uint32 _blockTimestamp) internal { uint32 _timeElapsed; unchecked { _timeElapsed = _blockTimestamp - lastBlockTimeStamp; } uint256 _prevCumulativeValuation = twavObservations[((twavObservationsIndex + TWAV_BLOCK_NUMBERS) - 1) % TWAV_BLOCK_NUMBERS].cumulativeValuation; twavObservations[twavObservationsIndex] = TwavObservation(_blockTimestamp, _prevCumulativeValuation + (_valuation * _timeElapsed)); //add the previous observation to make it cumulative twavObservationsIndex = (twavObservationsIndex + 1) % TWAV_BLOCK_NUMBERS; lastBlockTimeStamp = _blockTimestamp; }
Within the NibblVault
contract, the _updateTWAV
function will be called whenever the following events happen during the buyout period:
NibbleVault.buy()
and NibbleVault.Sell()
functions are calledNibbleVault.initiateBuyout
function is calledNibbleVault.updateTWAV
function is calledPer the code and comment of _getTwav()
function, the function will return the TWAV of the last four (4) blocks. This function can be called by anyone.
/// @notice returns the TWAV of the last 4 blocks /// @return _twav TWAV of the last 4 blocks function _getTwav() internal view returns(uint256 _twav){ if (twavObservations[TWAV_BLOCK_NUMBERS - 1].timestamp != 0) { uint8 _index = ((twavObservationsIndex + TWAV_BLOCK_NUMBERS) - 1) % TWAV_BLOCK_NUMBERS; TwavObservation memory _twavObservationCurrent = twavObservations[(_index)]; TwavObservation memory _twavObservationPrev = twavObservations[(_index + 1) % TWAV_BLOCK_NUMBERS]; _twav = (_twavObservationCurrent.cumulativeValuation - _twavObservationPrev.cumulativeValuation) / (_twavObservationCurrent.timestamp - _twavObservationPrev.timestamp); } }
Time weighted average valuation (TWAV) is supposed to be the average value of a security over a specified time (e.g. 15 minutes, 1 hour, 24 hours). However, based on the above implementation of the _getTwav
function, it is not the average value of a security over a specific time.
A user could call the updateTWAV
function to add the new valuation/observation to the twavObservations
array each time a new Ethereum block is mined. As such, the current implementation becomes the average value of a security over a specific number of observations (in this case 4 observations), thus it can be considered as Observation weighted average valuation (OWAV).
There is a fundamental difference between TWAV and OWAV.
In Ethereum, the average block time is around 15 seconds, so the time to take to mine 4 blocks will be 1 minute. As such, in term of TWAV, the current implementation only have a period of 1 minute, which is too short to prevent price manipulation.
The following shows an example where it is possible to buy tokens→ increase the valuation above the rejection valuation→ reject the buyout→ dump the tokens within 1 minute:
Assume that a buyer has triggered a buyout on the vault/NFT, and the buyout rejection price is 120 ETH and the current valuation is 100 ETH. Further assume that all elements in the twavObservations
array have already been populated.
Note: Fees are ignored to illustrate the issue.
buy
function to increase the current valuation to 120 ETH attempting to reject the buyout.updateTWAV
function. The current valuation (120 ETH) will be replaced the first element in twavObservations
array.updateTWAV
function. The current valuation (120 ETH) will be replaced the second element in twavObservations
array.updateTWAV
function. The current valuation (120 ETH) will be replaced the third element in twavObservations
array.sell
function to sell/dump all his shares. Within the sell
function, _updateTWAV
will be first called, thus the current valuation (120 ETH) will be replaced the fourth element in twavObservations
array. Then, the _rejectBuyout()
will be called, and the _getTwav
function will be triggered. At this point, the TWAV valuation is finally 120 ETH, thus the buyout is rejected. Subseqently, attacker's shares are burned, and attacker get back his funds.Since attacker could perform the above attack within 1 minute, it is very unlikely that the attackers will lose money to arbitrageurs as it takes some time for the arbitrageurs to notice such an opportunity.
Attacker could also front-run or set a higher gas fee to ensure that their transaction get mined in the next block to minimize the attack window period.
Buyout can be easily rejected by attackers
Implement a proper TWAV that provides the average value of a security over a specified time. The time period/windows of the TWAV must be explicitly defined (e.g. 15 minutes, 1 hour, 24 hours) in the contract.
There are trade offs when choosing the length of the period of time to calculate a TWAP. Longer periods are better to protect against price manipulation, but come at the expense of a slower, and potentially less accurate, price. Thus, the team should determine the optimal period.
Consider referencing the popular Uniswap V2 TWAP design (https://docs.uniswap.org/protocol/V2/concepts/core-concepts/oracles)
#0 - sseefried
2022-06-25T00:58:17Z
I had a Low Risk (#142) associated with _getTWAV
too. I'm not sure it even averages over 4 observations.
#1 - Picodes
2022-06-26T11:16:59Z
Some comments, as I personally don't think it's an high security issue:
the primary goal of the TWAV here is to avoid manipulations within the same block. As long as it's over multiple blocks, the attackers takes a risk as there could be arbitrages, so the attack risk is mitigated.
the main assumption of the issue is: "it takes some time for the arbitrageurs to notice such an opportunity, and the 4 block window is too short." which seems false when you check on chain data: arbitrageurs are frequently super quick to react as it's their primary job: the first to check an opportunity takes it.
#2 - HardlyDifficult
2022-07-03T13:00:18Z
Great summary @Picodes , I agree with both points.
Lowering this to a Medium risk. I may be incorrect, but it seems a secondary goal of Twap is price smoothing to avoid scenarios like what was outlined here. If that's correct then this impacts the function of the protocol and the recommendation is a good consideration.
#3 - dmitriia
2022-07-04T23:27:37Z
Same here, TWAP is essential to the protocol, while the ability to manipulate the price during last minute breaks the core logic of price discovery by greatly reducing the number of participants. The rationale that 4 blocks are enough and arbitrage is generally quick is sufficient for mainstream cases only, all other trading is at risk of direct manipulation, which is existential risk for the protocol. Can't see why the team pressing for medium here.
#4 - HardlyDifficult
2022-07-04T23:33:35Z
Same here, TWAP is essential to the protocol, while the ability to manipulate the price during last minute breaks the core logic of price discovery by greatly reducing the number of participants. The rationale that 4 blocks are enough and arbitrage is generally quick is sufficient for mainstream cases only, all other trading is at risk of direct manipulation, which is existential risk for the protocol. Can't see why the team pressing for medium here.
The stated goal of using Twap in their documentation is the same as above, to prevent same-block attacks. It seems the concern is the implicit behavior expected from using a "time weighted" variable. Personally I agree this seems like an area they may want to revisit. However the system behaves correctly and there is a tiny window for bots to respond.
@mundhrakeshav @Picodes would you mind elaborating here as well?
#5 - mundhrakeshav
2022-07-05T04:50:22Z
Yeah, makes sense. We do plan to increase the array length.
#6 - Picodes
2022-07-05T07:19:42Z
I fully agree with both of you: it'd be indeed better to increase the array length to increase the robustness, but I still feel this is a medium issue as the system works as intended
#7 - dmitriia
2022-07-05T08:43:52Z
The system formally/technically works as intended, but being a price discovery engine, where participants having incentives to try to determine the NFT price, it will not be used if the window for such a discovery is mere 4 blocks. Formally the bots will have space to react. Realistically it will happen in the most mainstream cases only, when price discovery isn't much needed. I.e. exactly when the system can bring in something new, adding a mechanics for 'slow' usual users to add price information, it will not work as the set of participants who can add the information to the metric (react to move the price) is shrunk by a tiny reaction window. Who will take part in a price discovery game knowing that last minute bots are always can surface? Quite not everyone. This reduces the traction, and so the amount of information brought in by the system, as this is the users who bring in the additional signal, and ultimately it will be a piece of well known NFTs synched with main market with bots, adding no value to it. I.e. the system will work technically, but economically it will not make much sense, so will not be widely used by market actors and can end up provide little value to broad market. This is what I mean by existential risk, which is, of course high.
I just feel that here and in general in C4 economic issues are tend to be underestimated, while having one usually tend to be a game changing aspect for a project.
#8 - IllIllI000
2022-07-05T11:00:10Z
@HardlyDifficult If the sponsor had used the term OWAV rather than TWAV, would this still be a medium-severity issue? It seems as though they knew the behavior they wanted (at least four user interactions where the average price is above the threshold) and just used the wrong term to describe it. I didn't file this issue because it seemed that way. The screenshot in this issue shows that they're interested in interactions, not duration of time https://github.com/code-423n4/2022-06-nibbl-findings/issues/144. It's possible they confirmed the issue because they weren't aware that comment vs code consistency issues are usually categorized as low risk
#9 - HardlyDifficult
2022-07-05T15:06:32Z
Great points @dmitriia and @IllIllI000 ! Both are compelling. This is certainly a grey area..
Given how significantly this impacts how users would potentially view and interact with the system, I'm inclined to leave this a Medium risk instead of downgrading to Low, falling under "the function of the protocol or its availability could be impacted".
And since this was intentional design and there is a window for bots to respond, I don't feel that High risk is justified.
I'm happy to continue here or on Discord, and would love more input if others want to chime in on the severity here. I don't feel strongly but do think that Medium is the best fit here.
230.2266 USDC - $230.23
Per the documentation, the admin and curator fees are charged when buying on the secondary curve.
Whenever someone mints and burns tokens on the bonding curve, they need to pay some trading fees which is accrued in ETH
Within the NibblVault.buy
function, there are three (3) trading scenarios:
Trade/Buy in primary curve
Trade/Buy in secondary curve
Trade/Buy in both primary and secondary curve.
However, it was observed that in the third/last scenario where the trading/buying happens on both the primary and secondary curve, the admin and curator fee is not charged when trading/buying on the secondary curve.
At Line 319 and 320, it attempts perform some gas optimization by directly calculate the _purchaseReturn
and update the secondaryReserveBalance
on the secondary curve, but it did not take into consideration of the admin and curator fee for the secondary curve.
function buy(uint256 _minAmtOut, address _to) external override payable notBoughtOut lock whenNotPaused returns(uint256 _purchaseReturn) { ..SNIP.. uint256 _initialTokenSupply = initialTokenSupply; uint256 _totalSupply = totalSupply(); if (_totalSupply >= _initialTokenSupply) { _purchaseReturn = _buyPrimaryCurve(msg.value, _totalSupply); } else { uint256 _lowerCurveDiff = getMaxSecondaryCurveBalance() - secondaryReserveBalance; if (_lowerCurveDiff >= msg.value) { _purchaseReturn = _buySecondaryCurve(msg.value, _totalSupply); } else { //Gas Optimization _purchaseReturn = _initialTokenSupply - _totalSupply; secondaryReserveBalance += _lowerCurveDiff; // _purchaseReturn = _buySecondaryCurve(_to, _lowerCurveDiff); _purchaseReturn += _buyPrimaryCurve(msg.value - _lowerCurveDiff, _totalSupply + _purchaseReturn); } } require(_minAmtOut <= _purchaseReturn, "NibblVault: Return too low"); _mint(_to, _purchaseReturn); emit Buy(msg.sender, _purchaseReturn, msg.value); }
Admin fee and curator fee are not update correctly.
Ensure that admin and curator fees are charged when trading/buying in the secondary curves.
Consider using the _buySecondaryCurve
function to perform any buying/trading on secondary curve as it will automatically charge the relevant fees and it is less prone to error.
function buy(uint256 _minAmtOut, address _to) external override payable notBoughtOut lock whenNotPaused returns(uint256 _purchaseReturn) { ..SNIP.. uint256 _initialTokenSupply = initialTokenSupply; uint256 _totalSupply = totalSupply(); if (_totalSupply >= _initialTokenSupply) { _purchaseReturn = _buyPrimaryCurve(msg.value, _totalSupply); } else { uint256 _lowerCurveDiff = getMaxSecondaryCurveBalance() - secondaryReserveBalance; if (_lowerCurveDiff >= msg.value) { _purchaseReturn = _buySecondaryCurve(msg.value, _totalSupply); } else { - //Gas Optimization - _purchaseReturn = _initialTokenSupply - _totalSupply; - secondaryReserveBalance += _lowerCurveDiff; + _purchaseReturn = _buySecondaryCurve(_lowerCurveDiff, _totalSupply); - // _purchaseReturn = _buySecondaryCurve(_to, _lowerCurveDiff); _purchaseReturn += _buyPrimaryCurve(msg.value - _lowerCurveDiff, _totalSupply + _purchaseReturn); } } require(_minAmtOut <= _purchaseReturn, "NibblVault: Return too low"); _mint(_to, _purchaseReturn); emit Buy(msg.sender, _purchaseReturn, msg.value); }
#0 - HardlyDifficult
2022-07-03T12:36:51Z
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, 0x52, 0xNazgul, 0xNineDec, 0xc0ffEE, 0xf15ers, 0xkatana, BowTiedWardens, Chom, ElKu, Funen, GalloDaSballo, JC, JMukesh, JohnSmith, Lambda, Limbooo, MadWookie, MiloTruck, Nethermind, Noah3o6, Nyamcil, Picodes, PwnedNoMore, Randyyy, RoiEvenHaim, SmartSek, StErMi, Tadashi, TerrierLover, TomJ, Tomio, Treasure-Seeker, UnusualTurtle, Varun_Verma, Wayne, Waze, _Adam, apostle0x01, asutorufos, berndartmueller, c3phas, catchup, cccz, cloudjunky, codexploder, cryptphi, defsec, delfin454000, dipp, ellahi, exd0tpy, fatherOfBlocks, hansfriese, hyh, joestakey, kebabsec, kenta, masterchief, minhquanym, naps62, oyc_109, pashov, peritoflores, reassor, rfa, robee, sach1r0, saian, sashik_eth, shenwilly, simon135, slywaters, sorrynotsorry, sseefried, unforgiven, xiaoming90, ych18, zuhaibmohd, zzzitron
35.144 USDC - $35.14
The following function did not check if the _assetAddresses
array and _assetIDs
array are of the same length.
///@notice withdraw multiple ERC721s /// @param _assetAddresses the addresses of assets to be unlocked /// @param _assetIDs the IDs of assets to be unlocked /// @param _to the address where unlocked NFT will be sent function withdrawMultipleERC721(address[] memory _assetAddresses, uint256[] memory _assetIDs, address _to) external override boughtOut { require(msg.sender == bidder,"NibblVault: Only winner"); for (uint256 i = 0; i < _assetAddresses.length; i++) { IERC721(_assetAddresses[i]).safeTransferFrom(address(this), _to, _assetIDs[i]); } }
Implement check to ensure that _assetAddresses
array and _assetIDs
array are of the same length.
When the safeTransferETH
function is called, it is possible that the recipient can re-enter back to the function due to lack of re-entrancy guard.
function initiateBuyout() external override payable whenNotPaused returns(uint256 _buyoutBid) { require(block.timestamp >= minBuyoutTime, "NibblVault: minBuyoutTime < now"); require(status == Status.initialized, "NibblVault: Status!=initialized"); _buyoutBid = msg.value + (primaryReserveBalance - fictitiousPrimaryReserveBalance) + secondaryReserveBalance; //_buyoutBid: Bid User has made uint256 _currentValuation = getCurrentValuation(); require(_buyoutBid >= _currentValuation, "NibblVault: Bid too low"); // buyoutValuationDeposit = _currentValuation - ((primaryReserveBalance - fictitiousPrimaryReserveBalance) + secondaryReserveBalance); buyoutValuationDeposit = msg.value - (_buyoutBid - _currentValuation); bidder = msg.sender; buyoutBid = _currentValuation; // buyoutBid: Bid can only be placed at current valuation buyoutRejectionValuation = (_currentValuation * (SCALE + REJECTION_PREMIUM)) / SCALE; buyoutEndTime = block.timestamp + BUYOUT_DURATION; status = Status.buyout; _updateTWAV(_currentValuation, uint32(block.timestamp % 2**32)); if (_buyoutBid > _currentValuation) { safeTransferETH(payable(msg.sender), (_buyoutBid - _currentValuation)); } emit BuyoutInitiated(msg.sender, _buyoutBid); }
function redeem(address payable _to) external override boughtOut returns(uint256 _amtOut){ uint256 _balance = balanceOf(msg.sender); _amtOut = ((address(this).balance - feeAccruedCurator - totalUnsettledBids) * _balance) / totalSupply(); _burn(msg.sender, _balance); safeTransferETH(_to, _amtOut); }
Implement re-entrancy guard on the affected functions
#0 - mundhrakeshav
2022-06-26T11:56:45Z
Updates happen at end of every function
#1 - HardlyDifficult
2022-07-03T12:42:57Z
#2 - HardlyDifficult
2022-07-03T14:10:03Z
#3 - HardlyDifficult
2022-07-03T14:13:52Z
#4 - HardlyDifficult
2022-07-03T22:51:35Z
#5 - HardlyDifficult
2022-07-04T14:05:29Z
#6 - HardlyDifficult
2022-07-04T14:23:03Z
#7 - HardlyDifficult
2022-07-04T14:25:22Z