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: 65/96
Findings: 1
Award: $31.59
π Selected for report: 0
π Solo Findings: 0
π 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
31.5862 USDC - $31.59
If the NibblVault.sell
function is called with the wrong parameters and no _to
address is given, the counterpart of ether that must be sent to the user will be lost, acting the named function as an ether sink.
The NibblVault.buy
function calls the _mint
method which has a built in require
statement that prevents having the address(0)
as the recipient of the minted tokens.
The scenario is different with the NibblVault.sell
. The _burn
method does not use the _to
parameter which is an input for the further safeTransferETH(_to, _saleReturn)
call, in the end of the function in question, which performs a low level ether transfer.
In every supply state of the token, value can be irreversibly lost. There is no safe case between the scenario where the sell amount estimation is made via _sellPrimaryCurve
or _sellSecondaryCurve
because the return of either functions is the forwarded effective ETH value, having the _to
parameter constant for each call.
The following scenarios may happen:
Scenario A:
NibblVault.sell
.address(0)
Scenario B:
Based on the given testing beta, the swap is performed automatically and no input prompt is given to the user to specify the _to
parameter while calling NibblVault.sell
. The ether counterpart is automatically sent to the msg.sender
. This means that a development error of the Web3 application which misses the third parameter may also convey in a lost of funds.
Both scenarios (primary and secondary curve) were tested with two scripts on the same test framework given by the development team, the return values are console logged:
it("Will lose ether on primary curve", async function () { const _buyAmount = ethers.utils.parseEther("1"); const _feeTotal = constants.FEE_ADMIN.add(constants.FEE_CURATOR).add(constants.FEE_CURVE); const _initialSecondaryBalance = await vaultContract.secondaryReserveBalance(); const _initialPrimaryBalance = await vaultContract.primaryReserveBalance(); const _buyAmountWithFee = _buyAmount.sub(_buyAmount.mul(_feeTotal).div(constants.SCALE)); const _purchaseReturn = await mintTokens(testBancorFormula, constants.initialTokenSupply, constants.initialPrimaryReserveBalance, constants.primaryReserveRatio, _buyAmountWithFee); let _initialBalanceFactory = await admin.provider.getBalance(vaultFactoryContract.address); let _newSecBalance = _initialSecondaryBalance.add((_buyAmount.mul(constants.FEE_CURVE)).div(constants.SCALE)); await vaultContract.connect(buyer1).buy(_purchaseReturn, await buyer1.getAddress(), { value: _buyAmount }); // ------------------Tokens Bought---------------- // Sell Tokens const _feeAccruedInitial = await vaultContract.feeAccruedCurator(); const _sellAmount = _purchaseReturn.div(2); //Only selling half the amount bought const _sellReturn = await burnTokens(testBancorFormula, constants.initialTokenSupply.add(_purchaseReturn), _initialPrimaryBalance.add(_buyAmountWithFee), constants.primaryReserveRatio, _sellAmount); const _sellReturnWithFee = _sellReturn.sub(_sellReturn.mul(_feeTotal).div(constants.SCALE)); _initialBalanceFactory = await admin.provider.getBalance(vaultFactoryContract.address); console.log(`Initial Address(0) ETH: ${ethers.utils.formatEther(await ethers.provider.getBalance(ethers.constants.AddressZero))}`); console.log(`Initial Seller tokens: ${ethers.utils.formatEther(await vaultContract.balanceOf(buyer1.getAddress()))}`); await vaultContract.connect(buyer1).sell(_sellAmount, _sellReturnWithFee, ethers.constants.AddressZero); console.log("---") console.log(`Final Address(0) ETH: ${ethers.utils.formatEther(await ethers.provider.getBalance(ethers.constants.AddressZero))}`); console.log(`Final Seller tokens: ${ethers.utils.formatEther(await vaultContract.balanceOf(buyer1.getAddress()))}`); }) it("Will lose ether on secondary curve", async function () { const _sellAmount = (constants.initialTokenSupply).div(5); let _balanceAddr1 = await addr1.provider.getBalance(await addr1.getAddress()); const _expectedSaleReturn = await burnTokens(testBancorFormula, constants.initialTokenSupply, constants.initialSecondaryReserveBalance, constants.initialSecondaryReserveRatio, _sellAmount); const _expectedSaleReturnWithFee = _expectedSaleReturn.sub(_expectedSaleReturn.mul(constants.FEE_SECONDARY_CURVE).div(constants.SCALE)); console.log(`Initial Address(0) ETH: ${ethers.utils.formatEther(await ethers.provider.getBalance(ethers.constants.AddressZero))}`); console.log(`Initial Seller tokens: ${ethers.utils.formatEther(await vaultContract.balanceOf(curator.getAddress()))}`); await vaultContract.connect(curator).sell(_sellAmount, _expectedSaleReturnWithFee, ethers.constants.AddressZero); console.log("---") console.log(`Final Address(0) ETH: ${ethers.utils.formatEther(await ethers.provider.getBalance(ethers.constants.AddressZero))}`); console.log(`Final Seller tokens: ${ethers.utils.formatEther(await vaultContract.balanceOf(curator.getAddress()))}`); })
The results given by both simulations are the following:
Curve / State | Before Sell Address(0) ETH | After Sell Address(0) ETH | Before Sell Seller Tokens Balance | After Sell Seller Tokens |
---|---|---|---|---|
Primary Curve | 0 | 0.49 | 9,683 | 4,841 |
Secondary Curve | 0 | 8.84 | 1,000,000 | 800,000 |
On both scenarios the address(0)
increased its ether balance and also the token balance of the user decreased.
This issue has a chance to happen every time an user wants to call NibblVault.sell
for every vault generated (one for each fractionalized NFT) which yields in an extremely high likelihood (the probability is exponential because every NFT has its own contract with the same implementation and an user can interact with several fractionalized NFTs). Regarding the impact, each successfully sent transaction with the _to
parameter as the address(0)
will irreversibly lose funds. Although this is not a properly executed attack, the combination of likelihood of happening and its potential impact convey this to be a considerable vulnerability. The given arguments and final risk level determination are subject to change by the criteria of the judges.
Even if both scenarios (A, B) are not attacks because of having a straightforward non-conflictive solution to this problem, it is important to prevent having further issues with how users interact with the core functions of each generated NFT vault smart contract.
A non-conflictive mitigation can be done in order to reduce the likelihood of failure on off-chain implementations (such as frontend development or user interaction).
The only internal call of NibblVault.safeTransferETH
which parameters can be manipulated is by executing NibblVault.sell
.
There are two possible patches to this issue:
NibblVault.safeTransferETH(_to, _amount)
that _to
is not the address(0)
.NibblVault.sell(_amtIn, _minAmtOut, _to)
that _to
is not the address(0)
. This option leaves open the chance to send ether to the address(0)
if NibblVault.safeTransferETH(_to, _amount)
is called with _to
as the address(0)
.#0 - GalloDaSballo
2022-06-26T22:08:02Z
Lack of address 0 validation, historically Low Severity, being used for High severity finding
#1 - HardlyDifficult
2022-07-01T00:54:22Z
This is a very detailed and well explained report. I agree that checking for != address(0) is a worthwhile addition.
Validating the input field is non-zero is a nice to have. It's effectively not much different than withdrawing and then using a standard transfer to address(0), or making a simple typo when inserting the expected address - which we cannot guard against in the contract. Lowing to Low risk and converting this into a QA report for the warden.
#2 - HardlyDifficult
2022-07-03T15:24:45Z
#3 - HardlyDifficult
2022-07-03T15:26:29Z
#4 - HardlyDifficult
2022-07-04T15:06:34Z
Good low risk suggestions with detailed reports.