Platform: Code4rena
Start Date: 07/07/2022
Pot Size: $75,000 USDC
Total HM: 32
Participants: 141
Period: 7 days
Judge: HardlyDifficult
Total Solo HM: 4
Id: 144
League: ETH
Rank: 26/141
Findings: 4
Award: $581.90
🌟 Selected for report: 0
🚀 Solo Findings: 0
If the vault wants to migrate to a new set of permissions, one of the fractional holders can create a proposal and other holders can show the support(vote) by sending their fractions and ether.
When a targetPrice
is reached, the Migration contract can start a buyout to resolve the outcome. But there is an error in the Migration contract
which enables someone to sent their contribution, thus join
ing the proposal. And immediately he can withdraw it without affecting the state variables
which are used for determining whether the target price of the proposal has reached.
Alice proposes a new migration with the propose
function with a _targetPrice of 1 ether and newFractionSupply of 20000. The current vault has a totalSupply
of 15000.
Alice joins this proposal via join
function with 0.5 ether and 5000 fractions. The state variables look like this now:
proposal.totalEth = 0.5; userProposalEth[_proposalId][Alice] = 0.5; proposal.totalFractions = 5000; userProposalFractions[_proposalId][Alice] = 5000;
If the commit
function is called now, the currentPrice will be calculate as follows:
currentPrice = (proposal.totalEth * 100) / (100 - ((proposal.totalFractions * 100) / IVaultRegistry(registry).totalSupply(_vault)));
so currentPrice = (0.5 * 100) / (100 - 5000*100 / 15000) = 50 / (100 - 33) = 50 / 67 = 0.0.
At the moment the currentPrice
is lower than the targetPrice
and so commit will not go through.
if (currentPrice > proposal.targetPrice)
Code link
Now Bob joins the same proposal via join
function with another 0.5 ether and 5000 fractions. The state variables look like this now:
proposal.totalEth = 0.5 + 0.5 = 1; userProposalEth[_proposalId][Alice] = 0.5; userProposalEth[_proposalId][Bob] = 0.5; proposal.totalFractions = 5000 + 5000 = 10000; userProposalFractions[_proposalId][Alice] = 5000; userProposalFractions[_proposalId][Bob] = 5000;
If the commit
function is called again, then the currentPrice is calculated as follows:
currentPrice = (proposal.totalEth * 100) / (100 - ((proposal.totalFractions * 100) / IVaultRegistry(registry).totalSupply(_vault)));
so currentPrice = (1.0 * 100) / (100 - 10000*100 / 15000)
= 100 / (100 - 66) = 100 / 34 = 2.0.
Which is greater than the targetPrice of 1.0 and commit
will go through.
So far everything is good.
But consider another scenario. Alice starts a proposal , Alice joins it and Bob joins it, just as mentioned in the previous scenario.
But before the commit
is called Bob changes his mind and calls the withdrawContribution
function. He withdraws all his eth and fractions back to his account. This call to withdrawContribution
will NOT revert because the buyout current State is still INACTIVE and newVault is still address(0). So the below revert statement is not reached when Bob tries to withdraw his assets.
(, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); if ( current != State.INACTIVE || migrationInfo[_vault][_proposalId].newVault != address(0) ) revert NoContributionToWithdraw();
In the withdrawContribution
function, state variables related to Bob are reset like shown below:
userProposalFractions[_proposalId][msg.sender] = 0; userProposalEth[_proposalId][msg.sender] = 0;
But the proposal.totalEth
and proposal.totalFractions
are not modified.
So after Bob withdraws his assets, the state variables will have the following values:
proposal.totalEth = 1; //should be 0.5 userProposalEth[_proposalId][Alice] = 0.5; userProposalEth[_proposalId][Bob] = 0; proposal.totalFractions = 10000; //should be 5000 userProposalFractions[_proposalId][Alice] = 5000; userProposalFractions[_proposalId][Bob] = 0;
And now if commit
function is called, then the currentPrice which is calculated as per the following formula will have a value of 2.0(which is greater than the target price of 1.0) and proposal will go through.
currentPrice = (proposal.totalEth * 100) / (100 - ((proposal.totalFractions * 100) / IVaultRegistry(registry).totalSupply(_vault)));
VSCode
Update the proposal.totalEth
and proposal.totalFractions
state variables in the withdrawContribution
function as follows:
proposal.totalFractions -= userFractions; proposal.totalEth -= userEth;
Even though the contract can be tricked to start a buyout process, the buyout has to be successful (that is, enter the SUCCESS state) for the vault to be migrated with new permissions. So trickster holder still needs other holder's support for the migration.
#0 - 0x0aa0
2022-07-21T15:35:52Z
Duplicate of #375
🌟 Selected for report: berndartmueller
Also found by: 0xA5DF, 0xSky, 0xsanson, ElKu, Kumpa, Treasure-Seeker, TrungOre, cccz, cryptphi, hansfriese, jonatascm, kenzo, minhquanym, s3cunda, shenwilly, smiling_heretic, zzzitron
41.4866 USDC - $41.49
The cash
function in the Buyout.sol contract has an error which doesn't allow all fraction holders to withdraw eth after a successful buyout. The following points could be noted about this issue:
The eth which needs to be sent to the holder of fractions is calculated in the following formula:
uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance);
The ethBalance
is assigned at line 251 and points to the final eth present in the buyout pool just after the buyout has ended.
(, , State current, , uint256 ethBalance, ) = this.buyoutInfo(_vault);
The issue is that after a holder is sent his share of eth, the ethBalance
in the pool is not updated.
Let's take an example. Suppose after the buyout, the pool has 5000 eth. Which has to be shared among two holders each holding 2000 fractions(holder A) and 3000 fractions(holder B) respectively.
When holder A cashes out, his 2000 fractions are burned first and then buyoutShare
is calculated. Notice that totalSupply
is only 3000, because 2000 fractions of holder A was just burned. so 5000-2000=3000.
buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); = (2000 * 5000) / (3000 + 2000) = 10000 / 5000 = 2000 eth will be sent to holder A. Which is correct.
At this point the pool has only 5000-2000=3000 eth, though the buyoutInfo(_vault).ethBalance
will still show a value of 5000.
Next, holder B wants to cash out. His 2000 fractions are burned first and then buyoutShare
is calculated. Notice that totalSupply
is 0, because 3000 fractions of holder A was just burned. so 3000-3000=0.
buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); = (3000 * 5000) / (0 + 3000) = 15000 / 3000 = 5000.
Which means the cash
function will try to send 5000 eth to holder B. Which is incorrect. As the contract pool doesn't have this much eth, the transaction will revert, causing holder B's funds to be forever stuck in the pool.
VSCode.
This can be resolved by updating the pool ethBalance in the cash
function as follows:
buyoutInfo[_vault].ethBalance = ethBalance - buyoutShare;
#0 - mehtaculous
2022-07-21T18:12:33Z
Duplicate of #440
🌟 Selected for report: xiaoming90
Also found by: 0x1f8b, 0x29A, 0x52, 0xA5DF, 0xDjango, 0xNazgul, 0xNineDec, 0xf15ers, 0xsanson, 0xsolstars, 242, 8olidity, Amithuddar, Aymen0909, Bnke0x0, BowTiedWardens, David_, Deivitto, ElKu, Funen, Hawkeye, IllIllI, JC, Kaiziron, Keen_Sheen, Kthere, Kulk0, Kumpa, Lambda, MEP, ReyAdmirado, Rohan16, Ruhum, Sm4rty, TomJ, Tomio, Treasure-Seeker, TrungOre, Tutturu, Viksaa39, Waze, _Adam, __141345__, ak1, apostle0x01, asutorufos, async, ayeslick, aysha, bbrho, benbaessler, berndartmueller, c3phas, cccz, chatch, cloudjunky, codexploder, cryptphi, delfin454000, dipp, durianSausage, dy, exd0tpy, fatherOfBlocks, hake, hansfriese, horsefacts, hubble, joestakey, jonatascm, kebabsec, kenzo, kyteg, mektigboy, neumo, oyc_109, pashov, pedr02b2, peritoflores, rajatbeladiya, rbserver, robee, rokinot, s3cunda, sach1r0, sahar, sashik_eth, scaraven, shenwilly, simon135, sorrynotsorry, sseefried, svskaushik, unforgiven, z3s, zzzitron
61.9379 USDC - $61.94
if (owner != msg.sender) revert NotOwner(owner, msg.sender);
is used multiple times in Vault.sol. Create a modifier for it to avoid redundancy.
Functions where its used are:
Instance in Vault.sol
:
Migration.sol
.Functions where an emit statement is missing are:
🌟 Selected for report: joestakey
Also found by: 0x1f8b, 0x29A, 0xA5DF, 0xKitsune, 0xNazgul, 0xNineDec, 0xalpharush, 0xkatana, 0xsanson, 0xsolstars, 8olidity, Avci, Bnke0x0, BowTiedWardens, Chom, Deivitto, ElKu, Fitraldys, Funen, IllIllI, JC, Kaiziron, Lambda, Limbooo, MEP, NoamYakov, PwnedNoMore, RedOneN, ReyAdmirado, Rohan16, Ruhum, Saintcode_, Sm4rty, TomJ, Tomio, TrungOre, Tutturu, Waze, _Adam, __141345__, ajtra, apostle0x01, asutorufos, benbaessler, brgltd, c3phas, codexploder, cryptphi, delfin454000, dharma09, djxploit, durianSausage, fatherOfBlocks, giovannidisiena, gogo, horsefacts, hrishibhat, hyh, ignacio, jocxyen, jonatascm, karanctf, kebabsec, kyteg, m_Rassska, mektigboy, oyc_109, pedr02b2, rbserver, robee, rokinot, sach1r0, sashik_eth, simon135, slywaters
37.7866 USDC - $37.79
address public implementation;
In IMigration.sol
, the struct Proposal can be reshaped so that bool fractionsMigrated;
is moved to under the isCommited
declaration. This will save you one storage slot.
uint256 buyoutPrice = (msg.value * 100) / (100 - ((depositAmount * 100) / totalSupply)); uint256 fractionPrice = buyoutPrice / totalSupply;
These equations from Buyout.sol can be rearranged and gas optimized as follows:
uint256 fractionPrice = msg.value / (totalSupply - depositAmount); uint256 buyoutPrice = fractionPrice * totalSupply;
Migration.sol
, function _calculateTotal can be simplified. Assuming scalar is used to keep the precision loss to a minimum we can just rewrite it as:return (_totalEth * _lastTotalSupply) / (_lastTotalSupply - _totalFractions);
If the _scalar need to be used it has to be a high value and not something smaller like 100.