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: 22/141
Findings: 6
Award: $875.50
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: kenzo
Also found by: 0x1f8b, bin2chen, codexploder, dipp, minhtrng, smiling_heretic
The user can lose ether due to incorrect proposalId
in the method join
of Migration
contract.
In the join
method of Migration
contract, If the registry (migrationInfo[_vault][_proposalId]
) does not exist, it will take a default structure where it will deposit the ether, but it can never be recovered since the proposalId
is shared between the different vaults, so it does not have to match even if a new proposal is created.
Imagine the following scenario:
join(_vault, 123, 0)
with 1 etherProposal storage proposal = migrationInfo[_vault][_proposalId]; // Updates ether balances of the proposal and caller proposal.totalEth += msg.value; userProposalEth[_proposalId][msg.sender] += msg.value;
proposal
exists.#0 - stevennevins
2022-07-20T18:34:27Z
Duplicate of #208
#1 - HardlyDifficult
2022-07-28T21:33:38Z
🌟 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#L38-L41 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L15-L17
The execute
proof is bypassed by using the fallback method which allows third parties to perform transfer actions on accounts that are not in the merkletree.
Like you can see in the fallback
method:
fallback(bytes calldata _data) external payable returns (bytes memory response) { address plugin = methods[msg.sig]; (,response) = _execute(plugin, _data); }
it will call _execute
with an arbitrary data without any proof.
The _execute
method contains the following protection to prevent a takeover:
if (owner_ != owner) revert OwnerChanged(owner_, owner);
However, if you change the nonce to 0, you could call the init method to takeover the contract, or just by changing the merkleRoot
, you could already take a big advantage of the vault. You could also modify the owner, install a plugin, and restore the owner to the previous one, there are many possible scenarios like just drain the contract.
fallback
method to call execute
.#0 - mehtaculous
2022-07-19T15:50:35Z
Duplicate of #535
#1 - HardlyDifficult
2022-07-27T00:02:12Z
Duping to #487
🌟 Selected for report: 0xNineDec
Also found by: 0x1f8b, infosec_us_team, kenzo, pashov, xiaoming90
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/VaultRegistry.sol#L74 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L63 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L131
A malicious user can perform arbitrary burn
and mint
actions on the VaultRegistry.fNFT
token. This allows an attacker to alter user balances for the VaultRegistry.fNFT
token, handled by all vaults created with create
and createFor
methods of VaultRegistry
.
Consider the following exploit for our proof of concept (it will run in delegatecall
mode in our vault):
interface IRegistry { function mint(address _to, uint256 _value) external; function burn(address _from, uint256 _value) external; } contract exploitContract { address constant _attacker = address(0x0A..); // Attacker address address constant _registryAddress = address(0x0B..); // VaultRegistry.address uint256 constant _value = 123; function exploit() public { IRegistry(_registryAddress).mint(_attacker, _value); // IRegistry(_registryAddress).burn(_attacker, _value) } }
Recipe:
createFor
method of VaultRegistry
with the following arguments:
bytes32 _merkleRoot
= bytes32(0)
.address _owner
= _attacker
.address[] memory _plugins
= new address[](0)
.bytes4[] memory _selectors
= new bytes4[](0)
.owner
of the vault is attacker
because the IVault(vault).transferOwnership(_owner)
and the token is address(fNFT)
.vault.execute
with the following arguments:
address _target
= our exploitContract
.bytes calldata _data
= abi.encodeWithSignature('exploit()');
bytes32[] calldata _proof
= new bytes32[](0)
What happens is the following:
merkleRoot
verification fails but since the msg.sender
is the owner
it doesn't matter (this is not relevant because having a correct merkle tree could be also done).if (!MerkleProof.verify(_proof, merkleRoot, leaf)) { + if (msg.sender != owner) revert NotAuthorized(msg.sender, _target, selector); } (success, response) = _execute(_target, _data);
_execute(_target, _data);
_target
is our exploitContract
and the _data
is the call to our exploit()
method.vault
calls (success, response) = exploitContract.delegatecall{gas: stipend}(_data);
exploitContract
contract in the vault context._registryAddress
to do either mint
or burn
.function burn(address _from, uint256 _value) external { VaultInfo memory info = vaultToToken[msg.sender]; uint256 id = info.id; if (id == 0) revert UnregisteredVault(msg.sender); + FERC1155(info.token).burn(_from, id, _value); }
msg.sender
is our vault, the info.token
is address(fNFT)
so the burn
being called from registry
will bypass the onlyRegistry
modifier, resulting in a loss of funds.It's also possible to do it with create
but it require to use plugins
and selectors
.
#0 - 0x0aa0
2022-07-21T16:03:09Z
Duplicate of #95
#1 - HardlyDifficult
2022-07-28T00:37:23Z
Duping to #267. The core concern appears to be the owner of the vault has complete control to change or execute logic at anytime and this could abuse users trust, including giving them the ability to steal assets.
🌟 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
The user's ether could be locked and unrecoverable.
Because to transfer ether the .transfer
method (which is capped at 2300 gas) is used instead of .call
which is limited to the gas provided by the user. If a contract that has a fallback
method more expensive than 2300 gas, creates an offer, it will be impossible for this contract cancel the offer or receive funds back to that address.
Reference:
Affected lines:
.call
instead of .transfer
#0 - mehtaculous
2022-07-19T21:44:59Z
Duplicate of #325
#1 - HardlyDifficult
2022-07-28T15:45:46Z
Duping to #504
🌟 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
247.3588 USDC - $247.36
The project contains packages that urgently need to be updated because they contain important vulnerabilities.
npm audit
:
54 vulnerabilities (11 moderate, 40 high, 3 critical)
The pragma version used is:
pragma solidity 0.8.13;
But recently solidity released a new version with important Bugfixes:
The first one is related to ABI-encoding nested arrays directly from calldata. You can find more information here.
The second bug is triggered in certain inheritance structures and can cause a memory pointer to be interpreted as a calldata pointer or vice-versa. We also have a dedicated blog post about this bug.
Apart from these, there are several minor bug fixes and improvements.
The minimum required version should be 0.8.14
The following code doesn't check the result of the ERC20 calls. ERC20 standard specify that the token can return false if these calls fails, so it's mandatory to check the result of these ERC20 methods.
Reference:
NOTES: The following specifications use syntax from Solidity 0.4.17 (or above). Callers MUST handle false from returns (bool success). Callers MUST NOT assume that false is never returned!
Affected source code for transfer
:
function ERC20Transfer( address _token, address _to, uint256 _value ) external { - IERC20(_token).transfer(_to, _value); + require(IERC20(_token).transfer(_to, _value)); }
Affected source code for transferFrom
:
It's possible to lose the ownership under specific circumstances.
Because an human error it's possible to set a new invalid owner. When you want to change the owner's address it's better to propose a new owner, and then accept this ownership with the new wallet.
Affected source code:
nonReentrant
in Buyout
and Migration
ERC1155 tokens incorporate the ability to react to a transfer using the onERC1155Received
method on the receiver. And in the Buyout
contract there is an alteration of the states after the call to safeTransferFrom
, so a reentry is possible.
IERC1155(token).safeTransferFrom( address(this), msg.sender, id, _amount, "" ); // Updates ether balance of pool buyoutInfo[_vault].ethBalance += msg.value;
In the end
method, you play with fire by sending ether after the transfer, saving the reentrancy by deleting the buyoutInfo[_vault];
Safe Transfer Rules To be more explicit about how the standard safeTransferFrom and safeBatchTransferFrom functions MUST operate with respect to the ERC1155TokenReceiver hook functions, a list of scenarios and rules follows.
The definition of the burn
method of an ERC1155 is as follows:
A contract MAY skip calling the ERC1155TokenReceiver hook function(s) if the mint operation is transferring the token(s) to itself. In all other cases the ERC1155TokenReceiver rules MUST be followed as appropriate for the implementation (i.e. safe, custom and/or hybrid).
Reference:
So some implementations that follow the standard of an ERC1155 could also trigger a reentrancy.
In the case of the settleFractions
method of the Migration
contract, reentrancy is possible since the fractionsMigrated
flag is set after minting, which would allow the contract to be minted multiple times, however the receiver of the ERC1155TokenReceiver
event is address(this)
and is considered non-exploitable.
Under all these premises, I consider that the nonReentrant
modifier is needed in the following methods:
If someone sends ether to the Buyout
contract without using the buy/sell methods, via the receive
method, they will be blocked forever. A similar behavior occurs in the Migration
contract.
Affected source code:
fallback
The fallback method iss too complex and can be denied in certain cases.
According to the fallback
solidity (documentation](https://docs.soliditylang.org/en/develop/contracts.html#fallback-function)
In the worst case, if a payable fallback function is also used in place of a receive function, it can only rely on 2300 gas being available (see receive Ether function for a brief description of the implications of this).
In the worst case, the receive function can only rely on 2300 gas being available (for example when send or transfer is used), leaving little room to perform other operations except basic logging. The following operations will consume more gas than the 2300 gas stipend:
- Writing to storage
- Creating a contract
- Calling an external function which consumes a large amount of gas
- Sending Ether
Reference:
Affected source code:
Some used packages are out of date, it is good practice to use the latest version of these packages:
"@openzeppelin/contracts": "^4.6.0"
last 4.7.0
Check for address(0)
during constructor
, otherwise it could lead to bad initialization, bad implementations, or bad admin changes.
Affected source code for address(0)
:
Royalty _percentage
must be less than 100, otherwise it could result in a Denial of Service in royaltyInfo:
The code that contains "open todos" reflects that the development is not finished and that the code can change a posteriori, prior release, with or without audit.
Affected source code:
abstract
for base contractsAbstract contracts are contracts that have at least one function without its implementation. An instance of an abstract cannot be created.
Reference:
Affected source code:
install
allows uninstall
It is possible to use the method install
for uninstall
, and the emmited event will be different. Being able to affect the correct functioning of the dApps that consume it. You only need to specify an address(0)
as plugin during install
.
Affected source code:
The BaseVault
, Transfer
or TransferReference
contracts allows an attacker to steal all the tokens to the implementation it has without having any type of auth, although it is not expected to have tokens, and trying to call with a delegate call is a risk that must be known.
function ERC20Transfer( address _token, address _to, uint256 _value ) external { IERC20(_token).transfer(_to, _value); }
Affected source code:
The same thing happens in the Supply
contract with mint
and burn
:
/// @notice Mints fractional tokens /// @param _to Target address /// @param _value Transfer amount function mint(address _to, uint256 _value) external { IVaultRegistry(registry).mint(_to, _value); } /// @notice Burns fractional tokens /// @param _from Source address /// @param _value Burn amount function burn(address _from, uint256 _value) external { IVaultRegistry(registry).burn(_from, _value); }
Affected source code:
propose
method designThe propose
method of Migration
contract should return the proposalId
in order to avoid human errors like the one mentioned in the "User may lose ether due to incorrect proposalId
.
Affected source code:
#0 - HardlyDifficult
2022-07-28T00:23:44Z
Merging with #29, #68, https://github.com/code-423n4/2022-07-fractional-findings/issues/52, https://github.com/code-423n4/2022-07-fractional-findings/issues/58, #51, #62
🌟 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
149.629 USDC - $149.63
constants
expressions are expressions, not constants
Due to how constant variables are implemented (replacements at compile-time), an expression assigned to a constant variable is recomputed each time that the variable is used, which wastes some gas.
If the variable was immutable instead: the calculation would only be done once at deploy time (in the constructor), and then the result would be saved and read directly at runtime rather than being recalculated.
Reference:
Consequences: each usage of a "constant" costs ~100gas more on each access (it is still a little better than storing the result in storage, but not much..). since these are not real constants, they can't be referenced from a real constant environment (e.g. from assembly, or from another library )
Affected source code:
The following structs could be optimized moving the position of certains values in order to save slot storages:
Put booleans types together on IMigration.sol#L8-L33
struct Proposal { // Start time of the migration proposal uint256 startTime; // Target buyout price for the migration uint256 targetPrice; // Total ether contributed to the migration uint256 totalEth; // Total fractions contributed to the migration uint256 totalFractions; // Module contract addresses proposed for the migration address[] modules; // Plugin contract addresses proposed for the migration address[] plugins; // Function selectors for the proposed plugins bytes4[] selectors; + // Old fraction supply for a given vault + uint256 oldFractionSupply; + // New fraction supply for a given vault that has succesfully migrated + uint256 newFractionSupply; // Address for the new vault to migrate to (if buyout is succesful) address newVault; // Boolean status to check if the propoal is active bool isCommited; - // Old fraction supply for a given vault - uint256 oldFractionSupply; - // New fraction supply for a given vault that has succesfully migrated - uint256 newFractionSupply; // Boolean status to check that the fractions have already been migrated bool fractionsMigrated; }
calldata
instead of memory
The following methods are external
but memory
is used for arguments.
These one require to change the visibility to external
:
If a variable is not set/initialized, the default value is assumed (0, false
, 0x0 ... depending on the data type). You are simply wasting gas if you directly initialize it with its default value.
Affected source code:
It's cheaper to store the length of the array inside a local variable and iterate over it.
Affected source code:
++i
costs less gas compared to i++
or i += 1
++i
costs less gas compared to i++
or i += 1
for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.
i++
increments i
and returns the initial value of i
. Which means:
uint i = 1; i++; // == 1 but i == 2
But ++i
returns the actual incremented value:
uint i = 1; ++i; // == 2 and i == 2 too, so no need for a temporary variable
In the first case, the compiler has to create a temporary variable (when used) for returning 1
instead of 2
I suggest using ++i
instead of i++
to increment the value of an uint variable. Same thing for --i
and i--
Affected source code:
Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.
Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.
I suggest shortening the revert strings to fit in 32 bytes, or that using custom errors as described next (require pragma upgrade).
Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)
Source Custom Errors in Solidity:
Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.
Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).
Affected source code:
library
instead of abstract
contractIf the following contracts were libraries, they could save gas thanks to compiler optimizations since there are functions that are not called and being an abstract contract implies publishing all the code marked as public, such as library, the compiler can choose which functions to eliminate by not be used.
Smaller contracts would be produced, with less inheritance and therefore more auditable and readable.
Affected source code:
The following methods can be moved to inline calls without greatly affecting readability, this will increase the performance of the contract.
Affected source code:
immutable
It's possible to avoid storage access a save gas using immutable
keyword for the following variables:
It's also better to remove the initial values, because they will be set during the constructor.
Affected source code:
migrateFractions
logicmigrateFractions method could be optimized as follows:
- (, address proposer, State current, , , ) = IBuyout(buyout).buyoutInfo( + (, address proposer, State current, , , uint256 lastTotalSupply) = 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 - );
There is no need to duplicate the call of buyoutInfo(_vault)