Platform: Code4rena
Start Date: 22/09/2022
Pot Size: $30,000 USDC
Total HM: 12
Participants: 133
Period: 3 days
Judge: 0xean
Total Solo HM: 2
Id: 165
League: ETH
Rank: 20/133
Findings: 4
Award: $183.19
🌟 Selected for report: 0
🚀 Solo Findings: 0
There is a removeMinter
function in ERC20PermitPermissionedMint
. The function performs the removal minter_address
from the special address list. In other words, the function is needed to remove special access for a specific address.
// Remove a minter function removeMinter(address minter_address) public onlyByOwnGov { require(minter_address != address(0), "Zero address detected"); require(minters[minter_address] == true, "Address nonexistant"); // Delete from the mapping delete minters[minter_address]; // 'Delete' from the array by setting the address to 0x0 for (uint i = 0; i < minters_array.length; i++){ if (minters_array[i] == minter_address) { minters_array[i] = address(0); // This will leave a null in the array and keep the indices the same break; } } emit MinterRemoved(minter_address); }
Minter addresses are stored in two structures:
mapping(address => bool) minters
address[] minters_array
So the function should remove the address from both of them.
The removal from the first structure was implemented as delete minters[minter_address]
.
Please note, it is always consumed at most gas as one sstore
can consume, so that is safe in terms of DoS attack.
The removing from the second structure is implemented as traversing the minters_array
until the array element will be equal to the removing address and then assigning this element to address(0)
. Please note, traversing storage array consume as much gas as sload
of all elements will cost, it can lead to DoS attack.
for (uint i = 0; i < minters_array.length; i++){ if (minters_array[i] == minter_address) { minters_array[i] = address(0); // This will leave a null in the array and keep the indices the same break; } }
If minters_array
will contain many elements then traversing all of them may require more gas than is available in one Ethereum block! It can lead to the inability to remove the minter address.
Also, note that the size of the array is never reduced, the elements are simply set to zero, which makes the DoS more probable. Even if the insolvency will not happen, the gas consumption is still much higher than it can be, which will complicate the removal of minters and replacing them with others.
// SPDX-License-Identifier: GPL-2.0-or-later pragma solidity ^0.8.0; contract ERC20PermitPermissionedMint { address[] public minters_array; // Allowed to mint mapping(address => bool) public minters; // Mapping is also used for faster verification event MinterAdded(address minter_address); event MinterRemoved(address minter_address); function addMinter(address minter_address) public { require(minter_address != address(0), "Zero address detected"); require(minters[minter_address] == false, "Address already exists"); minters[minter_address] = true; minters_array.push(minter_address); emit MinterAdded(minter_address); } function removeMinter(address minter_address) public { require(minter_address != address(0), "Zero address detected"); require(minters[minter_address] == true, "Address nonexistant"); // Delete from the mapping delete minters[minter_address]; // 'Delete' from the array by setting the address to 0x0 for (uint i = 0; i < minters_array.length; i++){ if (minters_array[i] == minter_address) { minters_array[i] = address(0); // This will leave a null in the array and keep the indices the same break; } } emit MinterRemoved(minter_address); } } contract PoC is ERC20PermitPermissionedMint { uint256 addedMinters; function addManyAddresses(uint256 x) external { uint256 currentMinter = addedMinters; for(uint256 i = currentMinter + 1; i <= currentMinter + x; ++i) { addMinter(address(uint160(i))); } addedMinters = currentMinter + x; } }
The contract above is inherited from ERC20PermitPermissionedMint
, with a modification to add many minters at a time. With this modification, it is easy to check how much gas will be consumed for the removeMinter
function.
Here is some test that we have made on Solidity version 0.8.17 with enabled 200 optimizations:
Test | minters_array.length | removeMinter gas consumption |
---|---|---|
1 | 100 | 279k |
2 | 200 | 534k |
3 | 300 | 790k |
4 | 400 | 1.045m |
5 | 500 | 1.301m |
6 | 600 | 1.557m |
7 | 700 | 1.812m |
8 | 800 | 2.068m |
12 | 1200 | 3.090m |
20 | 2000 | 5.135m |
30 | 3000 | 7.701m |
40 | 4000 | 10.257m |
60 | 6000 | 15.360m |
Remove element from minters_array
by swapping such element with the last one and calling pop
on the array.
#0 - FortisFortuna
2022-09-25T23:05:28Z
Technically correct, but in practice, the number of minters will always remain low. If it becomes an issue, we can designate one minter as a "pre-minter" that has a batch of tokens minted to it beforehand, then auxiliary contracts can connect to that instead of ERC20PermitPermissionedMint.sol instead.
#1 - joestakey
2022-09-26T17:02:26Z
Duplicate of #12
39.1574 USDC - $39.16
https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L120 https://github.com/code-423n4/2022-09-frax/blob/55ea6b1ef3857a277e2f47d42029bc0f3d6f9173/src/frxETHMinter.sol#L125
There is a depositEther
function in frxETHMinter
contract. The function performs multiple deposits to the depositContract
. More detailed, the contract calculates the amount of ether that was submitted to it, and everything, except withheld amount, is deposited to depositContract
. Deposits are made as separate calls to the depositContract
each with 32 ether value.
/// @notice Deposit batches of ETH to the ETH 2.0 deposit contract /// @dev Usually a bot will call this periodically function depositEther() external nonReentrant { // Initial pause check require(!depositEtherPaused, "Depositing ETH is paused"); // See how many deposits can be made. Truncation desired. uint256 numDeposits = (address(this).balance - currentWithheldETH) / DEPOSIT_SIZE; require(numDeposits > 0, "Not enough ETH in contract"); // Give each deposit chunk to an empty validator for (uint256 i = 0; i < numDeposits; ++i) { // Get validator information ( bytes memory pubKey, bytes memory withdrawalCredential, bytes memory signature, bytes32 depositDataRoot ) = getNextValidator(); // Will revert if there are not enough free validators // Make sure the validator hasn't been deposited into already, to prevent stranding an extra 32 eth // until withdrawals are allowed require(!activeValidators[pubKey], "Validator already has 32 ETH"); // Deposit the ether in the ETH 2.0 deposit contract depositContract.deposit{value: DEPOSIT_SIZE}( pubKey, withdrawalCredential, signature, depositDataRoot ); // Set the validator as used so it won't get an extra 32 ETH activeValidators[pubKey] = true; emit DepositSent(pubKey, withdrawalCredential); } }
According to the code, the number of deposits performed by the function is calculated by the following formula:
// See how many deposits can be made. Truncation desired. uint256 numDeposits = (address(this).balance - currentWithheldETH) / DEPOSIT_SIZE;
The deposits are performed in a loop with numDeposits
iterations, without any upper bound on the number of iterations from the caller side. Please note, that non-malicious users actively increase the number of iterations in this loop just by using the deposit logic of the contract. In other words, the more users submit funds to the contract the heavier (in terms of gas amount) call of the depositEther
will be.
Let's consider the case when the activity of contract users is high and they deposit many ether value between calls of depositEther
function. In such a scenario, the function may consume more gas than the Ethereum block has. This means that in this case the function can never be called successfully.
Please note, that there is an access-controlled function recoverEther
that allows the owner to transfer ether from the contract, and therefore make the logic alive. Although such a possibility exists, it is still considered as unintentional DoS and should be fixed toward safer design.
Also it should be taken into account, that it is possible to send to the contract some value using selfdestruct(payable(contract_address))
instruction to increase (address(this).balance - currentWithheldETH)
difference in the most significant way.
Add an input parameter to be used as the upper bound on the number of deposits to be processed inside of depositEther
function.
#0 - FortisFortuna
2022-09-25T22:51:37Z
We plan to keep an eye on the number free validators and have a decent sized buffer of them.
#1 - FortisFortuna
2022-09-26T04:00:08Z
#2 - FortisFortuna
2022-09-26T16:29:24Z
Adding a maxLoops parameter or similar can help mitigate this for sure.
#3 - 0xean
2022-10-12T16:53:48Z
dupe of #17 / #224
🌟 Selected for report: rotcivegaf
Also found by: 0x040, 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, 0xf15ers, 8olidity, Aymen0909, B2, Bahurum, Bnke0x0, Ch_301, CodingNameKiki, Deivitto, Diana, Funen, IllIllI, JC, JLevick, KIntern_NA, Lambda, OptimismSec, PaludoX0, RockingMiles, Rolezn, Sm4rty, Soosh, Tagir2003, Tointer, TomJ, Triangle, Trust, V_B, Waze, Yiko, __141345__, a12jmx, ajtra, asutorufos, ayeslick, aysha, bbuddha, bharg4v, bobirichman, brgltd, bytera, c3phas, cryptostellar5, cryptphi, csanuragjain, datapunk, delfin454000, durianSausage, exd0tpy, gogo, got_targ, jag, joestakey, karanctf, ladboy233, leosathya, lukris02, mics, millersplanet, natzuu, neko_nyaa, obront, oyc_109, parashar, peritoflores, rbserver, ret2basic, rokinot, ronnyx2017, rvierdiiev, sach1r0, seyni, sikorico, slowmoses, tnevler, yasir, yongskiws
28.0705 USDC - $28.07
It is unsafe to rely on logic that underlying token have permit
function implemented. It is considered good practice to use ERC20 standard modification that contains logic of safe calling of permit
function. This is so, because of possibility of existence of phantom function that will be used when contract calls permit
and, as result, incorrect logic of usage of such function.
It is reasonable to use indexed
for some of the parameters of the events EmergencyERC20Recovered
, TimelockChanged
, ValidatorsSwapped
, MinterAdded
, MinterRemoved
, TimelockChanged
, OwnerNominated
, OwnerChanged
.
It is reasonable to add allowance for the owner account to call any contract by call
/delegate call
on behalf of contract. As we can see owner already have an ability to withdraw any amount of ETH or any ERC20 token, so it will be good to give owner rights to call any logic and to not have many special recover
functions.
minter_burn_from
and minter_mint
functions declared as public
, but it is reasonable to delcare them as external
, since they are not used throught internal calls.
There is a function clearValidatorArray
in OperatorRegistry
contract. It clears all the validator array, so for each of the validators it writes to the corresponding storage slot a zero value. In case of big amount of validators there will be no chance to call this function due to very big amount of gas to be used, which actually can be even greater than the block gas limit.
🌟 Selected for report: pfapostol
Also found by: 0x040, 0x1f8b, 0x4non, 0x5rings, 0xA5DF, 0xNazgul, 0xSmartContract, 0xmatt, 0xsam, Amithuddar, Aymen0909, B2, Ben, Bnke0x0, Chom, CodingNameKiki, Deivitto, Diana, Fitraldys, Funen, IllIllI, JAGADESH, JC, Metatron, Ocean_Sky, PaludoX0, Pheonix, RaymondFam, ReyAdmirado, RockingMiles, Rohan16, Rolezn, Satyam_Sharma, Sm4rty, SnowMan, SooYa, Tagir2003, TomJ, Tomio, Triangle, V_B, Waze, __141345__, ajtra, albincsergo, asutorufos, aysha, beardofginger, bobirichman, brgltd, bulej93, bytera, c3phas, ch0bu, cryptostellar5, cryptphi, d3e4, delfin454000, dharma09, drdr, durianSausage, emrekocak, erictee, fatherOfBlocks, gogo, got_targ, imare, jag, karanctf, ladboy233, leosathya, lukris02, medikko, mics, millersplanet, natzuu, neko_nyaa, oyc_109, peanuts, prasantgupta52, rbserver, ret2basic, rokinot, ronnyx2017, rotcivegaf, sach1r0, samruna, seyni, slowmoses, tnevler, wagmi, zishansami
12.811 USDC - $12.81
It is reasonable to use calldata
instead of memory
for input arrays in setWithdrawalCredential
and getValidatorStruct
functions to reduce gas consumption and make the code more clear.
It will be more efficient to use a little bit different logic for removeValidator
function from OperatorRegistry.sol
contract.
There is the following logic for the case when dont_care_about_ordering
input parameter is false
:
// Save the original validators Validator[] memory original_validators = validators; // Clear the original validators list delete validators; // Fill the new validators array with all except the value to remove for (uint256 i = 0; i < original_validators.length; ++i) { if (i != remove_idx) { validators.push(original_validators[i]); } }
It is reasonable to not clear all the array and do redundant reads and writes from the storage, but instead this implement the following logic:
for (uint256 i = remove_idx; i + 1 < validators.length; ++i) { validators[i] = validators[i + 1]; } validators.pop();