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: 17/141
Findings: 8
Award: $1,271.94
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xA5DF
Also found by: 0x, 0xsanson, 242, Critical, sorrynotsorry, unforgiven, zzzitron
https://github.com/code-423n4/2022-07-fractional/blob/main/src/VaultFactory.sol#L20-L22 https://github.com/code-423n4/2022-07-fractional/blob/main/src/Vault.sol#L24-L29
Every Vault is a proxy of the same implementation contract. This implementation is deployed from VaultFactory but never initialized.
/// @notice Initializes implementation contract constructor() { implementation = address(new Vault()); }
Someone can call init()
in the implementation and become the owner.
/// @dev Initializes nonce and proxy owner function init() external { if (nonce != 0) revert Initialized(owner, msg.sender, nonce); nonce = 1; owner = msg.sender; emit TransferOwnership(address(0), msg.sender); }
Having total control over the contract, they can delegatecall to a selfdestruct. This basically blocks every Vault functionality.
init()
on the Vault's implementation;install
s a plugin that has a selfdestruct
function;fallback
);Now the implementation contract is destroyed, leading to the loss of functionality of all Vaults.
init()
the implementation
after creating it.
#0 - ecmendenhall
2022-07-15T03:16:14Z
440.6759 USDC - $440.68
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Migration.sol#L206-L213 https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Buyout.sol#L72-L82
The Migration contract holds all fractional token balance of all proposals.
Let's suppose a single vault has multiple proposals going on, and one gets committed. If the target price is satisfied, then a buyout starts
// Checks if the current price is greater than target price of the proposal if (currentPrice > proposal.targetPrice) { // Sets token approval to the buyout contract IFERC1155(token).setApprovalFor(address(buyout), id, true); // Starts the buyout process IBuyout(buyout).start{value: proposal.totalEth}(_vault); proposal.isCommited = true; started = true; }
Inside Buyout.start, all balance of the contract is taken:
// 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, "" );
Basically the buyout starts using also the fractional balance allocated for other proposals.
Alice starts a malicious proposal (id = 1). After a while a good proposal (id = 2) picks up, and users joins it by depositing their tokens.
Alice can call commit
for proposal 1, starting the buyout (suppose her target price was very low). Since the buyout starts with also the balance for proposal 2, it's possible that her malicious proposal passes the buyout and is settled.
Buyout.start(...)
should have an input argument for the token balance to transfer. Migration.commit(...)
should pass migrationInfo[_vault][_proposalId].totalFractions
.
#0 - HardlyDifficult
2022-08-02T21:24:36Z
🌟 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/main/src/modules/Buyout.sol#L244-L273
When a buyout is successful, token owners can cash out their fractional tokens for ETH. The amount of ETH cashed out (buyoutShare
) is calculated like this (L268):
uint256 buyoutShare = (tokenBalance * ethBalance) / (totalSupply + tokenBalance);
with tokenBalance
the user's fractional token amounts getting burned, totalSupply
the total supply after the burn, ethBalance
the buyoutInfo.ethBalance
.
During the function, buyoutInfo
isn't changed.
The next user that calls cash
will have totalSupply
lower compared to the first user, meaning that they'll get a bigger share. This leads to certain steal of funds as described below.
Let's consider the buyout for a vault just ended successfully, with a total 10 ETH raised (buyoutInfo.ethBalance
). The total supply of fractionals is currently 1000
, Alice and Bob both have 500
tokens.
Alice can call cash
. Her share is 10ETH * 500/1000 = 5 ETH
. The total supply goes down to 500
.
Bob calls cash
next. His share is 10ETH * 500/500 = 10 ETH
, when it was supposed to be equal to 5 ETH
.
Since all ETH of all vault's buyouts is kept in the Buyout contract, it's easy to see that it can all be drained using this method.
For example, Alice can make a vault with an irrelevant NFT. She can then start a buyout for it and at end get out more ETH than what she deposited (in the Alice+Bob example they got 15 ETH out of a deposit of 10 ETH). By repeating the process, or by having a large initial capital, all funds can be drained.
Another way for Alice to game the system is the following:
100
each;cash
from all of them:
10ETH * 100/1000
10ETH * 100/900
10ETH * (100/1000 + 100/900 + ... + 100/600) ~= 6.45 ETH
, compared to the fair 5 ETH
.Change the share formula to use an old totalSupply. Also write more extensive tests to catch these errors.
#0 - stevennevins
2022-07-20T21:08:27Z
Duplicate of #440
🌟 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/main/src/modules/Migration.sol#L292-L326
There are two functions that users can use to withdraw deposited ether/tokens from a joined proposal: leave
and withdrawContribution
.
leave
correctly decreases the total contribution from a proposal alongside the user's one:
// Updates fraction balances of the proposal and caller uint256 amount = userProposalFractions[_proposalId][msg.sender]; proposal.totalFractions -= amount; userProposalFractions[_proposalId][msg.sender] = 0; // Updates ether balances of the proposal and caller uint256 ethAmount = userProposalEth[_proposalId][msg.sender]; proposal.totalEth -= ethAmount; userProposalEth[_proposalId][msg.sender] = 0;
However, withdrawContribution
only zeroes the user's values, and not the totals. It's possible then to artificially increase a proposal balance, without truly depositing that quantity of ETH/tokens.
This may lead to malicious proposal passing and/or stealing of assets from the migration contracts.
Alice starts a proposal.
join
s it with 1 ETH
;
proposal.totalEth = 1 ETH
userProposalEth = 1 ETH
withdrawContribution
, getting 1 ETH back;
proposal.totalEth = 1 ETH
userProposalEth = 0 ETH
With this method it's possible to increase the proposal's balance without spending anything. Alice can stop until her proposal reaches the true address(this).balance
, so she can call commit
stealing ETH from other proposals.
Same argument works for fractional tokens instead of ETH.
Remove withdrawContribution
, since it serves the same purpose as leave
.
#0 - mehtaculous
2022-07-20T18:09:56Z
Duplicate of #27
🌟 Selected for report: panprog
Also found by: 0xsanson, PwnedNoMore, Treasure-Seeker, TrungOre, bin2chen, hansfriese, kenzo, smiling_heretic
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Migration.sol#L221-L229
When committing a migration proposal, it's status is changed to isCommited = true
and the buyout starts.
After 4 days, the buyout ends. Considering the case it was rejected, its status goes to INACTIVE
. Nothing happens to the proposal in the Migration contract.
When someone makes another buyout (it doesn't matter if it starts from a migration), and it ends successfully, our rejected proposal can be suddenly settled.
This is because in Migration#settleVault the requirements are only for the buyout to be SUCCESS
and the proposal to be isCommited
.
Someone starts a malicious proposal. It's easily commited by using a low targetPrice
, but of course it's rejected during the Buyout. Later on, a valid Buyout is successful but immediately the exploiter can call settleVault
and settleFractions
to pass his proposal and steal from the vault.
I believe it's hard to solve this without changing a little the architecture of the contracts. Maybe Migration.sol should have its own version of Buyout that tracks proposalId
?
#0 - Ferret-san
2022-07-20T18:40:27Z
Duplicate of #286
#1 - HardlyDifficult
2022-08-11T19:26:48Z
🌟 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
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Buyout.sol#L57
Everyone can start
a Buyout for a vault by paying only 1 wei. For the next 4 days no other Buyout can start. If someone is fast enough, they can start another griefing buyout as soon as one finishes, meaning that it's possible to block the functionality of the contract forever.
Among others, these are functionality impacted:
redeem
, meaning that even if someone has 100% the fractional tokens of a vault, they will never get the NFT back;leave
wants the buyout inactive;Blocking buyouts and migrations will make fractional tokens worthless, since there's no way to get the underlying back.
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Buyout.sol#L57 https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Migration.sol#L148-L150
Consider allowing to overwrite a buyout with another with better price. Alternatively, allow multiple buyouts active at the same time using an index, like with migration's proposals.
Also add a require(depositAmount > 0)
in Buyout.start, so someone who has 100% of tokens can redeem
immediately without a griefer making him wait.
#0 - 0x0aa0
2022-07-18T16:47:51Z
Duplicate of #87
#1 - HardlyDifficult
2022-08-02T21:54:25Z
🌟 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
184.035 USDC - $184.03
In Migration.sol propose
function (and similarly in join
and leave
):
// 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);
I don't see the need to check the Buyout state at these steps. This check only slows a possible proposal initiation.
call
instead of transfer
At the end of Migration.sol#leave and #withdrawContribution, ether is sent back to the caller.
// Withdraws ether from contract back to caller payable(msg.sender).transfer(ethAmount);
As usual, if msg.sender
is a contract that performs some operation on fallback, the transfer may out-of-gas and revert, leading to the impossibility to withdraw that funds.
Consider using the low-level call
instead. No re-entrancy issues, since this line is at the end of the function.
According to the documentation, "For 7 days, users can contribute their fractions / ether to signal support" to a proposal (https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Migration.sol#L23). However an user can call join
for a proposal regardless of the time.
Consider adding a requirement in Migration.sol#join like require(block.timestamp <= proposal.startTime + PROPOSAL_PERIOD)
.
Something like a proposal creation needs to be easily caught off-chain. Consider adding a specific event at the end of Migration.sol#propose.
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Migration.sol#L98
SellFractions
and BuyFractions
may need a vault
parameterRight now the events SellFractions
and BuyFractions
don't track for which vault the user selled their fractions. Such a parameter should be essential to track the buyout of a vault.
https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Buyout.sol#L143 https://github.com/code-423n4/2022-07-fractional/blob/main/src/modules/Buyout.sol#L178
#0 - HardlyDifficult
2022-08-03T21:44:29Z
🌟 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
40.9293 USDC - $40.93
VaultRegistry.totalSupply(vault)
to token.totalSupply(id)
When the total supply of fractional tokens of a vault is needed, we can make a call VaultRegistry.totalSupply(vault)
, that gets the token
/id
and then calls FERC1155(info.token).totalSupply(info.id)
.
However, sometimes we already have these values, so with this method we have a staticall
and two warm SLOAD
extra. In the following lines, consider using directly FERC1155(token).totalSupply(id)
.
Buyout.sol: L71, L210, L267, L288 Migration.sol: L95, L200, L470
VaultInfo.id
can be uint96token
and id
can be packed in a single storage slot if instead of using uint256 we use uint96. The number of vaults won't realistically reach 2**96.
i++
In general, a for-loop like this can be made more gas efficient.
for (uint256 i = 0; i < length; i++) { ... }
Consider rewriting it like this:
for (uint256 i = 0; i < length; ) { ... unchecked { ++i; } }
grep -r 'i++'
buyout
can be immutableSInce this variable doesn't change, it can be set immutable
. It saves a SLOAD in every function.
nonReentrant
modifier in join
isn't neededThere's a nonReentrant
modifier in Migration.sol#join. I don't a way to reenter from this function, so I suggest to remove it (to save gas of course).