├── .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 | 
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 |
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 |
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 |
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 | 
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 | 
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 | 
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 | 
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 = '"))),
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 |
6 |
Moonwell Audit Report
7 |
An open lending and borrowing DeFi protocol built on Base, Moonbeam, and Moonriver.
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 |
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.
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 |
6 |
Index Coop Report
7 |
The Index Coop builds decentralized structured products
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 |
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 |
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 |
--------------------------------------------------------------------------------