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: 9/141
Findings: 7
Award: $2,435.83
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 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
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L123-L135 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L299-L305 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L308-L325 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L241
proposal.totalEth
and proposal.totalFractions
may be increased without cost to a user.By calling join
and withdrawContributions
, a user may inflate the values of the current proposal.totalEth
and proposal.totalFractions
at no cost at all.
This is because withdrawContributions
is guarded by revert
conditions that satisfy the contract's states before and after a settleVault
call begins, even though it is meant to only be used after a settleVault
at the end of a Migration
life cycle.
withdrawContributions
only contains functions to decrease a user's transacted ETH and ERC1155 tokens, but not any that affect the proposal's totalETH and totalFractions, which can be used to replace the leave
function that does remove from totalETH and totalFractions, and is meant to be used alongside join
.
Consider malicious user Alice is interacting with an existing vault
with Migration
as one of the active modules.
Once a propose
is called, the State
of the contract is INACTIVE
, so the functions join
and leave
may be called.
Alice calls the join
function with 5ETH and paramter amount = 5
, which:
userProposalEth[vault][Alice]
by 5,proposal.totalEth
by 5,userProposalFractions[vault][Alice
by 5, andproposal.totalFractions
by 5.On the other hand, as long as settleVault
is not called, Alice can call withdrawContributions[vault]
, and this call will not revert, as the only different check that this function has from the join
and leave
revert checks, is
migrationInfo[_vault][_proposalId].newVault != address(0)
This passes, as it is only updated within settleVault
, which is only called at the very end of a Migration
cycle.
This will return all of Alice's 5 ETH and ERC1155's , but will not decrease proposal.totalEth
nor 'proposal.totalFractions'.
Hence, as long as settleVault
is not called, Alice can repeatedly call join
and withdrawCOntributions
again and again for only the fee of gas to inflate proposal.totalEth
and/or proposal.totalFractions
to however high she wants.
One possible consequence is that the proposal may use other proposals' deposited ETH to start a buyout. Note that all proposals' deposited ETH is locked in this Migration
contract. Assuming Alice deposits her ETH and withdraws them by withdrawContributions
, the proposal.totalEth
will increase to any amount Alice wants. After commit
, the contract will use other proposals' ETH to start its buyout, rendering users' funds at high risk.
Another possible consequence is a DoS attack, which may arise from commit
computing the fraction price based on both proposal.totalEth
and proposal.totalFractions
, it may be possible to make commit
revert every time it is called by making proposal.totalFractions
> totalSupply
, which is possible, as proposal.totalFractions
is not governed by the number of tokens minted, while totalSupply
is. Attackers can exploit this vulnerability to reject any proposal they dislike.
Apart from the proposer
, all other users may withdraw their contributions with leave
, so making withdrawContributions
only accessible to the proposer
should be able to prevent this exploit from occurring.
#0 - Ferret-san
2022-07-20T18:09:56Z
Duplicate of #27
🌟 Selected for report: dipp
Also found by: 0x52, Lambda, PwnedNoMore, Ruhum, Treasure-Seeker, ak1, auditor0517, hansfriese, jonatascm, kenzo, panprog, smiling_heretic, xiaoming90
The migrateFractions
function is used to migrate the caller's fractions from an old vault to a new one after a successful migration. Normally, a user's funds only needs to be migrated once, and CANNOT be migrated twice or more times.
function migrateFractions(address _vault, uint256 _proposalId) external { // Reverts if address is not a registered vault (, uint256 id) = IVaultRegistry(registry).vaultToToken(_vault); if (id == 0) revert NotVault(_vault); // Reverts if buyout state is not successful (, address proposer, State current, , , ) = IBuyout(buyout).buyoutInfo( _vault ); State required = State.SUCCESS; if (current != required) revert IBuyout.InvalidState(required, current); // Reverts if proposer of buyout is not this contract if (proposer != address(this)) revert NotProposalBuyout(); // Gets the last total supply of fractions for the vault (, , , , , uint256 lastTotalSupply) = IBuyout(buyout).buyoutInfo( _vault ); // Calculates the total ether amount of a successful proposal uint256 totalInEth = _calculateTotal( 1 ether, lastTotalSupply, migrationInfo[_vault][_proposalId].totalEth, migrationInfo[_vault][_proposalId].totalFractions ); // Calculates balance of caller based on ether contribution uint256 balanceContributedInEth = _calculateContribution( totalInEth, lastTotalSupply, userProposalEth[_proposalId][msg.sender], userProposalFractions[_proposalId][msg.sender] ); // Gets the token and fraction ID of the new vault address newVault = migrationInfo[_vault][_proposalId].newVault; (address token, uint256 newFractionId) = IVaultRegistry(registry) .vaultToToken(newVault); // 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; // Transfers fractional tokens to caller based on share amount IFERC1155(token).safeTransferFrom( address(this), msg.sender, newFractionId, shareAmount, "" ); }
However, as shown above, the migrateFractions
function does not validate whether the user/caller has migrated his funds before and sends new fraction tokens to him anyway.
This vulnerability lets a malicious user able to claim new tokens multiple times. And other users cannot claim their legitimate funds at the end (i.e., the Migration
contract does not have enought new tokens).
After a successful migration, a malicious user Alice invoke the migrateFractions
functions multiple time. She will receive much more new tokens than she should do, while some other users cannot retrieve their new tokens (i.e., Alice steals others' funds).
Zero out any user's contribution, i.e., userProposalEth[_proposalId][msg.sender]
and userProposalFractions[_proposalId][msg.sender]
, after he migrates his fraction tokens.
#0 - mehtaculous
2022-07-20T16:58:11Z
Duplicate of #460
#1 - HardlyDifficult
2022-08-11T17:18:48Z
🌟 Selected for report: panprog
Also found by: 0xsanson, PwnedNoMore, Treasure-Seeker, TrungOre, bin2chen, hansfriese, kenzo, smiling_heretic
214.1685 USDC - $214.17
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L225 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L220 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L257 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L433
A proposed migration can be processed if and only if the underlying buyout succeeds. However, the settleVault
function does not validate whether the current buyout is indeed the one triggered by the given proposal. This lets a malicious proposal be able to process even though on one voted for it.
Specifically, as the following code shows, the settleVault
function retrieves the buyout information by querying the IBuyout(buyout).buyoutInfo(_vault)
, regardless of who triggered this buyout.
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; // Emits event for settling the new vault emit VaultMigrated( _vault, newVault, _proposalId, proposal.modules, proposal.plugins, proposal.selectors ); }
As a result, a malicious proposal can be processed as long as there is a successfuly buyout triggered by any other benign proposal. Even though the malicious one has been expired/rejected, it can be re-active.
proposal X
, which gives Alice permission to mint and burn fraction tokens
Proposal X
will definitely be rejected due to its malicious natureproposal Y
.Proposal Y
gets approved as long as it is attractive enoughsettleVault
on proposal X
.
settleVault
only checks whether the current buyout for the given vault succeeds or not, the malicious proposal X will be processedsettleFractions
, migrateFractions
, and all other migration functions to prepare the new vaultFor each buyout, record the proposal id if it is triggered by a migration proposal.
#0 - HardlyDifficult
2022-08-11T19:26:34Z
🌟 Selected for report: PwnedNoMore
Also found by: Treasure-Seeker
2014.9788 USDC - $2,014.98
The end
function in the Buyout
contract uses IERC1155(token).balanceOf(address(this), id)
to determine the amount of deposited fraction tokens without distinguishing whether those fraction tokens are depositied by the sellFractions
function or by direct transferring. Note that only the sellFractions
function is constrained by PROPOSAL_PERIOD
.
This vulnerability lets a 51-holder gain the whole batch of NFTs without paying for the rest 49% fractions.
Assume a vault X creates 100 fraction tokens and the market-decided price of a fraction token is 1 ether (i.e., the ideal value of the locked NFTs in vault X is 100 ether). Let's also assume that Alice holds 51 tokens (maybe by paying 51 ether on opensea).
Followings are two scenarios, where the benign one follows the normal workflow and the malicious one exploits the vulnerability.
fractionPrice
1 ethercash
to redeem their fraction tokens with a price of 1 etherfractionPrice
0.01 wei.
Buyout
contract from Alice, other users do not need to do anythingend
function
end
function, Alice directly invokes IERC1155(token).safeTransferFrom
to send the rest 51 fraction token to the Buyout
contractend
function will treat the buyout successful, since the IERC1155(token).balanceOf(address(this), id)
is bigger than 50%In short, a malicious users can buy any NFT by just paying half of the NFT's market price
For each buyout, add a new field to record the amount of fraction tokens deposited by sellFractions
. And in the end
function, use the newly-added field to determine whether the buyout can be processed or not.
#0 - HardlyDifficult
2022-08-11T19:39:55Z
Assets can be transferred in after a failed buyout to treat it as successful. Agree this is High risk.
🌟 Selected for report: byterocket
Also found by: 0x1f8b, 242, ACai, BradMoon, Chom, Lambda, PwnedNoMore, Twpony, __141345__, ayeslick, berndartmueller, cccz, giovannidisiena, infosec_us_team, minhtrng, nine9, oyc_109, reassor, slywaters, sseefried, tofunmi, unforgiven
4.9607 USDC - $4.96
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L125 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L132 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L25
Vaults use DELEGATECALL to execute plugins/targets. To ensure the owner cannot be modified during the DELEGATECALL, vaults store the owner address in memory and check it after the DELEGATECALL (as shown below).
// Save the owner address in memory to ensure that it cannot be modified during the DELEGATECALL address owner_ = owner; // Reserve some gas to ensure that the function has enough to finish the execution uint256 stipend = gasleft() - MIN_GAS_RESERVE; // Delegate call to the target contract (success, response) = _target.delegatecall{gas: stipend}(_data); if (owner_ != owner) revert OwnerChanged(owner_, owner);
However, it only guards owner
but not nonce
. Note that in the init
function, the owner is assigned as msg.sender
as long as nonce == 0
(as shown below).
function init() external { if (nonce != 0) revert Initialized(owner, msg.sender, nonce); nonce = 1; owner = msg.sender; emit TransferOwnership(address(0), msg.sender); }
That is, if the DELEGATEDCALL changes the nonce
as 0, a malicious user can invoke init
function to become the owner.
In other words, the integrity check of owner
is insufficient. The project can either remove the check (to save gas) or add another integrity check of nonce
.
#0 - mehtaculous
2022-07-19T15:54:50Z
Duplicate of #535
#1 - HardlyDifficult
2022-07-27T00:04:54Z
Duping to #487
🌟 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
The start
function starts a buyout, requiring that there is no any live buyout for the same given vault. However, there is no any protocol fee for starting a buyout, rendering malicious users able to front-run to create a meaningless buyout and reject other users' proposals, with a minimal cost. The situtation would become extreme for those layer-2 chains with low gas fees.
Besides, to start a buyout, the proposal does not need to hold any fraction token, make any user able launch this attack.
vault X
.start
transaction
start
, Alice only deposits 1 wei and 0 fraction tokenAdd some (reasonable) protocol fee for a rejected buyout. This would also encourage users to propose buyouts with a reasonable price.
#0 - 0x0aa0
2022-07-18T16:23:52Z
Duplicate of #87
#1 - HardlyDifficult
2022-08-02T21:54:46Z
🌟 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.4891 USDC - $37.49
OpenZeppelin 4.7.0 supports a gas-friend verifyCalldata
.
Use this one to save gas.
Link:
Put the length function of an array out of a loop to save gas, especially for those in MerkleBase.sol
.
if ( current != State.INACTIVE || migrationInfo[_vault][_proposalId].newVault != address(0) ) revert NoContributionToWithdraw();
The migrationInfo[_vault][_proposalId].newVault != address(0)
is redundant.