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: 12/141
Findings: 5
Award: $2,097.92
🌟 Selected for report: 1
🚀 Solo Findings: 0
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L39 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L66-L68 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L235
Note: This submission contains a link to a private fork of the contest repo. Please ask warden @sseefried for GitHub collaborator access.
A user can start a perpetual buyout that cannot be stopped except by making the buyout succeed. This can be done by creating a malicious contract that will call back to start
when it receives ETH via its receive
function. The user then starts the perpetual buyout by calling start
from the malicious contract.
Assume the rejection period has passed and the auction pool is not large enough (i.e. < 50%). If end
is called then the method _sendEthOrWeth
will attempt to send ETH to the malicious contract. The contract will simply call back to start
sending the ETH it has just received.
The impact is that end
can never be called on this buyout proposal if the buyout auction has failed. Worse, no new buyout proposal can be made since the current one is still live, and it is never in a state where it is not live.
The others users will either need to accept that assets are locked inside the vault, or that they will need to sellFractions
in order to make the buyout succeed.
buyoutInfo
associated with it as can be seen on line 39.buyoutInfo
state is State.INACTIVE
as can be seen in lines 66-68start
. They do this from a smart contract that simply calls start
again when its receive
function is called.end
is called, _sendEthOrWeth
is called using the proposer
value which is the smart contract that re-enters. See line 235. _sendETHOrWeth
is cleverly written so that if receive
were to revert the reversion would not "bubble up". However, it does not protect against re-entrancy.buyoutInfo[vault]
can never be overwritten. It is permanently stuck in state State.LIVE
meaning that start
can never be called for vault
by anyone else.sellFractions
to make the auction succeed or to accept that assets are locked in the vault forever.A foundry test exhibiting this attack has been written in a private fork of the contest repo.
Note that onERC1155Received
needs to be implemented in the malicious contract.
Manual inspection + foundry
Prevent re-entrancy in the start
function by using the nonReentrant
modifier provided by OpenZeppelin's ReentrancyGuard contract, or use an equivalent custom solution.
#0 - 0x0aa0
2022-07-18T16:53:14Z
Duplicate of #87
#1 - sseefried
2022-07-19T09:47:35Z
This exploit is a duplicate of the others in most respects but there is one key difference. In the other submissions there is at least a chance that someone else will get in their buyout bid after 4 days by carefully submitting a transaction at just the right moment. With the exploit I have outlined they cannot even do this. The call to end
will automatically create a new buyout with no chance of anyone else ever getting their transaction in. It is a truly perpetual buyout.
To see an executable PoC of this (using a malicious contract to ensure the perpetual buyout) apply the diff in this gist and run
$ forge test -m testPerpetualBuyoutBug
#2 - stevennevins
2022-07-19T15:05:03Z
Thanks for the reply @sseefried! We felt this was the same underlying issue as #87 and others labeled as duplicates while having a more certain path to griefing.
#3 - HardlyDifficult
2022-08-02T21:53:27Z
Starting a buyout can result in assets being stuck in a contract. This submission shows how reentrancy can be used to make this even worse resulting in locking the assets up forever. This combination of concerns raises the issue to High risk.
Selecting this submission as the primary for identifying this potential impact and including a coded POC.
🌟 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#L126 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
Note: This submission contains a link to a private fork of the contest repo. Please ask warden @sseefried for GitHub collaborator access.
In the event that a malicious target is proposed and migrated to, there is code on line 127 and line 132 of the Vault
contract that checks whether the owner
has been changed by target of delegatecall
.
While this offers some protection this check is not sufficient. A malicious target (which should normally be stateless) could have 3 or more storage slots. If so, it can update the nonce
to 0
via a storage slot collision via delegatecall
which will then allow init
to be called on the vault, thus updating the owner
to the message sender.
f
exists that updates the storage slot 3 with a user specified value.f
in the malicious target with the value 0
.nonce
field of the vault is set to 0
.init()
on the vault and transfer ownership. This is because line 25 will not revert when nonce == 0
A foundry test has been written that exhibits this attack.
Manual inspection + foundry
Consider protecting all the important state variables in the Vault
contract, not just owner
, by saving them before the call to delegatecall
and reverting if any of them have changed after the call.
#0 - ecmendenhall
2022-07-15T03:41:24Z
#1 - HardlyDifficult
2022-07-26T23:59:09Z
Duping to #487
🌟 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#L172 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L325
The .transfer
method is used to transfer ETH in both the withdrawContribution
and leave
functions of the Migration
. This is not recommended because if the recipient is a contract then it will call the fallback
or receive
function but it is only given 2300 gas. This can cause the call to revert, thus locking the user's funds in the Migration
contract for when the recipient contract
payable
function.payable
fallback/receive function but it uses more than 2300 gas.payable
fallback/receive function that needs less than 2300 gas units but is called through proxy, raising the call's gas usage above 2300.Migration
contract by calling the join
function. They do this from a smart contract with a payable receive
(or fallback
) function that uses more than 2300 gas.leave
to leave the proposal and get their ETH back. The call reverts since .transfer
only provides 2300 gas to the call to receive
A similar PoC is possible for withdrawContribution
.
Manual inspection
Replace payable(msg.sender).transfer(ethAmount)
with payable(msg.sender).call{value: ethAmount}("")
in the relevant functions.
#0 - mehtaculous
2022-07-19T21:52:20Z
Duplicate of #325
#1 - HardlyDifficult
2022-07-28T15:48:32Z
Duping to #504
🌟 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
Note: This submission contains a link to a private fork of the contest repo. Please ask warden @sseefried for GitHub collaborator access.
A serious proposal will usually require substantial amounts of ETH and fractions to be added via calls to join
. However, a malicious user can always submit a proposal with a low targetPrice
call join
with more ETH than the target price and then quickly call commit
. This will have the effect of starting a 4 day buyout on the vault. Since there can only be one buyout per vault at any one time, these "indecent proposals" can DOS serious ones indefinitely. On each occasion 4 days are lost to the buyout process.
propose
for a target price of 1ETHjoin
with a value of 2 weicommit
successfully committing and starting the buyout process.commit
but the
function reverts with InvalidState(0, 1)
because line 210 is called but a buyout already existsA foundry test exhibiting this bug has been written in a private fork of the contest repo.
Manual inspection + foundry
The logic of _calculateTotal
needs to be changed so that a user cannot easily propose a migration, then join
and commit
it if they do not have not submitted a substantial amount of the fractions of the total supply.
#0 - sseefried
2022-07-19T09:54:47Z
You can also run this test by applying the diff in this gist and running
$ forge test -m testGriefCommit
#1 - HardlyDifficult
2022-08-11T19:14:11Z
🌟 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
Some of the documentation mentions that Buyouts need 51% support but the code shows that, in reality, it is anything strictly greater than 50%. e.g. 50.0001%
permissions.size
to intialize nodes
in Buyout.getLeafNodes
Instead of using the magic number 5 on line 451 why not simply use permissions.length
to allocate the correct number of array indices?
Like so:
function getLeafNodes() external view returns (bytes32[] memory nodes) { Permission[] memory permissions = getPermissions(); nodes = new bytes32[](permissions.length); for (uint256 i; i < permissions.length; ) { // Hashes permission into leaf node nodes[i] = keccak256(abi.encode(permissions[i])); // Can't overflow since loop is a fixed size unchecked { ++i; } } }
BaseVault.generateMerkleTree
will not work with new targets when total leaf length is greater than 6Function BaseVault.deployVault
takes a modules
parameter which allows for any set of modules to be used with the contract.
If those modules have targets which have a total number of leaf nodes greater than 6 then generateMerkleTree
will revert, which in turn causes deployVault
to revert.
The impact is that one can successfully create a BaseVault
but not call deployVault
.
BaseVault
called baseVault
.baseVault.deployVault
with modules parameter which, collectively, have more than 6 leaf nodes.Manual Inspection
If the intention of BaseVault
is to allow arbitrary modules then one could rewrite generateMerkleTree
as follows.
function generateMerkleTree(address[] calldata _modules) public view returns (bytes32[] memory hashes) { uint256 numLeaves; uint256 counter; bytes32[][] memory leavesList = new bytes32[][](_modules.length); // Get leaf nodes unchecked { for (uint256 i; i < _modules.length; ++i) { bytes32[] memory leaves = IModule(_modules[i]).getLeafNodes(); leavesList[i] = leaves; numLeaves += leaves.length; } } hashes = new bytes32[](numLeaves); unchecked { for (uint256 i; i < leavesList.length; ++i) { bytes32[] memory leaves = leavesList[i]; for (uint256 j; j < leaves.length; ++j) { hashes[counter++] = leaves[j]; } } } }
If the intention is for it only to allow Supply
and Buyout
modules then simply add some checks with require
statements.
Vault
when accidentally sent to itThere is no function to retrieve ETH accidentally sent to the Vault
contract.
BaseVault
batch deposit functions do not check arrays are of the same lengthThe functions batchDepositERC20
, batchDepositERC721
, and batchDepositERC1155
all fail to check that the length of the their array arguments have the same length.
Although this only results in a revert, it will result in increased gas use.