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
Rank: 28/110
Findings: 3
Award: $228.29
π Selected for report: 1
π Solo Findings: 0
π Selected for report: deepplus
Also found by: 0xDING99YA, 0xmystery, Aymen0909, DanielArmstrong, Inference, KupiaSec, SadeeqXmosh, SpicyMeatball, Tricko, adeolu, jnforja, passteque, rvierdiiev, wangxx2026, zhaojie
25.1638 USDC - $25.16
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.
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.
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.
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.
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
π Selected for report: DanielArmstrong
Also found by: 0xDING99YA, MrPotatoMagic, SpicyMeatball, bart1e, mojito_auditor, nmirchev8, pep7siup
151.988 USDC - $151.99
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
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.
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.
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.
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.
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.
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.
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.
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.
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:
#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.
π Selected for report: osmanozdemir1
Also found by: 0xDING99YA, BARW, Brenzee, DanielArmstrong, KupiaSec, SpicyMeatball, bart1e, deepplus, haxatron, rouhsamad, rvierdiiev
51.1381 USDC - $51.14
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.
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.
Manual Review
There are two methods to fix the error.
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; ...
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