Summary
The updateDAOMembership function allows setting tier amounts lower than the number of already minted tokens. This creates an inconsistent state where tier.minted > tier.amount.
Vulnerability Details
In updateDAOMembership, when updating tier configurations, the function preserves the old minted values but doesn't validate that new amount values are greater than or equal to minted:
function updateDAOMembership(string calldata ensName, TierConfig[] memory tierConfigs)
external onlyRole(EXTERNAL_CALLER) returns (address) {
address daoAddress = getENSAddress[ensName];
require(tierConfigs.length <= TIER_MAX, "Invalid tier count.");
require(tierConfigs.length > 0, "Invalid tier count.");
require(daoAddress != address(0), "DAO does not exist.");
DAOConfig storage dao = daos[daoAddress];
if(dao.daoType == DAOType.SPONSORED){
require(tierConfigs.length == TIER_MAX, "Invalid tier count.");
}
uint256 maxMembers = 0;
for (uint256 i = 0; i < tierConfigs.length; i++) {
if (i < dao.tiers.length) {
tierConfigs[i].minted = dao.tiers[i].minted;
}
}
delete dao.tiers;
for (uint256 i = 0; i < tierConfigs.length; i++) {
dao.tiers.push(tierConfigs[i]);
maxMembers += tierConfigs[i].amount;
}
if(maxMembers > dao.maxMembers){
dao.maxMembers = maxMembers;
}
dao.noOfTiers = tierConfigs.length;
return daoAddress;
}
The function copies over the existing minted values but allows new amounts to be set arbitrarily low:
for (uint256 i = 0; i < tierConfigs.length; i++) {
if (i < dao.tiers.length) {
tierConfigs[i].minted = dao.tiers[i].minted;
}
}
This creates an invalid state where more tokens can be minted than the tier's capacity allows.
Impact
Whether intentionally or by mistake, when a tier's amount is set lower than its minted value in updateDAOMembership, it creates a mismatch state in the tier configuration. This mismatch happens when the tier's minted tokens exceed its amount, which could lead to a temporary DOS for that specific tier. New users attempting to join the affected tier will have their transactions revert due to the tier appearing full, even though it should have available capacity based on the original configuration. This disruption to DAO operations continues until an admin notices the issue and calls updateDAOMembership again with correct values that respect the number of tokens already minted.
POC
The following test demonstrates the vulnerability by first creating a DAO with a tier capacity of 100 tokens and minting 50 tokens in tier 0. It then calls updateDAOMembership with a new configuration that sets the tier capacity to 25, which is lower than the 50 tokens already minted. The test verifies this by creating an inconsistent state where amount (25) is less than minted (50). A second test extends this to show the practical impact. after creating this mismatched state, any attempt to join the DAO's tier 0 fails with "Tier full" even though the original configuration allowed for 100 members. This proves that the mismatched state leads to a DOS for new members trying to join the affected tier.
pragma solidity ^0.8.22;
import "lib/forge-std/src/Test.sol";
import "../../contracts/dao/MembershipFactory.sol";
import "../../contracts/dao/tokens/MembershipERC1155.sol";
import "../../contracts/dao/CurrencyManager.sol";
import "../../contracts/dao/libraries/MembershipDAOStructs.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {
_mint(msg.sender, 1000000 * 10**18);
}
}
contract MembershipDAOTest is Test {
MembershipFactory factory;
MembershipERC1155 implementation;
CurrencyManager currencyManager;
MockERC20 token;
address owner = makeAddr("owner");
function setUp() public {
vm.startPrank(owner);
token = new MockERC20();
currencyManager = new CurrencyManager();
currencyManager.addCurrency(address(token));
implementation = new MembershipERC1155();
factory = new MembershipFactory(
address(currencyManager),
owner,
"baseURI/",
address(implementation)
);
factory.grantRole(keccak256("EXTERNAL_CALLER"), owner);
vm.stopPrank();
}
function testTierUpdateAndDOS() public {
vm.startPrank(owner);
console.log("\n=== 1. Initial DAO Setup ===");
TierConfig[] memory tiers = new TierConfig[]();
for(uint i = 0; i < 7; i++) {
tiers[i] = TierConfig({
price: 0,
amount: 100,
minted: 0,
power: 1
});
}
DAOInputConfig memory daoConfig = DAOInputConfig({
ensname: "test",
daoType: DAOType.SPONSORED,
currency: address(token),
maxMembers: 700,
noOfTiers: 7
});
address daoAddress = factory.createNewDAOMembership(daoConfig, tiers);
console.log("DAO created at:", daoAddress);
console.log("\n=== 2. Initial Minting ===");
console.log("Minting 50 tokens in tier 0");
for(uint i = 0; i < 50; i++) {
factory.joinDAO(daoAddress, 0);
}
TierConfig[] memory currentTiers = factory.tiers(daoAddress);
console.log("Current tier 0 state:");
console.log("- Amount:", currentTiers[0].amount);
console.log("- Minted:", currentTiers[0].minted);
console.log("\n=== 3. Inconsistent Update ===");
console.log("Updating tier capacity to 25 (less than minted 50)");
TierConfig[] memory newTiers = new TierConfig[]();
for(uint i = 0; i < 7; i++) {
newTiers[i] = TierConfig({
price: 0,
amount: 25,
minted: 0,
power: 1
});
}
factory.updateDAOMembership("test", newTiers);
TierConfig[] memory updatedTiers = factory.tiers(daoAddress);
console.log("\n=== 4. Post-Update State ===");
console.log("Tier 0 state after update:");
console.log("- New Amount:", updatedTiers[0].amount);
console.log("- Current Minted:", updatedTiers[0].minted);
console.log("\n=== 5. DOS Test ===");
console.log("Attempting to join DAO after update (should revert)");
vm.expectRevert("Tier full.");
factory.joinDAO(daoAddress, 0);
console.log("Join attempt reverted as expected");
vm.stopPrank();
}
}
Output:
forge test --match-test testTierUpdateAndDOS -vvv
Compiler run successful!
Ran 1 test for test/foundry/MembershipDAO.t.sol:MembershipDAOTest
[PASS] testTierUpdateAndDOS() (gas: 2844450)
Logs:
+=== 1. Initial DAO Setup ===
DAO created at: 0x9d98f5d796Ca3C34E23701E5361B169bB585df65
+=== 2. Initial Minting ===
Minting 50 tokens in tier 0
Current tier 0 state:
- Amount: 100
- Minted: 50
+=== 3. Inconsistent Update ===
Updating tier capacity to 25 (less than minted 50)
+=== 4. Post-Update State ===
Tier 0 state after update:
- New Amount: 25
- Current Minted: 50
+=== 5. DOS Test ===
Attempting to join DAO after update (should revert)
Join attempt reverted as expected
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.24ms (5.02ms CPU time)
Ran 1 test suite in 336.27ms (6.24ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Tools Used
Manual Review
Foundry
Remix IDE
Recommendations
Add validation to ensure new tier amounts are not less than minted tokens:
function updateDAOMembership(string calldata ensName, TierConfig[] memory tierConfigs)
//EXISTING CODE
// Preserve minted values and adjust the length of dao.tiers
for (uint256 i = 0; i < tierConfigs.length; i++) {
if (i < dao.tiers.length) {
uint256 minted = dao.tiers[i].minted;
tierConfigs[i].minted = minted;
+ require(tierConfigs[i].amount >= minted, "Amount cannot be less than minted");
}
}
// REST
}