Revolution Protocol - DanielArmstrong's results

A protocol to empower communities to raise funds, fairly distribute governance, and maximize their impact in the world.

General Information

Platform: Code4rena

Start Date: 13/12/2023

Pot Size: $36,500 USDC

Total HM: 18

Participants: 110

Period: 8 days

Judge: 0xTheC0der

Id: 311

League: ETH

Collective

Findings Distribution

Researcher Performance

Rank: 28/110

Findings: 3

Award: $228.29

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Awards

25.1638 USDC - $25.16

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
duplicate-397

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/ERC20TokenEmitter.sol#L152

Vulnerability details

Impact

The price is governance token is volatile and varies according to the VRGDA formula. Thus when the large amount of governance tokens are minted in a short time, the price will be increased. ERC20TokenEmitter.sol#buyToken function has no slippage control. Therefore while a user's call to buyToken function stays in mempool, if other users mint large amount of governance tokens, the user will receive small amount of tokens than expectation.

Proof of Concept

ERC20TokenEmitter.sol#buyToken function is following.

File: ERC20TokenEmitter.sol
152:     function buyToken(
153:         address[] calldata addresses,
154:         uint[] calldata basisPointSplits,
155:         ProtocolRewardAddresses calldata protocolRewardsRecipients
156:     ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) {
157:         //prevent treasury from paying itself
158:         require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens");
159: 
160:         require(msg.value > 0, "Must send ether");
161:         // ensure the same number of addresses and bps
162:         require(addresses.length == basisPointSplits.length, "Parallel arrays required");
163: 
164:         // Get value left after protocol rewards
165:         uint256 msgValueRemaining = _handleRewardsAndGetValueToSend(
166:             msg.value,
167:             protocolRewardsRecipients.builder,
168:             protocolRewardsRecipients.purchaseReferral,
169:             protocolRewardsRecipients.deployer
170:         );
171: 
172:         //Share of purchase amount to send to treasury
173:         uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000;
174: 
175:         //Share of purchase amount to reserve for creators
176:         //Ether directly sent to creators
177:         uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000;
178:         //Tokens to emit to creators
179:         int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0
180:             ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment)
181:             : int(0);
182: 
183:         // Tokens to emit to buyers
184:         int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0);
185: 
186:         //Transfer ETH to treasury and update emitted
187:         emittedTokenWad += totalTokensForBuyers;
188:         if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;
189: 
190:         //Deposit funds to treasury
191:         (bool success, ) = treasury.call{ value: toPayTreasury }(new bytes(0));
192:         require(success, "Transfer failed.");
193: 
194:         //Transfer ETH to creators
195:         if (creatorDirectPayment > 0) {
196:             (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0));
197:             require(success, "Transfer failed.");
198:         }
199: 
200:         //Mint tokens for creators
201:         if (totalTokensForCreators > 0 && creatorsAddress != address(0)) {
202:             _mint(creatorsAddress, uint256(totalTokensForCreators));
203:         }
204: 
205:         uint256 bpsSum = 0;
206: 
207:         //Mint tokens to buyers
208: 
209:         for (uint256 i = 0; i < addresses.length; i++) {
210:             if (totalTokensForBuyers > 0) {
211:                 // transfer tokens to address
212:                 _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000));
213:             }
214:             bpsSum += basisPointSplits[i];
215:         }
216: 
217:         require(bpsSum == 10_000, "bps must add up to 10_000");
218: 
219:         emit PurchaseFinalized(
220:             msg.sender,
221:             msg.value,
222:             toPayTreasury,
223:             msg.value - msgValueRemaining,
224:             uint256(totalTokensForBuyers),
225:             uint256(totalTokensForCreators),
226:             creatorDirectPayment
227:         );
228: 
229:         return uint256(totalTokensForBuyers);
230:     }

As can be seen, the function has no parameter to protect the slippage.

Impact

The price is governance token is volatile and varies according to the VRGDA formula. Thus when the large amount of governance tokens are minted in a short time, the price will be increased. Therefore while a user's call to buyToken function stays in mempool, if other users mint large amount of governance tokens, the user will receive small amount of tokens than expectation.

Tools Used

Manual Review

Any user can predict the amount of tokens to receive through the call to getTokenQuoteForPayment function before the calling to the buyToken function. Therefore, the user can add the smallest amount limit of tokens as a parameter to buyToken function.

Assessed type

Other

#0 - c4-pre-sort

2023-12-22T04:20:09Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-12-22T04:20:23Z

raymondfam marked the issue as duplicate of #26

#2 - c4-pre-sort

2023-12-24T06:00:44Z

raymondfam marked the issue as duplicate of #397

#3 - c4-judge

2024-01-06T16:29:31Z

MarioPoneder marked the issue as satisfactory

Findings Information

Labels

bug
2 (Med Risk)
disagree with severity
grade-a
primary issue
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-08

Awards

151.988 USDC - $151.99

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L102 https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/MaxHeap.sol#L156

Vulnerability details

Impact

MaxHeap.sol#extractMax function only decreases the size variable without initializing the heap state variable. On the other hand, MaxHeap.sol#maxHeapify function involves the heap variable for the out-of-bound index which will contain dirty non-zero value. As a result, uncleared dirty value of heap state variable will be used in the process and already extracted tokenId will be extracted again.

Proof of Concept

MaxHeap.sol#extractMax function is following.

File: MaxHeap.sol
156:     function extractMax() external onlyAdmin returns (uint256, uint256) {
157:         require(size > 0, "Heap is empty");
158: 
159:         uint256 popped = heap[0];
160:         heap[0] = heap[--size];
161:         maxHeapify(0);
162: 
163:         return (popped, valueMapping[popped]);
164:     }

As can be seen, the above funcion decreases size state variable by one, but does not initialize the heap[size] value to zero. In the meantime,MaxHeap.sol#maxHeapify function is following.

File: MaxHeap.sol
094:     function maxHeapify(uint256 pos) internal {
095:         uint256 left = 2 * pos + 1;
096:         uint256 right = 2 * pos + 2;
097: 
098:         uint256 posValue = valueMapping[heap[pos]];
099:         uint256 leftValue = valueMapping[heap[left]];
100:         uint256 rightValue = valueMapping[heap[right]];
101: 
102:         if (pos >= (size / 2) && pos <= size) return;
103: 
104:         if (posValue < leftValue || posValue < rightValue) {
105:             if (leftValue > rightValue) {
106:                 swap(pos, left);
107:                 maxHeapify(left);
108:             } else {
109:                 swap(pos, right);
110:                 maxHeapify(right);
111:             }
112:         }
113:     }

For example, if size=2 and pos=0, right = 2 = size holds true. So the heap[right]=heap[size] indicates the value of out-of-bound index which may be not initialized in extractMax function ahead. But in L102 since pos = 0 < (size / 2) = 1 holds true, the function does not return and continue to proceed the below section of function. Thus, abnormal phenomena occurs due to the value that should not be used.

We can verify the above issue by adding and running the following test code to test/max-heap/Updates.t.sol.

    function testExtractUpdateError() public {
        // Insert 3 items with value 20 and remove them all
        maxHeapTester.insert(1, 20);
        maxHeapTester.insert(2, 20);
        maxHeapTester.insert(3, 20);

        maxHeapTester.extractMax();
        maxHeapTester.extractMax();
        maxHeapTester.extractMax(); // Because all of 3 items are removed, itemId=1,2,3 should never be extracted after.

        // Insert 2 items with value 10 which is small than 20
        maxHeapTester.insert(4, 10);
        maxHeapTester.insert(5, 10);
        // Update value to cause maxHeapify
        maxHeapTester.updateValue(4, 5);

        // Now the item should be itemId=5, value=10
        // But in fact the max item is itemId=3, value=20 now.
        (uint256 itemId, uint256 value) = maxHeapTester.extractMax(); // itemId=3 will be extracted again

        require(itemId == 5, "Item ID should be 5 but 3 now");
        require(value == 10, "value should be 10 but 20 now");
    }

As a result of test code, the return value of the last extractMax call is not (itemId, value) = (5, 10) but (itemId, value) = (3, 20) which is error. According to READM.md#L313, the above result must not be forbidden.

Tools Used

Manual Review

Modify the MaxHeap.sol#extractMax function as follows.

    function extractMax() external onlyAdmin returns (uint256, uint256) {
        require(size > 0, "Heap is empty");

        uint256 popped = heap[0];
        heap[0] = heap[--size];
 ++     heap[size] = 0;
        maxHeapify(0);

        return (popped, valueMapping[popped]);
    }

Since the value of heap[size] is initialized to zero, no errors will occur even though the value of out-of-bound index is used in maxHeapify function.

Assessed type

Math

#0 - c4-pre-sort

2023-12-22T05:22:05Z

raymondfam marked the issue as insufficient quality report

#1 - c4-pre-sort

2023-12-22T05:22:44Z

raymondfam marked the issue as sufficient quality report

#2 - c4-pre-sort

2023-12-22T05:23:00Z

raymondfam marked the issue as primary issue

#3 - raymondfam

2023-12-24T04:07:48Z

Duplicated submissions come with various POC, but the root cause is the same.

#4 - rocketman-21

2024-01-04T20:15:38Z

yea this is duplicated from another, but confirmed an issue

#5 - c4-sponsor

2024-01-04T20:15:42Z

rocketman-21 (sponsor) disputed

#6 - c4-sponsor

2024-01-04T20:16:06Z

rocketman-21 (sponsor) confirmed

#7 - rocketman-21

2024-01-04T20:16:34Z

think this should be medium, it requires that we make the values of the maxheap less than they were inserted at, which isn't possible in CultureIndex (which is upvote only)

#8 - c4-sponsor

2024-01-04T20:16:38Z

rocketman-21 marked the issue as disagree with severity

#9 - MarioPoneder

2024-01-06T12:27:53Z

This is a tough one. First of all I genuinely appreciate the efforts put into this report and many of its duplicates, clearly an underlying bug is shown.
However, no impacts on the function of protocol as a whole are shown. Therefore, it's hard to justify High/Medium severity for this group of findings.
I have to downgrade to QA for now, but invite the author of this submission or its duplicates to provide a runnable PoC during PJQA which shows impacts through the main entry points of the protocol, i.e. not purely interacting with the MaxHeap contract.

Thank you for your understanding!

#10 - c4-judge

2024-01-06T12:28:14Z

MarioPoneder changed the severity to QA (Quality Assurance)

#11 - c4-judge

2024-01-07T16:21:51Z

MarioPoneder marked the issue as grade-a

#12 - mcgrathcoutinho

2024-01-10T12:05:07Z

Hi @MarioPoneder, here is why I believe this issue should be a valid Medium.

  1. The MaxHeap.sol contract is expected to implement its full spec (as confirmed by sponsor here). The issue clearly demonstrates the spec non-conformity.
  2. The MaxHeap.sol contract is a data structure which cannot change due to existing past data and ongoing current piece data. This means that if downvoting is introduced in the future version (by upgrading CultureIndex.sol i.e. changing the admin of MaxHeap.sol), the max heap tree would function incorrectly. This would mean that the team would not be able to add downvoting as a feature even if they want to (as stated by sponsor here and in the above link).

Thank you for taking the time to read this.

#13 - NicolaMirchev

2024-01-10T14:49:13Z

Hello @MarioPoneder,

I'd like to explain why I think this issue should be considered a valid concern.

  1. The MaxHeap.sol contract is expected to adhere to its full specification, as confirmed by the sponsor. However, this issue clearly highlights a deviation from the specified behavior.

  2. MaxHeap.sol serves as a critical data structure that should remain consistent over time, considering both historical data and ongoing data interactions. If a future version introduces downvoting (possibly by upgrading CultureIndex.sol and changing the admin of MaxHeap.sol), the Max Heap tree could malfunction. This means that even if the team intends to add downvoting as a feature, as mentioned by the sponsor in the provided link, it would be impossible due to this discrepancy. It's essential to emphasize the seriousness of this bug because the data structure should consistently adhere to its standard behavior within its context. Furthermore, as this contract falls within the scope of our responsibilities as auditors, neglecting this issue now may result in MaxHeap.sol being excluded from potential audits in the future. This exclusion could occur because there haven't been any changes, even though a critical bug exists at its core.

  3. Additionally, it's crucial for us to address this issue promptly. Neglecting it at this stage may lead to MaxHeap.sol being omitted from future audits, despite the presence of a critical bug at its core. This omission could happen because there are no planned changes to the file, even though a significant issue persists.

  4. Furthermore, it's worth noting that the sponsor is aware of this issue and is likely to address it. As a result, future updates may not introduce bugs with root in audited code, and there's a possibility that this contract could be excluded from the scope of future audits. This finding is valuable as it not only saves funds for the client in terms of potential audits for upcoming updates but also eliminates a potential source of critical impact.

Thank you for your time and consideration.

#14 - MarioPoneder

2024-01-10T23:45:43Z

Thank you for your comments!

As I agree with many of your points there is an even simpler logic behind upgrading this. I judged this contest with a strict baseline for Medium severity findings. However, I was "forced" to accept a view-only ERC-721 violation as valid Medium due to historical precedence on C4. As the present issue is similarly deviating from spec without severe impacts on the functionality of the protocol at its current state, it seems fair and consistent to move forward with Medium severity and therefore appropriately value the effort behind uncovering this very valid bug.

#15 - c4-judge

2024-01-10T23:49:25Z

This previously downgraded issue has been upgraded by MarioPoneder

#16 - c4-judge

2024-01-10T23:49:46Z

MarioPoneder marked the issue as satisfactory

#17 - c4-judge

2024-01-10T23:49:52Z

MarioPoneder marked the issue as selected for report

#18 - bart1e

2024-01-11T06:18:00Z

@MarioPoneder I would like to add several comments to this discussion:

  1. I believe that #41 (and possible its duplicates) is a duplicate as it shares the same root cause (accessing index of of bounds). Both will only be exploitable if downvoting mechanism is introduced in the future as @mcgrathcoutinho correctly pointed out.
  2. By the way, I think that the recommended fix is incorrect both here and in the majority of duplicated issues as resetting heap[size] to 0 doesn’t help as there can be an NFT with index 0 and hence valueMapping[0] may not be equal to 0. This doesn’t invalidate these submissions, of course, but I think that the Sponsors should be aware of it.

#19 - MarioPoneder

2024-01-11T14:35:34Z

Thank you for your comment!

What you wrote aligns with what I've discussed with the sponsor, therefore it is appropriate to duplicate the mentioned group of findings.

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
duplicate-194

Awards

51.1381 USDC - $51.14

External Links

Lines of code

https://github.com/code-423n4/2023-12-revolutionprotocol/blob/main/packages/revolution/src/ERC20TokenEmitter.sol#L179-L188

Vulnerability details

Impact

ERC20TokenEmitter.sol#buyToken function calculates the amount of governance tokens to be minted to the creators and the treasury by two times respectively.

The VRGDAC.sol#yToX function involved in the calculation is a concave function. That is yToX(t, s, x1) + yToX(t, s, x2) > yToX(t, s, x1 + x2) holds for 0 < x1, 0 < x2.

Therefore, the amount of minted tokens will be greater than the case of calculating the amount of tokens minted to the treasury and creators at a time. Thus, the VRGDA formula is misapplied.

Proof of Concept

ERC20TokenEmitter.sol#buyToken function is following.

File: ERC20TokenEmitter.sol
152:     function buyToken(
153:         address[] calldata addresses,
154:         uint[] calldata basisPointSplits,
155:         ProtocolRewardAddresses calldata protocolRewardsRecipients
156:     ) public payable nonReentrant whenNotPaused returns (uint256 tokensSoldWad) {
157:         //prevent treasury from paying itself
158:         require(msg.sender != treasury && msg.sender != creatorsAddress, "Funds recipient cannot buy tokens");
159: 
160:         require(msg.value > 0, "Must send ether");
161:         // ensure the same number of addresses and bps
162:         require(addresses.length == basisPointSplits.length, "Parallel arrays required");
163: 
164:         // Get value left after protocol rewards
165:         uint256 msgValueRemaining = _handleRewardsAndGetValueToSend(
166:             msg.value,
167:             protocolRewardsRecipients.builder,
168:             protocolRewardsRecipients.purchaseReferral,
169:             protocolRewardsRecipients.deployer
170:         );
171: 
172:         //Share of purchase amount to send to treasury
173:         uint256 toPayTreasury = (msgValueRemaining * (10_000 - creatorRateBps)) / 10_000;
174: 
175:         //Share of purchase amount to reserve for creators
176:         //Ether directly sent to creators
177:         uint256 creatorDirectPayment = ((msgValueRemaining - toPayTreasury) * entropyRateBps) / 10_000;
178:         //Tokens to emit to creators
179:         int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0
180:             ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment)
181:             : int(0);
182: 
183:         // Tokens to emit to buyers
184:         int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0);
185: 
186:         //Transfer ETH to treasury and update emitted
187:         emittedTokenWad += totalTokensForBuyers;
188:         if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;
189: 
190:         //Deposit funds to treasury
191:         (bool success, ) = treasury.call{ value: toPayTreasury }(new bytes(0));
192:         require(success, "Transfer failed.");
193: 
194:         //Transfer ETH to creators
195:         if (creatorDirectPayment > 0) {
196:             (success, ) = creatorsAddress.call{ value: creatorDirectPayment }(new bytes(0));
197:             require(success, "Transfer failed.");
198:         }
199: 
200:         //Mint tokens for creators
201:         if (totalTokensForCreators > 0 && creatorsAddress != address(0)) {
202:             _mint(creatorsAddress, uint256(totalTokensForCreators));
203:         }
204: 
205:         uint256 bpsSum = 0;
206: 
207:         //Mint tokens to buyers
208: 
209:         for (uint256 i = 0; i < addresses.length; i++) {
210:             if (totalTokensForBuyers > 0) {
211:                 // transfer tokens to address
212:                 _mint(addresses[i], uint256((totalTokensForBuyers * int(basisPointSplits[i])) / 10_000));
213:             }
214:             bpsSum += basisPointSplits[i];
215:         }
216: 
217:         require(bpsSum == 10_000, "bps must add up to 10_000");
218: 
219:         emit PurchaseFinalized(
220:             msg.sender,
221:             msg.value,
222:             toPayTreasury,
223:             msg.value - msgValueRemaining,
224:             uint256(totalTokensForBuyers),
225:             uint256(totalTokensForCreators),
226:             creatorDirectPayment
227:         );
228: 
229:         return uint256(totalTokensForBuyers);
230:     }

As can be seen, L180 and L184 calcualates the amount of token minted to the creators and treasury by two times respectively. There the getTokenQuoteForEther function calls the VRGDAC.sol#yToX internaly with the same emittedTokenWad.

The yToX function is a concave function as stated in README.md. That is yToX(t, s, x1) + yToX(t, s, x2) > yToX(t, s, x1 + x2) holds for 0 < x1, 0 < x2.

On the other hand L187-L188 add the sum of token amount minted to creators and treasury to the emittedTokenWad state variable. Thus the more tokens than the VRGDA formula will be minted to treasury and creators.

Tools Used

Manual Review

There are two methods to fix the error.

  1. After the calculation of the amount of tokens minted to creators, at first increase the emittedTokenWad with the calculated amount and then calculate the amount of tokens minted to treasury. That is, move the code of L188 of ERC20TokenEmitter.sol#buyToken to the line above L184 as follows.
        ...
        int totalTokensForCreators = ((msgValueRemaining - toPayTreasury) - creatorDirectPayment) > 0
180:        ? getTokenQuoteForEther((msgValueRemaining - toPayTreasury) - creatorDirectPayment)
            : int(0);
++      if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;

        // Tokens to emit to buyers
184:    int totalTokensForBuyers = toPayTreasury > 0 ? getTokenQuoteForEther(toPayTreasury) : int(0);

        //Transfer ETH to treasury and update emitted
        emittedTokenWad += totalTokensForBuyers;
--      if (totalTokensForCreators > 0) emittedTokenWad += totalTokensForCreators;
        ...
  1. Calculate the sum amount of tokens minted to creators and treasury at a time and distribute them to the creators and treasury due to the respective ratios.

Assessed type

Math

#0 - c4-pre-sort

2023-12-22T04:19:45Z

raymondfam marked the issue as sufficient quality report

#1 - c4-pre-sort

2023-12-22T04:19:57Z

raymondfam marked the issue as duplicate of #194

#2 - c4-judge

2024-01-06T13:54:42Z

MarioPoneder changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-01-06T13:54:48Z

MarioPoneder marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter