Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dimulski - If all LV tokens are requested for redeem, issuing new CT and DS tokens will revert #114

Closed
sherlock-admin4 opened this issue Sep 10, 2024 · 71 comments
Labels
Escalation Resolved This issue's escalations have been approved/rejected Non-Reward This issue will not receive a payout Sponsor Confirmed The sponsor acknowledged this issue is valid Won't Fix The sponsor confirmed this issue will not be fixed

Comments

@sherlock-admin4
Copy link
Contributor

sherlock-admin4 commented Sep 10, 2024

dimulski

Medium

If all LV tokens are requested for redeem, issuing new CT and DS tokens will revert

Summary

The Corc protocol creates a pair for RA:PA tokens, and then can issue CT and DS tokens for that pair, which can be used for different purposes such as hedging against PA tokens deepening from the RA token. The CT and DS tokens have an expiration. Once they have expired the protocol admins can issue new CT and DS tokens via the ModuleCore::issueNewDs() function. However in some cases that won't be possible. The ModuleCore::issueNewDs() function calls a lot of functions that have to do with deploying asset contracts and setting up parameters, but the problem arises in the VaultLib::onNewIssuance() function, which calls the VaultLib::__provideAmmLiquidityFromPool() function:

    function __provideAmmLiquidityFromPool(
        State storage self,
        IDsFlashSwapCore flashSwapRouter,
        address ctAddress,
        IUniswapV2Router02 ammRouter
    ) internal {
        uint256 dsId = self.globalAssetIdx;

        uint256 ctRatio = __getAmmCtPriceRatio(self, flashSwapRouter, dsId);

        (uint256 ra, uint256 ct) = self.vault.pool.rationedToAmm(ctRatio);

        __provideLiquidity(self, ra, ct, flashSwapRouter, ctAddress, ammRouter, dsId);

        self.vault.pool.resetAmmPool();
    }

There are several overengineered calls that first separate the liquidity for the previously expired CT and DS tokens, based on how much LV tokens users minted, and how much of those LV tokens were requested for withdrawal. If all of the users that minted LV tokens request to withdraw them, the internal call to VaultPoolLib::rationedToAmm() function will return 0. And when the contract tries to deposit 0 RA and 0 CT tokens to a newly created UniV2 Pair, the contract will revert due to underflow.

    function mint(address to) external lock returns (uint liquidity) {
        ...
        uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
        if (_totalSupply == 0) {
            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
            _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
        } else {
            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
        }
        ...
    }

All users requesting to redeem their LV in the same time frame is fairly possible scenario. Keep in mind that there is one LV token for a RA:PA pair, and several CT and DS tokens can be issued for a pair of RA:PA tokens. So it just may happens that at the 3rd issuing of CT and DS tokens, all users have requested to redeem their LV tokens.

Root Cause

In the VaultLib::__provideAmmLiquidityFromPool() function there is no check whether there is liquidity that can be provided to the newly created UniV2 pair.

Internal pre-conditions

  1. All the LV tokens that were minted by the system are requested for redemption.

External pre-conditions

No response

Attack Path

No response

Impact

The ModuleCore::issueNewDs() function is most critical function for the protocol, without it new DS and CT tokens can't be issued and the protocol becomes obsolete, simply redeploying the protocol won't fix this issue, as there can be other pairs for RA:PA tokens, and there can be tokens that users are still to reclaim, keep in mind that once CT and DS tokens have expired RA tokens that were deposited by users can still be withdrawn from users by calling the Psm::redeemWithCT() function and depositing CT tokens, that have already expired. Thus the medium severity.

PoC

Gist

After following the steps in the above mentioned gist add the following test to the AuditorTests.t.sol file:

    function test_IssuingNewDsWillRevertWhenNoLiquidityCanBeProvided() public {
        vm.startPrank(alice);
        WETH.mint(alice, 10e18);
        WETH.approve(address(moduleCore), type(uint256).max);
        moduleCore.depositLv(id, 10e18);
        Asset(lvAddress).approve(address(moduleCore), type(uint256).max);
        moduleCore.requestRedemption(id, 10e18);
        vm.stopPrank();

        vm.startPrank(bob);
        WETH.mint(bob, 10e18);
        WETH.approve(address(moduleCore), type(uint256).max);
        moduleCore.depositPsm(id, 10e18);
        vm.stopPrank();

        vm.startPrank(tom);
        WETH.mint(tom, 10e18);
        WETH.approve(address(moduleCore), type(uint256).max);
        moduleCore.depositLv(id, 10e18);
        Asset(lvAddress).approve(address(moduleCore), type(uint256).max);
        moduleCore.requestRedemption(id, 10e18);
        vm.stopPrank();

        /// @notice skip 1100 seconds so that the CT and DS tokens are expired
        skip(1100);

        vm.startPrank(owner);
        vm.expectRevert(bytes("ds-math-sub-underflow"));
        corkConfig.issueNewDs(id, block.timestamp + expiry, 1e18, 5e18);
        vm.stopPrank();
    }

To run the test use: forge test -vvv --mt test_IssuingNewDsWillRevertWhenNoLiquidityCanBeProvided

Mitigation

In the VaultLib::__provideAmmLiquidityFromPool() function first check whether there is liquidity that can be provided.

@github-actions github-actions bot added the Medium A Medium severity issue. label Sep 14, 2024
@ziankork
Copy link
Collaborator

ziankork commented Sep 23, 2024

This is a valid issue, though there's an update in which we removed all the functionality related to the expiry redemption in the LV(all of withdrawal is done through redeemEarly). so won't fix

@sherlock-admin3 sherlock-admin3 added Sponsor Confirmed The sponsor acknowledged this issue is valid Won't Fix The sponsor confirmed this issue will not be fixed labels Sep 23, 2024
@sherlock-admin3 sherlock-admin3 changed the title Colossal Magenta Elk - If all LV tokens are requested for redeem, issuing new CT and DS tokens will revert dimulski - If all LV tokens are requested for redeem, issuing new CT and DS tokens will revert Sep 25, 2024
@sherlock-admin3 sherlock-admin3 added the Reward A payout will be made for this issue label Sep 25, 2024
@SakshamGuruji3
Copy link

Escalate

The above issue describes about an edge case where all the users are redeeming causing division by 0.
The same edge case is described here also ( #211 ). Should be dupe.

@sherlock-admin3
Copy link

Escalate

The above issue describes about an edge case where all the users are redeeming causing division by 0.
The same edge case is described here also ( #211 ). Should be dupe.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

@sherlock-admin4 sherlock-admin4 added the Escalated This issue contains a pending escalation label Sep 26, 2024
@AtanasDimulski
Copy link

Escalate

The above issue describes about an edge case where all the users are redeeming causing division by 0. The same edge case is described here also ( #211 ). Should be dupe.

This issue has nothing to do with #211, the reason why division by 0 is possible in #211 is because when the redeemEarly() function is called the LV tokens are burned. On the contrary, when the requestRedemption() function is called the LV tokens are not burned.
Also the revert, in this case, has nothing to do with the MathHelper::separateLiquidity() function where the revert described in #211 happens. The problem here is that the VaultLib::__provideAmmLiquidityFromPool() function will try to provide 0 liquidity to the pair contract, and the mint will revert due to underflow.

@0xsimao
Copy link
Collaborator

0xsimao commented Sep 27, 2024

Agreed this is not a dup of #211, explanation above.

@cvetanovv
Copy link
Collaborator

I agree with @AtanasDimulski comment. This issue is unique Medium.

@WangSecurity
Copy link

I agree with the 3 comments above. This perfectly explains why it's not a duplicate of #211. Planning to reject the escalation and leave the issue as it is.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 5, 2024

This issue is low severity because anyone can donate some ra to the uniswap pool so that there is enough liquidity and it does not revert. Hence, the DoS does not hold for 1 week.

If the ra amount is 0, it skips adding liquidity, so no problem here either.

@AtanasDimulski
Copy link

This issue is low severity because anyone can donate some ra to the uniswap pool so that there is enough liquidity and it does not revert. Hence, the DoS does not hold for 1 week.

If the ra amount is 0, it skips adding liquidity, so no problem here either.

First of all, I want to state that this comment has nothing to do with the original escalation, and shouldn't even be considered but nevertheless let's entertain its validity. The issueNewDs() function, calls the deploySwapAssets() function which deploys the CT and DS tokens, as well as the CT and RA pair, so it is not possible to donate anything to something that doesn't exist. As can be seen from the code snippet below:

        (address ct, address ds) = IAssetFactory(SWAP_ASSET_FACTORY).deploySwapAssets(
            ra, state.info.pair0, address(this), expiry, exchangeRates
        );

        address ammPair = getAmmFactory().createPair(ra, ct);
    function deploySwapAssets(address ra, address pa, address owner, uint256 expiry, uint256 psmExchangeRate)
        external
        override
        onlyOwner
        notDelegated
        returns (address ct, address ds)
    {
        Pair memory asset = Pair(pa, ra);

        // prevent deploying a swap asset of a non existent pair, logically won't ever happen
        // just to be safe
        if (lvs[asset.toId()] == address(0)) {
            revert NotExist(ra, pa);
        }

        string memory pairname = string(abi.encodePacked(Asset(ra).name(), "-", Asset(pa).name()));

        ct = address(new Asset(CT_PREFIX, pairname, owner, expiry, psmExchangeRate));
        ds = address(new Asset(DS_PREFIX, pairname, owner, expiry, psmExchangeRate));

        swapAssets[Pair(pa, ra).toId()].push(Pair(ct, ds));

        deployed[ct] = true;
        deployed[ds] = true;

        emit AssetDeployed(ra, ct, ds);
    }

As can be seen from the above code snippets when the issueNewDs() function reverts, the CT token won't exist and the pair contract won't exist. Secondly, you can't simply donate RA assets to the pair, you have to mint LP tokens, and it is a bit hard to mint LP tokens when one of the tokens of the pair doesn't exist and its supply can't be more than 0. In the mint() function:

liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

Multiplication by 0 will always be 0.
Before another speculation is presented as a viable execution flow, I want to state that it won't be possible to predict the address of the CT token and thus deploy the pair first directly by calling the createPair() function in the router:

    function createPair(address tokenA, address tokenB) external returns (address pair) {
        require(tokenA != tokenB, 'UniswapV2: IDENTICAL_ADDRESSES');
        (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
        require(token0 != address(0), 'UniswapV2: ZERO_ADDRESS');
        require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient
        bytes memory bytecode = type(UniswapV2Pair).creationCode;
        bytes32 salt = keccak256(abi.encodePacked(token0, token1));
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }
        IUniswapV2Pair(pair).initialize(token0, token1, dsFlashSwapRouter);
        getPair[token0][token1] = pair;
        getPair[token1][token0] = pair; // populate mapping in the reverse direction
        allPairs.push(pair);
        emit PairCreated(token0, token1, pair, allPairs.length);
    }

As can be seen from the code snippet above getPair will be set to the address of the newly deployed pair, and if the function is called again with the same tokens it will revert:

require(getPair[token0][token1] == address(0), 'UniswapV2: PAIR_EXISTS'); // single check is sufficient

The issueNewDs() function attempts to call the createPair() function

address ammPair = getAmmFactory().createPair(ra, ct);

We can even speculate that the protocol won't be used by anyone an thus there won't be any vulnerabilities in it, but this is not the point of the audit and it doesn't change the fact that the protocol doesn't work as expected, and one of its most critical functions reverts in certain cases.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 5, 2024

so it is not possible to donate anything to something that doesn't exist
Secondly, you can't simply donate RA assets to the pair, you have to mint LP tokens

It is possible, ra can be donated before the pair is created. So when it reaches the mint() function in the pair, it would be as if more ra was deposited, leading to liquidity above MINIMUM_LIQUIDITY, not reverting.

@AtanasDimulski
Copy link

so it is not possible to donate anything to something that doesn't exist
Secondly, you can't simply donate RA assets to the pair, you have to mint LP tokens

It is possible, ra can be donated before the pair is created. So when it reaches the mint() function in the pair, it would be as if more ra was deposited, leading to liquidity above MINIMUM_LIQUIDITY, not reverting.

A bit difficult, I would say even impossible when you have this in the mint function

liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 5, 2024

Admin calculates the pair address and sends a dust ra to the pair. The pair now has a dust ra balance before being created.
Admin calls onNewIssuance(), deploying the pool and calling mint() in the pair, depositing a dust amount of ra and ct, which would not be enough to reach MINIMUM_LIQUIDITY without the ra donation.
However, when it reaches the liquidity calculation amount0 or amount1 is the ra amount from the onNewIssuance() call + the ra donated.
So, it is possible to get more than MINIMUM_LIQUIDITY, due to the donation, not reverting

@AtanasDimulski
Copy link

The CT token doesn't exist he can't donate it. The CT token is created in the issueNewDS function

        ct = address(new Asset(CT_PREFIX, pairname, owner, expiry, psmExchangeRate));
        ds = address(new Asset(DS_PREFIX, pairname, owner, expiry, psmExchangeRate));

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 5, 2024

Only ra needs to be donated, ct will always be non null due to this check.

@WangSecurity
Copy link

I don't think that "donating tokens to the contract" to prevent low liquidity is the correct reason to invalidate the issue. Otherwise, any issue about low liquidity could be mitigated by users just donating tokens into the contract to get their stuck tokens.

Hence, my decision remains, reject the escalation and leave the issue as it is.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 10, 2024

@WangSecurity it's not to prevent, but to fix. So the DoS is 1 block.

@AtanasDimulski
Copy link

In order to donate the new CT token you first have to have it - you have to mint it. The contract for the CT token doesn't exist until the ModuleCore::issueNewDs() function is called. The function reverts. You can't mint ERC20 tokens from a contract that doesn't exist. After that even if you donate all the RA tokens in existence it won't matter.

liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

this will always try to subtract minimum liquidity from 0.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 10, 2024

No need to use ct tokens, the explanation of how the fix works is here.

@AtanasDimulski
Copy link

@0xsimao for the last time you can't donate a token that doesn't exist, the calculation is clearly amount0 * amount1 - something. The CT token simply doesn't exist, it is supposedly deployed in the issueNewDS() function, however that function will revert thus the token won't exist. if amount0 is the CT token 0 * type(uint128).max is 0. What you are suggesting is simply not possible.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 10, 2024

Some dust ct exists and is deposited to the pool, which is enough to donate ra to the pool such that the calculation exceeds MINIMUM_LIQUIDITY. There is no need to donate ct.

@AtanasDimulski
Copy link

How does it exist when there is no contract deployed for that token

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 10, 2024

ModuleCore::issueNewDs() deploys the swap assets (ra and ct). Then, it calls VaultLibrary.onNewIssuance(), which calls inside VaultLib::__provideAmmLiquidityFromPool(), getting a ct and ra amount to send as initial liquidity.

@AtanasDimulski
Copy link

ModuleCore::issueNewDs() deploys the swap assets (ra and ct). Then, it calls VaultLibrary.onNewIssuance(), which calls inside VaultLib::__provideAmmLiquidityFromPool(), getting a ct and ra amount to send as initial liquidity.

That is not correct issewNedDs() deploys new ERC20 contracts for CT and DS

        (address ct, address ds) = IAssetFactory(SWAP_ASSET_FACTORY).deploySwapAssets(
            ra, state.info.pair0, address(this), expiry, exchangeRates
        );
    function deploySwapAssets(address ra, address pa, address owner, uint256 expiry, uint256 psmExchangeRate)
        external
        override
        onlyOwner
        notDelegated
        returns (address ct, address ds)
    {
        Pair memory asset = Pair(pa, ra);

        // prevent deploying a swap asset of a non existent pair, logically won't ever happen
        // just to be safe
        if (lvs[asset.toId()] == address(0)) {
            revert NotExist(ra, pa);
        }

        string memory pairname = string(abi.encodePacked(Asset(ra).name(), "-", Asset(pa).name()));

        ct = address(new Asset(CT_PREFIX, pairname, owner, expiry, psmExchangeRate));
        ds = address(new Asset(DS_PREFIX, pairname, owner, expiry, psmExchangeRate));

        swapAssets[Pair(pa, ra).toId()].push(Pair(ct, ds));

        deployed[ct] = true;
        deployed[ds] = true;

        emit AssetDeployed(ra, ct, ds);
    }

Then it creates a pair for the newly created CT token with RA(RA is a token that is not deployed by the Cork team, for example ETH)

address ammPair = getAmmFactory().createPair(ra, ct);

Given the fact that the issueNewDs() function will revert so will the deployment of the CT token. If the admin is not Gandalf it is not possible to donate a token that doesn't exist.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 10, 2024

It is possible to donate any token to any address without it having code - and only ra is needed. The ct token is deployed before VaultLib::onNewIssuance() is called (so it exists) , and a dust amount of it is sent to the amm pool on liquidity provision. As ra was donated, there will be more than MINIMUM_LIQUIDITY, so it does not revert.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 15, 2024

The rounding error occurs here (MathHelper)

        // with 1e18 precision
        ratePerLv = ((totalAmount * 1e18) / totalLvIssued);

        // attribute all to AMM if no lv issued or withdrawn
        if (totalLvIssued == 0 || totalLvWithdrawn == 0) {
            return (0, totalAmount, ratePerLv);
        }

        attributedWithdrawal = (ratePerLv * totalLvWithdrawn) / 1e18;
        attributedAmm = totalAmount - attributedWithdrawal;

totalAmount is the amount of ra left from the previous issuance. The previous issuance gets ra from withdrawing liquidity from the uniswap pool. If the price is tweaked a bit, it can withdraw more or less ra such that no rounding error occurs.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 15, 2024

The rounding error occurs here (MathHelper)

        // with 1e18 precision
        ratePerLv = ((totalAmount * 1e18) / totalLvIssued);

        // attribute all to AMM if no lv issued or withdrawn
        if (totalLvIssued == 0 || totalLvWithdrawn == 0) {
            return (0, totalAmount, ratePerLv);
        }

        attributedWithdrawal = (ratePerLv * totalLvWithdrawn) / 1e18;
        attributedAmm = totalAmount - attributedWithdrawal;

totalAmount is the amount of ra left from the previous issuance. The ra + ct (and the ratio ra:ct depends on the exchange rate) amount to mint in the new issuance is attributedToAmm. The previous issuance gets ra from withdrawing liquidity from the uniswap pool. If the price is tweaked a bit, it can withdraw more or less ra, which changes totalAmount, such that no rounding error occurs.

@AtanasDimulski
Copy link

Hey @WangSecurity there are several internal calls that the protocol makes, you are mostly correct in your statement. It would be very hard to track everything and explain it line by line. In your first example when we are issuing CT & DS tokens for a second or third time for example there is a call to _liquidatedLp() - this function has to liquidate all the LP that the Cork protocol owns. The Ra and CT amounts returned from that liquidation are the main factors that determine what the rationedToAmm() function will return. Now if rationedToAmm() returns RA > 0, and CT = 0, we have a problem, no matter how much RA is donated the mint() in the uniV2 pair will always revert. The admin can manipulate the amount of tokens in the RA:CT1 pair(this is the already deployed uniV2 pair, which we are trying to liquidate LP from). The purpose of the admin manipulating the amount of tokens in the RA:CT1 pair is so such amounts are returned from liquidation that either makes CT > 0, or RA = 0 and CT = 0, the admin can do that via donating tokens directly to the uniV2 paid, swapping, or if he had previously minted LP tokens directly from the uniV2 pair he can burn a portion of them, note that LV and LP tokens are different. However, a malicious actor can perform the same steps in order to manipulate the amounts of RA and CT that are returned when the Cork protocol liquidates the LP it owns and so we go back to the scenario where RA > 0 and CT = 0. The only way the admin can guarantee he is going to successfully mitigate this issue is if he first manipulates the price of the RA:CT1 pair, and then immediately calls issueNewDs(). Thus the admin will have to make sure nobody manipulates the rate again before he can call issueNewDs() - either make sure his transaction is the first in the block, or submit it trough some private rpcs. I belive we have established those actions are not something the admin is expected to perform.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 15, 2024

Someone stopping the admin from fixing this issue when ra > 0 and ct = 0 by frontrunning them and tweaking the price from the previous pool so that rounding errors always occur is 1 block DoS.

@AtanasDimulski
Copy link

AtanasDimulski commented Oct 15, 2024

It is not a 1 block DOS, because the issue already exists, only the admin trying to fix the issue is dosed. It is not someone donating 1 WEI to stop someone from withdrawing for example, in this scenario the issue occurs because of the donation thus it can be considered 1 block DOS. Here the issue already exists and someone is trying to fix it by executing a series of actions, if those actions are dosed or prevented this is regarding the fix, not the issue itself.

@WangSecurity
Copy link

So, to mitigate the issue if it comes up in reality, the admin can make a swap on RA:CT1 pair so both are non-0 and then call issueNewDs, correct?

But, if it's not in the same transaction, then:

  1. Admin swaps RA:CT1 to resolve.
  2. An attacker swaps again
  3. Admin tries to call issueNewDs
  4. The code reverts.

But we can continue here, and the admin again swaps RA: CT1 and calls issueNewDs, and there will be no problem unless someone makes another swap, i.e. repeat the "attack". Correct? Without this attack repeated, i.e. without users swapping after the admin has swapped, would there be no issue?

@AtanasDimulski
Copy link

Yes, or the admin can frontrun them. But given the fact that the code doesn't work as expected and we are presenting a series of admin actions as a potential fix, we can only say that the admin is guaranteed to fix the issue only if he frontruns - thus he has to perform actions which are not expected from him as you have previously stated + I believe we shouldn't even be discussing the possibility of this admin fix because the code that was presented clearly doesn't work, which satisfies the requirements for a medium.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 15, 2024

Without this attack repeated, i.e. without users swapping after the admin has swapped, would there be no issue?

Yep, that is right, attackers need to keep frontrunning the admin, that is, repeating the attack. Also, as said previously, the admin can use a private rpc or a multicall to batch transactions. This is a fix, not a prevention, the admin can no anything.

@WangSecurity
Copy link

I agree that the code doesn't work as expected here and leads to locked funds. But, if this comes up in reality, the admin could resolve the issue, and the funds would be unlocked in fairly less than seven days. Yes, someone could front-run the admin calling issueNewDs to skew the rate again. But, the admin can repeat the swap and call issueNewDs without a problem. For the issue to persist, the attacker has to repeat the swap perpetually, so this part falls under the following:

Griefing for gas (frontrunning a transaction to fail, even if can be done perpetually) is considered a DoS of a single block, hence only if the function is clearly time-sensitive, it can be a Medium severity issue.

So, considering DOS caused by the attacker's swap to be only one block, the admin would be able to call issueNewDs, and all the funds would be unlocked.

Note: No front-running, private rpcs or multicalls are required here.

Hence, this is a low-severity finding though a very good catch. Planning to accept the escalation and invalidate the issue tomorrow after 10 AM UTC.

@AtanasDimulski
Copy link

AtanasDimulski commented Oct 16, 2024

@WangSecurity I strongly disagree with the way the rules are interpreted and applied to this issue. First of all in regard to a similar admin issue in #214 you said:

Hence, I agree that admin rules here are not applicable because the way to potentially mitigate an issue are impractical and are not expected.

Admin is not supposed to be fixing problems in the code with donations and manipulating the price of a uniV2 pair.

When it comes to the rule about 1 block DOS, as I have clarified previously the issue exists not because a malicious actor causes it, but because the code doesn't work as expected. The fix by the admin is impractical - because he has to either fronturn - which we established earlier is not an expected behavior, or he has to hope that nobody changes the rate. This clearly becomes a very impractical and certainly not an expected action by the admin. The DOS rules clearly talks about issues not admin actions trying to fix an already existing issue: The ISSUE impacts the availability of time-sensitive functions.

Without the admin actions the funds will be locked forever and the whole protocol will be bricked. I believe two rules are applied in a way that is detrimental to the impact presented in this report, I don't believe we can expect an admin to fix issues by donations and manipulating uniV2 prices and if this fix doesn't work in 100% of the cases we can say that this fix is sufficient. Clearly here not expected admin actions are being dosed, the potential attacker is not dosing any functionality of the protocol directly. We can not expect the admin to fix something by executing a series of actions, and then apply the 1 block DOS rule on those actions. Applying the 1 block DOS rule in favour of the admin actions is equivalent to saying the admin will frontrun other users or submit the transaction via a private rpc. We can only consider that the admin can sufficiently fix a preexisting issue if his actions can't be denied.

@WangSecurity
Copy link

Admin is not supposed to be fixing problems in the code with donations and manipulating the price of a uniV2 pair

The problem in #214 is that the admin would have to create dummy CTs, which may take hundreds of pairs to be created. This is why I don't apply the admin rules there. Here, it's only making a swap on the RA:CT pair and/or dust donation, which I don't see as impractical.

When it comes to the rule about 1 block DOS, as I have clarified previously the issue exists not because a malicious actor causes it, but because the code doesn't work as expected. The fix by the admin is impractical - because he has to either fronturn - which we established earlier is not an expected behavior, or he has to hope that nobody changes the rate. This clearly becomes a very impractical and certainly not an expected action by the admin. The DOS rules clearly talks about issues not admin actions trying to fix an already existing issue: The ISSUE impacts the availability of time-sensitive functions

To clarify, I don't apply the 1-block DOS rule to the entire issue, but to a specific part:

  1. The admin makes a swap.
  2. Gets front-run by the attacker making another swap.
  3. The admin can make another swap and call issueNewDs successfully.

Here, the attacker would have to repeat the swap each time to persist this DOS. But one iteration of his swap leads to only one-block DOS.

So, I don't apply the 1-block DOS rule to the entire issue but to the part where the attacker swaps to DOS issueNewDs again.

Without the admin actions the funds will be locked forever and the whole protocol will be bricked

However, the admin's actions that can resolve the issue if it happens are taken into account when determining the severity/validity.

I believe two rules are applied in a way that is detrimental to the impact presented in this report, I don't believe we can expect an admin to fix issues by donations and manipulating uniV2 prices and if this fix doesn't work in 100% of the cases we can say that this fix is sufficient. Clearly here not expected admin actions are being dosed, the potential attacker is not dosing any functionality of the protocol directly. We can not expect the admin to fix something by executing a series of actions, and then apply the 1 block DOS rule on those actions

That's a fair concern, but due to the fact that the donation can be smaller than 1e4 or even no donation and only a swap, this is taken into account here.

Applying the 1 block DOS rule in favour of the admin actions is equivalent to saying the admin will frontrun other users or submit the transaction via a private rpc. We can only consider that the admin can sufficiently fix a preexisting issue if his actions can't be denied

Hope my elaboration above cleared the confusion about 1-block DOS. Excuse me for not explaining it clearly in the beginning.

The decision remains: accept the escalation and invalidate the issue. Planning to apply this decision in a couple of hours.

@AtanasDimulski
Copy link

AtanasDimulski commented Oct 16, 2024

@WangSecurity you are applying the 1 DOS block rule exactly to the part where you shouldn't. There is no place else where you can apply this rule.

To clarify, I don't apply the 1-block DOS rule to the entire issue, but to a specific part:
The admin makes a swap.
Gets front-run by the attacker making another swap.
The admin can make another swap and call issueNewDs successfully.
Here, the attacker would have to repeat the swap each time to persist this DOS. But one iteration of his swap leads to only one-block DOS.

If you apply the 1 block DOS rule to this exact part is the exact same thing as saying the admin will frontrun or run the transaction trough a private rpc. It makes absolutely no difference. Why would someone need to frontrun something if we expect he will get the result he wants? Fontruning in this case will be absolutely the same as saying the admin actions can't be prevented via another swap. We established that admin is not supposed to frontrun or execute transactions via private rpcs, however when you apply the 1 block DOS rule to this you are basically saying by default admin frontruns everybody else, this is not correct.

@WangSecurity
Copy link

If you apply the 1 block DOS rule to this exact part is the exact same thing as saying the admin will frontrun or run the transaction trough a private rpc. It makes absolutely no difference. Why would someone need to frontrun something if we expect he will get the result he wants? Fontruning in this case will be absolutely the same as saying the admin actions can't be prevented via another swap. We established that admin is not supposed to frontrun or execute transactions via private rpcs, however when you apply the 1 block DOS rule to this you are basically saying by default admin frontruns everybody else, this is not correct.

Applying the rule and saying the admin would front-run anyone is not the same, and this situation perfectly fits the situation. The attacker's swap would DOS issueNewDs again for only one block, as the admin can repeat the swap and successfully call issueNewDs. For the issueNewDs to remain DOSed here, the attacker has to repeat the swap again and again every couple of blocks, which perfectly fits the rule:

Griefing for gas (frontrunning a transaction to fail, even if can be done perpetually) is considered a DoS of a single block, hence only if the function is clearly time-sensitive, it can be a Medium severity issue.

Here is the attacker front-runs the admin to fail his transaction, but they need to do it every time to keep the DOS of issueNewDs, hence, we consider only one iteration.
The decision remains, accept the escalation and invalidate the issue. The decision will be applied in a couple of hours.

@AtanasDimulski
Copy link

@WangSecurity your answer lacks any logic, I understand it is a bit hard to see how frontrunning and quoting the 1 block DOS are different when you lack experience and a deep understanding of the code base. I don't know what words to use so you stop mindlessly quoting the same incorrect thing. Please explain to me logically what the difference is between the admin frontrunning the potential swap of another user, and saying another user swapping is a dos for 1 block you are clearly stating that another user is not supposed to interact with the swap, and this is so wrong I can start to describe it. Do you understand how fronturning works in real life, and why it is used? It is not normal for you to refuse to acknowledge this. The arguments you have provided in the discussion in #214 are mutually exclusive. Given the fact you can’t provide a logical argument as to why dos for 1 block is different from frontruning in this scenario (mindlessly quoting the rule from the rule book, is not a logical argument), either make a correct logical argument or validate the issue.

@WangSecurity
Copy link

To understand your arguments, do you think this is valid because the attacker would continue to make swaps after the admin to keep the DOS? And the only way to stop this is the admin front-running the attacker's swap?

No need to use arguments like "your comments are illogical", "you don't understand the codebase", "mindlessly quoting the same thing", etc. It doesn't add value to the discussion. I try to explain my view, but if you just give vague statements about why I'm incorrect, it doesn't help me understand how I can explain my POV better. Also, please provide logical arguments as to why front-running the attacker's swap == 1-block DOS because you've only stated that it is the same thing without an explanation as to why. This is completely different to me, and I never stated we expect the admin to front-run anyone.

@AtanasDimulski
Copy link

Yes, this is the only way this issue can be mitigated at 100%, as I have stated previously the issue is not caused by an attacker dossing a function of the protocol for 1 block, the admin actions trying to fix an issue that originated from the code base can be denied by another actor. Otherwise, the admin can only hope for the best and this does not guarantee a fix of this issue via admin actions. If the admin actions can't guarantee that the issue is fixed, why are we even considering this as a potential fix? It either works in 100% of the cases or the issue continues to exist.
I have previously explained that for the admin to successfully execute the issueNewDS function he has to guarantee the liquidated amounts will result in the values for the newly created pair being either RA = 0 && CT = 0, or CT > 0. If he frontruns all other users his swap transaction for example will be the first in the block and then the issueNewDS function will be executed correctly(the call to issueNewDs() can be in the same transaction as the swap for example). Same if he submits the transaction via a private RPC. He has to perform all of those actions because someone else can manipulate the price after the admin has manipulated it, or a malicious actor can fronturn the admin and swap with such parameters that will make the admin swap obsolete, and the issueNewDS() function will revert. If he doesn’t guarantee he will frontrun other users a malicious actor can see the parameters the admin intends to swap with, and counter-swap with other parameters that after the admin swap is executed the CT will be 0 again. If we say that no one else is expected to manipulate the price then we are saying no matter at what time the admin executes the swap he is guaranteed to succeed. When in fact he is guaranteed to succeed only if he submits via a private RPC or by frontruning all other potential counter swaps(it is pretty much the same as you have to buy the right to build the block or use a service as FlashBots). Saying that no other actor is supposed to manipulate the price(because of the 1 block DOS) is the same as saying the admin will get his transactions ordered perfectly to successfully execute the issueNewDs() function.

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 16, 2024

It is not reasonable to expect the admin will fail to fix this within 7 days. Also, a multicall can be used, which is bulletproof.

@AtanasDimulski
Copy link

Expecting admin to fix issues originating from a vulnerable code is not reasonable for me as well, but apparently, we are considering it. Not reasonable is not the same as it can't happen. Multicall is not bulletproof by itself because it can be frontrun and the swap transaction can be nullified if the frontrunner swaps with arguments that counter the admin swap. Nevertheless, this will be my last comment, I believe we wasted more time on this contest than we should have. I hope the correct decision is made, and the same standards are applied to each issue. Have a nice evening/day, everybody!

@0xsimao
Copy link
Collaborator

0xsimao commented Oct 16, 2024

We need to consider if an issue can be fixed in less than 7 days to figure low or medium severity. With the multicall any logic can be placed, so the price can always be tweaked correctly.

@WangSecurity
Copy link

I'll try to re-iterate again explaining why 1-block DOS is applicable.

Here's the issue and how it could be resolved if it comes up:

  1. The issue happens and issueNewDs cannot be called.
  2. The admin can make a swap on RA:CT pair.
  3. Admin can successfully call issueNewDs and the issue is resolved.

But, an attacker can swap as well and the path would become the following:

  1. The issue happens and issueNewDs cannot be called.
  2. The admin can make a swap on RA:CT pair.
  3. The admin initiates the issueNewDs.
  4. The attacker front-runs by making a swap and the call to issueNewDs reverts.
  5. The admin makes a swap again and then calls issueNewDs successfully.

For the issueNewDs to remain DOSed the attacker would have to repeat the swap again and again. This perfectly fits to the rule about 1-block DOS. In other words, if one iteration of the attack leads to 1-block DOS, then we shouldn't consider them to repeat the attack over and over again, even if can be done perpetually. The 1-block DOS rule is exactly for those situations. The admin is not expected to user private RPC, multicall, front-running, it's the fact that the attack is not repeated.

I see that the initial issue wasn't caused by an attacker. But the initial issue can be resolved easily by making just one swap (or two). That's why it should be low severity, because it's causes lock of funds, but the funds can be unlocked in less than a week fairly.

Hence, my decision to invalidate remains. Will apply it in a couple of hours. If the dispute continues to use the same arguments of "the admin has to use private RPC/multicalls/front-running to resolve the issue, "the fact that the admin can resolve it shouldn't be taken into account", "the rule is interpreted incorrectly", "you don't understand the code", or other arguments that were already answered. The decision will be applied without a time window to dispute.

@AtanasDimulski
Copy link

This discussion is pointless, invalidate the issue and release the results. Let's enjoy the can of warms that was open in this contest.

@WangSecurity WangSecurity removed the Medium A Medium severity issue. label Oct 16, 2024
@sherlock-admin2 sherlock-admin2 added Non-Reward This issue will not receive a payout and removed Reward A payout will be made for this issue labels Oct 16, 2024
@WangSecurity
Copy link

Result:
Invalid
Unique

The escalation is rejected, because it asks to duplicate this issue with another one, but it's incorrect

@sherlock-admin2 sherlock-admin2 removed the Escalated This issue contains a pending escalation label Oct 16, 2024
@sherlock-admin3 sherlock-admin3 added the Escalation Resolved This issue's escalations have been approved/rejected label Oct 16, 2024
@sherlock-admin4
Copy link
Contributor Author

Escalations have been resolved successfully!

Escalation status:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Escalation Resolved This issue's escalations have been approved/rejected Non-Reward This issue will not receive a payout Sponsor Confirmed The sponsor acknowledged this issue is valid Won't Fix The sponsor confirmed this issue will not be fixed
Projects
None yet
Development

No branches or pull requests

9 participants