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: 23/141
Findings: 7
Award: $848.66
🌟 Selected for report: 1
🚀 Solo Findings: 0
When starting a buyout, it is very easy for sender's assets to get rounded down to zero if totalSupply
is larger than 100
. This would result in an inaccurate buyout price, leaking value for the sender.
uint256 buyoutPrice = (msg.value * 100) / (100 - ((depositAmount * 100) / totalSupply));
When depositAmount * 100
is lower than totalSupply
, the result will be rounded down as if the sender didn't transfer any token. This would result in inaccurate buyoutPrice
and respectively, fractionPrice
.
Use a reasonably high number for the scalar:
uint256 buyoutPrice = (msg.value * 10e18) / (10e18 - ((depositAmount * 10e18) / totalSupply));
#0 - 0x0aa0
2022-07-18T15:36:50Z
Duplicate of #647
#1 - HardlyDifficult
2022-08-01T23:40:39Z
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L210 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L72-L82
Starting a buyout via Migration.commit
could steal tokens from other pending proposals.
It is possible to have multiple ongoing proposals on the same vault. The issue is that when committing one of the proposal, Buyout
will try to transfer all token from the msg.sender
, which is Migration.sol
.
Buyout.sol#L72-82
// Gets total balance of fractional tokens owned by caller uint256 depositAmount = IERC1155(token).balanceOf(msg.sender, id); // Transfers fractional tokens into the buyout pool IERC1155(token).safeTransferFrom( msg.sender, address(this), id, depositAmount, "" );
This means that any token contributed to other proposals of the same vault would also be transferred, effectively stealing tokens from other users.
commit
, and all 11 token are sent to Buyout
.Add _amount
parameter to Buyout.start
so sender can choose how many tokens to send instead of everything. Implement this new behaviour in Migration.commit
.
function start(address _vault, uint256 _depositAmount) external payable { ... IERC1155(token).safeTransferFrom( msg.sender, address(this), id, _depositAmount, "" ); // 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)); ... }
function commit(address _vault, uint256 _proposalId) external returns (bool started) { ... if (currentPrice > proposal.targetPrice) { ... IBuyout(buyout).start{value: proposal.totalEth}(_vault, proposal.totalFractions); ... } }
#0 - 0x0aa0
2022-07-20T17:58:18Z
Duplicate of #619
#1 - HardlyDifficult
2022-08-02T21:24:45Z
🌟 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
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L255-L270 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/VaultRegistry.sol#L102-L112
An attacker could deploy a malicious FERC1155
to steal all ETH in Buyout.sol
.
There are two things that makes this vulnerability possible:
VaultRegistry
allows anyone to deploy vault with a custom FERC1155
. This means anyone can use a token with custom minting behaviour.Buyout.cash
doesn't reduce the total ethBalance
that could be withdrawn. An attacker that can manipulate token balance can keep calling cash
to withdraw funds from the contract.Specifically, these are the problematic lines: Buyout.sol#L255-270
uint256 tokenBalance = IERC1155(token).balanceOf(msg.sender, id); if (tokenBalance == 0) revert NoFractions(); // Initializes vault transaction bytes memory data = abi.encodeCall( ISupply.burn, (msg.sender, tokenBalance) ); // Executes burn of fractional tokens from caller IVault(payable(_vault)).execute(supply, data, _burnProof); // Transfers buyout share amount to caller based on total supply uint256 totalSupply = IVaultRegistry(registry).totalSupply(_vault); uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); _sendEthOrWeth(msg.sender, buyoutShare);
Note that even without the custom contract attack, the implementation is still problematic. It would only be correct for the first redeemer, giving later redeemers more fund than they should receive. Assume a 100
supply token divided between Alice and Bob. ethBalance
is 100 eth
:
cash
, burning 50
token and receiving 50 eth
. Total supply is now 50
.
uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); = (50 * 100) / (50 + 50); = 50 eth
cash
, burning 50
token and receiving 100
eth instead of 50 eth
.
uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); = (50 * 100) / (0 + 50); = 100 eth
100 eth
reserved for this buyout redemption, Bob is effectively stealing an extra 50 eth
.FERC1155
with controllable mint/burn capability.VaultRegistry.createInCollection
, using the custom ERC1155 as the token.ethBalance
of the auction to 10 eth
, and end it succesfully.cash
to redeem 10 eth
. Repeat this step until there is no more eth in the contract to steal.Reducing the ethBalance
at the end of cash
would solve the issue:
function cash(address _vault, bytes32[] calldata _burnProof) external { ... uint256 totalSupply = IVaultRegistry(registry).totalSupply(_vault); uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance); buyoutInfo[_vault].ethBalance -= buyoutShare; _sendEthOrWeth(msg.sender, buyoutShare); ... }
#0 - HardlyDifficult
2022-08-14T23:01:31Z
🌟 Selected for report: infosec_us_team
Also found by: 0x29A, 0xsanson, BowTiedWardens, Lambda, MEP, PwnedNoMore, Treasure-Seeker, TrungOre, berndartmueller, panprog, shenwilly, smiling_heretic, xiaoming90, zzzitron
A manipulated pending proposal can steal funds in the contract to start a buyout.
The issue here is that there is no check to prevent withdrawContribution
from being called when the proposal is still in the contribution phase.
The withdrawContribution
function is called to retrieve assets after a proposal has failed to reach target price. Functionality wise, this is similar to the leave
function which withdraws assets previously contributed to a proposal. However, it differs in one aspect: withdrawContribution
does not update the totalEth
and totalFractions
of the proposal, while leave
does.
A malicious actor could exploit this by contributing to a proposal and subsequently calls withdrawContribution
to withdraw their assets without reducing the value of proposal.totalEth
. They can then pass the following check to commit the proposal, even though the actual contributed eth doesn't match the proposal.totalEth
of the proposal:
uint256 currentPrice = _calculateTotal( 100, IVaultRegistry(registry).totalSupply(_vault), proposal.totalEth, proposal.totalFractions ); // Checks if the current price is greater than target price of the proposal if (currentPrice > proposal.targetPrice) { ... IBuyout(buyout).start{value: proposal.totalEth}(_vault); ... }
This attack can be executed as long as there is an proposal.totalEth
amount of ETH available in the contract.
For simplicity, we assume Bob didn't contribute any fraction and total supply of fraction is 100
.
join
with 100 eth, increasing proposal.totalEth
to 100 eth.withdrawContribution
to withdraw his 100 eth. However, proposal.totalEth
is still at 100 eth.commit
to start the buyout.
Asuint256 currentPrice = _calculateTotal( 100, IVaultRegistry(registry).totalSupply(_vault), / 100 proposal.totalEth, // 100 proposal.totalFractions // 0 ); // given these parameters, currentPrice would return 100.
currentPrice
is 100
and proposal.targetPrice
is 99
, the currentPrice > proposal.targetPrice
condition is satisfied. Alice's 100 eth is then used to call Buyout.start
.Below is the test case to demonstrate the attack:
function testCommitAfterWithdraw() public { initializeMigration(alice, bob, TOTAL_SUPPLY, HALF_SUPPLY, true); (nftReceiverSelectors, nftReceiverPlugins) = initializeNFTReceiver(); // Migrate to a vault with no permissions (just to test out migration) address[] memory modules = new address[](1); modules[0] = address(mockModule); // Bob makes the proposal bob.migrationModule.propose( vault, modules, nftReceiverPlugins, nftReceiverSelectors, TOTAL_SUPPLY * 2, 1 ether ); // Bob joins the proposal bob.migrationModule.join{value: 1 ether}(vault, 1, HALF_SUPPLY); // Alice made proposal as well alice.migrationModule.propose( vault, modules, nftReceiverPlugins, nftReceiverSelectors, TOTAL_SUPPLY * 2, 1 ether ); alice.migrationModule.join{value: 1 ether}(vault, 2, HALF_SUPPLY); // bob withdraw contribution bob.migrationModule.withdrawContribution(vault, 1); vm.warp(proposalPeriod + 100); // bob is still able to commit the buyout process bool started = bob.migrationModule.commit(vault, 1); assertTrue(started); }
Add a check to prevent withdrawContribution
from being called when proposal has not been commited yet:
function withdrawContribution(address _vault, uint256 _proposalId) external { if (!migrationInfo[_vault][_proposalId].isCommited) revert ProposalNotCommited(); ... }
#0 - Ferret-san
2022-07-21T18:57:09Z
Duplicate of #27
🌟 Selected for report: shenwilly
Also found by: 0x52, Lambda, MEP, Treasure-Seeker, TrungOre, codexploder, dipp, kenzo, panprog, smiling_heretic, xiaoming90, zzzitron
Funds in migration proposals could potentially be stuck forever if a buyout auction on the same vault is started by other party.
Most of the functions within Migration.sol
can only be executed depending on the state of buyout auction in Buyout.sol
. When there is no buyout happening, a migration proposal can be made and anyone can contribute to the proposal. However, it is possible that a buyout auction is started by another party while a pending proposal is not commited yet.
When this scenario happens, there is no action that could be taken to interact with the pending proposal. All funds that have been contributed cannot be withdrawn. This is because the functions only check for the state of the buyout auction, instead of also considering whether the buyout auction's proposer is Migration.sol
:
(address token, uint256 id) = IVaultRegistry(registry).vaultToToken(_vault); if (id == 0) revert NotVault(_vault); // Reverts if buyout state is not inactive (, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current);
Proposal contributors have to wait until the buyout failed before they can withdraw their funds. In case the buyout succeeded, their funds will be stuck forever.
0.5 eth
.ACTIVE
.SUCCESS
.Below are the test cases that show the scenarios described above.
function testLeaveBuyoutStarted() public { initializeMigration(alice, bob, TOTAL_SUPPLY, HALF_SUPPLY, true); (nftReceiverSelectors, nftReceiverPlugins) = initializeNFTReceiver(); // Migrate to a vault with no permissions (just to test out migration) address[] memory modules = new address[](1); modules[0] = address(mockModule); // Bob makes the proposal bob.migrationModule.propose( vault, modules, nftReceiverPlugins, nftReceiverSelectors, TOTAL_SUPPLY * 2, 1 ether ); // Bob joins the proposal bob.migrationModule.join{value: 0.5 ether}(vault, 1, HALF_SUPPLY); // Alice started buyout alice.buyoutModule.start{value: 1 ether}(vault); (, , State current, , , ) = alice.buyoutModule.buyoutInfo(vault); assert(current == State.LIVE); vm.expectRevert( abi.encodeWithSelector(IBuyout.InvalidState.selector, 0, 1) ); // Bob cannot leave bob.migrationModule.leave(vault, 1); } function testLeaveBuyoutSuccess() public { // Send Bob a smaller amount so Alice can win the auction initializeMigration(alice, bob, TOTAL_SUPPLY, HALF_SUPPLY/2, true); (nftReceiverSelectors, nftReceiverPlugins) = initializeNFTReceiver(); // Migrate to a vault with no permissions (just to test out migration) address[] memory modules = new address[](1); modules[0] = address(mockModule); // Bob makes the proposal bob.migrationModule.propose( vault, modules, nftReceiverPlugins, nftReceiverSelectors, TOTAL_SUPPLY * 2, 1 ether ); // Bob joins the proposal bob.migrationModule.join{value: 0.5 ether}(vault, 1, HALF_SUPPLY/2); // Alice did a buyout alice.buyoutModule.start{value: 1 ether}(vault); vm.warp(rejectionPeriod + 1); alice.buyoutModule.end(vault, burnProof); (, , State current, , , ) = alice.buyoutModule.buyoutInfo(vault); assert(current == State.SUCCESS); vm.expectRevert( abi.encodeWithSelector(IBuyout.InvalidState.selector, 0, 2) ); // Bob cannot leave bob.migrationModule.leave(vault, 1); }
Modify the checks for the following functions:
leave
withdrawContribution
so users can withdraw their funds from the proposal when the buyout auction proposer is not Migration.sol
.
In addition, it's also possible that there are multiple ongoing proposals on the same vault and the buyout is started by one of them. To allow other proposals' contributors to withdraw their fund, consider tracking the latest proposalId
that started the buyout on a vault:
mapping(address => uint256) public latestCommit; function commit(address _vault, uint256 _proposalId) { ... if (currentPrice > proposal.targetPrice) { ... latestCommit[_vault] = _proposalId; } }
For leave
:
(, address proposer, State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); // if buyout is started by this proposal, check that state is inactive. Else allow leaving. if (proposer == address(this) && latestCommit[_vault] == _proposalId) { State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current); }
For withdrawContribution
:
(, address proposer, State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); // if buyout is started by this proposal, check that state is inactive. Else allow withdrawing. if (proposer == address(this) && latestCommit[_vault] == _proposalId) { State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current); } if ( migrationInfo[_vault][_proposalId].newVault != address(0) ) revert NoContributionToWithdraw();
#0 - stevennevins
2022-07-20T16:44:31Z
#1 - HardlyDifficult
2022-08-11T16:31:00Z
Starting a buyout can cause migration funds to become stuck in the contract. Agree this is High risk.
Selecting this submission as the primary for including POC code and including clear recs.
🌟 Selected for report: 0xA5DF
Also found by: 0x52, 0xDjango, 0xsanson, Lambda, PwnedNoMore, Ruhum, Treasure-Seeker, async, berndartmueller, cccz, hubble, kenzo, scaraven, shenwilly, sseefried, xiaoming90
14.6423 USDC - $14.64
A malicious actor can grief the Buyout.start
function to deny vaults from ever starting a proper buyout.
There is practically no cost to start a buyout auction for a vault. Once a buyout is started, one has to wait until the buyout is ended before they are able to start a new one.
An actor that wants to prevent a vault from getting bought out could frontran a proper start
transaction by starting a buyout with unreasonable parameter such as a buyoutPrice
of 1 wei
. They can keep repeating this forever, preventing a proper buyout auction from happening.
start
transaction with 1 wei
as the msg.value
, setting the fractionPrice
to 0
.fractionPrice
is unreasonable, no one wants to sell their fraction and the auction fails. Bob can keep doing this whenever anyone tried to start a buyout on vault A.Consider allowing multiple buyout auctions on a same vault to happen at the same time. This could be done in a similar way to how proposal works in Migration.sol
.
#0 - mehtaculous
2022-07-18T16:42:43Z
Duplicate of #87
#1 - HardlyDifficult
2022-08-02T21:54:33Z
🌟 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
69.1835 USDC - $69.18
settleVault
and settleFractions
can be called even if proposal failedhttps://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L225-L226 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L263-L264
It is possible to call settleVault
and settleFractions
even if the proposal failed to pass the priceTarget
, as long as there is another party that succesfully did a buyout on the same vault.
While there is no damage from calling these functions, it would lead to incorrect state and misleading event emissions.
settleVault
on the failed proposal to create a new vault, and settleFractions
to mint new tokens and mark the vault as migrated.Add a check in settleVault
and settleFractions
to ensure that Migrator
is the proposer of the successful buyout:
(, address proposer, State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); if (address(this) != proposer) revert UnsuccessfulMigration();
install
When installing new plugins in Vault
, there is no check to ensure that new selectors did not clash with previously installed selectors. Unaware users might inadvertently replaced it and misused the function later.
Consider adding toggleable check in install
to prevent accidental replacement of clashing selectors:
function install(bytes4[] memory _selectors, address[] memory _plugins, bool _force) external { if (owner != msg.sender) revert NotOwner(owner, msg.sender); uint256 length = _selectors.length; for (uint256 i = 0; i < length; i++) { if (!_force) { if (methods[_selectors[i]] != address(0)) revert SelectorAlreadyInstalled(); } methods[_selectors[i]] = _plugins[i]; } emit InstallPlugin(_selectors, _plugins); }
It is best practice to use a two-step pattern when updating critical variables such as contract ownership. An input mistake could cause vault ownership to be lost.
Implement two-step pattern when transferring vault ownership:
transferOwnership
to set a pending new owner.acceptOwnership
to be called by the new owner to assume ownership.FERC1155
royalty sanity checkThere is no sanity check when setting royalties. It is possible for a compromised or malicious controller to frontran a trade transaction by setting royalties to an unexpectedly high value, effectively stealing funds intended for the seller.
Add a sanity check to ensure royalties cannot be set to an unreasonable amount:
function setRoyalties( uint256 _id, address _receiver, uint256 _percentage ) external onlyController { if (_percentage > 30) revert RoyaltyTooHigh(); royaltyAddress[_id] = _receiver; royaltyPercent[_id] = _percentage; emit SetRoyalty(_receiver, _id, _percentage); }
targetPrice
When committing a migration proposal, the code checks that currentPrice
must be higher than proposal.targetPrice
.
Using an inclusive operator >=
might be more intuitive here.
function commit(address _vault, uint256 _proposalId) { ... if (currentPrice > proposal.targetPrice) { ... } ... }
// Boolean status to check if the propoal is active
propoal
should be proposal
.