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: 41/133
Findings: 3
Award: $65.07
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Lambda
Also found by: 0x1f8b, 0x5rings, 0xSky, 0xSmartContract, 8olidity, Chom, CodingNameKiki, IllIllI, Ruhum, Sm4rty, brgltd, hansfriese, m9800, magu, pashov, pedroais, peritoflores, prasantgupta52, rokinot, seyni
12.4859 USDC - $12.49
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L199-L203
It will not be possible to recover all ERC20 tokens sent to the contract
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L199-L203
Not all tokens follow the ERC20 standard. E.g. USDT will not return boolean on transfer.
Even if the protocol does not plan to add USDT integration, it's possible to USDT to be sent accidentaly, in which case the function recoverERC20
will fail due to abnormal ERC20 token behaviour, which will prevent the tokens from being recovered.
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyByOwnGov { require(IERC20(tokenAddress).transfer(owner, tokenAmount), "recoverERC20: Transfer failed"); emit EmergencyERC20Recovered(tokenAddress, tokenAmount); }
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L199-L203
Replace IERC20
with a SafeERC20 implementation. OpenZeppelin and Solmate are the most populars. With this change, it would be possible to recover ERC20 tokens even without boolean returns.
#0 - FortisFortuna
2022-09-25T21:35:35Z
Not really medium risk. Technically you could use safeTransfer, but if someone were to accidentally send something to this contract, it would most likely be either ETH, FRAX, frxETH, or sfrxETH, all of which are transfer compliant.
#1 - joestakey
2022-09-26T16:22:43Z
Duplicate of #18
π 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
39.7592 USDC - $39.76
function popValidators(uint256 times) public onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L82
function removeValidator(uint256 remove_idx, bool dont_care_about_ordering) public onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L93
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;
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L120-L151
The depositEther()
function is updating the state variable activeValidators
before the depositContract.deposit()
call. The function is not at risk of reentrancy due to the nonReentrant
modifier. However, to follow the checks-effects-interactions, state modifications should be computed before the external call to ensure the state is only updated on successul calls.
In this case, the following modification could be made.
$ git diff --no-index frxETHMinter.sol.orig frxETHMinter.sol diff --git a/frxETHMinter.sol.orig b/frxETHMinter.sol index a33bdbe..6f5fd2e 100644 --- a/frxETHMinter.sol.orig +++ b/frxETHMinter.sol @@ -139,6 +139,9 @@ contract frxETHMinter is OperatorRegistry, ReentrancyGuard { // until withdrawals are allowed require(!activeValidators[pubKey], "Validator already has 32 ETH"); + // Set the validator as used so it won't get an extra 32 ETH + activeValidators[pubKey] = true; + // Deposit the ether in the ETH 2.0 deposit contract depositContract.deposit{value: DEPOSIT_SIZE}( pubKey, @@ -147,9 +150,6 @@ contract frxETHMinter is OperatorRegistry, ReentrancyGuard { depositDataRoot ); - // Set the validator as used so it won't get an extra 32 ETH - activeValidators[pubKey] = true; - emit DepositSent(pubKey, withdrawalCredential); } }
Some arguments and variables are written in snake_case
function removeValidator(uint256 remove_idx, bool dont_care_about_ordering) public onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L93
bytes memory removed_pubkey = validators[remove_idx].pubKey;
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L95
While others are mixedCase.
function getValidatorStruct( bytes memory pubKey, bytes memory signature, bytes32 depositDataRoot ) external pure returns (Validator memory) {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L171-L175
uint arrayLength = validatorArray.length;
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L62
Consider using only one convention. Note that the solidity docs recommends mixedCase for both function arguments and local variables.
The current algorithm will create a new array and push every element except the one to be excluded.
// 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]); } }
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L107-L118
A better approach would be to switch elements (i with i + 1) starting from the remove_idx
until the last index, and then pop the last element. This would also improve performance and gas consumption if the target element is located at the end of the array (since it won't traverse the entire array anymore).
for (uint256 i = remove_idx; i < validators.length - 1; ++i) { validators[i] = validators[i + 1] } validators.pop();
Missing checks for zero-addresses may lead to infunctional protocol, if the variable addresses are initialized incorrectly.
constructor( address depositContractAddress, address frxETHAddress, address sfrxETHAddress, address _owner, address _timelock_address,
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L52-L57
constructor( address _creator_address, address _timelock_address,
Locking the pragma helps to ensure that contracts do not accidentally get deployed using an outdated compiler version.
Note that pragma statements can be allowed to float when a contract is intended for consumption by other developers, as in the case with contracts in a library or a package.
The following 5 contracts are not libraries and are floating the pragma.
src/frxETH.sol, src/sfrxETH.sol, src/ERC20/ERC20PermitPermissionedMint.sol, src/frxETHMinter.sol, src/OperatorRegistry.sol,
32-bits will stop working after January 19th 2038 for storing unix timestamp. This is likely not a real issue for the protocol, since 16 years is still a lot of time. However, from a purely software resilience and quality assurance point of view, I would recommend to use 40 bits when downcasting block.timestamp.
The official solidity style guide recommends to declare an event before functions. Also, another recommendation is to declare public functions bellow external functions.
/// @notice Empties the validator array /// @dev Need to do this before setWithdrawalCredential() function clearValidatorArray() external onlyByOwnGov { delete validators; emit ValidatorArrayCleared(); } /// @notice Returns the number of validators function numValidators() public view returns (uint256) { return validators.length; } /// @notice Set the timelock contract function setTimelock(address _timelock_address) external onlyByOwnGov { require(_timelock_address != address(0), "Zero address detected"); timelock_address = _timelock_address; emit TimelockChanged(_timelock_address); } event TimelockChanged(address timelock_address); event WithdrawalCredentialSet(bytes _withdrawalCredential); event ValidatorAdded(bytes pubKey, bytes withdrawalCredential); event ValidatorArrayCleared(); event ValidatorRemoved(bytes pubKey, uint256 remove_idx, bool dont_care_about_ordering); event ValidatorsPopped(uint256 times); event ValidatorsSwapped(bytes from_pubKey, bytes to_pubKey, uint256 from_idx, uint256 to_idx); event KeysCleared();
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L188-L215
Lack of two-step procedure for critical operations leaves them error-prone. Consider adding two step procedure on the critical functions.
function setTimelock(address _timelock_address) public onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/ERC20/ERC20PermitPermissionedMint.sol#L94
function setWithholdRatio(uint256 newRatio) external onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol
function setWithdrawalCredential(bytes memory _new_withdrawal_pubkey) external onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L181
function setTimelock(address _timelock_address) external onlyByOwnGov {
https://github.com/code-423n4/2022-09-frax/blob/main/src/OperatorRegistry.sol#L202
Consider removing the shares
state variable or refactoring submitAndDeposit()
to use the return named variable.
function submitAndDeposit(address recipient) external payable returns (uint256 shares) { // Give the frxETH to this contract after it is generated _submit(address(this)); // Approve frxETH to sfrxETH for staking frxETHToken.approve(address(sfrxETHToken), msg.value); // Deposit the frxETH and give the generated sfrxETH to the final recipient uint256 sfrxeth_recieved = sfrxETHToken.deposit(msg.value, recipient); require(sfrxeth_recieved > 0, 'No sfrxETH was returned'); return sfrxeth_recieved;
https://github.com/code-423n4/2022-09-frax/blob/main/src/frxETHMinter.sol#L70-L82
π 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.8158 USDC - $12.82
There's 1 instance of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 84: for (uint i = 0; i < minters_array.length; i++){
There are 5 instances of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 84: for (uint i = 0; i < minters_array.length; i++){
File: src/OperatorRegistry.sol 63: for (uint256 i = 0; i < arrayLength; ++i) { 84: for (uint256 i = 0; i < times; ++i) { 114: for (uint256 i = 0; i < original_validators.length; ++i) {
File: src/frxETHMinter.sol 129: for (uint256 i = 0; i < numDeposits; ++i) {
There are 2 instances of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 84: for (uint i = 0; i < minters_array.length; i++){
File: src/OperatorRegistry.sol 114: for (uint256 i = 0; i < original_validators.length; ++i) {
There are 5 instances of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 84: for (uint i = 0; i < minters_array.length; i++){
File: src/OperatorRegistry.sol 63: for (uint256 i = 0; i < arrayLength; ++i) { 84: for (uint256 i = 0; i < times; ++i) { 114: for (uint256 i = 0; i < original_validators.length; ++i) {
File: src/frxETHMinter.sol 129: for (uint256 i = 0; i < numDeposits; ++i) {
Replace > 0
with != 0
for unsigned integers.
There are 2 instances of this issue.
File: src/frxETHMinter.sol 79: require(sfrxeth_recieved > 0, 'No sfrxETH was returned'); 126: require(numDeposits > 0, "Not enough ETH in contract");
There are 21 instances of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 41: require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); 46: require(minters[msg.sender] == true, "Only minters"); 66: require(minter_address != address(0), "Zero address detected"); 68: require(minters[minter_address] == false, "Address already exists"); 77: require(minter_address != address(0), "Zero address detected"); 78: require(minters[minter_address] == true, "Address nonexistant"); 95: require(_timelock_address != address(0), "Zero address detected");
File: src/OperatorRegistry.sol 46: require(msg.sender == timelock_address || msg.sender == owner, "Not owner or timelock"); 137: require(numVals != 0, "Validator stack is empty"); 182: require(numValidators() == 0, "Clear validator array first"); 203: require(_timelock_address != address(0), "Zero address detected");
File: src/frxETHMinter.sol 79: require(sfrxeth_recieved > 0, 'No sfrxETH was returned'); 87: require(!submitPaused, "Submit is paused"); 88: require(msg.value != 0, "Cannot submit 0"); 122: require(!depositEtherPaused, "Depositing ETH is paused"); 126: require(numDeposits > 0, "Not enough ETH in contract"); 140: require(!activeValidators[pubKey], "Validator already has 32 ETH"); 167: require(amount <= currentWithheldETH, "Not enough withheld ETH in contract"); 171: require(success, "Invalid transfer"); 193: require(success, "Invalid transfer"); 200: require(IERC20(tokenAddress).transfer(owner, tokenAmount), "recoverERC20: Transfer failed");
There are 3 instances of this issue.
File: src/ERC20/ERC20PermitPermissionedMint.sol 46: require(minters[msg.sender] == true, "Only minters"); 68: require(minters[minter_address] == false, "Address already exists"); 78: require(minters[minter_address] == true, "Address nonexistant");
The values can still be inspected on the source code if necessary.
There are 2 instances of this issue.
File: src/frxETHMinter.sol 38: uint256 public constant DEPOSIT_SIZE = 32 ether; // ETH 2.0 minimum deposit size 39: uint256 public constant RATIO_PRECISION = 1e6; // 1,000,000
There are 4 instances of this issue.
File: lib/ERC4626/src/xERC4626.sol 67: storedTotalAssets -= amount; 72: storedTotalAssets += amount;
File: src/frxETHMinter.sol 97: currentWithheldETH += withheld_amt; 168: currentWithheldETH -= amount;