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: 19/141
Findings: 6
Award: $1,182.61
🌟 Selected for report: 1
🚀 Solo Findings: 0
117.0966 USDC - $117.10
Buyout's start() now determine fractional token price by dividing native tokens amount by total supply number. Whenever the supply is high enough the precision can be lost, leading to severe losses to buyout proposer as his staked fractional tokens can be valued at zero this way.
Setting the severity to be high as this is principal funds stealing impact with the only precondition of total the fractional tokens supply to be high enough, which isn't restricted in the system.
There is no limitation on the total supply number:
function deployVault( uint256 _fractionSupply, address[] calldata _modules, address[] calldata _plugins, bytes4[] calldata _selectors, bytes32[] calldata _mintProof ) external returns (address vault) { bytes32[] memory leafNodes = generateMerkleTree(_modules); bytes32 merkleRoot = getRoot(leafNodes); vault = IVaultRegistry(registry).create( merkleRoot, _plugins, _selectors ); emit ActiveModules(vault, _modules); _mintFractions(vault, msg.sender, _fractionSupply, _mintProof); }
Which is reasonable, as the system being base level functionality allows for various granularity of fractional tokens.
In the same time fractionPrice is now determined without precision multiplier, i.e. with straightforward division:
uint256 fractionPrice = buyoutPrice / totalSupply;
// Calculates price of buyout and fractions // @dev Reverts with division error if called with total supply of tokens uint256 buyoutPrice = (msg.value * 100) / (100 - ((depositAmount * 100) / totalSupply)); uint256 fractionPrice = buyoutPrice / totalSupply;
Once total suypply is big enough this can lead to substantial relative losses for Bob the buyout proposer:
Bob owns 90% of totalSupply, which is 1e18, and wants to buyout the rest
The only NFT Vault holds is not costly, having market value of 0.98 ETH, Bob's share is valued 0.9 * 0.98 = 0.882 ETH, and he needs to supply 0.1 * 0.98 = 0.098 ETH
Bob's buyoutPrice will be (0.098e18 * 100) / (100 - ((0.9e18 * 100) / 1e18)) = 0.98 ETH
Bob's fractionPrice = buyoutPrice / totalSupply = 0.98e18 / 1e18 = 0
As start() completes, Alice can now run buyFractions() to buy Bob's fractional tokens, paying 0 for the whole Bob's 90% share
Now Alice can wait for the auction to end, the buyout will not be successful as Alice took out the tokens from Vault balance with buyFractions()
Then Alice can initiate another buyout, running start() with 90% of tokens and 0.1 ETH msg.value to avoid the precision pitfall Bob experienced
Alice's buyoutPrice will be (0.1e18 * 100) / (100 - ((0.9e18 * 100) / 1e18)) = 1 ETH
Alice's fractionPrice = buyoutPrice / totalSupply = 1e18 / 1e18 = 1
This way Alice buyout will either succeed or someone else would buy her fractional tokens at even higher valuation, yielding at least 0.882 ETH profit. Observing such a possibility Alice can now make a bot that immediately back runs start() transaction similar to Bob's, so initiator will not have the chance to correct the mispricing when observing the buyoutPrice realized.
Consider adding more precision to the fractionPrice formula and its downstream usage, for example:
+ // @notice Fraction multiplier + uint256 public constant FRACTION_MULTI = 1e18; ... + uint256 fractionPrice = buyoutPrice * FRACTION_MULTI / totalSupply; - uint256 fractionPrice = buyoutPrice / totalSupply;
+ uint256 ethAmount = fractionPrice * _amount / FRACTION_MULTI; - uint256 ethAmount = fractionPrice * _amount;
+ if (msg.value != fractionPrice * _amount / FRACTION_MULTI) revert InvalidPayment(); - if (msg.value != fractionPrice * _amount) revert InvalidPayment();
#0 - stevennevins
2022-07-20T21:05:43Z
2 - Med
#1 - stevennevins
2022-07-21T17:49:14Z
Duplicate of #629
#2 - dmitriia
2022-07-31T00:33:49Z
#629 covers zero fractionPrice case only, the issue is bigger than that as any price will lose precision. I.e. all the findings that describe decimals loss in general aren't duplicates for #629, which is valid, but covers only one corner case.
#3 - HardlyDifficult
2022-08-01T23:38:01Z
440.6759 USDC - $440.68
withdrawContribution() aims to return the funds to Migration participants. However, it uses initial userProposalFractions[_proposalId][msg.sender]
and userProposalEth[_proposalId][msg.sender]
records for withdrawal accounting. Real funds structure is different after Buyout as it allows for exchange between tokens and ETH. This way the system becomes insolvent for some users, having excess funds that will be frozen.
Setting the severity to be high as that's principal fund freeze impact.
Currently withdrawContribution() does not account for the fact that fractional tokens and ETH composition of the contract balance was changed during the Buyout attempt:
/// @notice Retrieves ether and fractions deposited from an unsuccessful migration /// @param _vault Address of the vault /// @param _proposalId ID of the failed proposal function withdrawContribution(address _vault, uint256 _proposalId) external { // Reverts if address is not a registered vault (address token, uint256 id) = IVaultRegistry(registry).vaultToToken( _vault ); if (id == 0) revert NotVault(_vault); // Reverts if caller has no fractional balance to withdraw (, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); if ( current != State.INACTIVE || migrationInfo[_vault][_proposalId].newVault != address(0) ) revert NoContributionToWithdraw(); // Temporarily store user's fractions for the transfer uint256 userFractions = userProposalFractions[_proposalId][msg.sender]; // Updates fractional balance of caller userProposalFractions[_proposalId][msg.sender] = 0; // Withdraws fractional tokens from contract back to caller IFERC1155(token).safeTransferFrom( address(this), msg.sender, id, userFractions, "" ); // Temporarily store user's eth for the transfer uint256 userEth = userProposalEth[_proposalId][msg.sender]; // Udpates ether balance of caller userProposalEth[_proposalId][msg.sender] = 0; // Withdraws ether from contract back to caller payable(msg.sender).transfer(userEth); }
Consider introduction of new balance calculations: each fractional tokens bought out during the Buyout attempt should be replaced with ETH funds received and vice versa.
#0 - stevennevins
2022-07-20T17:11:40Z
Duplicate of #375
🌟 Selected for report: xiaoming90
Also found by: 0x52, Lambda, cccz, codexploder, hyh, kenzo, oyc_109, zzzitron
214.1685 USDC - $214.17
It is possible to withdraw all the assets after Buyout before settleVault() was run and newVault created as asset transfer functions do not check the address.
/// @notice Migrates an ERC-20 token to the new vault after a successful migration /// @param _vault Address of the vault /// @param _proposalId ID of the proposal /// @param _token Address of the ERC-20 token /// @param _amount Transfer amount /// @param _erc20TransferProof Merkle proof for transferring an ERC-20 token function migrateVaultERC20( address _vault, uint256 _proposalId, address _token, uint256 _amount, bytes32[] calldata _erc20TransferProof ) external { address newVault = migrationInfo[_vault][_proposalId].newVault; // Withdraws an ERC-20 token from the old vault and transfers to the new vault IBuyout(buyout).withdrawERC20(
/// @notice Migrates an ERC-721 token to the new vault after a successful migration /// @param _vault Address of the vault /// @param _proposalId ID of the proposal /// @param _token Address of the ERC-721 token /// @param _tokenId ID of the token /// @param _erc721TransferProof Merkle proof for transferring an ERC-721 token function migrateVaultERC721( address _vault, uint256 _proposalId, address _token, uint256 _tokenId, bytes32[] calldata _erc721TransferProof ) external { address newVault = migrationInfo[_vault][_proposalId].newVault; // Withdraws an ERC-721 token from the old vault and transfers to the new vault IBuyout(buyout).withdrawERC721(
/// @notice Migrates an ERC-1155 token to the new vault after a successful migration /// @param _vault Address of the vault /// @param _proposalId ID of the proposal /// @param _token Address of the ERC-1155 token /// @param _id ID of the token /// @param _amount amount to be transferred /// @param _erc1155TransferProof Merkle proof for transferring an ERC-1155 token function migrateVaultERC1155( address _vault, uint256 _proposalId, address _token, uint256 _id, uint256 _amount, bytes32[] calldata _erc1155TransferProof ) external { address newVault = migrationInfo[_vault][_proposalId].newVault; // Withdraws an ERC-1155 token from the old vault and transfers to the new vault IBuyout(buyout).withdrawERC1155(
/// @notice Batch migrates multiple ERC-1155 tokens to the new vault after a successful migration /// @param _vault Address of the vault /// @param _proposalId ID of the proposal /// @param _token Address of the ERC-1155 token /// @param _ids IDs of each token type /// @param _amounts Transfer amounts per token type /// @param _erc1155BatchTransferProof Merkle proof for batch transferring multiple ERC-1155 tokens function batchMigrateVaultERC1155( address _vault, uint256 _proposalId, address _token, uint256[] calldata _ids, uint256[] calldata _amounts, bytes32[] calldata _erc1155BatchTransferProof ) external { address newVault = migrationInfo[_vault][_proposalId].newVault; // Batch withdraws multiple ERC-1155 tokens from the old vault and transfers to the new vault IBuyout(buyout).batchWithdrawERC1155(
Consider checking for the newVault to be set, i.e. add non-zero check.
#0 - stevennevins
2022-07-20T16:26:19Z
I do tend to think that this should generally be handled by the token implementation
ie. Solmate ERC20 implementation would be vulnerable to this while OZ's wouldn't
ERC721 from Solmate and OZ have address(0)
on the to
And for ERC1155, we're using safeTransferFrom
and safeBatchTransferFrom,
which both have the address(0)
checks for OZ and Solmate. So I think the scope is pretty limited here
#1 - HardlyDifficult
2022-08-02T23:53:26Z
🌟 Selected for report: hyh
Also found by: Lambda, Treasure-Seeker
362.6962 USDC - $362.70
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L469-L472 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L95-L98
As new total supply can be arbitrary, setting it significantly lower than current (say to 100 when it was 1e9 before) can be used to remove current minority shareholders, whose shares will end up being zero on a precision loss due to low new total supply value. This can go unnoticed as the effect is implementation based.
During Buyout the remaining shareholders are left with ETH funds based valuation and can sell the shares, but the minority shareholders that did contributed to the Migration, that could have other details favourable to them, may not realize that new shares will be calculated with the numerical truncation as a result of the new total supply introduction.
Setting the severity to medium as this is a fund loss impact conditional on a user not understanding the particulars of the implementation.
Currently migrateFractions() calculates new shares to be transferred for a user as a fraction of her contribution:
// Calculates share amount of fractions for the new vault based on the new total supply uint256 newTotalSupply = IVaultRegistry(registry).totalSupply(newVault); uint256 shareAmount = (balanceContributedInEth * newTotalSupply) / totalInEth;
If Bob the msg.sender is a minority shareholder who contributed to Migration with say some technical enhancements of the Vault, not paying attention to the total supply reduction, his share can be lost on commit():
// Starts the buyout process IBuyout(buyout).start{value: proposal.totalEth}(_vault);
As commit() starts the Buyout, Bob will not be able to withdraw as both leave() and withdrawContribution() require INACTIVE state:
State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current);
If Buyout be successful, Bob's share can be calculated as zero given his small initial share and reduction in the Vault total shares.
For example, if Bob's share together with the ETH funds he provided to Migration were cumulatively less than 1%, and new total supply is 100, he will lose all his contribution on commit() as migrateFractions() will send him nothing.
Consider requiring that the new total supply should be greater than the old one:
proposal.oldFractionSupply = IVaultRegistry(registry).totalSupply( _vault ); proposal.newFractionSupply = _newFractionSupply; + require(proposal.newFractionSupply > proposal.oldFractionSupply, ""); // reference version
#0 - HardlyDifficult
2022-08-14T22:43:03Z
A migration that changes the supply can result in some users losing their expected share of funds. I agree with Medium risk here since the terms are known and the community could aim to reject the migration if they don't agree with these changes.
🌟 Selected for report: IllIllI
Also found by: 0x1f8b, 0x29A, Amithuddar, Avci, BowTiedWardens, Kthere, Limbooo, MEP, Ruhum, StyxRave, TomJ, Treasure-Seeker, TrungOre, Tutturu, Waze, bardamu, c3phas, cccz, codexploder, cryptphi, hake, horsefacts, hyh, oyc_109, pashov, peritoflores, scaraven, simon135, slywaters, sseefried, tofunmi, xiaoming90
1.3977 USDC - $1.40
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L171-L173 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L324-L326
Migration's leave() and withdrawContribution() transfer out native tokens via payable(to).transfer
call. This is unsafe as transfer
has hard coded gas budget and can fail when msg.sender
is a smart contract. Such transactions will fail for smart contract users which don't fit to 2300 gas stipend transfer
have.
Setting severity to be high as accounting is msg.sender
based and the users will not be able to workaround the limitation, so their funds will be frozen on unsuccessful Migration proposal. As the affected users are smart contracts it is a programmatic usage failure, i.e. denial of service with funds freeze for downstream systems.
Both functions are used to remove the contribution of an arbitrary shareholder before Buyout or after it was denied.
Native funds are attempted to be transferred with the payable.transfer
call:
leave():
// Withdraws ether from contract back to caller payable(msg.sender).transfer(ethAmount); }
withdrawContribution():
// Withdraws ether from contract back to caller payable(msg.sender).transfer(userEth); }
The issues with transfer()
are outlined here:
https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
Generally replacing the transfer()
should be coupled with the introduction of non-reentrancy feature, but here the transfer calls happen after all the accounting updates, so reentrancy isn't an issue.
This way the recommendation is to replace the call with SafeSend's _sendEthOrWeth() that is used in other parts of the system.
An alternative is using the OpenZeppelin Address.sendValue
:
https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol#L60
#0 - stevennevins
2022-07-19T21:44:27Z
Duplicate of #325
#1 - HardlyDifficult
2022-07-28T15:44:16Z
Duping to #504
🌟 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
46.5635 USDC - $46.56
Migration's leave() and withdrawContribution() are effectively the same for end user, but withdrawContribution() performs excessive check that can be safely removed.
newVault can be only set with Buyout in State.SUCCESS:
function settleVault(address _vault, uint256 _proposalId) external { // Reverts if the migration was not proposed Proposal storage proposal = migrationInfo[_vault][_proposalId]; if (!(proposal.isCommited)) revert NotProposed(); // Reverts if the migration was unsuccessful (, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); if (current != State.SUCCESS) revert UnsuccessfulMigration(); // Reverts if the new vault has already been deployed if (proposal.newVault != address(0)) revert NewVaultAlreadyDeployed(proposal.newVault); // Gets the merkle root for the vault and given proposal ID bytes32[] memory merkleTree = generateMerkleTree(proposal.modules); bytes32 merkleRoot = getRoot(merkleTree); // Deploys a new vault with set permissions and plugins address newVault = IVaultRegistry(registry).create( merkleRoot, proposal.plugins, proposal.selectors ); // Sets address of the newly deployed vault proposal.newVault = newVault;
Buyout's end() requires State.LIVE and set either SUCCESS or INACTIVE:
State required = State.LIVE; if (current != required) revert InvalidState(required, current);
State.SUCCESS cannot be reset to State.INACTIVE.
This way there cannot be a situation when current == State.INACTIVE
and newVault != address(0)
, as the latter requires State.SUCCESS, which cannot be transformed to the former.
migrationInfo[_vault][_proposalId].newVault != address(0)
check is redundant:
// Reverts if caller has no fractional balance to withdraw (, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); if ( current != State.INACTIVE || migrationInfo[_vault][_proposalId].newVault != address(0) ) revert NoContributionToWithdraw();
If it wasn't, i.e. if there be such a situation that newVault != address(0)
actually needed, it should be placed to leave() as well, as leave() and withdrawContribution() are effectively the same for end user, which can now avoid the check by calling leave():
(, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current);
Remove migrationInfo[_vault][_proposalId].newVault != address(0)
to save gas.