├── .gitignore ├── reports ├── IVX.pdf ├── Tokemak.md ├── canto.md ├── moonwell.md ├── ajna.md ├── index-coop.md ├── venus.md └── eigenlayer.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* -------------------------------------------------------------------------------- /reports/IVX.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0xVolodya/audits/HEAD/reports/IVX.pdf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 0xVolodya audit reports 2 | 3 | This repo holds a selection of recent auditing projects i've done that can be shared publicly. 4 | 5 | ### Past audits 6 | 7 | | protocol | info | place | report | 8 | |-------------------------------------------| ---- |----------------------------------------------------------------------------------------------------------------------|---------------------------------| 9 | | [IVX](https://www.ivx.fi//) | Options protocol | [Guardian Audits](https://github.com/GuardianAudits/Audits/tree/main/IVX) | [report](reports/IVX.pdf) | 10 | | [Eigenlayer](https://www.eigenlayer.xyz/) | Enabling restaking of staked Ether, to be used as cryptoeconomic security for decentralized protocols and applications | [code4rena,
2nd place](https://code4rena.com/contests/2023-04-eigenlayer-contest#top) | [report](reports/eigenlayer.md) | 11 | | [Venus](https://app.venus.io/) | Earn, Borrow & Lend on the #1 Decentralized Money Market on the BNB Chain | [code4rena,
3nd place](https://code4rena.com/contests/2023-05-venus-protocol-isolated-pools#top) | [report](reports/venus.md) | 12 | | [Ajna](https://www.ajna.finance/) | A peer to peer, oracleless, permissionless lending protocol with no governance, accepting both fungible and non fungible tokens as collateral. | code4rena | [report](reports/ajna.md) | 13 | | [Canto](https://www.cantoidentity.build/) | Subprotocols for Canto Identity Protocol. | [code4rena,
4nd place](https://code4rena.com/contests/2023-03-canto-identity-subprotocols-contest#top) | [report](reports/canto.md) | 14 | | [Index Coop](https://indexcoop.com/) | decentralized structured products that make crypto simple, accessible. | [sherlock,
4nd place](https://discord.com/channels/812037309376495636/1109133391904915557/1135569315924557914) | [report](reports/index-coop.md) | 15 | | [Tokemak](https://www.tokemak.xyz/) | Generating sustainable liquidity | [sherlock,
6nd place](https://audits.sherlock.xyz/contests/101/leaderboard) | [report](reports/Tokemak.md) | 16 | | [Moonwell](https://twitter.com/MoonwellDeFi) | An open lending and borrowing DeFi protocol | code4rena| [report](reports/moonwell.md) | 17 | 18 | ### About **0xVolodya** 19 | 20 | 0xVolodya is an independent smart contract security researcher. Warden at [code4rena](https://code4rena.com/).\ 21 | Ranked #1 on the 60-day leaderboard. You can say hi on Twitter at [@0xVolodya](https://twitter.com/0xVolodya). 22 | 23 | ![image](https://pbs.twimg.com/profile_banners/3988136668/1688113444/1500x500) 24 | 25 | ### Availability 26 | 27 | currently available for projects. dm me [@0xVolodya](https://twitter.com/0xVolodya). 28 | -------------------------------------------------------------------------------- /reports/Tokemak.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 |
6 |

Tokemak Audit Report

7 |

Generating sustainable liquidity for the tokenized world. Eliminating inefficiencies and helping LPs to deploy liquidity where it can do the most work is exactly why we are building Tokemak v2! 8 |

9 |

Prepared by: 0xVolodya, Independent Security Researcher

10 |

Date: Aug 1 to Aug 7, 2023

11 |
14 | 15 | # About Venus 16 | Generating sustainable liquidity for the tokenized world. Eliminating inefficiencies and helping LPs to deploy liquidity where it can do the most work is exactly why we are building Tokemak v2! 17 | 18 | # Summary of Findings 19 | 20 | |    ID        | Title | Severity | Fixed | 21 | |----------------------------------------------------------------|----------------------------------------------------------------------------------------|----------|------| 22 | | [H-01] | Liquidations sometimes will not work due to incorrect logic inside queueNewRewards | High | ✓ | 23 | | [H-02] | Incentive Pricing will not provide a robust estimate of incentive pricing to the LMP due to incorrect scaling | High | ✓ | 24 | | [H-03] | Curve pool reentrancy check doesn't work for some pools which lead to draining of funds | High | ✓ | 25 | 26 | # Detailed Findings 27 | 28 | ## [H-01] Liquidations sometimes will not work due to incorrect logic inside queueNewRewards 29 | 30 | ## Vulnerability Detail 31 | Whenever system conducts the liquidation process at some point there will be a call to queueNewRewards, lets note that approve is to amount 32 | ```solidity 33 | // approve main rewarder to pull the tokens 34 | LibAdapter._approve(IERC20(params.buyTokenAddress), address(mainRewarder), amount); 35 | mainRewarder.queueNewRewards(amount); 36 | ``` 37 | [src/liquidation/LiquidationRow.sol#L276](https://github.com/sherlock-audit/2023-06-tokemak/blob/main/v2-core-audit-2023-07-14/src/liquidation/LiquidationRow.sol#L276) 38 | 39 | Let's look at queueNewRewards at some point startingQueuedRewards will not be 0 thus 40 | newRewards = newRewards + startingQueuedRewards 41 | and rewarded will try to request those funds from liquidationRow, but we remember that allowance is only newRewards and not newRewards + startingQueuedRewards, so this will fail 42 | `IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), newRewards)` 43 | 44 | ```solidity 45 | 46 | function queueNewRewards(uint256 newRewards) external onlyWhitelisted { 47 | uint256 startingQueuedRewards = queuedRewards; 48 | uint256 startingNewRewards = newRewards; 49 | 50 | newRewards += startingQueuedRewards; 51 | 52 | if (block.number >= periodInBlockFinish) { 53 | notifyRewardAmount(newRewards); 54 | queuedRewards = 0; 55 | } else { 56 | uint256 elapsedBlock = block.number - (periodInBlockFinish - durationInBlock); 57 | uint256 currentAtNow = rewardRate * elapsedBlock; 58 | uint256 queuedRatio = currentAtNow * 1000 / newRewards; 59 | 60 | if (queuedRatio < newRewardRatio) { 61 | notifyRewardAmount(newRewards); 62 | queuedRewards = 0; 63 | } else { 64 | queuedRewards = newRewards; 65 | } 66 | } 67 | 68 | emit QueuedRewardsUpdated(startingQueuedRewards, startingNewRewards, queuedRewards); 69 | 70 | // Transfer the new rewards from the caller to this contract. 71 | IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), newRewards); // @audit should be newRewards - startingQueuedRewards 72 | } 73 | ``` 74 | [src/rewarders/AbstractRewarder.sol#L239](https://github.com/sherlock-audit/2023-06-tokemak/blob/main/v2-core-audit-2023-07-14/src/rewarders/AbstractRewarder.sol#L239) 75 | 76 | ## Recommendation 77 | There suppose to be a subtraction because we already transferred startingQueuedRewards in previous calls 78 | 79 | ```solidity 80 | 81 | function queueNewRewards(uint256 newRewards) external onlyWhitelisted { 82 | uint256 startingQueuedRewards = queuedRewards; 83 | uint256 startingNewRewards = newRewards; 84 | 85 | newRewards += startingQueuedRewards; 86 | 87 | if (block.number >= periodInBlockFinish) { 88 | notifyRewardAmount(newRewards); 89 | queuedRewards = 0; 90 | } else { 91 | uint256 elapsedBlock = block.number - (periodInBlockFinish - durationInBlock); 92 | uint256 currentAtNow = rewardRate * elapsedBlock; 93 | uint256 queuedRatio = currentAtNow * 1000 / newRewards; 94 | 95 | if (queuedRatio < newRewardRatio) { 96 | notifyRewardAmount(newRewards); 97 | queuedRewards = 0; 98 | } else { 99 | queuedRewards = newRewards; 100 | } 101 | } 102 | 103 | emit QueuedRewardsUpdated(startingQueuedRewards, startingNewRewards, queuedRewards); 104 | 105 | // Transfer the new rewards from the caller to this contract. 106 | - IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), newRewards); 107 | + IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), newRewards - startingQueuedRewards); 108 | } 109 | ``` 110 | 111 | ## [H-02] Incentive Pricing will not provide a robust estimate of incentive pricing to the LMP due to incorrect scaling 112 | 113 | ## Vulnerability Detail 114 | After the initialization phase first INIT_SAMPLE_COUNT prices will be summed and scaled * 1e18 while getting the average. 115 | But when the next time we will get existing.slowFilterPrice and existing.fastFilterPrice the current price will not be scaled 116 | 117 | ```solidity 118 | 119 | uint256 price = pricer.getPriceInEth(token); 120 | existing.lastSnapshot = uint40(block.timestamp); 121 | 122 | if (existing._initComplete) { 123 | existing.slowFilterPrice = Stats.getFilteredValue(SLOW_ALPHA, existing.slowFilterPrice, price); // price is not scaled by 1e18 124 | existing.fastFilterPrice = Stats.getFilteredValue(FAST_ALPHA, existing.fastFilterPrice, price);// price is not scaled by 1e18 125 | } else { 126 | // still the initialization phase 127 | existing._initCount += 1; 128 | existing._initAcc += price; 129 | 130 | if (existing._initCount == INIT_SAMPLE_COUNT) { 131 | existing._initComplete = true; 132 | uint256 averagePrice = existing._initAcc * 1e18 / INIT_SAMPLE_COUNT;// price scaled by 1e18 133 | existing.fastFilterPrice = averagePrice; 134 | existing.slowFilterPrice = averagePrice; 135 | } 136 | } 137 | ``` 138 | [calculators/IncentivePricingStats.sol#L156](https://github.com/sherlock-audit/2023-06-tokemak/blob/main/v2-core-audit-2023-07-14/src/stats/calculators/IncentivePricingStats.sol#L156) 139 | 140 | This is how priorValue and currentValue look like in the test inside getFilteredValue function, which seems like scaling is not correct 141 | 142 | ```solidity 143 | function getFilteredValue( 144 | uint256 alpha, 145 | uint256 priorValue, 146 | uint256 currentValue 147 | ) internal view returns (uint256) { 148 | if (alpha > 1e18 || alpha == 0) revert Errors.InvalidParam("alpha"); 149 | console.log("--------------------------------"); 150 | console.log(priorValue); 151 | console.log(currentValue); 152 | return ((priorValue * (1e18 - alpha)) + (currentValue * alpha)) / 1e18; 153 | } 154 | ``` 155 | 156 | ```solidity 157 | -------------------------------- 158 | 4288888888888888888888888888888888888 159 | 10000000000 160 | ``` 161 | 162 | ## [H-03] Curve pool reentrancy check doesn't work for some pools which lead to draining of funds 163 | 164 | ## Vulnerability Detail 165 | From there [post mortem](https://medium.com/@ConicFinance/post-mortem-eth-and-crvusd-omnipool-exploits-c9c7fa213a3d) 166 | 167 | > Our assumption was that Curve v2 pools using ETH have the ETH address (0xeee…eee) as one of their coins. However, they instead have the WETH address. This led to _isETH returning false, and in turn, to the reentrancy guard of the rETH pool being bypassed. 168 | 169 | E.x. in the readme there is 170 | 171 | > Curve stETH/ETH concentrated: 0x828b154032950C8ff7CF8085D841723Db2696056 172 | 173 | if you will go to [contract](https://etherscan.io/address/0x828b154032950c8ff7cf8085d841723db2696056#readContract) and see coins 174 | it will use 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 instead of 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE 175 | 176 | Same for this 0x6c38cE8984a890F5e46e6dF6117C26b3F1EcfC9C and maybe some others. 177 | 178 | ```solidity 179 | if (iToken == LibAdapter.CURVE_REGISTRY_ETH_ADDRESS_POINTER) { 180 | if (poolInfo.checkReentrancy == 1) { 181 | // This will fail in reentrancy 182 | ICurveOwner(pool.owner()).withdraw_admin_fees(address(pool)); 183 | } 184 | } 185 | ``` 186 | [CurveV1StableEthOracle.sol#L132](https://github.com/sherlock-audit/2023-06-tokemak/blob/main/v2-core-audit-2023-07-14/src/oracles/providers/CurveV1StableEthOracle.sol#L132) -------------------------------------------------------------------------------- /reports/canto.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

Canto Audit Report

7 |

Subprotocols for Canto Identity Protocol.

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: Mar 17 to Mar 20, 2023

10 |
13 | 14 | # About Canto 15 | The audit covers three subprotocols for the Canto Identity Protocol: 16 | 17 | Canto Bio Protocol: Allows the association of a biography to an identity 18 | Canto Profile Picture Protocol: Allows the association of a profile picture (arbitrary NFT) to an identity 19 | Canto Namespace Protocol: A subprotocol for minting names from tiles (characters in a specific font). 20 | 21 | # Summary of Findings 22 | 23 | | ID | Title | Severity | Fixed | 24 | |-------------| ---------------------------- |----------| ----- | 25 | | [H-01]| Users will be able to purchase fewer NFTs than the project had anticipated | High | ✓ | 26 | | [M-01]| Bio NFT incorrectly breaks SVG lines and doesn't support more than 120 characters effectively | Medium | ✓ | 27 | 28 | # Detailed Findings 29 | 30 | ## [H-01] Users will be able to purchase fewer NFTs than the project had anticipated 31 | 32 | ## Impact 33 | Users will be able to purchase fewer NFTs than the project had anticipated. The project had expected that users would be able to purchase a range of variations using both text and emoji characters. However, in reality, users will only be able to purchase a range of variations using emoji characters. 34 | 35 | For example, the list of characters available for users to choose from is as follows 36 | ![image](https://i.ibb.co/NjnD4Tf/Screenshot-from-2023-03-20-00-22-32.png) 37 | 38 | For instance, if a user chooses to mint an NFT namespace using font class 2 and the single letter 𝒶, then theoretically all other users should be able to mint font class 0 using the first emoji in the list, font class 1 using the single letter "a," font class 3 using the single letter 𝓪, and so on, the first letter on every class will be. However, in reality, they will not be able to do so. 39 | 40 | I consider this to be a critical issue because the project may not be able to sell as many NFTs as expected, potentially resulting in a loss of funds. 41 | 42 | ## Proof of Concept 43 | This is a function that creates namespace out of tray. 44 | ```solidity 45 | canto-namespace-protocol/src/Namespace.sol#L110 46 | function fuse(CharacterData[] calldata _characterList) external { 47 | uint256 numCharacters = _characterList.length; 48 | if (numCharacters > 13 || numCharacters == 0) revert InvalidNumberOfCharacters(numCharacters); 49 | uint256 fusingCosts = 2**(13 - numCharacters) * 1e18; 50 | SafeTransferLib.safeTransferFrom(note, msg.sender, revenueAddress, fusingCosts); 51 | uint256 namespaceIDToMint = ++nextNamespaceIDToMint; 52 | Tray.TileData[] storage nftToMintCharacters = nftCharacters[namespaceIDToMint]; 53 | bytes memory bName = new bytes(numCharacters * 33); // Used to convert into a string. Can be 33 times longer than the string at most (longest zalgo characters is 33 bytes) 54 | uint256 numBytes; 55 | // Extract unique trays for burning them later on 56 | uint256 numUniqueTrays; 57 | uint256[] memory uniqueTrays = new uint256[](_characterList.length); 58 | for (uint256 i; i < numCharacters; ++i) { 59 | bool isLastTrayEntry = true; 60 | uint256 trayID = _characterList[i].trayID; 61 | uint8 tileOffset = _characterList[i].tileOffset; 62 | // Check for duplicate characters in the provided list. 1/2 * n^2 loop iterations, but n is bounded to 13 and we do not perform any storage operations 63 | for (uint256 j = i + 1; j < numCharacters; ++j) { 64 | if (_characterList[j].trayID == trayID) { 65 | isLastTrayEntry = false; 66 | if (_characterList[j].tileOffset == tileOffset) revert FusingDuplicateCharactersNotAllowed(); 67 | } 68 | } 69 | Tray.TileData memory tileData = tray.getTile(trayID, tileOffset); // Will revert if tileOffset is too high 70 | uint8 characterModifier = tileData.characterModifier; 71 | 72 | if (tileData.fontClass != 0 && _characterList[i].skinToneModifier != 0) { 73 | revert CannotFuseCharacterWithSkinTone(); 74 | } 75 | 76 | if (tileData.fontClass == 0) { 77 | // Emoji 78 | characterModifier = _characterList[i].skinToneModifier; 79 | } 80 | bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier); 81 | ... 82 | ``` 83 | [canto-namespace-protocol/src/Namespace.sol#L110](https://github.com/code-423n4/2023-03-canto-identity/blob/077372297fc419ea7688ab62cc3fd4e8f4e24e66/canto-namespace-protocol/src/Namespace.sol#L110) 84 | 85 | There is a bug in this line of code where a character is retrieved from tile data. Instead of passing `tileData.fontClass`, we are passing `0`. 86 | 87 | ```solidity 88 | bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier); 89 | ``` 90 | Due to this bug, the names for all four different font classes will be the same. As a result, they will point to an existing namespace, and later, there will be a check for the existence of that name (token) using NameAlreadyRegistered. 91 | 92 | ```solidity 93 | string memory nameToRegister = string(bName); 94 | uint256 currentRegisteredID = nameToToken[nameToRegister]; 95 | if (currentRegisteredID != 0) revert NameAlreadyRegistered(currentRegisteredID); 96 | ``` 97 | 98 | ## Tools Used 99 | Manual review, forge tests 100 | ## Recommended Mitigation Steps 101 | Pass font class instead of 0 102 | ```diff 103 | - bytes memory charAsBytes = Utils.characterToUnicodeBytes(0, tileData.characterIndex, characterModifier); 104 | + bytes memory charAsBytes = Utils.characterToUnicodeBytes(tileData.fontClass, tileData.characterIndex, characterModifier); 105 | ``` 106 | 107 | ## [M-01] Bio NFT incorrectly breaks SVG lines and doesn't support more than 120 characters effectively 108 | 109 | ## Impact 110 | Bio NFT incorrectly breaks SVG lines and doesn't support more than 120 characters effectively. 111 | 112 | ## Proof of Concept 113 | According to the docs 114 | > Any user can mint a Bio NFT by calling Bio.mint and passing his biography. It needs to be shorter than 200 characters. 115 | 116 | Let's take two strings and pass them to create an NFT. The first one is 200 characters long, and the second one is 120 characters long. 117 | `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaW` 118 | `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaWaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaW` 119 | 120 | This is how they will look like. As you can see they look identical. 121 | ![image](https://i.ibb.co/Cvw62Rz/Screenshot-from-2023-03-19-12-20-26.png) 122 | 123 | Next, lets take this text for which we create nft. I took it from a test and double. `012345678901234567890123456789012345678👨‍👩‍👧‍👧012345678901234567890123456789012345678👨‍👩‍👧‍👧` 124 | 125 | Here is on the left how it looks now vs how it suppose to be. As you can you line breaking doesn't work. I did enlarge 126 | viewBox so you can see the difference. 127 | 128 | ![image](https://i.ibb.co/XDNxLWx/Screenshot-from-2023-03-19-12-28-42.png) 129 | 130 | The problem is in this part of the code, where `(i > 0 && (i + 1) % 40 == 0)` doesn't handle properly because you want 131 | to include emojis, so length will be more than 40 (`40 + length(emoji)`) 132 | ```solidity 133 | canto-bio-protocol/src/Bio.sol#L56 134 | for (uint i; i < lengthInBytes; ++i) { 135 | bytes1 character = bioTextBytes[i]; 136 | bytesLines[bytesOffset] = character; 137 | bytesOffset++; 138 | if ((i > 0 && (i + 1) % 40 == 0) || prevByteWasContinuation || i == lengthInBytes - 1) { 139 | bytes1 nextCharacter; 140 | ``` 141 | [canto-bio-protocol/src/Bio.sol#L56](https://github.com/code-423n4/2023-03-canto-identity/blob/077372297fc419ea7688ab62cc3fd4e8f4e24e66/canto-bio-protocol/src/Bio.sol#L56) 142 | Lastly, the NFT doesn't center-align text, but I believe it should. I took text from a test and on the left is how it currently appears, while on the right is how I think it should be. 143 | ![image](https://i.ibb.co/8r1jMhc/Screenshot-from-2023-03-18-13-43-21.png) 144 | 145 | Here is the code. dy doesn't apply correctly; it should be 0 for the first line. 146 | ```solidity 147 | canto-bio-protocol/src/Bio.sol#L104 148 | for (uint i; i < lines; ++i) { 149 | text = string.concat(text, '', strLines[i], ""); 150 | } 151 | ``` 152 | [canto-bio-protocol/src/Bio.sol#L104](https://github.com/code-423n4/2023-03-canto-identity/blob/077372297fc419ea7688ab62cc3fd4e8f4e24e66/canto-bio-protocol/src/Bio.sol#L104) 153 | ## Tools Used 154 | Manual review 155 | ## Recommended Mitigation Steps 156 | Enlarge viewBox so it will support 200 length or restrict to 120 characters. 157 | Here is a complete code with correct line breaking and center text. I'm sorry that I didn't add `differ` to code because there will be too many lines. It does pass tests and fix current issues 158 | ```solidity 159 | function tokenURI(uint256 _id) public view override returns (string memory) { 160 | if (_ownerOf[_id] == address(0)) revert TokenNotMinted(_id); 161 | string memory bioText = bio[_id]; 162 | bytes memory bioTextBytes = bytes(bioText); 163 | uint lengthInBytes = bioTextBytes.length; 164 | // Insert a new line after 40 characters, taking into account unicode character 165 | uint lines = (lengthInBytes - 1) / 40 + 1; 166 | string[] memory strLines = new string[](lines); 167 | bool prevByteWasContinuation; 168 | uint256 insertedLines; 169 | // Because we do not split on zero-width joiners, line in bytes can technically be much longer. Will be shortened to the needed length afterwards 170 | bytes memory bytesLines = new bytes(80); 171 | uint bytesOffset; 172 | uint j; 173 | for (uint i; i < lengthInBytes; ++i) { 174 | bytesLines[bytesOffset] = bytes1(bioTextBytes[i]); 175 | bytesOffset++; 176 | j+=1; 177 | if ((j>=40) || prevByteWasContinuation || i == lengthInBytes - 1) { 178 | bytes1 nextCharacter; 179 | if (i != lengthInBytes - 1) { 180 | nextCharacter = bioTextBytes[i + 1]; 181 | } 182 | if (nextCharacter & 0xC0 == 0x80) { 183 | // Unicode continuation byte, top two bits are 10 184 | prevByteWasContinuation = true; 185 | continue; 186 | } else { 187 | // Do not split when the prev. or next character is a zero width joiner. Otherwise, 👨‍👧‍👦 could become 👨>‍👧‍👦 188 | // Furthermore, do not split when next character is skin tone modifier to avoid 🤦‍♂️ 189 | 🏻 190 | if ( 191 | // Note that we do not need to check i < lengthInBytes - 4, because we assume that it's a valid UTF8 string and these prefixes imply that another byte follows 192 | (nextCharacter == 0xE2 && bioTextBytes[i + 2] == 0x80 && bioTextBytes[i + 3] == 0x8D) || 193 | (nextCharacter == 0xF0 && 194 | bioTextBytes[i + 2] == 0x9F && 195 | bioTextBytes[i + 3] == 0x8F && 196 | uint8(bioTextBytes[i + 4]) >= 187 && 197 | uint8(bioTextBytes[i + 4]) <= 191) || 198 | (i >= 2 && 199 | bioTextBytes[i - 2] == 0xE2 && 200 | bioTextBytes[i - 1] == 0x80 && 201 | bioTextBytes[i] == 0x8D) 202 | ) { 203 | prevByteWasContinuation = true; 204 | continue; 205 | } 206 | } 207 | 208 | assembly { 209 | mstore(bytesLines, bytesOffset) 210 | } 211 | strLines[insertedLines++] = string(bytesLines); 212 | bytesLines = new bytes(80); 213 | prevByteWasContinuation = false; 214 | bytesOffset = 0; 215 | j=0; 216 | } 217 | } 218 | string 219 | memory svg = ''; 220 | string memory text = ''; 221 | text = string.concat(text, '', strLines[0], "");// center first line and than add dy 222 | for (uint i=1; i < lines; ++i) { 223 | text = string.concat(text, '', strLines[i], ""); 224 | } 225 | string memory json = Base64.encode( 226 | bytes( 227 | string.concat( 228 | '{"name": "Bio #', 229 | LibString.toString(_id), 230 | '", "description": "', 231 | bioText, 232 | '", "image": "data:image/svg+xml;base64,', 233 | Base64.encode(bytes(string.concat(svg, text, ""))), 234 | '"}' 235 | ) 236 | ) 237 | ); 238 | return string(abi.encodePacked("data:application/json;base64,", json)); 239 | } 240 | 241 | ``` -------------------------------------------------------------------------------- /reports/moonwell.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

Moonwell Audit Report

7 |

An open lending and borrowing DeFi protocol built on Base, Moonbeam, and Moonriver.

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: July 25 to Aug 1, 2023

10 |
13 | 14 | # About Venus 15 | An open lending and borrowing DeFi protocol built on Base, Moonbeam, and Moonriver. 16 | 17 | # Summary of Findings 18 | 19 | |    ID        | Title | Severity | Fixed | 20 | |----------------------------------------------------------------|----------------------------------------------------------------------------------------|----------|------| 21 | | [M-01] | One check is missing when proposal execution bypassing queuing | Medium | ✓ | 22 | | [M-02] | Its not possible to liquidate deprecated market | Medium | ✓ | 23 | | [M-03] | Unsafe use of transfer()/transferFrom() with IERC20 | Medium | ✓ | 24 | | [M-04] | Insufficient oracle validation | Medium | ✓ | 25 | | [M-05] | Missing checks for whether the L2 Sequencer is active | Medium | ✓ | 26 | | [M-06] | The `owner` is a single point of failure and a centralization risk | Medium | ✓ | 27 | | [M-07] | Some tokens may revert when zero value transfers are made | Medium | ✓ | 28 | 29 | # Detailed Findings 30 | 31 | ## [M-01] One check is missing when proposal execution bypassing queuing 32 | ## Impact 33 | Detailed description of the impact of this finding. 34 | One check is missing when proposal execution bypassing queuing 35 | ## Proof of Concept 36 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 37 | In `TemporalGovernor`, `executeProposal` can be executed with a flag bypassing queuing, so there are the same checks as inside `queueProposal`, but there is one check that missing, and according to comments is important: 38 | ```solidity 39 | // Very important to check to make sure that the VAA we're processing is specifically designed 40 | // to be sent to this contract 41 | require(intendedRecipient == address(this), "TemporalGovernor: Incorrect destination"); 42 | ``` 43 | [Governance/TemporalGovernor.sol#L311](https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Governance/TemporalGovernor.sol#L311) 44 | so, the same check should be added to `executeProposal` 45 | ## Tools Used 46 | 47 | ## Recommended Mitigation Steps 48 | 49 | ```diff 50 | address[] memory targets; /// contracts to call 51 | uint256[] memory values; /// native token amount to send 52 | bytes[] memory calldatas; /// calldata to send 53 | - (, targets, values, calldatas) = abi.decode( 54 | + (intendedRecipient, targets, values, calldatas) = abi.decode( 55 | vm.payload, 56 | (address, address[], uint256[], bytes[]) 57 | ); 58 | 59 | + require(intendedRecipient == address(this), "TemporalGovernor: Incorrect destination"); 60 | 61 | /// Interaction (s) 62 | 63 | _sanityCheckPayload(targets, values, calldatas); 64 | 65 | ``` 66 | 67 | ## [M-02] Its not possible to liquidate deprecated market 68 | ## Impact 69 | Detailed description of the impact of this finding. 70 | Its not possible to liquidate deprecated market 71 | ## Proof of Concept 72 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 73 | Currently in the code that is a function `_setBorrowPaused` that paused pause borrowing. In [origin compound code](https://github.com/compound-finance/compound-protocol/blob/a3214f67b73310d547e00fc578e8355911c9d376/contracts/Comptroller.sol#L1452) `borrowGuardianPaused` is used to do liquidate markets that are bad. So now there is not way to get rid of bad markets. 74 | ```solidity 75 | function liquidateBorrowAllowed( 76 | address mTokenBorrowed, 77 | address mTokenCollateral, 78 | address liquidator, 79 | address borrower, 80 | uint repayAmount) override external view returns (uint) { 81 | // Shh - currently unused 82 | liquidator; 83 | 84 | if (!markets[mTokenBorrowed].isListed || !markets[mTokenCollateral].isListed) { 85 | return uint(Error.MARKET_NOT_LISTED); 86 | } 87 | 88 | /* The borrower must have shortfall in order to be liquidatable */ 89 | (Error err, , uint shortfall) = getAccountLiquidityInternal(borrower); 90 | if (err != Error.NO_ERROR) { 91 | return uint(err); 92 | } 93 | if (shortfall == 0) { 94 | return uint(Error.INSUFFICIENT_SHORTFALL); 95 | } 96 | 97 | /* The liquidator may not repay more than what is allowed by the closeFactor */ 98 | uint borrowBalance = MToken(mTokenBorrowed).borrowBalanceStored(borrower); 99 | uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance); 100 | if (repayAmount > maxClose) { 101 | return uint(Error.TOO_MUCH_REPAY); 102 | } 103 | 104 | return uint(Error.NO_ERROR); 105 | } 106 | 107 | ``` 108 | [src/core/Comptroller.sol#L394](https://github.com/code-423n4/2023-07-moonwell/blob/fced18035107a345c31c9a9497d0da09105df4df/src/core/Comptroller.sol#L394) 109 | ## Tools Used 110 | 111 | ## Recommended Mitigation Steps 112 | I think compound have a way to liquidate deprecated markets for a safety reason, so it needs to be restored 113 | ```diff 114 | function liquidateBorrowAllowed( 115 | address mTokenBorrowed, 116 | address mTokenCollateral, 117 | address liquidator, 118 | address borrower, 119 | uint repayAmount) override external view returns (uint) { 120 | // Shh - currently unused 121 | liquidator; 122 | /* allow accounts to be liquidated if the market is deprecated */ 123 | + if (isDeprecated(CToken(cTokenBorrowed))) { 124 | + require(borrowBalance >= repayAmount, "Can not repay more than the total borrow"); 125 | + return uint(Error.NO_ERROR); 126 | + } 127 | if (!markets[mTokenBorrowed].isListed || !markets[mTokenCollateral].isListed) { 128 | return uint(Error.MARKET_NOT_LISTED); 129 | } 130 | 131 | /* The borrower must have shortfall in order to be liquidatable */ 132 | (Error err, , uint shortfall) = getAccountLiquidityInternal(borrower); 133 | if (err != Error.NO_ERROR) { 134 | return uint(err); 135 | } 136 | if (shortfall == 0) { 137 | return uint(Error.INSUFFICIENT_SHORTFALL); 138 | } 139 | 140 | /* The liquidator may not repay more than what is allowed by the closeFactor */ 141 | uint borrowBalance = MToken(mTokenBorrowed).borrowBalanceStored(borrower); 142 | uint maxClose = mul_ScalarTruncate(Exp({mantissa: closeFactorMantissa}), borrowBalance); 143 | if (repayAmount > maxClose) { 144 | return uint(Error.TOO_MUCH_REPAY); 145 | } 146 | 147 | return uint(Error.NO_ERROR); 148 | } 149 | + function isDeprecated(CToken cToken) public view returns (bool) { 150 | + return 151 | + markets[address(cToken)].collateralFactorMantissa == 0 && 152 | + borrowGuardianPaused[address(cToken)] == true && 153 | + cToken.reserveFactorMantissa() == 1e18 154 | + ; 155 | + } 156 | 157 | ``` 158 | 159 | ## [M-03] Unsafe use of transfer()/transferFrom() with IERC20 160 | Some tokens do not implement the ERC20 standard properly but are still accepted by most code that accepts ERC20 tokens. For example Tether (USDT)'s `transfer()` and `transferFrom()` functions on L1 do not return booleans as the specification requires, and instead have no return value. When these sorts of tokens are cast to `IERC20`, their [function signatures](https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca) do not match and therefore the calls made, revert (see [this](https://gist.github.com/IllIllI000/2b00a32e8f0559e8f386ea4f1800abc5) link for a test case). Use OpenZeppelin’s `SafeERC20`'s `safeTransfer()`/`safeTransferFrom()` instead 161 | 162 | *There are 2 instances of this issue:* 163 | 164 | ```solidity 165 | File: src/core/Comptroller.sol 166 | 167 | 965: token.transfer(admin, token.balanceOf(address(this))); 168 | 169 | 967: token.transfer(admin, _amount); 170 | 171 | ``` 172 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Comptroller.sol#L965 173 | 174 | ## [M-04] Insufficient oracle validation 175 | There is no freshness check on the timestamp of the prices fetched from the Chainlink oracle, so old prices may be used if [OCR](https://docs.chain.link/architecture-overview/off-chain-reporting) was unable to push an update in time. Add a staleness threshold number of seconds configuration parameter, and ensure that the price fetched is from within that time range. 176 | 177 | *There are 2 instances of this issue:* 178 | 179 | ```solidity 180 | File: src/core/Oracles/ChainlinkCompositeOracle.sol 181 | 182 | 183 ( 183 | 184 uint80 roundId, 184 | 185 int256 price, 185 | 186 , 186 | 187 , 187 | 188 uint80 answeredInRound 188 | 189: ) = AggregatorV3Interface(oracleAddress).latestRoundData(); 189 | 190 | ``` 191 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkCompositeOracle.sol#L183-L189 192 | 193 | ```solidity 194 | File: src/core/Oracles/ChainlinkOracle.sol 195 | 196 | 100 (, int256 answer, , uint256 updatedAt, ) = AggregatorV3Interface(feed) 197 | 101: .latestRoundData(); 198 | 199 | ``` 200 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkOracle.sol#L100-L101 201 | 202 | 203 | 204 | ## [M-05] Missing checks for whether the L2 Sequencer is active 205 | Chainlink recommends that users using price oracles, check whether the Arbitrum Sequencer is [active](https://docs.chain.link/data-feeds/l2-sequencer-feeds#arbitrum). If the sequencer goes down, the Chainlink oracles will have stale prices from before the downtime, until a new L2 OCR transaction goes through. Users who submit their transactions via the [L1 Dealyed Inbox](https://developer.arbitrum.io/tx-lifecycle#1b--or-from-l1-via-the-delayed-inbox) will be able to take advantage of these stale prices. Use a [Chainlink oracle](https://blog.chain.link/how-to-use-chainlink-price-feeds-on-arbitrum/#almost_done!_meet_the_l2_sequencer_health_flag) to determine whether the sequencer is offline or not, and don't allow operations to take place while the sequencer is offline. 206 | 207 | *There are 2 instances of this issue:* 208 | 209 | ```solidity 210 | File: src/core/Oracles/ChainlinkCompositeOracle.sol 211 | 212 | 189: ) = AggregatorV3Interface(oracleAddress).latestRoundData(); 213 | 214 | ``` 215 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkCompositeOracle.sol#L189-L189 216 | 217 | ```solidity 218 | File: src/core/Oracles/ChainlinkOracle.sol 219 | 220 | 100 (, int256 answer, , uint256 updatedAt, ) = AggregatorV3Interface(feed) 221 | 101: .latestRoundData(); 222 | 223 | ``` 224 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Oracles/ChainlinkOracle.sol#L100-L101 225 | 226 | 227 | ## [M-06] The `owner` is a single point of failure and a centralization risk 228 | Having a single EOA as the only owner of contracts is a large centralization risk and a single point of failure. A single private key may be taken in a hack, or the sole holder of the key may become unable to retrieve the key when necessary. Consider changing to a multi-signature setup, or having a role-based authorization model. 229 | 230 | *There are 2 instances of this issue:* 231 | 232 | ```solidity 233 | File: src/core/Governance/TemporalGovernor.sol 234 | 235 | 266: function fastTrackProposalExecution(bytes memory VAA) external onlyOwner { 236 | 237 | 274: function togglePause() external onlyOwner { 238 | 239 | ``` 240 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Governance/TemporalGovernor.sol#L266-L266 241 | 242 | 243 | ## [M-07] Some tokens may revert when zero value transfers are made 244 | In spite of the fact that EIP-20 [states](https://github.com/ethereum/EIPs/blob/46b9b698815abbfa628cd1097311deee77dd45c5/EIPS/eip-20.md?plain=1#L116) that zero-valued transfers must be accepted, some tokens, such as LEND will revert if this is attempted, which may cause transactions that involve other tokens (such as batch operations) to fully revert. Consider skipping the transfer if the amount is zero, which will also save gas. 245 | 246 | *There are 2 instances of this issue:* 247 | 248 | ```solidity 249 | File: src/core/Comptroller.sol 250 | 251 | 964 if (_amount == type(uint).max) { 252 | 965 token.transfer(admin, token.balanceOf(address(this))); 253 | 966 } else { 254 | 967: token.transfer(admin, _amount); 255 | 256 | 962 IERC20 token = IERC20(_tokenAddress); 257 | 963 // Similar to mTokens, if this is uint.max that means "transfer everything" 258 | 964 if (_amount == type(uint).max) { 259 | 965: token.transfer(admin, token.balanceOf(address(this))); 260 | 261 | ``` 262 | https://github.com/code-423n4/2023-07-moonwell/blob/4aaa7d6767da3bc42e31c18ea2e75736a4ea53d4/src/core/Comptroller.sol#L964-L967 -------------------------------------------------------------------------------- /reports/ajna.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

Ajna Audit Report

7 |

A peer to peer, oracleless, permissionless lending protocol with no governance, accepting both fungible and non fungible tokens as collateral.

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: May 3 to May 11, 2023

10 |
13 | 14 | # About Ajna 15 | The Ajna protocol is a non-custodial, peer-to-peer, permissionless lending, borrowing and trading system that requires no governance or external price feeds to function. The protocol consists of pools: pairings of quote tokens provided by lenders and collateral tokens provided by borrowers. Ajna is capable of accepting fungible tokens as quote tokens and both fungible and non-fungible tokens as collateral tokens.# About **0xVolodya** 16 | 17 | # Summary of Findings 18 | 19 | | ID | Title | Severity | Fixed | 20 | |--------|----------------------------------------------------------------------|----------| ----- | 21 | | [H-01] | User can avoid bankruptcy for his position inside PositionManager | High | ✓ | 22 | | [L-01] | Find the status of proposal sometimes returns incorrect status | Low | ✓ | 23 | | [L-02] | Use of transferFrom() rather than safeTransferFrom() for NFTs| Low | ✓ | 24 | | [L-03] | _safeMint() should be used rather than _mint() wherever possible| Low | ✓ | 25 | | [L-04] | Users can claim delegate reward at the time they are not supposed to | Medium | ✓ | 26 | 27 | ## [H-01] User can avoid bankruptcy for his position inside PositionManager 28 | 29 | ## Impact 30 | Detailed description of the impact of this finding. 31 | User can avoid bankruptcy for his position inside PositionManager. 32 | ## Proof of Concept 33 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 34 | Whenever user would like to redeem his position, there is a check that position is not bankrupt. 35 | ```solidity 36 | function reedemPositions( 37 | RedeemPositionsParams calldata params_ 38 | ) external override mayInteract(params_.pool, params_.tokenId) { 39 | ... 40 | if (_bucketBankruptAfterDeposit(pool, index, position.depositTime)) revert BucketBankrupt(); 41 | ... 42 | ``` 43 | A user can revive their position (rather than going bankrupt) by creating a new position and transferring its liquidity to the desired position that they want to recover. As you can see that whenever position`s liquidity being moved to another position its position is being rewritten, instead of taking minimun from both of them 44 | 45 | ```solidity 46 | function moveLiquidity( 47 | MoveLiquidityParams calldata params_ 48 | ) external override mayInteract(params_.pool, params_.tokenId) nonReentrant { 49 | ... 50 | // update position LP state 51 | fromPosition.lps -= vars.lpbAmountFrom; 52 | toPosition.lps += vars.lpbAmountTo; 53 | // update position deposit time to the from bucket deposit time 54 | // @audit 55 | // toPosition.depositTime = Math.max(vars.depositTime, toPosition.depositTime); 56 | toPosition.depositTime = vars.depositTime; 57 | 58 | emit MoveLiquidity( 59 | ownerOf(params_.tokenId), 60 | params_.tokenId, 61 | params_.fromIndex, 62 | params_.toIndex, 63 | vars.lpbAmountFrom, 64 | vars.lpbAmountTo 65 | ); 66 | } 67 | 68 | ``` 69 | 70 | ## Tools Used 71 | 72 | ## Recommended Mitigation Steps 73 | Assign minimum deposition time to interacted position 74 | ```diff 75 | function moveLiquidity( 76 | MoveLiquidityParams calldata params_ 77 | ) external override mayInteract(params_.pool, params_.tokenId) nonReentrant { 78 | Position storage fromPosition = positions[params_.tokenId][params_.fromIndex]; 79 | 80 | MoveLiquidityLocalVars memory vars; 81 | vars.depositTime = fromPosition.depositTime; 82 | 83 | // handle the case where owner attempts to move liquidity after they've already done so 84 | if (vars.depositTime == 0) revert RemovePositionFailed(); 85 | 86 | // ensure bucketDeposit accounts for accrued interest 87 | IPool(params_.pool).updateInterest(); 88 | 89 | // retrieve info of bucket from which liquidity is moved 90 | ( 91 | vars.bucketLP, 92 | vars.bucketCollateral, 93 | vars.bankruptcyTime, 94 | vars.bucketDeposit, 95 | ) = IPool(params_.pool).bucketInfo(params_.fromIndex); 96 | 97 | // check that bucket hasn't gone bankrupt since memorialization 98 | if (vars.depositTime <= vars.bankruptcyTime) revert BucketBankrupt(); 99 | 100 | // calculate the max amount of quote tokens that can be moved, given the tracked LP 101 | vars.maxQuote = _lpToQuoteToken( 102 | vars.bucketLP, 103 | vars.bucketCollateral, 104 | vars.bucketDeposit, 105 | fromPosition.lps, 106 | vars.bucketDeposit, 107 | _priceAt(params_.fromIndex) 108 | ); 109 | 110 | EnumerableSet.UintSet storage positionIndex = positionIndexes[params_.tokenId]; 111 | 112 | // remove bucket index from which liquidity is moved from tracked positions 113 | if (!positionIndex.remove(params_.fromIndex)) revert RemovePositionFailed(); 114 | 115 | // update bucket set at which a position has liquidity 116 | // slither-disable-next-line unused-return 117 | positionIndex.add(params_.toIndex); 118 | 119 | // move quote tokens in pool 120 | ( 121 | vars.lpbAmountFrom, 122 | vars.lpbAmountTo, 123 | ) = IPool(params_.pool).moveQuoteToken( 124 | vars.maxQuote, 125 | params_.fromIndex, 126 | params_.toIndex, 127 | params_.expiry 128 | ); 129 | 130 | Position storage toPosition = positions[params_.tokenId][params_.toIndex]; 131 | 132 | // update position LP state 133 | fromPosition.lps -= vars.lpbAmountFrom; 134 | toPosition.lps += vars.lpbAmountTo; 135 | // update position deposit time to the from bucket deposit time 136 | + if(toPosition.depositTime ==0){ 137 | + toPosition.depositTime = vars.depositTime; 138 | + } else{ 139 | + toPosition.depositTime = vars.depositTime < toPosition.depositTime ? vars.depositTime :toPosition.depositTime; 140 | + } 141 | emit MoveLiquidity( 142 | ownerOf(params_.tokenId), 143 | params_.tokenId, 144 | params_.fromIndex, 145 | params_.toIndex, 146 | vars.lpbAmountFrom, 147 | vars.lpbAmountTo 148 | ); 149 | } 150 | 151 | ``` 152 | 153 | 154 | ## [L-01] Find the status of a given proposal returns incorrect status for some standard proposals 155 | ## Impact 156 | Detailed description of the impact of this finding. 157 | Find the status of a given proposal returns incorrect status for some standard proposals 158 | ## Proof of Concept 159 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 160 | Whevener user would like to know a state of standard proposal, he request `state` from GrantFund 161 | ```solidity 162 | function state( 163 | uint256 proposalId_ 164 | ) external view override returns (ProposalState) { 165 | FundingMechanism mechanism = findMechanismOfProposal(proposalId_); 166 | 167 | return mechanism == FundingMechanism.Standard ? _standardProposalState(proposalId_) : _getExtraordinaryProposalState(proposalId_); 168 | } 169 | ``` 170 | [ajna-grants/src/grants/GrantFund.sol#L45](https://github.com/code-423n4/2023-05-ajna/blob/76c254c0085e7520edd24cd2f8b79cbb61d7706c/ajna-grants/src/grants/GrantFund.sol#L45) 171 | 172 | which calls _standardProposalState where is a bug when ProposalState.Active 173 | ```solidity 174 | function _standardProposalState(uint256 proposalId_) internal view returns (ProposalState) { 175 | Proposal memory proposal = _standardFundingProposals[proposalId_]; 176 | 177 | if (proposal.executed) return ProposalState.Executed; 178 | else if (_distributions[proposal.distributionId].endBlock >= block.number) return ProposalState.Active; 179 | else if (_standardFundingVoteSucceeded(proposalId_)) return ProposalState.Succeeded; 180 | else return ProposalState.Defeated; 181 | } 182 | 183 | ``` 184 | [src/grants/base/StandardFunding.sol#L509](https://github.com/code-423n4/2023-05-ajna/blob/76c254c0085e7520edd24cd2f8b79cbb61d7706c/ajna-grants/src/grants/base/StandardFunding.sol#L509) 185 | 186 | if should be `block.number > screeningStageEndBlock && _distributions[proposal.distributionId].endBlock >= block.number` just like in `fundingVote` 187 | ```solidity 188 | function fundingVote( 189 | FundingVoteParams[] memory voteParams_ 190 | ) external override returns (uint256 votesCast_) { 191 | ... 192 | uint256 screeningStageEndBlock = _getScreeningStageEndBlock(endBlock); 193 | 194 | // check that the funding stage is active 195 | if (block.number <= screeningStageEndBlock || block.number > endBlock) revert InvalidVote(); 196 | ... 197 | 198 | ``` 199 | [/grants/base/StandardFunding.sol#L532](https://github.com/code-423n4/2023-05-ajna/blob/76c254c0085e7520edd24cd2f8b79cbb61d7706c/ajna-grants/src/grants/base/StandardFunding.sol#L532) 200 | ## Tools Used 201 | 202 | ## Recommended Mitigation Steps 203 | Change to this 204 | ```diff 205 | function _standardProposalState(uint256 proposalId_) internal view returns (ProposalState) { 206 | Proposal memory proposal = _standardFundingProposals[proposalId_]; 207 | + uint24 currentDistributionId = _currentDistributionId; 208 | + uint256 endBlock = _distributions[currentDistributionId].endBlock; 209 | + uint256 screeningStageEndBlock = _getScreeningStageEndBlock(endBlock); 210 | 211 | if (proposal.executed) return ProposalState.Executed; 212 | - else if (_distributions[proposal.distributionId].endBlock >= block.number) return ProposalState.Active; 213 | + else if (block.number > screeningStageEndBlock && _distributions[proposal.distributionId].endBlock >= block.number) return ProposalState.Active; 214 | else if (_standardFundingVoteSucceeded(proposalId_)) return ProposalState.Succeeded; 215 | else return ProposalState.Defeated; 216 | } 217 | 218 | ``` 219 | 220 | ## [L-02] Use of transferFrom() rather than safeTransferFrom() for NFTs in will lead to the loss of NFTs 221 | 222 | The EIP-721 standard says the following about `transferFrom()`: 223 | ```solidity 224 | /// @notice Transfer ownership of an NFT -- THE CALLER IS RESPONSIBLE 225 | /// TO CONFIRM THAT `_to` IS CAPABLE OF RECEIVING NFTS OR ELSE 226 | /// THEY MAY BE PERMANENTLY LOST 227 | /// @dev Throws unless `msg.sender` is the current owner, an authorized 228 | /// operator, or the approved address for this NFT. Throws if `_from` is 229 | /// not the current owner. Throws if `_to` is the zero address. Throws if 230 | /// `_tokenId` is not a valid NFT. 231 | /// @param _from The current owner of the NFT 232 | /// @param _to The new owner 233 | /// @param _tokenId The NFT to transfer 234 | function transferFrom(address _from, address _to, uint256 _tokenId) external payable; 235 | ``` 236 | https://github.com/ethereum/EIPs/blob/78e2c297611f5e92b6a5112819ab71f74041ff25/EIPS/eip-721.md?plain=1#L103-L113 237 | Code must use the `safeTransferFrom()` flavor if it hasn't otherwise verified that the receiving address can handle it 238 | 239 | *There is one instance of this issue:* 240 | 241 | ```solidity 242 | File: ajna-core/src/RewardsManager.sol 243 | 244 | 302: IERC721(address(positionManager)).transferFrom(address(this), msg.sender, tokenId_); 245 | 246 | ``` 247 | https://github.com/code-423n4/2023-05-ajna/blob/6995f24bdf9244fa35880dda21519ffc131c905c/ajna-core/src/RewardsManager.sol#L302 248 | 249 | ## [L-03] _safeMint() should be used rather than _mint() wherever possible 250 | `_mint()` is [discouraged](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/d4d8d2ed9798cc3383912a23b5e8d5cb602f7d4b/contracts/token/ERC721/ERC721.sol#L271) in favor of `_safeMint()` which ensures that the recipient is either an EOA or implements `IERC721Receiver`. Both [OpenZeppelin](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/d4d8d2ed9798cc3383912a23b5e8d5cb602f7d4b/contracts/token/ERC721/ERC721.sol#L238-L250) and [solmate](https://github.com/Rari-Capital/solmate/blob/4eaf6b68202e36f67cab379768ac6be304c8ebde/src/tokens/ERC721.sol#L180) have versions of this function 251 | 252 | *There is one instance of this issue:* 253 | 254 | ```solidity 255 | File: ajna-core/src/PositionManager.sol 256 | 257 | 238: _mint(params_.recipient, tokenId_); 258 | 259 | ``` 260 | https://github.com/code-423n4/2023-05-ajna/blob/6995f24bdf9244fa35880dda21519ffc131c905c/ajna-core/src/PositionManager.sol#L238 261 | 262 | 263 | ## [L-04] Users can claim delegate reward at the time they are not supposed to 264 | ## Impact 265 | Detailed description of the impact of this finding. 266 | Users can claim delegate reward at the time they are not supposed to. 267 | ## Proof of Concept 268 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 269 | 270 | Whenever a user wants to claim their delegate reward, they call the `claimDelegateReward` function inside StandardFunding. The function includes a check to verify if the period is active or not. 271 | ```solidity 272 | function claimDelegateReward( 273 | uint24 distributionId_ 274 | ) external override returns(uint256 rewardClaimed_) { 275 | // Revert if delegatee didn't vote in screening stage 276 | if(screeningVotesCast[distributionId_][msg.sender] == 0) revert DelegateRewardInvalid(); 277 | 278 | QuarterlyDistribution memory currentDistribution = _distributions[distributionId_]; 279 | 280 | // Check if Challenge Period is still active 281 | if(block.number < _getChallengeStageEndBlock(currentDistribution.endBlock)) revert ChallengePeriodNotEnded(); 282 | ... 283 | ``` 284 | [src/grants/base/StandardFunding.sol#L245](https://github.com/code-423n4/2023-05-ajna/blob/76c254c0085e7520edd24cd2f8b79cbb61d7706c/ajna-grants/src/grants/base/StandardFunding.sol#L245) 285 | That check suppose to be `block.number <= _getChallengeStageEndBlock(currentDistribution.endBlock` becuase if we will look at how that check holds thoughout the file we will see it. 286 | 287 | ``` 288 | if (currentDistributionId > 0 && (block.number > _getChallengeStageEndBlock(currentDistributionEndBlock))) { 289 | // Add unused funds from last distribution to treasury 290 | _updateTreasury(currentDistributionId); 291 | } 292 | ``` 293 | [ajna-grants/src/grants/base/StandardFunding.sol#L129](https://github.com/code-423n4/2023-05-ajna/blob/76c254c0085e7520edd24cd2f8b79cbb61d7706c/ajna-grants/src/grants/base/StandardFunding.sol#L129) 294 | ``` 295 | if (block.number <= _getChallengeStageEndBlock(_distributions[distributionId].endBlock)) revert ExecuteProposalInvalid(); 296 | ``` 297 | ``` 298 | if (block.number <= endBlock || block.number > _getChallengeStageEndBlock(endBlock)) { 299 | revert InvalidProposalSlate(); 300 | } 301 | ``` 302 | ## Tools Used 303 | 304 | ## Recommended Mitigation Steps 305 | ```diff 306 | function claimDelegateReward( 307 | uint24 distributionId_ 308 | ) external override returns(uint256 rewardClaimed_) { 309 | // Revert if delegatee didn't vote in screening stage 310 | if(screeningVotesCast[distributionId_][msg.sender] == 0) revert DelegateRewardInvalid(); 311 | 312 | QuarterlyDistribution memory currentDistribution = _distributions[distributionId_]; 313 | 314 | // Check if Challenge Period is still active 315 | - if(block.number < _getChallengeStageEndBlock(currentDistribution.endBlock)) revert ChallengePeriodNotEnded(); 316 | + if(block.number <= _getChallengeStageEndBlock(currentDistribution.endBlock)) revert ChallengePeriodNotEnded(); 317 | 318 | // check rewards haven't already been claimed 319 | if(hasClaimedReward[distributionId_][msg.sender]) revert RewardAlreadyClaimed(); 320 | 321 | QuadraticVoter memory voter = _quadraticVoters[distributionId_][msg.sender]; 322 | 323 | // calculate rewards earned for voting 324 | rewardClaimed_ = _getDelegateReward(currentDistribution, voter); 325 | 326 | hasClaimedReward[distributionId_][msg.sender] = true; 327 | 328 | emit DelegateRewardClaimed( 329 | msg.sender, 330 | distributionId_, 331 | rewardClaimed_ 332 | ); 333 | 334 | // transfer rewards to delegatee 335 | IERC20(ajnaTokenAddress).safeTransfer(msg.sender, rewardClaimed_); 336 | } 337 | 338 | ``` 339 | 340 | -------------------------------------------------------------------------------- /reports/index-coop.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

Index Coop Report

7 |

The Index Coop builds decentralized structured products

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: May 19 to Jun 14, 2023

10 |
13 | 14 | # About Index Coop 15 | The Index Coop building secure, accessible and simple to use DeFi products that make it easy for everyone from retail investors to institutions to gain exposure to the most important themes in DeFi. 16 | 17 | # Summary of Findings 18 | 19 | |    ID      | Title | Severity | Fixed | 20 | |----------------------------------------------------| ---------------------------- |----------| ----- | 21 | | [H-01] | AaveLeverageStrategyExtension doesn't work with turned on Efficiency Mode | High | ✓ | 22 | | [M-01] | Side effects of LTV = 0 assets: Index's users will not be able to withdraw (collateral), borrow | Medium | ✓ | 23 | | [M-02] |Some modules will not work with certain ERC20s reverting when trying to approve with allowance already >0| Medium | ✓ | 24 | | [M-03] |Deprecated chainlink oracle >0| Medium | ✓ | 25 | 26 | # Detailed Findings 27 | 28 | ## [H-01] AaveLeverageStrategyExtension doesn't work with turned on Efficiency Mode 29 | 30 | ## Summary 31 | AaveLeverageStrategyExtension doesn't work with turned-on Efficiency Mode. Incorrect 32 | 33 | - LTV (Loan to value) 34 | - Liquidation threshold 35 | - Liquidation bonus 36 | - A custom price oracle (optional) 37 | 38 | ## Vulnerability Detail 39 | According to aave [docs](https://docs.aave.com/developers/whats-new/efficiency-mode-emode) whenever a pool in eMode it does how its own 40 | 41 | - LTV (Loan to value) 42 | - Liquidation threshold 43 | - Liquidation bonus 44 | - A custom price oracle (optional) 45 | 46 | Whenever pool in eMode these params are not being fetched but instead using the same params as if eMode is not on which leads to the system having unexpected issues. 47 | 48 | You can see from Morpho protocol that they are using different ltv and other params if pool in eMode 49 | ```solidity 50 | function _assetLiquidityData(address underlying, Types.LiquidityVars memory vars) 51 | internal 52 | view 53 | returns (uint256 underlyingPrice, uint256 ltv, uint256 liquidationThreshold, uint256 underlyingUnit) 54 | { 55 | DataTypes.ReserveConfigurationMap memory config = _pool.getConfiguration(underlying); 56 | 57 | bool isInEMode; 58 | (isInEMode, underlyingPrice, underlyingUnit) = 59 | _assetData(underlying, vars.oracle, config, vars.eModeCategory.priceSource); 60 | 61 | // If the LTV is 0 on Aave V3, the asset cannot be used as collateral to borrow upon a breaking withdraw. 62 | // In response, Morpho disables the asset as collateral and sets its liquidation threshold 63 | // to 0 and the governance should warn users to repay their debt. 64 | if (config.getLtv() == 0) return (underlyingPrice, 0, 0, underlyingUnit); 65 | 66 | if (isInEMode) { 67 | ltv = vars.eModeCategory.ltv; 68 | liquidationThreshold = vars.eModeCategory.liquidationThreshold; 69 | } else { 70 | ltv = config.getLtv(); 71 | liquidationThreshold = config.getLiquidationThreshold(); 72 | } 73 | } 74 | ``` 75 | [src/MorphoInternal.sol#L322](https://github.com/morpho-org/morpho-aave-v3/blob/0f494b8321d20789692e50305532b7f1b8fb23ef/src/MorphoInternal.sol#L322) 76 | 77 | However, in Index eMode is not being used to determine ltv, liquidationThreshold and other params 78 | ```solidity 79 | function _calculateMaxBorrowCollateral(ActionInfo memory _actionInfo, bool _isLever) internal view returns(uint256) { 80 | 81 | // Retrieve collateral factor and liquidation threshold for the collateral asset in precise units (1e16 = 1%) 82 | ( , uint256 maxLtvRaw, uint256 liquidationThresholdRaw, , , , , , ,) = strategy.aaveProtocolDataProvider.getReserveConfigurationData(address(strategy.collateralAsset)); 83 | 84 | // Normalize LTV and liquidation threshold to precise units. LTV is measured in 4 decimals in Aave which is why we must multiply by 1e14 85 | // for example ETH has an LTV value of 8000 which represents 80% 86 | if (_isLever) { 87 | uint256 netBorrowLimit = _actionInfo.collateralValue 88 | .preciseMul(maxLtvRaw.mul(10 ** 14)) 89 | .preciseMul(PreciseUnitMath.preciseUnit().sub(execution.unutilizedLeveragePercentage)); 90 | 91 | return netBorrowLimit 92 | .sub(_actionInfo.borrowValue) 93 | .preciseDiv(_actionInfo.collateralPrice); 94 | } else { 95 | uint256 netRepayLimit = _actionInfo.collateralValue 96 | .preciseMul(liquidationThresholdRaw.mul(10 ** 14)) 97 | .preciseMul(PreciseUnitMath.preciseUnit().sub(execution.unutilizedLeveragePercentage)); 98 | 99 | return _actionInfo.collateralBalance 100 | .preciseMul(netRepayLimit.sub(_actionInfo.borrowValue)) 101 | .preciseDiv(netRepayLimit); 102 | } 103 | } 104 | 105 | ``` 106 | [adapters/AaveLeverageStrategyExtension.sol#L1095](https://github.com/sherlock-audit/2023-05-Index/blob/main/index-coop-smart-contracts/contracts/adapters/AaveLeverageStrategyExtension.sol#L1095) 107 | ## Impact 108 | System will not work properly in eMode 109 | ## Code Snippet 110 | 111 | ## Tool used 112 | 113 | Manual Review 114 | 115 | ## Recommendation 116 | First, add global eModCategoryId variable which will be 0 by default 117 | ```diff 118 | function setEModeCategory(uint8 _categoryId) external onlyOperator { 119 | + eModCategoryId = _categoryId; 120 | _setEModeCategory(_categoryId); 121 | } 122 | ``` 123 | fetch data from eMode if its not 0, also do the same for oracle, like Morho is doing 124 | ```diff 125 | function _calculateMaxBorrowCollateral(ActionInfo memory _actionInfo, bool _isLever) internal view returns(uint256) { 126 | 127 | // Retrieve collateral factor and liquidation threshold for the collateral asset in precise units (1e16 = 1%) 128 | ( , uint256 maxLtvRaw, uint256 liquidationThresholdRaw, , , , , , ,) = strategy.aaveProtocolDataProvider.getReserveConfigurationData(address(strategy.collateralAsset)); 129 | 130 | + if (eModCategoryId > 0) { 131 | + eModeCategory = _pool.getEModeCategoryData(eModCategoryId); 132 | + maxLtvRaw = eModeCategory.ltv; 133 | + liquidationThreshold = eModeCategory.liquidationThreshold; 134 | + } 135 | 136 | // Normalize LTV and liquidation threshold to precise units. LTV is measured in 4 decimals in Aave which is why we must multiply by 1e14 137 | // for example ETH has an LTV value of 8000 which represents 80% 138 | if (_isLever) { 139 | uint256 netBorrowLimit = _actionInfo.collateralValue 140 | .preciseMul(maxLtvRaw.mul(10 ** 14)) 141 | .preciseMul(PreciseUnitMath.preciseUnit().sub(execution.unutilizedLeveragePercentage)); 142 | 143 | return netBorrowLimit 144 | .sub(_actionInfo.borrowValue) 145 | .preciseDiv(_actionInfo.collateralPrice); 146 | } else { 147 | uint256 netRepayLimit = _actionInfo.collateralValue 148 | .preciseMul(liquidationThresholdRaw.mul(10 ** 14)) 149 | .preciseMul(PreciseUnitMath.preciseUnit().sub(execution.unutilizedLeveragePercentage)); 150 | 151 | return _actionInfo.collateralBalance 152 | .preciseMul(netRepayLimit.sub(_actionInfo.borrowValue)) 153 | .preciseDiv(netRepayLimit); 154 | } 155 | } 156 | 157 | ``` 158 | 159 | ## [M-01] Side effects of LTV = 0 assets: Index's users will not be able to withdraw (collateral), borrow 160 | 161 | ## Summary 162 | [Link to report from spearbit](https://solodit.xyz/issues/16216) 163 | When an AToken has LTV = 0, Aave restricts the usage of some operations. In particular, if the user 164 | owns at least one AToken as collateral that has LTV = 0, operations could revert. 165 | 1) Withdraw: if the asset withdrawn is collateral, the user is borrowing something, the operation will revert if the 166 | withdrawn collateral is an AToken with LTV > 0. 167 | 2) Transfer: if the from is using the asset as collateral, is borrowing something and the asset transferred is an 168 | AToken with LTV > 0 the operation will revert. 169 | 3) Set the reserve of an AToken as not collateral: if the AToken you are trying to set as non-collateral is an 170 | AToken with LTV > 0 the operation will revert. 171 | Note that all those checks are done on top of the "normal" checks that would usually prevent an operation, de- 172 | pending on the operation itself 173 | ## Vulnerability Detail 174 | ```solidity 175 | function _validateNewCollateralAsset(ISetToken _setToken, IERC20 _asset) internal view { 176 | require(!collateralAssetEnabled[_setToken][_asset], "Collateral already enabled"); 177 | 178 | (address aToken, , ) = protocolDataProvider.getReserveTokensAddresses(address(_asset)); 179 | require(address(underlyingToReserveTokens[_asset].aToken) == aToken, "Invalid aToken address"); 180 | 181 | ( , , , , , bool usageAsCollateralEnabled, , , bool isActive, bool isFrozen) = protocolDataProvider.getReserveConfigurationData(address(_asset)); 182 | // An active reserve is an alias for a valid reserve on Aave. 183 | // We are checking for the availability of the reserve directly on Aave rather than checking our internal `underlyingToReserveTokens` mappings, 184 | // because our mappings can be out-of-date if a new reserve is added to Aave 185 | require(isActive, "IAR"); 186 | // A frozen reserve doesn't allow any new deposit, borrow or rate swap but allows repayments, liquidations and withdrawals 187 | require(!isFrozen, "FAR"); 188 | require(usageAsCollateralEnabled, "CNE"); 189 | } 190 | 191 | ``` 192 | [v1/AaveV3LeverageModule.sol#L1101](https://github.com/sherlock-audit/2023-05-Index/blob/main/index-protocol/contracts/protocol/modules/v1/AaveV3LeverageModule.sol#L1101) 193 | ## Impact 194 | The Index protocol might stop working for users who would use those particular markets. 195 | ## Code Snippet 196 | 197 | ## Tool used 198 | 199 | Manual Review 200 | 201 | ## Recommendation 202 | Add restriction to those markets and add documentation to restrict those markets 203 | E.x. 204 | 205 | ```diff 206 | function _validateNewCollateralAsset(ISetToken _setToken, IERC20 _asset) internal view { 207 | require(!collateralAssetEnabled[_setToken][_asset], "Collateral already enabled"); 208 | 209 | (address aToken, , ) = protocolDataProvider.getReserveTokensAddresses(address(_asset)); 210 | require(address(underlyingToReserveTokens[_asset].aToken) == aToken, "Invalid aToken address"); 211 | 212 | - ( , , , , , bool usageAsCollateralEnabled, , , bool isActive, bool isFrozen) = protocolDataProvider.getReserveConfigurationData(address(_asset)); 213 | + ( , uint256 ltv , , , , bool usageAsCollateralEnabled, , , bool isActive, bool isFrozen) = protocolDataProvider.getReserveConfigurationData(address(_asset)); 214 | 215 | // An active reserve is an alias for a valid reserve on Aave. 216 | // We are checking for the availability of the reserve directly on Aave rather than checking our internal `underlyingToReserveTokens` mappings, 217 | // because our mappings can be out-of-date if a new reserve is added to Aave 218 | require(isActive, "IAR"); 219 | + require(ltv != 0, "ltv should be non zero"); 220 | 221 | // A frozen reserve doesn't allow any new deposit, borrow or rate swap but allows repayments, liquidations and withdrawals 222 | require(!isFrozen, "FAR"); 223 | require(usageAsCollateralEnabled, "CNE"); 224 | } 225 | 226 | ``` 227 | 228 | ## [M-02] Some modules will not work with certain ERC20s reverting when trying to approve with allowance already >0 229 | 230 | ## Summary 231 | Some ERC20 tokens like USDT require resetting the approval to 0 first before being able to reset it to another value. 232 | ## Vulnerability Detail 233 | There are multiple files where using invokeApprove without resetting approve to 0. 234 | There was 1 module where it was [fixed before](https://github.com/ckoopmann/set-protocol-v2/issues/3) 235 | >Proposed Mitigation: Resetting allowance to 0 after every mint / redeem. This should also fix the issue of certain ERC20s reverting when trying to approve with allowance already >0 236 | 237 | Here are files. 238 | ```solidity 239 | function _executeComponentApprovals(ActionInfo memory _actionInfo) internal { 240 | address spender = _actionInfo.ammAdapter.getSpenderAddress(_actionInfo.liquidityToken); 241 | 242 | // Loop through and approve total notional tokens to spender 243 | for (uint256 i = 0; i < _actionInfo.components.length ; i++) { 244 | _actionInfo.setToken.invokeApprove( 245 | _actionInfo.components[i], 246 | spender, 247 | _actionInfo.totalNotionalComponents[i] 248 | ); 249 | } 250 | } 251 | 252 | ``` 253 | [v1/AmmModule.sol#L422](https://github.com/sherlock-audit/2023-05-Index/blob/main/index-protocol/contracts/protocol/modules/v1/AmmModule.sol#L422) 254 | 255 | Other files: 256 | contracts/protocol/modules/v1/WrapModuleV2.sol 257 | contracts/protocol/modules/v1/StakingModule.sol 258 | contracts/protocol/modules/v1/TradeModule.sol 259 | contracts/protocol/modules/v1/AaveV3LeverageModule.sol 260 | ## Impact 261 | Some modules will not work with certain ERC20s and there might be residual allowance left just like in this [issue](https://github.com/ckoopmann/set-protocol-v2/issues/3) 262 | ## Code Snippet 263 | 264 | ## Tool used 265 | 266 | Manual Review 267 | 268 | ## Recommendation 269 | Reset to 0 in those modules so certain ERC20 will work without problems 270 | 271 | ## [M-03] Deprecated chainlink oracle 272 | 273 | ## Summary 274 | Deprecated chainlink oracle leads to incorrect and stale prices 275 | 276 | ## Vulnerability Detail 277 | According to chainlink [docs](https://docs.chain.link/data-feeds/api-reference#latestanswer) latestAnswer shouldn't be used 278 | > latestAnswer | (Deprecated - Do not use this function.) 279 | 280 | latestAnswer is being used in the code and there is no check for stale prices 281 | ```solidity 282 | function _createActionInfo() internal view returns(ActionInfo memory) { 283 | ActionInfo memory rebalanceInfo; 284 | 285 | // Calculate prices from chainlink. Chainlink returns prices with 8 decimal places, but we need 36 - underlyingDecimals decimal places. 286 | // This is so that when the underlying amount is multiplied by the received price, the collateral valuation is normalized to 36 decimals. 287 | // To perform this adjustment, we multiply by 10^(36 - 8 - underlyingDecimals) 288 | // @audit latestAnswer (Deprecated - Do not use this function.) 289 | // https://docs.chain.link/getting-started/consuming-data-feeds 290 | int256 rawCollateralPrice = strategy.collateralPriceOracle.latestAnswer(); 291 | rebalanceInfo.collateralPrice = rawCollateralPrice.toUint256().mul(10 ** strategy.collateralDecimalAdjustment); 292 | int256 rawBorrowPrice = strategy.borrowPriceOracle.latestAnswer(); 293 | rebalanceInfo.borrowPrice = rawBorrowPrice.toUint256().mul(10 ** strategy.borrowDecimalAdjustment); 294 | 295 | rebalanceInfo.collateralBalance = strategy.targetCollateralAToken.balanceOf(address(strategy.setToken)); 296 | rebalanceInfo.borrowBalance = strategy.targetBorrowDebtToken.balanceOf(address(strategy.setToken)); 297 | rebalanceInfo.collateralValue = rebalanceInfo.collateralPrice.preciseMul(rebalanceInfo.collateralBalance); 298 | rebalanceInfo.borrowValue = rebalanceInfo.borrowPrice.preciseMul(rebalanceInfo.borrowBalance); 299 | rebalanceInfo.setTotalSupply = strategy.setToken.totalSupply(); 300 | 301 | return rebalanceInfo; 302 | } 303 | ``` 304 | [adapters/AaveLeverageStrategyExtension.sol#L889](https://github.com/sherlock-audit/2023-05-Index/blob/main/index-coop-smart-contracts/contracts/adapters/AaveLeverageStrategyExtension.sol#L895) 305 | ## Impact 306 | 307 | ## Code Snippet 308 | 309 | ## Tool used 310 | 311 | Manual Review 312 | 313 | ## Recommendation 314 | Use latestRoundData and check for stale prices 315 | 316 | ```solidity 317 | (uint80 roundId, int256 assetChainlinkPriceInt, , uint256 updatedAt, uint80 answeredInRound) = strategy.collateralPriceOracle.latestRoundData(); 318 | require(answeredInRound >= roundId, "price is stale"); 319 | require(updatedAt > 0, "round is incomplete"); 320 | int256 rawCollateralPrice = assetChainlinkPriceInt; 321 | ``` -------------------------------------------------------------------------------- /reports/venus.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

Venus Protocol Audit Report

7 |

Algorithmic money market

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: May 9 to May 16, 2023

10 |
13 | 14 | # About Venus 15 | Venus is a decentralized finance (DeFi) algorithmic money market protocol on BNB Chain. 16 | 17 | Decentralized lending pools are very similar to traditional lending services offered by banks, except that they are offered by P2P decentralized platforms. Users can leverage assets by borrowing and lending assets listed in a pool. Lending pools help crypto holders earn a substantial income through interest paid on their supplied assets and access assets they don't currently own without selling any of their portfolio. 18 | 19 | # Summary of Findings 20 | 21 | |    ID        | Title | Severity | Fixed | 22 | |----------------------------------|----------------------------------------------------------------------------------------|----------|------| 23 | | [H-01] | blocksPerYear is not sync like it supposed to in bsc chain | High | ✓ | 24 | | [M-01] | Users can borrow the borrowCap amount, but they should borrow less than that and not equal | Medium | ✓ | 25 | | [M-02] | Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results | Medium | ✓ | 26 | | [M-03] | First Deposit Bug | Medium | ✓ | 27 | | [M-04] | Inconsistent scaling of USD in bad debt in the project. | Medium | ✓ | 28 | | [M-05] | There are no incentives for users to start bidding after the auction restarts. | Medium | x | 29 | | [M-06] | There are no restriction on auction duration | Medium | x | 30 | | [M-07] | The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice | Medium | ✓ | 31 | | [M-08] | Repayments Paused While Liquidations Enabled | Medium | ✓ | 32 | 33 | # Detailed Findings 34 | 35 | ## [H-01] blocksPerYear is not sync like it supposed to in bsc chain 36 | ## Impact 37 | Detailed description of the impact of this finding. 38 | The variable calculations in WhitePaperInterestRateModel are incorrect compared to BaseJumpRateModelV2 due to a lack of synchronization in blocksPerYear. 39 | ## Proof of Concept 40 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 41 | blocksPerYear should be 10512000 just like in WhitePaperInterestRateModel for bsc chain 42 | ```solidity 43 | /** 44 | * @notice The approximate number of blocks per year that is assumed by the interest rate model 45 | */ 46 | uint256 public constant blocksPerYear = 2102400; 47 | ``` 48 | [contracts/WhitePaperInterestRateModel.sol#L17](https://github.com/VenusProtocol/isolated-pools/blob/f075e8256a5215d438ff610f34cd3e25eea7c79d/contracts/WhitePaperInterestRateModel.sol#L17) 49 | 50 | 51 | ```solidity 52 | /** 53 | * @notice The approximate number of blocks per year that is assumed by the interest rate model 54 | */ 55 | uint256 public constant blocksPerYear = 10512000; 56 | ``` 57 | [contracts/BaseJumpRateModelV2.sol#L23](https://github.com/VenusProtocol/isolated-pools/blob/f075e8256a5215d438ff610f34cd3e25eea7c79d/contracts/BaseJumpRateModelV2.sol#L23) 58 | 59 | There will be invalid `baseRatePerBlock` and `multiplierPerBlock` 60 | ```solidity 61 | constructor(uint256 baseRatePerYear, uint256 multiplierPerYear) { 62 | baseRatePerBlock = baseRatePerYear / blocksPerYear; 63 | multiplierPerBlock = multiplierPerYear / blocksPerYear; 64 | 65 | emit NewInterestParams(baseRatePerBlock, multiplierPerBlock); 66 | } 67 | 68 | ``` 69 | [contracts/WhitePaperInterestRateModel.sol#L36](https://github.com/VenusProtocol/isolated-pools/blob/f075e8256a5215d438ff610f34cd3e25eea7c79d/contracts/WhitePaperInterestRateModel.sol#L36) 70 | ## Tools Used 71 | Manual 72 | ## Recommended Mitigation Steps 73 | Change to 10512000 74 | 75 | ## [M-01] Users can borrow the borrowCap amount, but they should borrow less than that and not equal 76 | ## Impact 77 | Detailed description of the impact of this finding. 78 | Users can borrow up to the borrowCap amount, but they should borrow less than that. 79 | ## Proof of Concept 80 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 81 | According to the docs from the code function `Borrowing that brings total borrows to borrow cap will revert` 82 | ```solidity 83 | /** 84 | * @notice Set the given borrow caps for the given vToken markets. Borrowing that brings total borrows to or above borrow cap will revert. 85 | ... 86 | ``` 87 | [contracts/Comptroller.sol#L839](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Comptroller.sol#L839) 88 | 89 | this means that whevener user should not be able to hit borrowCap and revert if `nextTotalBorrows == borrowCap` but it doesn't 90 | ```solidity 91 | if (borrowCap != type(uint256).max) { 92 | uint256 totalBorrows = VToken(vToken).totalBorrows(); 93 | uint256 nextTotalBorrows = totalBorrows + borrowAmount; 94 | // @audit should be, users can borrow more than allowed 95 | // if (nextTotalBorrows >= borrowCap) { 96 | if (nextTotalBorrows > borrowCap) { 97 | revert BorrowCapExceeded(vToken, borrowCap); 98 | } 99 | } 100 | ``` 101 | [contracts/Comptroller.sol#L354](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Comptroller.sol#L354) 102 | ## Tools Used 103 | 104 | ## Recommended Mitigation Steps 105 | 106 | ```diff 107 | if (borrowCap != type(uint256).max) { 108 | uint256 totalBorrows = VToken(vToken).totalBorrows(); 109 | uint256 nextTotalBorrows = totalBorrows + borrowAmount; 110 | - if (nextTotalBorrows > borrowCap) { 111 | + if (nextTotalBorrows >= borrowCap) { 112 | revert BorrowCapExceeded(vToken, borrowCap); 113 | } 114 | } 115 | ``` 116 | 117 | ## [M-02] Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results 118 | 119 | ## Impact 120 | Detailed description of the impact of this finding. 121 | Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results 122 | ## Proof of Concept 123 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 124 | Whenever user wants to know his pending rewards he calls `getPendingRewards` sometimes it returns incorrect results. 125 | 126 | There is a bug inside `calculateBorrowerReward` and `calculateSupplierReward` 127 | ```solidity 128 | function calculateBorrowerReward( 129 | address vToken, 130 | RewardsDistributor rewardsDistributor, 131 | address borrower, 132 | RewardTokenState memory borrowState, 133 | Exp memory marketBorrowIndex 134 | ) internal view returns (uint256) { 135 | Double memory borrowIndex = Double({ mantissa: borrowState.index }); 136 | Double memory borrowerIndex = Double({ 137 | mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower) 138 | }); 139 | // @audit 140 | // if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { 141 | if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) { 142 | // Covers the case where users borrowed tokens before the market's borrow state index was set 143 | borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); 144 | } 145 | Double memory deltaIndex = sub_(borrowIndex, borrowerIndex); 146 | uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex); 147 | uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex); 148 | return borrowerDelta; 149 | } 150 | 151 | ``` 152 | [contracts/Lens/PoolLens.sol#L495](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Lens/PoolLens.sol#L495) 153 | 154 | ```solidity 155 | function calculateSupplierReward( 156 | address vToken, 157 | RewardsDistributor rewardsDistributor, 158 | address supplier, 159 | RewardTokenState memory supplyState 160 | ) internal view returns (uint256) { 161 | Double memory supplyIndex = Double({ mantissa: supplyState.index }); 162 | Double memory supplierIndex = Double({ 163 | mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier) 164 | }); 165 | // @audit 166 | // if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { 167 | if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) { 168 | // Covers the case where users supplied tokens before the market's supply state index was set 169 | supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); 170 | } 171 | Double memory deltaIndex = sub_(supplyIndex, supplierIndex); 172 | uint256 supplierTokens = VToken(vToken).balanceOf(supplier); 173 | uint256 supplierDelta = mul_(supplierTokens, deltaIndex); 174 | return supplierDelta; 175 | } 176 | 177 | ``` 178 | [contracts/Lens/PoolLens.sol#L516](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Lens/PoolLens.sol#L516) 179 | 180 | Inside rewardsDistributor original functions written likes this 181 | ```solidity 182 | function _distributeSupplierRewardToken(address vToken, address supplier) internal { 183 | ... 184 | if (supplierIndex == 0 && supplyIndex >= rewardTokenInitialIndex) { 185 | // Covers the case where users supplied tokens before the market's supply state index was set. 186 | // Rewards the user with REWARD TOKEN accrued from the start of when supplier rewards were first 187 | // set for the market. 188 | supplierIndex = rewardTokenInitialIndex; 189 | } 190 | ... 191 | } 192 | ``` 193 | [contracts/Rewards/RewardsDistributor.sol#L340](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Rewards/RewardsDistributor.sol#L340) 194 | 195 | ```solidity 196 | function _distributeBorrowerRewardToken( 197 | address vToken, 198 | address borrower, 199 | Exp memory marketBorrowIndex 200 | ) internal { 201 | ... 202 | if (borrowerIndex == 0 && borrowIndex >= rewardTokenInitialIndex) { 203 | // Covers the case where users borrowed tokens before the market's borrow state index was set. 204 | // Rewards the user with REWARD TOKEN accrued from the start of when borrower rewards were first 205 | // set for the market. 206 | borrowerIndex = rewardTokenInitialIndex; 207 | } 208 | ... 209 | } 210 | ``` 211 | [Rewards/RewardsDistributor.sol#L374](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Rewards/RewardsDistributor.sol#L374) 212 | ## Tools Used 213 | 214 | ## Recommended Mitigation Steps 215 | 216 | ```diff 217 | function calculateSupplierReward( 218 | address vToken, 219 | RewardsDistributor rewardsDistributor, 220 | address supplier, 221 | RewardTokenState memory supplyState 222 | ) internal view returns (uint256) { 223 | Double memory supplyIndex = Double({ mantissa: supplyState.index }); 224 | Double memory supplierIndex = Double({ 225 | mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier) 226 | }); 227 | - if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) { 228 | + if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { 229 | // Covers the case where users supplied tokens before the market's supply state index was set 230 | supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); 231 | } 232 | Double memory deltaIndex = sub_(supplyIndex, supplierIndex); 233 | uint256 supplierTokens = VToken(vToken).balanceOf(supplier); 234 | uint256 supplierDelta = mul_(supplierTokens, deltaIndex); 235 | return supplierDelta; 236 | } 237 | ``` 238 | ```diff 239 | function calculateBorrowerReward( 240 | address vToken, 241 | RewardsDistributor rewardsDistributor, 242 | address borrower, 243 | RewardTokenState memory borrowState, 244 | Exp memory marketBorrowIndex 245 | ) internal view returns (uint256) { 246 | Double memory borrowIndex = Double({ mantissa: borrowState.index }); 247 | Double memory borrowerIndex = Double({ 248 | mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower) 249 | }); 250 | - if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) { 251 | + if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) { 252 | // Covers the case where users borrowed tokens before the market's borrow state index was set 253 | borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex(); 254 | } 255 | Double memory deltaIndex = sub_(borrowIndex, borrowerIndex); 256 | uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex); 257 | uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex); 258 | return borrowerDelta; 259 | } 260 | ``` 261 | 262 | ## [M-03] First Deposit Bug 263 | 264 | ## Vulnerability details 265 | 266 | The CToken is a yield bearing asset which is minted when any user deposits some units of 267 | `underlying` tokens. The amount of CTokens minted to a user is calculated based upon 268 | the amount of `underlying` tokens user is depositing. 269 | 270 | As per the implementation of CToken contract, there exist two cases for CToken amount calculation: 271 | 272 | 1. First deposit - when `VToken.totalSupply()` is `0`. 273 | 2. All subsequent deposits. 274 | 275 | Here is the actual CToken code (extra code and comments clipped for better reading): 276 | 277 | ```solidity 278 | function _exchangeRateStored() internal view virtual returns (uint256) { 279 | uint256 _totalSupply = totalSupply; 280 | if (_totalSupply == 0) { 281 | /* 282 | * If there are no tokens minted: 283 | * exchangeRate = initialExchangeRate 284 | */ 285 | return initialExchangeRateMantissa; 286 | } else { 287 | /* 288 | * Otherwise: 289 | * exchangeRate = (totalCash + totalBorrows + badDebt - totalReserves) / totalSupply 290 | */ 291 | uint256 totalCash = _getCashPrior(); 292 | uint256 cashPlusBorrowsMinusReserves = totalCash + totalBorrows + badDebt - totalReserves; 293 | uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply; 294 | 295 | return exchangeRate; 296 | } 297 | } 298 | 299 | function _mintFresh( 300 | address payer, 301 | address minter, 302 | uint256 mintAmount 303 | ) internal { 304 | /* Fail if mint not allowed */ 305 | comptroller.preMintHook(address(this), minter, mintAmount); 306 | 307 | /* Verify market's block number equals current block number */ 308 | if (accrualBlockNumber != _getBlockNumber()) { 309 | revert MintFreshnessCheck(); 310 | } 311 | 312 | Exp memory exchangeRate = Exp({ mantissa: _exchangeRateStored() }); 313 | ... 314 | ``` 315 | [/contracts/VToken.sol#L1463](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/VToken.sol#L1463) 316 | 317 | ## Impact 318 | A sophisticated attack can impact all user deposits until the lending protocols owners and users are notified and contracts are paused. Since this attack is a replicable attack it can be performed continuously to steal the deposits of all depositors that try to deposit into the CToken contract. 319 | 320 | The loss amount will be the sum of all deposits done by users into the CToken multiplied by the underlying token's price. 321 | 322 | Suppose there are `10` users and each of them tries to deposit `1,000,000` underlying tokens into the CToken contract. Price of underlying token is `$1`. 323 | 324 | `Total loss (in $) = $10,000,000` 325 | 326 | 327 | ## The Fix 328 | The fix to prevent this issue would be to enforce a minimum deposit that cannot be withdrawn. This can be done by minting small amount of CToken units to `0x00` address on the first deposit. 329 | 330 | Instead of a fixed `1000` value an admin controlled parameterized value can also be used to control the burn amount on a per CToken basis. 331 | 332 | ## [M-04] Inconsistent scaling of USD in bad debt in the project. 333 | 334 | ## Impact 335 | Detailed description of the impact of this finding. 336 | Inconsistent scaling of USD in bad debt in the project. 337 | 338 | ## Proof of Concept 339 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 340 | usdvalue is being calculated differently in project. This is how its calculated for auction, As you can see its being scaled down by `/ 1e18`. Its the only place in project where `getUnderlyingPrice * tokenAmount` is being scaled down. 341 | ```solidity 342 | for (uint256 i; i < marketsCount; ++i) { 343 | uint256 marketBadDebt = vTokens[i].badDebt(); 344 | 345 | priceOracle.updatePrice(address(vTokens[i])); 346 | uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18; 347 | 348 | poolBadDebt = poolBadDebt + usdValue; 349 | auction.markets[i] = vTokens[i]; 350 | auction.marketDebt[vTokens[i]] = marketBadDebt; 351 | marketsDebt[i] = marketBadDebt; 352 | } 353 | 354 | ``` 355 | [contracts/Shortfall/Shortfall.sol#L393](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Shortfall/Shortfall.sol#L393) 356 | 357 | This is how badDebtUsd is being calculated inside poollens 358 | ```solidity 359 | for (uint256 i; i < markets.length; ++i) { 360 | BadDebt memory badDebt; 361 | badDebt.vTokenAddress = address(markets[i]); 362 | badDebt.badDebtUsd = 363 | VToken(address(markets[i])).badDebt() * 364 | priceOracle.getUnderlyingPrice(address(markets[i])); 365 | badDebtSummary.badDebts[i] = badDebt; 366 | totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd; 367 | } 368 | ``` 369 | [contracts/Lens/PoolLens.sol#L268](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Lens/PoolLens.sol#L268) 370 | 371 | ## Tools Used 372 | Manual 373 | ## Recommended Mitigation Steps 374 | As I understood, scaling down should be removed inside shortfall due the only place in the project where `getUnderlyingPrice * tokenAmount` is being scaled down 375 | 376 | ```diff 377 | for (uint256 i; i < marketsCount; ++i) { 378 | uint256 marketBadDebt = vTokens[i].badDebt(); 379 | 380 | priceOracle.updatePrice(address(vTokens[i])); 381 | - uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18; 382 | + uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt); 383 | 384 | poolBadDebt = poolBadDebt + usdValue; 385 | auction.markets[i] = vTokens[i]; 386 | auction.marketDebt[vTokens[i]] = marketBadDebt; 387 | marketsDebt[i] = marketBadDebt; 388 | } 389 | 390 | ``` 391 | 392 | ## [M-05] There are no incentives for users to start bidding after the auction restarts. 393 | 394 | 395 | ## Impact 396 | Detailed description of the impact of this finding. 397 | There are no incentives for users to start bidding after the auction restarts this means that auction might restarts forever. 398 | ## Proof of Concept 399 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 400 | If the auction becomes stale without receiving any bids, anyone can restart the auction by calling restartAuction. If nobody is placing bids this means that its not profitable for users, so protocol should make some changes to auction to incentive users to start placing bids. E.x. [makerdao](https://docs.makerdao.com/smart-contract-modules/system-stabilizer-module/flop-detailed-documentation) increasing lot(funds) by 50% on the restart. 401 | > If the auction expires without receiving any bids, anyone can restart the auction by calling tick(uint auction_id). This will do two things: 402 | 1. It resets bids[id].end to now + tau 403 | 2. It resets bids[id].lot to bids[id].lot * pad / ONE 404 | 405 | ## Tools Used 406 | 407 | ## Recommended Mitigation Steps 408 | Make it appealing for users to start bidding after the auction becomes stale. For example, you can consider calling `swapPoolsAssets` to increase the `riskFundBalance` inside the `_startAuction` function 409 | 410 | 411 | ## [M-06] There are no restriction on auction duration 412 | 413 | ## Impact 414 | Detailed description of the impact of this finding. 415 | There are no restriction on auction duration, if nextBidderBlockLimit will be changed then auction might never ends 416 | ## Proof of Concept 417 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 418 | There are no restriction on auction duration, current length of auction is `nextBidderBlockLimit * MAX_BPS = 10*10000` 10*10000 / 28753 ~ 3.5 days. That's the maximum amount of time a user can prolong the auction, its depens on `auction.highestBidBlock + nextBidderBlockLimit` inside `closeAuction` 419 | 420 | ```solidity 421 | function closeAuction(address comptroller) external nonReentrant { 422 | ... 423 | require( 424 | block.number > auction.highestBidBlock + nextBidderBlockLimit && auction.highestBidder != address(0), 425 | "waiting for next bidder. cannot close auction" 426 | ); 427 | ... 428 | ``` 429 | [contracts/Shortfall/Shortfall.sol#L214](https://github.com/VenusProtocol/isolated-pools/blob/f075e8256a5215d438ff610f34cd3e25eea7c79d/contracts/Shortfall/Shortfall.sol#L214) 430 | 431 | According to the code nextBidderBlockLimit can be changed, e.x. admin would like to change it to the same as [makerdao](https://docs.makerdao.com/keepers/the-auctions-of-the-maker-protocol) to 6 hours ~ 7188 blocks 432 | 433 | > ttl: Bid duration (for example, 6 hours). The auction ends if no new bid is placed during this time. 434 | 435 | ```sodliity 436 | function updateNextBidderBlockLimit(uint256 _nextBidderBlockLimit) external { 437 | _checkAccessAllowed("updateNextBidderBlockLimit(uint256)"); 438 | require(_nextBidderBlockLimit != 0, "_nextBidderBlockLimit must not be 0"); 439 | uint256 oldNextBidderBlockLimit = nextBidderBlockLimit; 440 | nextBidderBlockLimit = _nextBidderBlockLimit; 441 | emit NextBidderBlockLimitUpdated(oldNextBidderBlockLimit, _nextBidderBlockLimit); 442 | } 443 | ``` 444 | [contracts/Shortfall/Shortfall.sol#L293](https://github.com/VenusProtocol/isolated-pools/blob/f075e8256a5215d438ff610f34cd3e25eea7c79d/contracts/Shortfall/Shortfall.sol#L293) 445 | That would mean that max lenght of auction would be `nextBidderBlockLimit * MAX_BPS = 7188*10000` 7188 *10000 / 28753 ~ 2499 days 446 | Which is too much, without any way to restrict an auction duration. 447 | 448 | ## Tools Used 449 | 450 | ## Recommended Mitigation Steps 451 | Introduce auction time limit variable so the system will be for flexible for the admin without never ending auction just like [makerDao](https://docs.makerdao.com/keepers/the-auctions-of-the-maker-protocol) has `tau` 452 | 453 | > tau: Auction duration (for example, 24 hours). The auction ends after this period under all circumstances. 454 | 455 | ## [M-07] The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice 456 | 457 | ## Impact 458 | Detailed description of the impact of this finding. 459 | The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice 460 | ## Proof of Concept 461 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 462 | 463 | According to the [comments](https://github.com/VenusProtocol/oracle/blob/develop/contracts/ResilientOracle.sol#L175) from oracle file. updatePrice should always be called before calling getUnderlyingPrice. 464 | ```solidity 465 | /** 466 | * @notice Updates the TWAP pivot oracle price. 467 | * @dev This function should always be called before calling getUnderlyingPrice 468 | * @param vToken vToken address 469 | */ 470 | function updatePrice(address vToken) external override { 471 | (address pivotOracle, bool pivotOracleEnabled) = getOracle(vToken, OracleRole.PIVOT); 472 | if (pivotOracle != address(0) && pivotOracleEnabled) { 473 | //if pivot oracle is not TwapOracle it will revert so we need to catch the revert 474 | try TwapInterface(pivotOracle).updateTwap(vToken) {} catch {} 475 | } 476 | } 477 | ``` 478 | [/contracts/ResilientOracle.sol#L175](https://github.com/VenusProtocol/oracle/blob/develop/contracts/ResilientOracle.sol#L175) 479 | 480 | There are functions in the code that do not call updatePrice before calling getUnderlyingPrice while some functions call updatePrice. E.x. this function is not calling updatePrice 481 | calls _checkRedeemAllowed -> calls _getHypotheticalLiquiditySnapshot -> calls _safeGetUnderlyingPrice(asset) 482 | ```solidity 483 | function exitMarket(address vTokenAddress) external override returns (uint256) { 484 | _checkActionPauseState(vTokenAddress, Action.EXIT_MARKET); 485 | VToken vToken = VToken(vTokenAddress); 486 | /* Get sender tokensHeld and amountOwed underlying from the vToken */ 487 | (uint256 tokensHeld, uint256 amountOwed, ) = _safeGetAccountSnapshot(vToken, msg.sender); 488 | 489 | /* Fail if the sender has a borrow balance */ 490 | if (amountOwed != 0) { 491 | revert NonzeroBorrowBalance(); 492 | } 493 | 494 | /* Fail if the sender is not permitted to redeem all of their tokens */ 495 | _checkRedeemAllowed(vTokenAddress, msg.sender, tokensHeld); 496 | 497 | Market storage marketToExit = markets[address(vToken)]; 498 | 499 | /* Return true if the sender is not already ‘in’ the market */ 500 | if (!marketToExit.accountMembership[msg.sender]) { 501 | return NO_ERROR; 502 | } 503 | 504 | /* Set vToken account membership to false */ 505 | delete marketToExit.accountMembership[msg.sender]; 506 | 507 | /* Delete vToken from the account’s list of assets */ 508 | // load into memory for faster iteration 509 | VToken[] memory userAssetList = accountAssets[msg.sender]; 510 | uint256 len = userAssetList.length; 511 | 512 | uint256 assetIndex = len; 513 | for (uint256 i; i < len; ++i) { 514 | if (userAssetList[i] == vToken) { 515 | assetIndex = i; 516 | break; 517 | } 518 | } 519 | 520 | // We *must* have found the asset in the list or our redundant data structure is broken 521 | assert(assetIndex < len); 522 | 523 | // copy last item in list to location of item to be removed, reduce length by 1 524 | VToken[] storage storedList = accountAssets[msg.sender]; 525 | storedList[assetIndex] = storedList[storedList.length - 1]; 526 | storedList.pop(); 527 | 528 | emit MarketExited(vToken, msg.sender); 529 | 530 | return NO_ERROR; 531 | } 532 | 533 | ``` 534 | [contracts/Comptroller.sol#L199](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Comptroller.sol#L199) 535 | 536 | The same way you can track that these functions doesn't call updatePrice before calling getUnderlyingPrice 537 | `liquidateCalculateSeizeTokens` calls _safeGetUnderlyingPrice -> getUnderlyingPrice 538 | `getHypotheticalAccountLiquidity` calls _getHypotheticalLiquiditySnapshot -> _safeGetUnderlyingPrice -> getUnderlyingPrice 539 | `setCollateralFactor` 540 | inside Comptroller.sol 541 | 542 | Inside PoolLens.sol 543 | ```solidity 544 | function getPoolBadDebt(address comptrollerAddress) external view returns (BadDebtSummary memory) { 545 | uint256 totalBadDebtUsd; 546 | 547 | // Get every market in the pool 548 | ComptrollerViewInterface comptroller = ComptrollerViewInterface(comptrollerAddress); 549 | VToken[] memory markets = comptroller.getAllMarkets(); 550 | PriceOracle priceOracle = comptroller.oracle(); 551 | 552 | BadDebt[] memory badDebts = new BadDebt[](markets.length); 553 | 554 | BadDebtSummary memory badDebtSummary; 555 | badDebtSummary.comptroller = comptrollerAddress; 556 | badDebtSummary.badDebts = badDebts; 557 | 558 | // // Calculate the bad debt is USD per market 559 | for (uint256 i; i < markets.length; ++i) { 560 | BadDebt memory badDebt; 561 | badDebt.vTokenAddress = address(markets[i]); 562 | badDebt.badDebtUsd = 563 | VToken(address(markets[i])).badDebt() * 564 | priceOracle.getUnderlyingPrice(address(markets[i])); 565 | badDebtSummary.badDebts[i] = badDebt; 566 | totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd; 567 | } 568 | 569 | badDebtSummary.totalBadDebtUsd = totalBadDebtUsd; 570 | 571 | return badDebtSummary; 572 | } 573 | ``` 574 | [contracts/Lens/PoolLens.sol#L268](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Lens/PoolLens.sol#L268) 575 | 576 | ```solidity 577 | function vTokenUnderlyingPrice(VToken vToken) public view returns (VTokenUnderlyingPrice memory) { 578 | ComptrollerViewInterface comptroller = ComptrollerViewInterface(address(vToken.comptroller())); 579 | PriceOracle priceOracle = comptroller.oracle(); 580 | 581 | return 582 | VTokenUnderlyingPrice({ 583 | vToken: address(vToken), 584 | underlyingPrice: priceOracle.getUnderlyingPrice(address(vToken)) 585 | }); 586 | } 587 | ``` 588 | [contracts/Lens/PoolLens.sol#L408](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Lens/PoolLens.sol#L408) 589 | ## Tools Used 590 | 591 | ## Recommended Mitigation Steps 592 | I think it will be cleaner to place update price inside _safeGetUnderlyingPrice instead of inside every function like its now. Code wil be cleaner as well. 593 | Add updatePrice to other functions inside PoolLens as well 594 | 595 | ```diff 596 | function _safeGetUnderlyingPrice(VToken asset) internal view returns (uint256) { 597 | + oracle.updatePrice(address(asset)); 598 | uint256 oraclePriceMantissa = oracle.getUnderlyingPrice(address(asset)); 599 | if (oraclePriceMantissa == 0) { 600 | revert PriceError(address(asset)); 601 | } 602 | return oraclePriceMantissa; 603 | } 604 | ``` 605 | 606 | ## [M-08] Repayments Paused While Liquidations Enabled 607 | 608 | ## Impact 609 | Detailed description of the impact of this finding. 610 | It is possible to have repayments Paused While Liquidations Enabled 611 | ## Proof of Concept 612 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 613 | Lending & Borrowing DeFi platforms should never be able to enter a state where repayments are paused but liquidations are enabled, since this would unfairly prevent Borrowers from making their repayments while still allowing them to be liquidated. If repayments can be paused then liquidations must also be paused at the same time. 614 | 615 | Right now if `_checkActionPauseState(vToken, Action.REPAY)` is true and `_checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE);` is false than Liquidations is enable but repayment not. 616 | 617 | ```solidity 618 | function preRepayHook(address vToken, address borrower) external override { 619 | _checkActionPauseState(vToken, Action.REPAY); 620 | 621 | oracle.updatePrice(vToken); 622 | 623 | if (!markets[vToken].isListed) { 624 | revert MarketNotListed(address(vToken)); 625 | } 626 | 627 | // Keep the flywheel moving 628 | uint256 rewardDistributorsCount = rewardsDistributors.length; 629 | 630 | for (uint256 i; i < rewardDistributorsCount; ++i) { 631 | Exp memory borrowIndex = Exp({ mantissa: VToken(vToken).borrowIndex() }); 632 | rewardsDistributors[i].updateRewardTokenBorrowIndex(vToken, borrowIndex); 633 | rewardsDistributors[i].distributeBorrowerRewardToken(vToken, borrower, borrowIndex); 634 | } 635 | } 636 | 637 | ``` 638 | [contracts/Comptroller.sol#L390](https://github.com/code-423n4/2023-05-venus/blob/9853f6f4fe906b635e214b22de9f627c6a17ba5b/contracts/Comptroller.sol#L390) 639 | ## Tools Used 640 | 641 | ## Recommended Mitigation Steps 642 | 643 | ```diff 644 | function preLiquidateHook( 645 | address vTokenBorrowed, 646 | address vTokenCollateral, 647 | address borrower, 648 | uint256 repayAmount, 649 | bool skipLiquidityCheck 650 | ) external override { 651 | // Pause Action.LIQUIDATE on BORROWED TOKEN to prevent liquidating it. 652 | // If we want to pause liquidating to vTokenCollateral, we should pause 653 | // Action.SEIZE on it 654 | _checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE); 655 | + _checkActionPauseState(vTokenBorrowed, Action.REPAY); 656 | 657 | oracle.updatePrice(vTokenBorrowed); 658 | oracle.updatePrice(vTokenCollateral); 659 | 660 | if (!markets[vTokenBorrowed].isListed) { 661 | revert MarketNotListed(address(vTokenBorrowed)); 662 | } 663 | if (!markets[vTokenCollateral].isListed) { 664 | revert MarketNotListed(address(vTokenCollateral)); 665 | } 666 | 667 | uint256 borrowBalance = VToken(vTokenBorrowed).borrowBalanceStored(borrower); 668 | 669 | /* Allow accounts to be liquidated if the market is deprecated or it is a forced liquidation */ 670 | if (skipLiquidityCheck || isDeprecated(VToken(vTokenBorrowed))) { 671 | if (repayAmount > borrowBalance) { 672 | revert TooMuchRepay(); 673 | } 674 | return; 675 | } 676 | 677 | /* The borrower must have shortfall and collateral > threshold in order to be liquidatable */ 678 | AccountLiquiditySnapshot memory snapshot = _getCurrentLiquiditySnapshot(borrower, _getLiquidationThreshold); 679 | 680 | if (snapshot.totalCollateral <= minLiquidatableCollateral) { 681 | /* The liquidator should use either liquidateAccount or healAccount */ 682 | revert MinimalCollateralViolated(minLiquidatableCollateral, snapshot.totalCollateral); 683 | } 684 | 685 | if (snapshot.shortfall == 0) { 686 | revert InsufficientShortfall(); 687 | } 688 | 689 | /* The liquidator may not repay more than what is allowed by the closeFactor */ 690 | uint256 maxClose = mul_ScalarTruncate(Exp({ mantissa: closeFactorMantissa }), borrowBalance); 691 | if (repayAmount > maxClose) { 692 | revert TooMuchRepay(); 693 | } 694 | } 695 | 696 | ``` -------------------------------------------------------------------------------- /reports/eigenlayer.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 |
6 |

EigenLayer Audit Report

7 |

Enabling restaking of staked Ether

8 |

Prepared by: 0xVolodya, Independent Security Researcher

9 |

Date: Apr 28 to May 5, 2023

10 |
13 | 14 | # About EigenLayer 15 | EigenLayer (formerly 'EigenLayr') is a set of smart contracts deployed on Ethereum that enable restaking of assets to secure new services. 16 | At present, this repository contains both the contracts for EigenLayer and a set of general "middleware" contracts, designed to be reuseable across different applications built on top of EigenLayer. 17 | 18 | # Summary & Scope 19 | 20 | The [Layr-Labs/eigenlayer-contracts](https://github.com/Layr-Labs/eigenlayer-contracts) repository was audited at commit [7a23e259050fe88a179ab0345cc8cfc9b5e57221](https://github.com/Layr-Labs/eigenlayer-contracts/commit/7a23e259050fe88a179ab0345cc8cfc9b5e57221). 21 | 22 | # Summary of Findings 23 | Not yet available 24 | 25 | |    ID        | Title | Severity | Fixed | 26 | |----------------------------------|----------------------------------------------------------------------------------------|----------| ----- | 27 | | [H-01] | "verifyAndProcessWithdrawal" can be abused to steal from validator | High | ✓ | 28 | | [H-02] | It is impossible to slash some queued withdrawals | High | ✓ | 29 | | [L-01] | computePhase0Eth1DataRoot returns an incorrect Merkle tree | Low | ✓ | 30 | | [L-02] | processInclusionProofKeccak does not work as expected | Low | ✓ | 31 | | [L-03] | merkleizeSha256 doesn’t work as expected | Low | ✓ | 32 | | [L-04] | claimableUserDelayedWithdrawals bug | Low | ✓ | 33 | | [L-05] | The condition for full withdrawals different fromthe documentation | Low | ✓ | 34 | | [L-06] | Missing validation to a threshold value on full withdrawal | Low | ✓ | 35 | | [L-07] | User can stake twice on beacon chain from same eipod | Low | ✓ | 36 | 37 | # Detailed Findings 38 | 39 | ## [H-01] "verifyAndProcessWithdrawal" can be abused to steal from every validator at least once 40 | 41 | ## Impact 42 | Detailed description of the impact of this finding. 43 | "verifyAndProcessWithdrawal" can be abused to steal from every validator at least once. 44 | ## Proof of Concept 45 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 46 | Whevener user call `verifyAndProcessWithdrawal` there is a verification that proofs are valid 47 | 48 | ```solidity 49 | function verifyAndProcessWithdrawal( 50 | BeaconChainProofs.WithdrawalProofs calldata withdrawalProofs, 51 | bytes calldata validatorFieldsProof, 52 | bytes32[] calldata validatorFields, 53 | bytes32[] calldata withdrawalFields, 54 | uint256 beaconChainETHStrategyIndex, 55 | uint64 oracleBlockNumber 56 | ) 57 | external 58 | onlyWhenNotPaused(PAUSED_EIGENPODS_VERIFY_WITHDRAWAL) 59 | onlyNotFrozen 60 | /** 61 | * Check that the provided block number being proven against is after the `mostRecentWithdrawalBlockNumber`. 62 | * Without this check, there is an edge case where a user proves a past withdrawal for a validator whose funds they already withdrew, 63 | * as a way to "withdraw the same funds twice" without providing adequate proof. 64 | * Note that this check is not made using the oracleBlockNumber as in the `verifyWithdrawalCredentials` proof; instead this proof 65 | * proof is made for the block number of the withdrawal, which may be within 8192 slots of the oracleBlockNumber. 66 | * This difference in modifier usage is OK, since it is still not possible to `verifyAndProcessWithdrawal` against a slot that occurred 67 | * *prior* to the proof provided in the `verifyWithdrawalCredentials` function. 68 | */ 69 | proofIsForValidBlockNumber(Endian.fromLittleEndianUint64(withdrawalProofs.blockNumberRoot)) 70 | { 71 | ... 72 | BeaconChainProofs.verifyWithdrawalProofs(beaconStateRoot, withdrawalProofs, withdrawalFields); 73 | ... 74 | ``` 75 | [](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/pods/EigenPod.sol#LL340C62-L340C62) 76 | 77 | 78 | 79 | Inside function `verifyWithdrawalProofs` there are not validation that `slotProof` is at least 32 length and `slotProof % 32 ==0` like all the other proofs in this function. 80 | ```solididty 81 | function verifyWithdrawalProofs( 82 | bytes32 beaconStateRoot, 83 | WithdrawalProofs calldata proofs, 84 | bytes32[] calldata withdrawalFields 85 | ) internal view { 86 | require(withdrawalFields.length == 2**WITHDRAWAL_FIELD_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: withdrawalFields has incorrect length"); 87 | 88 | require(proofs.blockHeaderRootIndex < 2**BLOCK_ROOTS_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: blockRootIndex is too large"); 89 | require(proofs.withdrawalIndex < 2**WITHDRAWALS_TREE_HEIGHT, "BeaconChainProofs.verifyWithdrawalProofs: withdrawalIndex is too large"); 90 | 91 | // verify the block header proof length 92 | require(proofs.blockHeaderProof.length == 32 * (BEACON_STATE_FIELD_TREE_HEIGHT + BLOCK_ROOTS_TREE_HEIGHT), 93 | "BeaconChainProofs.verifyWithdrawalProofs: blockHeaderProof has incorrect length"); 94 | require(proofs.withdrawalProof.length == 32 * (EXECUTION_PAYLOAD_HEADER_FIELD_TREE_HEIGHT + WITHDRAWALS_TREE_HEIGHT + 1), 95 | "BeaconChainProofs.verifyWithdrawalProofs: withdrawalProof has incorrect length"); 96 | require(proofs.executionPayloadProof.length == 32 * (BEACON_BLOCK_HEADER_FIELD_TREE_HEIGHT + BEACON_BLOCK_BODY_FIELD_TREE_HEIGHT), 97 | "BeaconChainProofs.verifyWithdrawalProofs: executionPayloadProof has incorrect length"); 98 | 99 | /** 100 | * Computes the block_header_index relative to the beaconStateRoot. It concatenates the indexes of all the 101 | * intermediate root indexes from the bottom of the sub trees (the block header container) to the top of the tree 102 | */ 103 | uint256 blockHeaderIndex = BLOCK_ROOTS_INDEX << (BLOCK_ROOTS_TREE_HEIGHT) | uint256(proofs.blockHeaderRootIndex); 104 | // Verify the blockHeaderRoot against the beaconStateRoot 105 | require(Merkle.verifyInclusionSha256(proofs.blockHeaderProof, beaconStateRoot, proofs.blockHeaderRoot, blockHeaderIndex), 106 | "BeaconChainProofs.verifyWithdrawalProofs: Invalid block header merkle proof"); 107 | 108 | //Next we verify the slot against the blockHeaderRoot 109 | require(Merkle.verifyInclusionSha256(proofs.slotProof, proofs.blockHeaderRoot, proofs.slotRoot, SLOT_INDEX), "BeaconChainProofs.verifyWithdrawalProofs: Invalid slot merkle proof"); 110 | 111 | // Next we verify the executionPayloadRoot against the blockHeaderRoot 112 | uint256 executionPayloadIndex = BODY_ROOT_INDEX << (BEACON_BLOCK_BODY_FIELD_TREE_HEIGHT)| EXECUTION_PAYLOAD_INDEX ; 113 | require(Merkle.verifyInclusionSha256(proofs.executionPayloadProof, proofs.blockHeaderRoot, proofs.executionPayloadRoot, executionPayloadIndex), 114 | "BeaconChainProofs.verifyWithdrawalProofs: Invalid executionPayload merkle proof"); 115 | 116 | // Next we verify the blockNumberRoot against the executionPayload root 117 | require(Merkle.verifyInclusionSha256(proofs.blockNumberProof, proofs.executionPayloadRoot, proofs.blockNumberRoot, BLOCK_NUMBER_INDEX), 118 | "BeaconChainProofs.verifyWithdrawalProofs: Invalid blockNumber merkle proof"); 119 | 120 | /** 121 | * Next we verify the withdrawal fields against the blockHeaderRoot: 122 | * First we compute the withdrawal_index relative to the blockHeaderRoot by concatenating the indexes of all the 123 | * intermediate root indexes from the bottom of the sub trees (the withdrawal container) to the top, the blockHeaderRoot. 124 | * Then we calculate merkleize the withdrawalFields container to calculate the the withdrawalRoot. 125 | * Finally we verify the withdrawalRoot against the executionPayloadRoot. 126 | */ 127 | uint256 withdrawalIndex = WITHDRAWALS_INDEX << (WITHDRAWALS_TREE_HEIGHT + 1) | uint256(proofs.withdrawalIndex); 128 | bytes32 withdrawalRoot = Merkle.merkleizeSha256(withdrawalFields); 129 | require(Merkle.verifyInclusionSha256(proofs.withdrawalProof, proofs.executionPayloadRoot, withdrawalRoot, withdrawalIndex), 130 | "BeaconChainProofs.verifyWithdrawalProofs: Invalid withdrawal merkle proof"); 131 | } 132 | ``` 133 | [contracts/libraries/BeaconChainProofs.sol#L245](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/libraries/BeaconChainProofs.sol#L245) 134 | 135 | Therefore its possible to set `slotProof` to any string below 32 length and there will be no Sha256 validation inside `Merkle.sol` file due to `proof.length` <32 and loop start with 32 136 | ```solidity 137 | function processInclusionProofSha256(bytes memory proof, bytes32 leaf, uint256 index) internal view returns (bytes32) { 138 | bytes32[1] memory computedHash = [leaf]; 139 | for (uint256 i = 32; i <= proof.length; i+=32) { 140 | if(index % 2 == 0) { 141 | // if ith bit of index is 0, then computedHash is a left sibling 142 | assembly { 143 | mstore(0x00, mload(computedHash)) 144 | mstore(0x20, mload(add(proof, i))) 145 | if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} 146 | index := div(index, 2) 147 | } 148 | } else { 149 | // if ith bit of index is 1, then computedHash is a right sibling 150 | assembly { 151 | mstore(0x00, mload(add(proof, i))) 152 | mstore(0x20, mload(computedHash)) 153 | if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} 154 | index := div(index, 2) 155 | } 156 | } 157 | } 158 | return computedHash[0]; 159 | } 160 | 161 | ``` 162 | [src/contracts/libraries/Merkle.sol#L99](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/libraries/Merkle.sol#L99) 163 | 164 | What we need to do in exploit is set `slotRoot` to `blockHeaderRoot` to get a bonus eth. There maybe other ways how to get a reward from more than 1 slot per each validator but I`ve not figured it out. 165 | ## Tools Used 166 | POC: 167 | ```solidity 168 | function testPartialWithdrawalFlow() public returns(IEigenPod){ 169 | //this call is to ensure that validator 61068 has proven their withdrawalcreds 170 | setJSON("./src/test/test-data/withdrawalCredentialAndBalanceProof_61068.json"); 171 | _testDeployAndVerifyNewEigenPod(podOwner, signature, depositDataRoot); 172 | IEigenPod newPod = eigenPodManager.getPod(podOwner); 173 | 174 | //generate partialWithdrawalProofs.json with: 175 | // ./solidityProofGen "WithdrawalFieldsProof" 61068 656 "data/slot_58000/oracle_capella_beacon_state_58100.ssz" "data/slot_58000/capella_block_header_58000.json" "data/slot_58000/capella_block_58000.json" "partialWithdrawalProof.json" 176 | setJSON("./src/test/test-data/partialWithdrawalProof.json"); 177 | BeaconChainProofs.WithdrawalProofs memory withdrawalProofs = _getWithdrawalProof(); 178 | bytes memory validatorFieldsProof = abi.encodePacked(getValidatorProof()); 179 | 180 | withdrawalFields = getWithdrawalFields(); 181 | validatorFields = getValidatorFields(); 182 | bytes32 newBeaconStateRoot = getBeaconStateRoot(); 183 | BeaconChainOracleMock(address(beaconChainOracle)).setBeaconChainStateRoot(newBeaconStateRoot); 184 | 185 | uint64 withdrawalAmountGwei = Endian.fromLittleEndianUint64(withdrawalFields[BeaconChainProofs.WITHDRAWAL_VALIDATOR_AMOUNT_INDEX]); 186 | uint64 slot = Endian.fromLittleEndianUint64(withdrawalProofs.slotRoot); 187 | cheats.deal(address(newPod), stakeAmount); 188 | 189 | uint256 delayedWithdrawalRouterContractBalanceBefore = address(delayedWithdrawalRouter).balance; 190 | newPod.verifyAndProcessWithdrawal(withdrawalProofs, validatorFieldsProof, validatorFields, withdrawalFields, 0, 0); 191 | // ------------start POC---------- 192 | withdrawalProofs.slotRoot = withdrawalProofs.blockHeaderRoot; // slotRoot should be the same as blockHeaderRoot 193 | withdrawalProofs.slotProof = ""; // any length below 32 so loop will be bypassed 194 | newPod.verifyAndProcessWithdrawal(withdrawalProofs, validatorFieldsProof, validatorFields, withdrawalFields, 0, 0); 195 | // ------------end POC---------- 196 | 197 | uint40 validatorIndex = uint40(getValidatorIndex()); 198 | require(newPod.provenPartialWithdrawal(validatorIndex, slot), "provenPartialWithdrawal should be true"); 199 | withdrawalAmountGwei = uint64(withdrawalAmountGwei*GWEI_TO_WEI); 200 | require(address(delayedWithdrawalRouter).balance - delayedWithdrawalRouterContractBalanceBefore == withdrawalAmountGwei * 2, 201 | "pod delayed withdrawal balance hasn't been updated correctly"); // double withdraw 202 | 203 | cheats.roll(block.number + PARTIAL_WITHDRAWAL_FRAUD_PROOF_PERIOD_BLOCKS + 1); 204 | uint podOwnerBalanceBefore = address(podOwner).balance; 205 | delayedWithdrawalRouter.claimDelayedWithdrawals(podOwner, 1); 206 | require(address(podOwner).balance - podOwnerBalanceBefore == withdrawalAmountGwei, "Pod owner balance hasn't been updated correctly"); 207 | return newPod; 208 | } 209 | ``` 210 | ## Recommended Mitigation Steps 211 | I think its important to add these require inside merkle for security 212 | ```diff 213 | function processInclusionProofSha256(bytes memory proof, bytes32 leaf, uint256 index) internal view returns (bytes32) { 214 | bytes32[1] memory computedHash = [leaf]; 215 | + require(proof.length % 32 == 0 && proof.length > 0, "Invalid proof length"); 216 | 217 | for (uint256 i = 32; i <= proof.length; i+=32) { 218 | if(index % 2 == 0) { 219 | // if ith bit of index is 0, then computedHash is a left sibling 220 | assembly { 221 | mstore(0x00, mload(computedHash)) 222 | mstore(0x20, mload(add(proof, i))) 223 | if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} 224 | index := div(index, 2) 225 | } 226 | } else { 227 | // if ith bit of index is 1, then computedHash is a right sibling 228 | assembly { 229 | mstore(0x00, mload(add(proof, i))) 230 | mstore(0x20, mload(computedHash)) 231 | if iszero(staticcall(sub(gas(), 2000), 2, 0x00, 0x40, computedHash, 0x20)) {revert(0, 0)} 232 | index := div(index, 2) 233 | } 234 | } 235 | } 236 | return computedHash[0]; 237 | } 238 | 239 | ``` 240 | 241 | ## [H-02] It is impossible to slash queued withdrawals that contain a malicious strategy due to a misplacement of the ++i increment 242 | ## Impact 243 | Detailed description of the impact of this finding. 244 | slashQueuedWithdrawal cannot skip malicious strategies 245 | ## Proof of Concept 246 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 247 | Whenever admin would like to skip malicious strategy in the `strategies` array which always reverts on calls to its 'withdraw' function. They will still be triggered. Whevener check `indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i` is in place, array doenst go to the next strategy on list but stays on the same index. 248 | ```solidity 249 | function slashQueuedWithdrawal(address recipient, QueuedWithdrawal calldata queuedWithdrawal, IERC20[] calldata tokens, uint256[] calldata indicesToSkip) 250 | external 251 | onlyOwner 252 | onlyFrozen(queuedWithdrawal.delegatedAddress) 253 | nonReentrant 254 | { 255 | require(tokens.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch"); 256 | 257 | // find the withdrawalRoot 258 | bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal); 259 | 260 | // verify that the queued withdrawal is pending 261 | require( 262 | withdrawalRootPending[withdrawalRoot], 263 | "StrategyManager.slashQueuedWithdrawal: withdrawal is not pending" 264 | ); 265 | 266 | // reset the storage slot in mapping of queued withdrawals 267 | withdrawalRootPending[withdrawalRoot] = false; 268 | 269 | // keeps track of the index in the `indicesToSkip` array 270 | uint256 indicesToSkipIndex = 0; 271 | 272 | uint256 strategiesLength = queuedWithdrawal.strategies.length; 273 | for (uint256 i = 0; i < strategiesLength;) { 274 | // check if the index i matches one of the indices specified in the `indicesToSkip` array 275 | if (indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i) { 276 | unchecked { 277 | ++indicesToSkipIndex; 278 | } 279 | } else { 280 | if (queuedWithdrawal.strategies[i] == beaconChainETHStrategy){ 281 | //withdraw the beaconChainETH to the recipient 282 | _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient, queuedWithdrawal.shares[i]); 283 | } else { 284 | // tell the strategy to send the appropriate amount of funds to the recipient 285 | queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], queuedWithdrawal.shares[i]); 286 | } 287 | unchecked { 288 | ++i; 289 | } 290 | } 291 | } 292 | } 293 | 294 | ``` 295 | [/src/contracts/core/StrategyManager.sol#L537](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/core/StrategyManager.sol#L537) 296 | ## Tools Used 297 | POC 298 | ```solidity 299 | function testSlashQueuedWithdrawalNotBeaconChainETH2() external { 300 | address recipient = address(333); 301 | uint256 depositAmount = 1e18; 302 | uint256 withdrawalAmount = depositAmount; 303 | bool undelegateIfPossible = false; 304 | 305 | (IStrategyManager.QueuedWithdrawal memory queuedWithdrawal, /*IERC20[] memory tokensArray*/, bytes32 withdrawalRoot) = 306 | testQueueWithdrawal_ToSelf_NotBeaconChainETH(depositAmount, withdrawalAmount, undelegateIfPossible); 307 | 308 | uint256 balanceBefore = dummyToken.balanceOf(address(recipient)); 309 | 310 | // slash the delegatedOperator 311 | slasherMock.freezeOperator(queuedWithdrawal.delegatedAddress); 312 | 313 | cheats.startPrank(strategyManager.owner()); 314 | uint256[] memory emptyUintArray2 = new uint256[](1); 315 | emptyUintArray2[0] = 0; 316 | strategyManager.slashQueuedWithdrawal(recipient, queuedWithdrawal, _arrayWithJustDummyToken(), emptyUintArray2); 317 | cheats.stopPrank(); 318 | 319 | uint256 balanceAfter = dummyToken.balanceOf(address(recipient)); 320 | 321 | require(balanceAfter == balanceBefore, "balance should be equal to before because we skip it inside emptyUintArray2");// should pass but it doesn't 322 | require(!strategyManager.withdrawalRootPending(withdrawalRoot), "withdrawalRootPendingAfter is true!"); 323 | } 324 | 325 | ``` 326 | ## Recommended Mitigation Steps 327 | Move `++i` outside of if block 328 | ```diff 329 | function slashQueuedWithdrawal(address recipient, QueuedWithdrawal calldata queuedWithdrawal, IERC20[] calldata tokens, uint256[] calldata indicesToSkip) 330 | external 331 | onlyOwner 332 | onlyFrozen(queuedWithdrawal.delegatedAddress) 333 | nonReentrant 334 | { 335 | require(tokens.length == queuedWithdrawal.strategies.length, "StrategyManager.slashQueuedWithdrawal: input length mismatch"); 336 | 337 | // find the withdrawalRoot 338 | bytes32 withdrawalRoot = calculateWithdrawalRoot(queuedWithdrawal); 339 | 340 | // verify that the queued withdrawal is pending 341 | require( 342 | withdrawalRootPending[withdrawalRoot], 343 | "StrategyManager.slashQueuedWithdrawal: withdrawal is not pending" 344 | ); 345 | 346 | // reset the storage slot in mapping of queued withdrawals 347 | withdrawalRootPending[withdrawalRoot] = false; 348 | 349 | // keeps track of the index in the `indicesToSkip` array 350 | uint256 indicesToSkipIndex = 0; 351 | 352 | uint256 strategiesLength = queuedWithdrawal.strategies.length; 353 | for (uint256 i = 0; i < strategiesLength;) { 354 | // check if the index i matches one of the indices specified in the `indicesToSkip` array 355 | if (indicesToSkipIndex < indicesToSkip.length && indicesToSkip[indicesToSkipIndex] == i) { 356 | unchecked { 357 | ++indicesToSkipIndex; 358 | } 359 | } else { 360 | if (queuedWithdrawal.strategies[i] == beaconChainETHStrategy){ 361 | //withdraw the beaconChainETH to the recipient 362 | _withdrawBeaconChainETH(queuedWithdrawal.depositor, recipient, queuedWithdrawal.shares[i]); 363 | } else { 364 | // tell the strategy to send the appropriate amount of funds to the recipient 365 | queuedWithdrawal.strategies[i].withdraw(recipient, tokens[i], queuedWithdrawal.shares[i]); 366 | } 367 | } 368 | + unchecked { 369 | + ++i; 370 | + } 371 | } 372 | } 373 | 374 | ``` 375 | 376 | ## [L-01] computePhase0Eth1DataRoot always returns an incorrect Merkle tree 377 | 378 | ## Impact 379 | Detailed description of the impact of this finding. 380 | The Merkle tree creation inside the computePhase0Eth1DataRoot function is incorrect 381 | ## Proof of Concept 382 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 383 | Not all fields of eth1DataFields being used in an array due to usage of `i < ETH1_DATA_FIELD_TREE_HEIGHT` instead of `i 0, "Invalid proof length"); 460 | 461 | bytes32 computedHash = leaf; 462 | for (uint256 i = 32; i <= proof.length; i+=32) { 463 | if(index % 2 == 0) { 464 | // if ith bit of index is 0, then computedHash is a left sibling 465 | assembly { 466 | mstore(0x00, computedHash) 467 | mstore(0x20, mload(add(proof, i))) 468 | computedHash := keccak256(0x00, 0x40) 469 | index := div(index, 2) 470 | } 471 | } else { 472 | // if ith bit of index is 1, then computedHash is a right sibling 473 | assembly { 474 | mstore(0x00, mload(add(proof, i))) 475 | mstore(0x20, computedHash) 476 | computedHash := keccak256(0x00, 0x40) 477 | index := div(index, 2) 478 | } 479 | } 480 | } 481 | return computedHash; 482 | } 483 | ``` 484 | 485 | ## [L-03] merkleizeSha256 doesn’t work as expected 486 | 487 | ## Impact 488 | Detailed description of the impact of this finding. 489 | merkleizeSha256 doesn't work as expected 490 | ## Proof of Concept 491 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 492 | Whenever merkleizeSha256 is being used in the code there is always a check that array length is power of 2. E.x. 493 | ```solidity 494 | bytes32[] memory paddedHeaderFields = new bytes32[](2**BEACON_BLOCK_HEADER_FIELD_TREE_HEIGHT); 495 | ``` 496 | [contracts/libraries/BeaconChainProofs.sol#L131](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/libraries/BeaconChainProofs.sol#L131) 497 | 498 | But inside the function `merkleizeSha256` there is no check that incoming array is power of 2 499 | ```solidity 500 | /** 501 | @notice this function returns the merkle root of a tree created from a set of leaves using sha256 as its hash function 502 | @param leaves the leaves of the merkle tree 503 | 504 | @notice requires the leaves.length is a power of 2 505 | */ 506 | function merkleizeSha256( 507 | bytes32[] memory leaves 508 | ) internal pure returns (bytes32) { 509 | //there are half as many nodes in the layer above the leaves 510 | uint256 numNodesInLayer = leaves.length / 2; 511 | //create a layer to store the internal nodes 512 | bytes32[] memory layer = new bytes32[](numNodesInLayer); 513 | //fill the layer with the pairwise hashes of the leaves 514 | for (uint i = 0; i < numNodesInLayer; i++) { 515 | layer[i] = sha256(abi.encodePacked(leaves[2*i], leaves[2*i+1])); 516 | } 517 | //the next layer above has half as many nodes 518 | numNodesInLayer /= 2; 519 | //while we haven't computed the root 520 | while (numNodesInLayer != 0) { 521 | //overwrite the first numNodesInLayer nodes in layer with the pairwise hashes of their children 522 | for (uint i = 0; i < numNodesInLayer; i++) { 523 | layer[i] = sha256(abi.encodePacked(layer[2*i], layer[2*i+1])); 524 | } 525 | //the next layer above has half as many nodes 526 | numNodesInLayer /= 2; 527 | } 528 | //the first node in the layer is the root 529 | return layer[0]; 530 | } 531 | ``` 532 | 533 | There is a @notice that doesn't hold 534 | > @notice requires the leaves.length is a power of 2 535 | 536 | But whenever there is a require in natspec inside the project it always holds. E.x. 537 | ``` 538 | /** 539 | * @notice Delegates from `staker` to `operator`. 540 | * @dev requires that: 541 | * 1) if `staker` is an EOA, then `signature` is valid ECSDA signature from `staker`, indicating their intention for this action 542 | * 2) if `staker` is a contract, then `signature` must will be checked according to EIP-1271 543 | */ 544 | ``` 545 | [src/contracts/core/DelegationManager.sol#L89](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/core/DelegationManager.sol#L89) 546 | ```solidity 547 | * WARNING: In order to mitigate against inflation/donation attacks in the context of ERC_4626, this contract requires the 548 | * minimum amount of shares be either 0 or 1e9. A consequence of this is that in the worst case a user will not 549 | * be able to withdraw for 1e9-1 or less shares. 550 | * 551 | ``` 552 | [/src/contracts/strategies/StrategyBase.sol#L72](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/strategies/StrategyBase.sol#L72) 553 | ## Tools Used 554 | You can insert this into remix to check 555 | ```solidity 556 | // SPDX-License-Identifier: GPL-3.0 557 | 558 | pragma solidity >=0.7.0 <0.9.0; 559 | 560 | import "hardhat/console.sol"; 561 | 562 | contract Owner { 563 | 564 | mapping(address => bool) internal frozenStatus; 565 | constructor() { 566 | } 567 | 568 | function dod() external returns (bytes32){ 569 | bytes32[] memory leaves = new bytes32[](7); 570 | for (uint256 i = 0; i < 7; ++i) { 571 | leaves[i] = bytes32(i); 572 | } 573 | return merkleizeSha256(leaves); 574 | } 575 | 576 | function merkleizeSha256( 577 | bytes32[] memory leaves 578 | ) internal pure returns (bytes32) { 579 | //there are half as many nodes in the layer above the leaves 580 | uint256 numNodesInLayer = leaves.length / 2; 581 | //create a layer to store the internal nodes 582 | bytes32[] memory layer = new bytes32[](numNodesInLayer); 583 | //fill the layer with the pairwise hashes of the leaves 584 | for (uint i = 0; i < numNodesInLayer; i++) { 585 | layer[i] = sha256(abi.encodePacked(leaves[2*i], leaves[2*i+1])); 586 | } 587 | //the next layer above has half as many nodes 588 | numNodesInLayer /= 2; 589 | //while we haven't computed the root 590 | while (numNodesInLayer != 0) { 591 | //overwrite the first numNodesInLayer nodes in layer with the pairwise hashes of their children 592 | for (uint i = 0; i < numNodesInLayer; i++) { 593 | layer[i] = sha256(abi.encodePacked(layer[2*i], layer[2*i+1])); 594 | } 595 | //the next layer above has half as many nodes 596 | numNodesInLayer /= 2; 597 | } 598 | //the first node in the layer is the root 599 | return layer[0]; 600 | } 601 | } 602 | ``` 603 | ## Recommended Mitigation Steps 604 | Either remove @notice or add this code for more security because sometimes you can just forget to check arrey size before calling that function 605 | ```diff 606 | function merkleizeSha256( 607 | bytes32[] memory leaves 608 | ) internal pure returns (bytes32) { 609 | + uint256 len = leaves.length; 610 | + while (len > 1 && len % 2 == 0) { 611 | + len /= 2; 612 | + } 613 | + require(len==1, "requires the leaves.length is a power of 2"); 614 | //there are half as many nodes in the layer above the leaves 615 | uint256 numNodesInLayer = leaves.length / 2; 616 | //create a layer to store the internal nodes 617 | bytes32[] memory layer = new bytes32[](numNodesInLayer); 618 | //fill the layer with the pairwise hashes of the leaves 619 | for (uint i = 0; i < numNodesInLayer; i++) { 620 | layer[i] = sha256(abi.encodePacked(leaves[2*i], leaves[2*i+1])); 621 | } 622 | //the next layer above has half as many nodes 623 | numNodesInLayer /= 2; 624 | //while we haven't computed the root 625 | while (numNodesInLayer != 0) { 626 | //overwrite the first numNodesInLayer nodes in layer with the pairwise hashes of their children 627 | for (uint i = 0; i < numNodesInLayer; i++) { 628 | layer[i] = sha256(abi.encodePacked(layer[2*i], layer[2*i+1])); 629 | } 630 | //the next layer above has half as many nodes 631 | numNodesInLayer /= 2; 632 | } 633 | //the first node in the layer is the root 634 | return layer[0]; 635 | } 636 | 637 | ``` 638 | Remix 639 | ```solidity 640 | // SPDX-License-Identifier: GPL-3.0 641 | 642 | pragma solidity >=0.7.0 <0.9.0; 643 | 644 | import "hardhat/console.sol"; 645 | 646 | contract Owner { 647 | 648 | mapping(address => bool) internal frozenStatus; 649 | constructor() { 650 | } 651 | 652 | function dod(uint len) external returns (bytes32){ 653 | bytes32[] memory leaves = new bytes32[](len); 654 | for (uint256 i = 0; i < len; ++i) { 655 | leaves[i] = bytes32(i); 656 | } 657 | return merkleizeSha256(leaves); 658 | } 659 | function merkleizeSha256( 660 | bytes32[] memory leaves 661 | ) internal pure returns (bytes32) { 662 | uint256 len = leaves.length; 663 | while (len > 1 && len % 2 == 0) { 664 | len /= 2; 665 | } 666 | require(len==1, "requires the leaves.length is a power of 2"); 667 | //there are half as many nodes in the layer above the leaves 668 | uint256 numNodesInLayer = leaves.length / 2; 669 | //create a layer to store the internal nodes 670 | bytes32[] memory layer = new bytes32[](numNodesInLayer); 671 | //fill the layer with the pairwise hashes of the leaves 672 | for (uint i = 0; i < numNodesInLayer; i++) { 673 | layer[i] = sha256(abi.encodePacked(leaves[2*i], leaves[2*i+1])); 674 | } 675 | //the next layer above has half as many nodes 676 | numNodesInLayer /= 2; 677 | //while we haven't computed the root 678 | while (numNodesInLayer != 0) { 679 | //overwrite the first numNodesInLayer nodes in layer with the pairwise hashes of their children 680 | for (uint i = 0; i < numNodesInLayer; i++) { 681 | layer[i] = sha256(abi.encodePacked(layer[2*i], layer[2*i+1])); 682 | } 683 | //the next layer above has half as many nodes 684 | numNodesInLayer /= 2; 685 | } 686 | //the first node in the layer is the root 687 | return layer[0]; 688 | } 689 | 690 | 691 | } 692 | ``` 693 | ## [L-04] claimableUserDelayedWithdrawals sometimes returns unclaimable DelayedWithdrawals, so users will see incorrect data 694 | ## Impact 695 | Detailed description of the impact of this finding. 696 | claimableUserDelayedWithdrawals sometimes returns unclaimable DelayedWithdrawals, so users will see incorrect data 697 | 698 | ## Proof of Concept 699 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 700 | The "canClaimDelayedWithdrawal" function will return false for a withdrawal for which the block duration has not passed. The same restriction will be checked whenever an actual withdrawal is triggered, but the "claimableUserDelayedWithdrawals" function does not take into account block duration validation. 701 | 702 | ```solidity 703 | function claimableUserDelayedWithdrawals(address user) external view returns (DelayedWithdrawal[] memory) { 704 | uint256 delayedWithdrawalsCompleted = _userWithdrawals[user].delayedWithdrawalsCompleted; 705 | uint256 delayedWithdrawalsLength = _userWithdrawals[user].delayedWithdrawals.length; 706 | uint256 claimableDelayedWithdrawalsLength = delayedWithdrawalsLength - delayedWithdrawalsCompleted; 707 | DelayedWithdrawal[] memory claimableDelayedWithdrawals = new DelayedWithdrawal[](claimableDelayedWithdrawalsLength); 708 | for (uint256 i = 0; i < claimableDelayedWithdrawalsLength; i++) { 709 | claimableDelayedWithdrawals[i] = _userWithdrawals[user].delayedWithdrawals[delayedWithdrawalsCompleted + i]; 710 | } 711 | return claimableDelayedWithdrawals; 712 | } 713 | ... 714 | function canClaimDelayedWithdrawal(address user, uint256 index) external view returns (bool) { 715 | return ((index >= _userWithdrawals[user].delayedWithdrawalsCompleted) && (block.number >= _userWithdrawals[user].delayedWithdrawals[index].blockCreated + withdrawalDelayBlocks)); 716 | } 717 | ``` 718 | [src/contracts/pods/DelayedWithdrawalRouter.sol#L110](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/pods/DelayedWithdrawalRouter.sol#L110) 719 | ## Tools Used 720 | Manual 721 | ## Recommended Mitigation Steps 722 | 723 | ```solidity 724 | function claimableUserDelayedWithdrawals(address user) external view returns (DelayedWithdrawal[] memory) { 725 | uint256 delayedWithdrawalsCompleted = _userWithdrawals[user].delayedWithdrawalsCompleted; 726 | uint256 delayedWithdrawalsLength = _userWithdrawals[user].delayedWithdrawals.length; 727 | uint256 claimableDelayedWithdrawalsLength = delayedWithdrawalsLength - delayedWithdrawalsCompleted; 728 | DelayedWithdrawal[] memory claimableDelayedWithdrawals; 729 | for (uint256 i = 0; i < claimableDelayedWithdrawalsLength; i++) { 730 | if (block.number < _userWithdrawals[user].delayedWithdrawals[delayedWithdrawalsCompleted + i].blockCreated + withdrawalDelayBlocks) { 731 | break; 732 | } 733 | claimableDelayedWithdrawals.push(_userWithdrawals[user].delayedWithdrawals[delayedWithdrawalsCompleted + i]); 734 | } 735 | return claimableDelayedWithdrawals; 736 | } 737 | 738 | ``` 739 | 740 | ## [L-05] The condition for full withdrawals in the code is different from that in the documentation 741 | 742 | ## Impact 743 | Detailed description of the impact of this finding. 744 | The condition for full withdrawals in the code is different from that in the documentation. 745 | ## Proof of Concept 746 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 747 | The condition in [docs](https://github.com/code-423n4/2023-04-eigenlayer/blob/138cf7edb887f641ae48e33e963ab1be4ff474c1/docs/EigenPods.md) for full withdrawal is `validator.withdrawableEpoch < executionPayload.slot/SLOTS_PER_EPOCH` while in the code its `validator.withdrawableEpoch <= executionPayload.slot/SLOTS_PER_EPOCH` 748 | 749 | ```solidity 750 | function verifyAndProcessWithdrawal( 751 | BeaconChainProofs.WithdrawalProofs calldata withdrawalProofs, 752 | bytes calldata validatorFieldsProof, 753 | bytes32[] calldata validatorFields, 754 | bytes32[] calldata withdrawalFields, 755 | uint256 beaconChainETHStrategyIndex, 756 | uint64 oracleBlockNumber 757 | ) 758 | ... 759 | // reference: uint64 withdrawableEpoch = Endian.fromLittleEndianUint64(validatorFields[BeaconChainProofs.VALIDATOR_WITHDRAWABLE_EPOCH_INDEX]); 760 | if (Endian.fromLittleEndianUint64(validatorFields[BeaconChainProofs.VALIDATOR_WITHDRAWABLE_EPOCH_INDEX]) <= slot/BeaconChainProofs.SLOTS_PER_EPOCH) { 761 | _processFullWithdrawal(withdrawalAmountGwei, validatorIndex, beaconChainETHStrategyIndex, podOwner, validatorStatus[validatorIndex]); 762 | } else { 763 | _processPartialWithdrawal(slot, withdrawalAmountGwei, validatorIndex, podOwner); 764 | } 765 | } 766 | 767 | ``` 768 | [src/contracts/pods/EigenPod.sol#L354](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/pods/EigenPod.sol#L354) 769 | ## Tools Used 770 | 771 | ## Recommended Mitigation Steps 772 | Synchronize them with each other. 773 | 774 | ## [L-06] Missing validation to a threshold value on full withdrawal 775 | 776 | ## Impact 777 | Detailed description of the impact of this finding. 778 | Missing validation to a threshold value on full withdrawal. 779 | ## Proof of Concept 780 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 781 | According to the [docs](https://github.com/code-423n4/2023-04-eigenlayer/blob/138cf7edb887f641ae48e33e963ab1be4ff474c1/docs/EigenPods.md) there suppose to be a validation against a const on full withdrawal, but its missing which lead to system not work as expected. 782 | >In this second case, in order to withdraw their balance from the EigenPod, stakers must provide a valid proof of their full withdrawal (differentiated from partial withdrawals through a simple comparison of the amount to a threshold value named MIN_FULL_WITHDRAWAL_AMOUNT_GWEI) against a beacon state root. 783 | ```solidity 784 | function _processFullWithdrawal( 785 | uint64 withdrawalAmountGwei, 786 | uint40 validatorIndex, 787 | uint256 beaconChainETHStrategyIndex, 788 | address recipient, 789 | VALIDATOR_STATUS status 790 | ) internal { 791 | uint256 amountToSend; 792 | 793 | // if the validator has not previously been proven to be "overcommitted" 794 | if (status == VALIDATOR_STATUS.ACTIVE) { 795 | // if the withdrawal amount is greater than the REQUIRED_BALANCE_GWEI (i.e. the amount restaked on EigenLayer, per ETH validator) 796 | if (withdrawalAmountGwei >= REQUIRED_BALANCE_GWEI) { 797 | // then the excess is immediately withdrawable 798 | amountToSend = uint256(withdrawalAmountGwei - REQUIRED_BALANCE_GWEI) * uint256(GWEI_TO_WEI); 799 | // and the extra execution layer ETH in the contract is REQUIRED_BALANCE_GWEI, which must be withdrawn through EigenLayer's normal withdrawal process 800 | restakedExecutionLayerGwei += REQUIRED_BALANCE_GWEI; 801 | } else { 802 | // otherwise, just use the full withdrawal amount to continue to "back" the podOwner's remaining shares in EigenLayer (i.e. none is instantly withdrawable) 803 | restakedExecutionLayerGwei += withdrawalAmountGwei; 804 | // remove and undelegate 'extra' (i.e. "overcommitted") shares in EigenLayer 805 | eigenPodManager.recordOvercommittedBeaconChainETH(podOwner, beaconChainETHStrategyIndex, uint256(REQUIRED_BALANCE_GWEI - withdrawalAmountGwei) * GWEI_TO_WEI); 806 | } 807 | // if the validator *has* previously been proven to be "overcommitted" 808 | } else if (status == VALIDATOR_STATUS.OVERCOMMITTED) { 809 | // if the withdrawal amount is greater than the REQUIRED_BALANCE_GWEI (i.e. the amount restaked on EigenLayer, per ETH validator) 810 | if (withdrawalAmountGwei >= REQUIRED_BALANCE_GWEI) { 811 | // then the excess is immediately withdrawable 812 | amountToSend = uint256(withdrawalAmountGwei - REQUIRED_BALANCE_GWEI) * uint256(GWEI_TO_WEI); 813 | // and the extra execution layer ETH in the contract is REQUIRED_BALANCE_GWEI, which must be withdrawn through EigenLayer's normal withdrawal process 814 | restakedExecutionLayerGwei += REQUIRED_BALANCE_GWEI; 815 | /** 816 | * since in `verifyOvercommittedStake` the podOwner's beaconChainETH shares are decremented by `REQUIRED_BALANCE_WEI`, we must reverse the process here, 817 | * in order to allow the podOwner to complete their withdrawal through EigenLayer's normal withdrawal process 818 | */ 819 | eigenPodManager.restakeBeaconChainETH(podOwner, REQUIRED_BALANCE_WEI); 820 | } else { 821 | // otherwise, just use the full withdrawal amount to continue to "back" the podOwner's remaining shares in EigenLayer (i.e. none is instantly withdrawable) 822 | restakedExecutionLayerGwei += withdrawalAmountGwei; 823 | /** 824 | * since in `verifyOvercommittedStake` the podOwner's beaconChainETH shares are decremented by `REQUIRED_BALANCE_WEI`, we must reverse the process here, 825 | * in order to allow the podOwner to complete their withdrawal through EigenLayer's normal withdrawal process 826 | */ 827 | eigenPodManager.restakeBeaconChainETH(podOwner, uint256(withdrawalAmountGwei) * GWEI_TO_WEI); 828 | } 829 | // If the validator status is withdrawn, they have already processed their ETH withdrawal 830 | } else { 831 | revert("EigenPod.verifyBeaconChainFullWithdrawal: VALIDATOR_STATUS is WITHDRAWN or invalid VALIDATOR_STATUS"); 832 | } 833 | 834 | // set the ETH validator status to withdrawn 835 | validatorStatus[validatorIndex] = VALIDATOR_STATUS.WITHDRAWN; 836 | 837 | emit FullWithdrawalRedeemed(validatorIndex, recipient, withdrawalAmountGwei); 838 | 839 | // send ETH to the `recipient`, if applicable 840 | if (amountToSend != 0) { 841 | _sendETH(recipient, amountToSend); 842 | } 843 | } 844 | ``` 845 | [src/contracts/pods/EigenPod.sol#L364](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/pods/EigenPod.sol#L364) 846 | ## Tools Used 847 | 848 | ## Recommended Mitigation Steps 849 | ```diff 850 | function _processFullWithdrawal( 851 | uint64 withdrawalAmountGwei, 852 | uint40 validatorIndex, 853 | uint256 beaconChainETHStrategyIndex, 854 | address recipient, 855 | VALIDATOR_STATUS status 856 | ) internal { 857 | + require(withdrawalAmountGwei >= MIN_FULL_WITHDRAWAL_AMOUNT_GWEI, 858 | + "stakers must provide a valid proof of their full withdrawal"); 859 | 860 | uint256 amountToSend; 861 | 862 | // if the validator has not previously been proven to be "overcommitted" 863 | if (status == VALIDATOR_STATUS.ACTIVE) { 864 | // if the withdrawal amount is greater than the REQUIRED_BALANCE_GWEI (i.e. the amount restaked on EigenLayer, per ETH validator) 865 | if (withdrawalAmountGwei >= REQUIRED_BALANCE_GWEI) { 866 | // then the excess is immediately withdrawable 867 | amountToSend = uint256(withdrawalAmountGwei - REQUIRED_BALANCE_GWEI) * uint256(GWEI_TO_WEI); 868 | // and the extra execution layer ETH in the contract is REQUIRED_BALANCE_GWEI, which must be withdrawn through EigenLayer's normal withdrawal process 869 | restakedExecutionLayerGwei += REQUIRED_BALANCE_GWEI; 870 | } else { 871 | // otherwise, just use the full withdrawal amount to continue to "back" the podOwner's remaining shares in EigenLayer (i.e. none is instantly withdrawable) 872 | restakedExecutionLayerGwei += withdrawalAmountGwei; 873 | // remove and undelegate 'extra' (i.e. "overcommitted") shares in EigenLayer 874 | eigenPodManager.recordOvercommittedBeaconChainETH(podOwner, beaconChainETHStrategyIndex, uint256(REQUIRED_BALANCE_GWEI - withdrawalAmountGwei) * GWEI_TO_WEI); 875 | } 876 | // if the validator *has* previously been proven to be "overcommitted" 877 | } else if (status == VALIDATOR_STATUS.OVERCOMMITTED) { 878 | // if the withdrawal amount is greater than the REQUIRED_BALANCE_GWEI (i.e. the amount restaked on EigenLayer, per ETH validator) 879 | if (withdrawalAmountGwei >= REQUIRED_BALANCE_GWEI) { 880 | // then the excess is immediately withdrawable 881 | amountToSend = uint256(withdrawalAmountGwei - REQUIRED_BALANCE_GWEI) * uint256(GWEI_TO_WEI); 882 | // and the extra execution layer ETH in the contract is REQUIRED_BALANCE_GWEI, which must be withdrawn through EigenLayer's normal withdrawal process 883 | restakedExecutionLayerGwei += REQUIRED_BALANCE_GWEI; 884 | /** 885 | * since in `verifyOvercommittedStake` the podOwner's beaconChainETH shares are decremented by `REQUIRED_BALANCE_WEI`, we must reverse the process here, 886 | * in order to allow the podOwner to complete their withdrawal through EigenLayer's normal withdrawal process 887 | */ 888 | eigenPodManager.restakeBeaconChainETH(podOwner, REQUIRED_BALANCE_WEI); 889 | } else { 890 | // otherwise, just use the full withdrawal amount to continue to "back" the podOwner's remaining shares in EigenLayer (i.e. none is instantly withdrawable) 891 | restakedExecutionLayerGwei += withdrawalAmountGwei; 892 | /** 893 | * since in `verifyOvercommittedStake` the podOwner's beaconChainETH shares are decremented by `REQUIRED_BALANCE_WEI`, we must reverse the process here, 894 | * in order to allow the podOwner to complete their withdrawal through EigenLayer's normal withdrawal process 895 | */ 896 | eigenPodManager.restakeBeaconChainETH(podOwner, uint256(withdrawalAmountGwei) * GWEI_TO_WEI); 897 | } 898 | // If the validator status is withdrawn, they have already processed their ETH withdrawal 899 | } else { 900 | revert("EigenPod.verifyBeaconChainFullWithdrawal: VALIDATOR_STATUS is WITHDRAWN or invalid VALIDATOR_STATUS"); 901 | } 902 | 903 | // set the ETH validator status to withdrawn 904 | validatorStatus[validatorIndex] = VALIDATOR_STATUS.WITHDRAWN; 905 | 906 | emit FullWithdrawalRedeemed(validatorIndex, recipient, withdrawalAmountGwei); 907 | 908 | // send ETH to the `recipient`, if applicable 909 | if (amountToSend != 0) { 910 | _sendETH(recipient, amountToSend); 911 | } 912 | } 913 | 914 | ``` 915 | 916 | ## [L-07] User can stake twice on beacon chain from same eipod, thus losing funds due to same withdrawal credentials 917 | 918 | ## Impact 919 | Detailed description of the impact of this finding. 920 | User can stake twice on beacon chain from eipod with the same params thus losing funds due to having same input params 921 | ## Proof of Concept 922 | Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. 923 | 924 | There are no restriction to how many times user can stake on beacon with EigenPodManager on EigenPod thus all of them will have the same `_podWithdrawalCredentials()` and I think first deposit will be lost 925 | ```sodlidity 926 | function stake(bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot) external payable onlyEigenPodManager { 927 | // stake on ethpos 928 | require(msg.value == 32 ether, "EigenPod.stake: must initially stake for any validator with 32 ether"); 929 | ethPOS.deposit{value : 32 ether}(pubkey, _podWithdrawalCredentials(), signature, depositDataRoot); 930 | emit EigenPodStaked(pubkey); 931 | } 932 | ``` 933 | [src/contracts/pods/EigenPod.sol#L159](https://github.com/code-423n4/2023-04-eigenlayer/blob/398cc428541b91948f717482ec973583c9e76232/src/contracts/pods/EigenPod.sol#L159) 934 | There are some ways how users can make a mistake by calling it twice or they would like to create another one. 935 | I've looked into rocketpool contracts they are not allowing users to stake twice with the same pubkeys, so I think its important to implement the same security issue. 936 | ```solidity 937 | function preStake(bytes calldata _validatorPubkey, bytes calldata _validatorSignature, bytes32 _depositDataRoot) internal { 938 | ... 939 | require(rocketMinipoolManager.getMinipoolByPubkey(_validatorPubkey) == address(0x0), "Validator pubkey is in use"); 940 | // Set minipool pubkey 941 | rocketMinipoolManager.setMinipoolPubkey(_validatorPubkey); 942 | // Get withdrawal credentials 943 | bytes memory withdrawalCredentials = rocketMinipoolManager.getMinipoolWithdrawalCredentials(address(this)); 944 | // Send staking deposit to casper 945 | casperDeposit.deposit{value : prelaunchAmount}(_validatorPubkey, withdrawalCredentials, _validatorSignature, _depositDataRoot); 946 | // Emit event 947 | emit MinipoolPrestaked(_validatorPubkey, _validatorSignature, _depositDataRoot, prelaunchAmount, withdrawalCredentials, block.timestamp); 948 | } 949 | ``` 950 | [contracts/contract/minipool/RocketMinipoolDelegate.sol#L235](https://github.com/rocket-pool/rocketpool/blob/967e4d3c32721a84694921751920af313d1467af/contracts/contract/minipool/RocketMinipoolDelegate.sol#L235) 951 | 952 | ## Tools Used 953 | POC 954 | ```diff 955 | function testWithdrawFromPod() public { 956 | cheats.startPrank(podOwner); 957 | eigenPodManager.stake{value: stakeAmount}(pubkey, signature, depositDataRoot); 958 | + eigenPodManager.stake{value: stakeAmount}(pubkey, signature, depositDataRoot); 959 | cheats.stopPrank(); 960 | 961 | IEigenPod pod = eigenPodManager.getPod(podOwner); 962 | uint256 balance = address(pod).balance; 963 | cheats.deal(address(pod), stakeAmount); 964 | 965 | cheats.startPrank(podOwner); 966 | cheats.expectEmit(true, false, false, false); 967 | emit DelayedWithdrawalCreated(podOwner, podOwner, balance, delayedWithdrawalRouter.userWithdrawalsLength(podOwner)); 968 | pod.withdrawBeforeRestaking(); 969 | cheats.stopPrank(); 970 | require(address(pod).balance == 0, "Pod balance should be 0"); 971 | } 972 | 973 | ``` 974 | ## Recommended Mitigation Steps 975 | You can look at rocketpool contracts and borrow their logic 976 | ```diff 977 | function stake(bytes calldata pubkey, bytes calldata signature, bytes32 depositDataRoot) external payable onlyEigenPodManager { 978 | + require(EigenPodManager.getEigenPodByPubkey(_validatorPubkey) == address(0x0), "Validator pubkey is in use"); 979 | + EigenPodManager.setEigenPodByPubkey(_validatorPubkey); 980 | 981 | require(msg.value == 32 ether, "EigenPod.stake: must initially stake for any validator with 32 ether"); 982 | ethPOS.deposit{value : 32 ether}(pubkey, _podWithdrawalCredentials(), signature, depositDataRoot); 983 | emit EigenPodStaked(pubkey); 984 | } 985 | 986 | ``` 987 | --------------------------------------------------------------------------------