GithubHelp home page GithubHelp logo

2024-04-dyad-findings's Introduction

DYAD Audit

Audit findings are submitted to this repo.

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.

Note that when the repo is public, after all issues are mitigated, your comments will be publicly visible; they may also be included in your C4 audit report.


Review phase

Sponsors have three critical tasks in the audit process: Reviewing the two lists of curated issues, and once you have mitigated your findings, sharing those mitigations.

  1. Respond to curated High- and Medium-risk submissions ↓
  2. Respond to curated Low/Non-critical submissions and Gas optimizations ↓
  3. Share your mitigation of findings (optional) ↓

Note: It’s important to be sure to only review issues from the curated lists. There are two lists of curated issues to review, which filter out unsatisfactory issues that don't require your attention.


     

Types of findings

(expand to read more)

High or Medium risk findings

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

QA reports, Gas reports, and Analyses

Any warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

1. Respond to curated High- and Medium-risk submissions

This curated list will shorten as you work. View the original, longer list →

For each curated High or Medium risk finding, please:

1a. Label as one of the following:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note: Adding or changing labels other than those in this list will be automatically reverted by our bot, which will note the change in a comment on the issue.

1b. Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, leave a comment indicating your reasoning for the judge to review. For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.


2. Respond to curated Low/Non-critical submissions and Gas optimizations

This curated list will shorten as you work. View the original, longer list →

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once Step 1 and 2 are complete

When you have finished labeling and responding to findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.


3. Share your mitigation of findings (Optional)

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-04-dyad-findings's People

Contributors

c4-bot-4 avatar c4-bot-8 avatar c4-bot-10 avatar c4-bot-3 avatar c4-bot-5 avatar c4-bot-9 avatar c4-bot-1 avatar c4-bot-2 avatar c4-bot-6 avatar c4-bot-7 avatar c4-judge avatar thebrittfactor avatar geoffchan23 avatar code4rena-id[bot] avatar liveactionllama avatar

Stargazers

manijeh avatar  avatar Scooby avatar saham avatar alchemystic avatar

Watchers

Ashok avatar

2024-04-dyad-findings's Issues

The malicious users could prevent anyone from withdrawing their deposits

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L119-L131
https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L134-L153

Vulnerability details

Impact

A malicious user could deposit zero assets to the victim DNft, updating the idToBlockOfLastDeposit[id] to the current block.number. This action would prevent the owner of id from withdrawing their deposits because that the withdraw function will revert if idToBlockOfLastDeposit[id] is equal to block.number.
As long as the malicious user continues to call deposit every block, the victim will be unable to retrieve their deposits.

Proof of Concept

Anyone can deposit any amount of assets into a valid DNft, updating the idToBlockOfLastDeposit[id] to the current block.number.

  /// @inheritdoc IVaultManager
  function deposit(
    uint    id,
    address vault,
    uint    amount
  ) 
    external 
      isValidDNft(id)
  {
  //@audit anyone could deposit to any valid DNft to update its idToBlockOfLastDeposit
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
  }

The withdraw function necessitates that idToBlockOfLastDeposit[id] does not equal the current block.number. Hence, a malicious user could deposit zero assets into the victim DNft every block to impede the victim from withdrawing their deposits.

  /// @inheritdoc IVaultManager
  function withdraw(
    uint    id,
    address vault,
    uint    amount,
    address to
  ) 
    public
      isDNftOwner(id)
  {
  //@ revert if idToBlockOfLastDeposit[id] == block.number
    if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
    uint dyadMinted = dyad.mintedDyad(address(this), id);
    Vault _vault = Vault(vault);
    uint value = amount * _vault.assetPrice() 
                  * 1e18 
                  / 10**_vault.oracle().decimals() 
                  / 10**_vault.asset().decimals();
    if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
    _vault.withdraw(id, to, amount);
    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
  }

Tools Used

Manual Review

Recommended Mitigation Steps

Only allow the owner of DNft to deposit.

Assessed type

DoS

No Asset Transfer Before the Deposit Function is Called

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.sol#L36

Vulnerability details

Impact

  1. If the deposit function is called without a preceding asset transfer, the contract's state will be incorrect because the id2asset mapping will be updated with an amount that does not reflect the actual balance of assets the contract holds.
  2. The incorrect state management could indirectly lead to security vulnerabilities. For example, the contract relies on the id2asset mapping to manage access or permissions based on the balance of assets, not updating the balance correctly could lead to unauthorized access or actions.

Proof of Concept

  1. Alice owns 100 units of the digital asset in her wallet
  2. Alice calls the deposit function on the KerosineVault contract, specifying an ID and the amount of 100 units she wishes to deposit. However, Alice does not perform the asset transfer to the contract before calling the function.
  3. The KerosineVault contract processes the deposit function call. Since no asset transfer has occurred, the contract's internal state (id2asset mapping) is updated to reflect the deposit of 100 units under the specified ID. However, Alice's wallet still shows that she owns 100 units of the digital asset, as the asset transfer was not made.
  4. Alice believes she has successfully deposited her assets into the vault. However, if she or someone else checks the id2asset mapping in the contract, they will see that the specified ID has been updated to reflect 100 units, even though Alice's wallet still shows she owns 100 units. This discrepancy arises because the contract's state was updated without a corresponding asset transfer

Tools Used

Manual review

Recommended Mitigation Steps

  1. Modify the deposit function to require that the asset transfer has occurred before the function is called. This can be achieved by checking the balance of the contract before updating the id2asset mapping.
  2. Implement a deposit pattern where users first call a function to approve the transfer of assets to the contract, and then call the deposit function to update the contract's state.
  3. Implement events to monitor asset transfers to the contract. This can help in tracking and verifying that asset transfers have occurred before updating the contract's state.

Assessed type

Other

Incorrect calculation of `numerator` in `Vault.kerosine.unbounded::assetPrice`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/Vault.kerosine.unbounded.sol#L50-L68

Vulnerability details

Impact

The price of Kerosence is calculated significantly smaller than it should be. Consequently, this decreases the collateral ratios, leading to the unfair liquidation of many legitimate ids.

Proof of Concept

In Vault.kerosine.unbounded::assetPrice, the calculation of numerator is incorrect at L65.

    function assetPrice() 
        public 
        view 
        override
        returns (uint) {
        uint tvl;
        address[] memory vaults = kerosineManager.getVaults();
        uint numberOfVaults = vaults.length;
        for (uint i = 0; i < numberOfVaults; i++) {
            Vault vault = Vault(vaults[i]);
            tvl += vault.asset().balanceOf(address(vault)) 
                    * vault.assetPrice() * 1e18
                    / (10**vault.asset().decimals()) 
                    / (10**vault.oracle().decimals());
        }
65      uint numerator   = tvl - dyad.totalSupply();
        uint denominator = kerosineDenominator.denominator();
        return numerator * 1e8 / denominator;
    }

In fact, in the calculation of numerator at L65, tvl should be subtracted by the total amount of dyad minted through VaultManagerV2. As a result, the return value of Vault.kerosine.unbounded::assetPrice is significantly smaller than it should be. This directly affects the value of VaultManagerV2::getKeroseneValue, causing the VaultManagerV2::collatRatio to return a much smaller value, which can unfairly lead to the liquidation of legitimate ids.

Tools Used

Manual review

Recommended Mitigation Steps

At L65 of Vault.kerosine.unbounded::assetPrice, dyad.totalSupply() should be replaced by the total amount of dyad minted through VaultManagerV2.

Assessed type

Other

latestRoundData() has no check for round completeness

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.sol#L91

Vulnerability details

Impact

If there is a problem with chainlink starting a new round and finding consensus on the new value for the oracle (e.g. chainlink nodes abandon the oracle, chain congestion, vulnerability/attacks on the chainlink system) consumers of this contract may continue using outdated stale data (if oracles are unable to submit no new round is started).

This could lead to stale prices and wrong price return value, or outdated price.

As a result, the functions rely on accurate price feed might not work as expected, sometimes can lead to fund loss. The impacts vary and depends on the specific situation like the following:

incorrect liquidation
some users could be liquidated when they should not
no liquidation is performed when there should be
wrong price feed
causing inappropriate loan being taken, beyond the current collateral factor

Proof of Concept

No check for round completeness could lead to stale prices and wrong price return value, or outdated price. The functions rely on accurate price feed might not work as expected, sometimes can lead to fund loss.

The oracle wrapper getOraclePrice() call out to an oracle with latestRoundData() to get the price of some token. Although the returned timestamp is checked, there is no check for round completeness.

According to Chainlink's documentation, this function does not error if no answer has been reached but returns 0 or outdated round data. The external Chainlink oracle, which provides index price information to the system, introduces risk inherent to any dependency on third-party data sources. For example, the oracle could fall behind or otherwise fail to be maintained, resulting in outdated data being fed to the index price calculations. Oracle reliance has historically resulted in crippled on-chain systems, and complications that lead to these outcomes can arise from things as simple as network congestion.

Chainlink documentation

Tools Used

Manual Review

Recommended Mitigation Steps

(
            uint80 roundID,
            int signedPrice,
            /*uint startedAt*/,
            uint timeStamp,
            uint80 answeredInRound
        ) = _priceFeed.latestRoundData();
        //check for Chainlink oracle deviancies, force a revert if any are present. Helps prevent a LUNA like issue
        require(signedPrice > 0, "Negative Oracle Price");
        require(timeStamp >= block.timestamp - HEARTBEAT_TIME , "Stale pricefeed");
        require(signedPrice < _maxPrice, "Upper price bound breached");
        require(signedPrice > _minPrice, "Lower price bound breached");
+        require(answeredInRound >= roundID, "round not complete");

        uint256 price = uint256(signedPrice);
        return price;

Assessed type

Oracle

Incorrect getNonKeroseneValue() implementation

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L250-L267

Vulnerability details

Impact

Function getNonKeroseneValue() implementation is incorrect, which could lead to exogenous collateral is less than dyad debt. This is not allowed.

Proof of Concept

In VaultManagerV2, two kinds of assets can be taken as the collateral, exogenous collateral(WETH/WSTETH) and kerosine(unbounded and bounded kerosine). Function getNonKeroseneValue() aims to calculate one NFT position's exogenous collateral's value.
Users can add collateral asset through add(). From deploy.v2.s.sol, permited vaults include ethVault, wstETHVault, and unboundedKerosineVault. Users can add unboundedKerosineVault into vaults[id].

  function add(
      uint    id,
      address vault
  ) 
    external
      isDNftOwner(id)
  {
    if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults();
    if (!vaultLicenser.isLicensed(vault))  revert VaultNotLicensed();
    if (!vaults[id].add(vault))            revert VaultAlreadyAdded();
    emit Added(id, vault);
  }

When we calculate users' getNonKeroseneValue(), unbounded kerosine assets are calculated as one part of non-kerosine value.

  function getNonKeroseneValue(
    uint id
  ) 
    public 
    view
    returns (uint) {
      uint totalUsdValue;
      uint numberOfVaults = vaults[id].length(); 
      for (uint i = 0; i < numberOfVaults; i++) {
        Vault vault = Vault(vaults[id].at(i));
        uint usdValue;
        if (vaultLicenser.isLicensed(address(vault))) {
          usdValue = vault.getUsdValue(id);        
        }
        totalUsdValue += usdValue;
      }
      return totalUsdValue;
  }

Tools Used

Manual

Recommended Mitigation Steps

Only weth and wsteth should be calculated for the getNonKeroseneValue() calculation.

Assessed type

Context

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Incorrect Collateralization Check Issue in VaultManagerV2 Contract

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L156

Vulnerability details

Description

The mintDyad function has an incorrect collateralization check that may allow the minting of dyad tokens without adequate collateral, posing a high risk to the system's economic security.

Impact

If getNonKeroseneValue returns an incorrect value due to an invalid ID, manipulation, or oracle failure, it could allow for the minting of Dyad tokens without adequate collateral. This could lead to the devaluation of Dyad tokens, potential solvency issues for the protocol, and loss of trust from users.

Proof of Concept

  1. User A has a DNFT with ID id.
  2. User A adds a vault to the DNFT with insufficient collateral.
  3. User A calls mintDyad with id and amount.
  4. The check getNonKeroseneValue(id) < newDyadMinted passes incorrectly.
  5. User A receives dyad tokens without proper backing, risking the system's integrity.

Tools Used

manual review

Recommended Mitigation Steps

  1. Ensure that the id parameter in the getKeroseneValue function is properly validated to ensure that it corresponds to a valid NFT ID.

Assessed type

Invalid Validation

Underflow Risk Issue in the move Function

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.sol#L47

Vulnerability details

Impact

An underflow can occur in the move function when the amount to be moved is greater than the balance of the from ID. This would result in an extremely large balance being set for the from ID due to the underflow, potentially allowing for theft of funds or manipulation of balances.

Proof of Concept

  1. The fromId balance is 10 units, and the toId balance is 0 units.
  2. The move function is called to transfer 20 units from the fromId to the toId.
  3. Instead of updating the fromId balance to 0 and the toId balance to 20, the fromId balance will wrap around to a very large number due to the underflow.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Add a check to verify that the fromId balance is sufficient before proceeding with the transfer. Here's an example of how this could be implemented:
function move(uint fromId, uint toId, uint amount) public onlyVaultManager {
    require(id2asset[fromId] >= amount, "Insufficient balance in source ID");
    id2asset[fromId] -= amount;
    id2asset[toId] += amount;
    emit Move(fromId, toId, amount);
}
  1. Use SafeMath

Assessed type

Math

QA Report

See the markdown file with the details of this report here.

Lack of Validation of the Amount Issue in the Deposit Function

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.sol#L36

Vulnerability details

Description

The deposit function in the KerosineVault contract is responsible for recording the deposit of assets against a specific ID. however the function does not perform any validation checks on the amount parameter before updating the internal id2asset mapping. This could potentially lead to scenarios where zero or an incorrect amount is recorded as deposited without an actual transfer of tokens, assuming the vaultManager does not implement the necessary checks.

Impact

Allowing a deposit of 0 or a negative amount could lead to incorrect state management within the contract. This could result in assets being incorrectly accounted for, potentially leading to discrepancies in the total amount of assets managed by the contract.

Proof of Concept

  1. Calling deposit(1, 0) would not change the state of the contract, as the amount is 0. This could lead to unnecessary transactions and gas costs.

Tools Used

Manual review

Recommended Mitigation Steps

it is recommended to add validation for the amount parameter in the deposit function. This could include checking that the amount is greater than 0 and handling any negative amounts appropriately. Here's an example of how this could be implemented:

function deposit(
 uint id,
 uint amount
)
 public 
    onlyVaultManager
{
 require(amount > 0, "Deposit amount must be greater than 0");
 id2asset[id] += amount;
 emit Deposit(id, amount);
}

Assessed type

Invalid Validation

saving mintedDyad by address and id can occur unexpected behaviour when using it in mintDyad and burnDyad function of VaultManagerV2 contract

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Dyad.sol#L12
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L156
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L172

Vulnerability details

Impact

Dyad contract stores mintedDyad by minter's address and nft id but it can result logical error in ValutManagerV2 contract for this following case.

Proof of Concept

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Dyad.sol#L12
In above code, Dyad contract stores mintedDyad by minter's address and nft id.
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L156
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L172
And in above code lines, VaultManagerV2 contract used mintedDyad values of Dyad contract so it can result logical error for this example:

Tools Used

VSCode, manual review

Recommended Mitigation Steps

Dyad contract can store mintedDyad by only nft id.

Assessed type

ERC20

QA Report

See the markdown file with the details of this report here.

Stale Collateralization Ratio (CR) Calculation in liquidate Function

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L205

Vulnerability details

Impact

Using an outdated collateralization ratio could lead to unnecessary or incorrect liquidations, impacting users unjustly and potentially leading to financial losses.

Proof of Concept

  1. Assume DNft id 123 has a collateralization ratio just above the liquidation threshold due to the amount of Dyad tokens minted against it.
  2. A liquidation is triggered, and all Dyad tokens for id 123 are burned, which would naturally increase the collateralization ratio significantly.
  3. The function proceeds using the old ratio, potentially leading to an unnecessary redistribution of collateral or other unintended actions.
  4. Post-burn, the function should recalculate the collateralization ratio to reflect the reduced debt, possibly halting any liquidation processes if the new ratio is above the threshold.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Modify the liquidate function to recalculate the collateralization ratio immediately after any token burn operation and before making further decisions based on this ratio.

Assessed type

Other

User can save itself from liquidation

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L205-L228
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L172-L202

Vulnerability details

Impact

In ValultManagerV2 if someone wants to liquidate a user, the user can see the transaction in mempool and save himslef from liquidation if burns or even redeem some amount of tokens. So if a user can be liquidated instead of depositing more collateral he will burn or redeem some tokens and this is cheaper than depositing because the collaterals cost more

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Tools Used

Manual review

Recommended Mitigation Steps

change the logic in a such way that user can only deposit if he wants to prevent liquidation

Assessed type

Other

DOS attack targeting `VaultManagerV2::withdraw`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153

Vulnerability details

Impact

An attacker can undo any legitimate withdrawals and redemption.

Proof of Concept

In VaultManagerV2::withdraw, there is a validation for idToBlockOfLastDeposit[id] at L143.

    function withdraw(
        uint    id,
        address vault,
        uint    amount,
        address to
    ) 
        public
        isDNftOwner(id)
    {
143     if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
        uint dyadMinted = dyad.mintedDyad(address(this), id);
        Vault _vault = Vault(vault);
        uint value = amount * _vault.assetPrice() 
                    * 1e18 
                    / 10**_vault.oracle().decimals() 
                    / 10**_vault.asset().decimals();
        if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
        _vault.withdraw(id, to, amount);
        if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
    }

If an attacker sets idToBlockOfLastDeposit[id] to the current block number through front-running, then the owner of id will be unable to withdraw his assets.

Let's consider the following scenario:

  1. Alice, the owner of id, initiates a transaction to call VaultManagerV2::withdraw.
  2. Bob then quickly makes a transaction to call VaultManagerV2::deposit with the 3rd parameter amount set to 1 wei, using front-running.
    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
    {
127     idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }

As indicated at L127 of VaultManagerV2::deposit, Bob sets idToBlockOfLastDeposit[id] to the current block number. Consequently, Alice's transaction is reverted at L143 of VaultManagerV2::withdraw.

Redemption is also impossible since the function VaultManagerV2::redeemDyad calls the function VaultManagerV2::withdraw.

Tools Used

Manual review

Recommended Mitigation Steps

There are 2 ways to fix this problem.

  1. Only the owner of id can deposit into id.
    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
+       isDNftOwner(id)
    {
        idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }
  1. Remove the block number validation.
    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
    {
-       idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }
    function withdraw(
        uint    id,
        address vault,
        uint    amount,
        address to
    ) 
        public
        isDNftOwner(id)
    {
-       if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
        uint dyadMinted = dyad.mintedDyad(address(this), id);
        Vault _vault = Vault(vault);
        uint value = amount * _vault.assetPrice() 
                    * 1e18 
                    / 10**_vault.oracle().decimals() 
                    / 10**_vault.asset().decimals();
        if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
        _vault.withdraw(id, to, amount);
        if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
    }

Assessed type

DoS

Front-running attack to undo `VaultManagerV2::remove`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L94-L104
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/Vault.sol#L42-L51

Vulnerability details

Impact

An attacker can reverse any vault removal, causing the owner of id to be unable to remove unnecessary vaults.

Proof of Concept

In VaultManagerV2::remove, there is a validation for Vault(vault).id2asset(id) at L101.

    function remove(
        uint    id,
        address vault
    ) 
        external
        isDNftOwner(id)
    {
101     if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets();
        if (!vaults[id].remove(vault))     revert VaultNotAdded();
        emit Removed(id, vault);
    }

If an attacker sets Vault(vault).id2asset(id) to a nonzero value through front-running, then the owner of id will be unable to remove the vault.

Let's consider the following scenario:

  1. Alice, the owner of id, initiates a transaction to call VaultManagerV2::remove.
  2. Bob then quickly makes a transaction to call VaultManagerV2::deposit with the 3rd parameter amount set to 1 wei, using front-running.
    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
    {
        idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
130     _vault.deposit(id, amount);
    }

As indicated at L130 of VaultManagerV2::deposit, it triggers Vault::deposit(id, 1).

    function deposit(
        uint id,
        uint amount
    )
        external 
        onlyVaultManager
    {
49      id2asset[id] += amount;
        emit Deposit(id, amount);
    }

Then, at L49 of Vault::deposit, id2asset[id] is set to 1. This results in Alices's transaction being reverted at L101 of VaultManagerV2::remove.

Tools Used

Manual review

Recommended Mitigation Steps

VaultManagerV2::deposit should only be callable by the owner of id.

    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
+       isDNftOwner(id)
    {
        idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }

Assessed type

Other

Depositing into an unadded vault should not be allowed in `VaultManagerV2::deposit`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153

Vulnerability details

Impact

If users depsit assets into vaults that are not added to their DNFT token, they might be unable to withdraw their assets.

Proof of Concept

Since there is no validation for vault to be already added to id in VaultManagerV2::deposit, it is quite possible for users to deposit assets into vaults that are not added to their DNFT token.

    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
    {
        idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }

On the other hand, in VaultManagerV2::withdraw, there is a collateral check at L150.

    function withdraw(
        uint    id,
        address vault,
        uint    amount,
        address to
    ) 
        public
        isDNftOwner(id)
    {
        if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
        uint dyadMinted = dyad.mintedDyad(address(this), id);
        Vault _vault = Vault(vault);
        uint value = amount * _vault.assetPrice() 
                    * 1e18 
                    / 10**_vault.oracle().decimals() 
                    / 10**_vault.asset().decimals();
150     if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
        _vault.withdraw(id, to, amount);
        if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
    }

If vault was not added to id, withdrawing from it will never decrease the value of getNonKeroseneValue(id) since this value is based on the vaults added to id. So, this criterion could potentially lead to unfair reversals, especially when dyadMinted is very close to getNonKeroseneValue(id).

Tools Used

Manual review

Recommended Mitigation Steps

Depositing to unadded vaults should not be allowed.

    function deposit(
        uint    id,
        address vault,
        uint    amount
    ) 
        external 
        isValidDNft(id)
    {
+       if (!vaults[id].contains(vault) && !vaultsKerosene[id].contains(vault)) revert VaultNotAdded();
        idToBlockOfLastDeposit[id] = block.number;
        Vault _vault = Vault(vault);
        _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
        _vault.deposit(id, amount);
    }

Assessed type

Other

No check for round completeness in price feed

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.sol#L91-L103

Vulnerability details

Summary

No check for round completeness could lead to stale prices and wrong price return value, or outdated price. The functions rely on accurate price feed might not work as expected, sometimes can lead to fund loss.

Proof of Concept

The oracle wrapper assetPrice() call out to an oracle with latestRoundData() to get the price of some token. Although the returned timestamp is checked, there is no check for round completeness.

  function assetPrice() 
    public 
    view 
    returns (uint) {
      (
        ,
        int256 answer,
        , 
        uint256 updatedAt, 
      ) = oracle.latestRoundData();
      if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData();
      return answer.toUint256();
  }

oracle.latestRoundData()

  function latestRoundData()
    external
    view
    returns (
      uint80 roundId,
      int256 answer,
      uint256 startedAt,
      uint256 updatedAt,
      uint80 answeredInRound
    );

According to Chainlink's documentation:

This function does not error if no answer has been reached but returns 0 or outdated round data. The external Chainlink oracle, which provides index price information to the system, introduces risk inherent to any dependency on third-party data sources. For example, the oracle could fall behind or otherwise fail to be maintained, resulting in outdated data being fed to the index price calculations.

Oracle reliance has historically resulted in crippled on-chain systems, and complications that lead to these outcomes can arise from things as simple as network congestion.

(Reference)

Impact

If there is a problem with chainlink starting a new round and finding consensus on the new value for the oracle (e.g. chainlink nodes abandon the oracle, chain congestion, vulnerability/attacks on the chainlink system) consumers of this contract may continue using outdated stale data (if oracles are unable to submit no new round is started).

This could lead to stale prices and wrong price return value, or outdated price.

As a result, the functions rely on accurate price feed might not work as expected, sometimes can lead to fund loss.

Tools Used

Manual Review

Recommended Mitigation Steps

Validate data feed for round completeness:

  function assetPrice() 
    public 
    view 
    returns (uint) {
      (
        ,
        int256 answer,
        , 
        uint256 updatedAt, 
      ) = oracle.latestRoundData();
      if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData();
      require(answeredInRound >= roundID, "round not complete"); // @audit Checked
      return answer.toUint256();
  }

Assessed type

Oracle

Protocol not compatible with Fee-on-transfer or rebasing tokens

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.sol#L42-L51
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.sol#L53-L64

Vulnerability details

Summary

According to the DYAD Documentation:

Notes are ERC-721 NFTs into which holders deposit approved ERC-20 tokens. These tokens are currently wETH and wstETH, and will soon include LSTs and LRTs, other types of yield-bearing collateral, as well as Kerosene.

From this, the protocol may introduce other types of yield-bearing collateral tokens. Thus if Fee-on-Transfer or rebasing tokens like (PAXG or stETH) are introduced, (which by the way are stated in README.md to be in scope), then the protocol needs to integrate them into the system. But as it is now the system is definitely not compatible with such tokens.

Proof of Concept

deposit(), withdraw() and any other accounting related functions performing operations using inputed/recorded amounts don't query the existing balance of tokens before or after receiving/sending in order to properly account for tokens that shift balance when received (FoT) or shift balance over time (rebasing).

Example:

The deposit() function Allows a dNFT owner to deposit collateral into a vault.

VaultManager.deposit()

  function deposit(
    uint    id,
    address vault,
    uint    amount
  ) 
    external 
      isValidDNft(id) 
  {
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
  }

Upon invokation, safeTransferFrom() is called internally that transfers the tokens from user, msg.sender to vault, address(vault).
Then, this function calls _vault.deposit() that does the accounting:

Vault.deposit()

  function deposit(
    uint id,
    uint amount
  )
    external 
      onlyVaultManager
  {
    id2asset[id] += amount;
    emit Deposit(id, amount);
  }

As seen above, this function does not query the existing balance of tokens before or after receiving in order to properly account.

Impact

High - It simply won't work with Fee-on-Transfer or rebasing tokens.

Tools Used

Manual review

Recommended Mitigation Steps

Adjust the code to check the token.balanceOf() before and after doing any operation related to the transfers.

Assessed type

Other

Attacker can lure unsuspecting users to a malicious contract Address via the Vault Manager contract address

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119

Vulnerability details

Impact

An attacker could exploit vulnerabilities in protocol functions to redirect users to a malicious contract, potentially leading to loss of funds.

Proof of Concept

To demonstrate this vulnerability, an attacker could deploy a malicious Vault contract, mimicking the functionality of the legitimate protocol, and modify the withdraw function to their advantage.

In the deposit function here protocol does not check to ensure the Vault users are depositing into has been licensed. Like this

This gives the attacker the advantage to lure unsuspecting users to a malicious contract despite interacting with the real protocol contract address.

Tools Used

Manual review

Recommended Mitigation Steps

Ensure only Licensed vaults can be deposited into like this

Assessed type

Other

Solmate `safetransfer` does not check the codesize of the token address, which may lead to fund loss

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.unbounded.sol#L30-L41

Vulnerability details

Impact

The safetransfer doesn't check the existence of code at the token address. This is a known issue while using solmate's libraries.
Hence this may lead to miscalculation of funds and may lead to loss of funds

Proof of Concept

Tools Used

Manual review

Recommended Mitigation Steps

Use openzeppelin's safeERC20 or implement a code existence check.

Assessed type

Solmate

VaultManagerV2#deposit is vulnerable to fee-on-transfer tokens

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L128-L130

Vulnerability details

Impact

VaultManagerV2#deposit is vulnerable to fee-on-transfer tokens

Proof of Concept

We assume the vault asset is a FoT token, so:

  • User deposits 10_000 of vault asset token (which is a FoT token) to the vault.
  • Vault will receive 9800 tokens (due to 2% fee)
  • User will receive 10_000 of vault share.

This produces an imbalance between the vault shares and vault token balance.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider comparing the vault balance before safeTransferFrom and also after it, then we can detect how much token is exactly transferred to the vault, then you can mint exactly and equal amount of share.

Assessed type

Token-Transfer

No liquidation reward when the collateral ratio is smaller than 100%.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228

Vulnerability details

Impact

If the collateral ratio of id is smaller than 100%, nobody wants to liquidate it. As a result, the entire protocol doesn't work well.

Proof of Concept

If cr < 1e18 in VaultManagerV2::liquidate, liquidationEquityShare will be 0 at L218.

    function liquidate(
      uint id,
      uint to
    ) 
      external 
        isValidDNft(id)
        isValidDNft(to)
      {
        uint cr = collatRatio(id);
        if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh();
        dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));

217     uint cappedCr               = cr < 1e18 ? 1e18 : cr;
218     uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
        uint liquidationAssetShare  = (liquidationEquityShare + 1e18).divWadDown(cappedCr);

        uint numberOfVaults = vaults[id].length();
        for (uint i = 0; i < numberOfVaults; i++) {
            Vault vault      = Vault(vaults[id].at(i));
            uint  collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
            vault.move(id, to, collateral);
        }
        emit Liquidate(id, msg.sender, to);
    }

So, the liquidator will incur loss. As a result, nobody wants to liquidate this id. Then, from that id, redeeming or withdrawing can't be done.

Tools Used

Manual review

Recommended Mitigation Steps

There should be a mechanism to process ids whose collateral ratio is smaller than 100%.

Assessed type

Other

deposit in VaultManagerV2 doesn't check zero amount so it causes to fail normal withdrawal if attacker find normal withdrawal in pending tx pool and create zero amount deposit

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L143

Vulnerability details

Impact

deposit function in VaultManagerV2 contract doesn't check zero amount so it can cause to fail normal withdraw operation if attacker find normal withdraw tx in pending pool and make zero amount deposit tx.

Proof of Concept

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L127
In the above line, deposit function of VaultManagerV2 updates idToBlockOfLastDeposit[id] as block number even though the given amount is zero.
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L143
so it can cause above checking code fails although it's normal withdraw operation if attacker create empty deposit when it founds any withdrawal operation in pending pool.
of course this can take some gas for attackers but it can make the tx fails each time user wants to withdraw so it can reduce the trust of your service and while withdraw tx fails several times, attacker can do another kind of attacking as they know withdraw tx will be created again in next time, etc.

Tools Used

vscode, manual review

Recommended Mitigation Steps

Add zero amount value in deposit function or use nonReentrant modifier of ReentrancyGuard from openzeppelin-contracts for withdraw function.

Assessed type

DoS

Analysis

See the markdown file with the details of this report here.

Migrating with new `Vault`s will disrupt the main invariants

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L48-L65
https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.unbounded.sol#L50-L68

Vulnerability details

Impact

The old WETH vault and wstETH vault managed by VaultManagerV1 contains assets leveraged to create the current Dyad. However, the old vaults are not added to VaultManagerV2, and new vaults are created instead. This results in the TVL becoming zero after migration because there are no assets in the new vaults. However, the total supply of DYAD is not zero, thus breaking the protocol's invariants.

Proof of Concept

The Deploy script of migration will create two new vault to the KerosineManager.

  function run() public returns (Contracts memory) {
    vm.startBroadcast();  // ----------------------

    Licenser vaultLicenser = new Licenser();

    // Vault Manager needs to be licensed through the Vault Manager Licenser
    VaultManagerV2 vaultManager = new VaultManagerV2(
      DNft(MAINNET_DNFT),
      Dyad(MAINNET_DYAD),
      vaultLicenser
    );

    // weth vault
    Vault ethVault = new Vault(
      vaultManager,
      ERC20        (MAINNET_WETH),
      IAggregatorV3(MAINNET_WETH_ORACLE)
    );

    // wsteth vault
    VaultWstEth wstEth = new VaultWstEth(
      vaultManager, 
      ERC20        (MAINNET_WSTETH), 
      IAggregatorV3(MAINNET_CHAINLINK_STETH)
    );

    KerosineManager kerosineManager = new KerosineManager();

    kerosineManager.add(address(ethVault));
    kerosineManager.add(address(wstEth));

And the assetPrice of Unbounded Vault is calculatd based on the vaults in the kerosineManager.
However, the old WETH vault and wstETH vault are not added to the VaultManagerV2, and new vaults are created, then the TVL could be zero because there are no assets in the new created vaults. This clearly violates the invariant that the TVL should exceed the total supply of DYAD.

  function assetPrice() 
    public 
    view 
    override
    returns (uint) {
      uint tvl;
      //@audit tvl will be zero because the vaults are new
      //@audit there are no asset in the new vaults
      address[] memory vaults = kerosineManager.getVaults();
      uint numberOfVaults = vaults.length;
      for (uint i = 0; i < numberOfVaults; i++) {
        Vault vault = Vault(vaults[i]);
        tvl += vault.asset().balanceOf(address(vault)) 
                * vault.assetPrice() * 1e18
                / (10**vault.asset().decimals()) 
                / (10**vault.oracle().decimals());
      }
      uint numerator   = tvl - dyad.totalSupply();
      uint denominator = kerosineDenominator.denominator();
      return numerator * 1e8 / denominator;
  }

POC

Add the test to test/fork/v2.t.sol and run it with forge test --match-test test_assetPrice --fork-url https://eth-mainnet.g.alchemy.com/v2/<API_KEY>.

The call to unboundedKerosineVault.assetPrice() will revert directly because the tvl is zero and the total supply of DYAD is not zero.

diff --git a/test/fork/v2.t.sol b/test/fork/v2.t.sol
index 349412f..d86e451 100644
--- a/test/fork/v2.t.sol
+++ b/test/fork/v2.t.sol
@@ -7,7 +7,6 @@ import "forge-std/Test.sol";
 import {DeployV2, Contracts} from "../../script/deploy/Deploy.V2.s.sol";
 import {Licenser} from "../../src/core/Licenser.sol";
 import {Parameters} from "../../src/params/Parameters.sol";
-
 contract V2Test is Test, Parameters {

   Contracts contracts;
@@ -52,4 +51,11 @@ contract V2Test is Test, Parameters {
     uint denominator = contracts.kerosineDenominator.denominator();
     assertTrue(denominator < contracts.kerosene.balanceOf(MAINNET_OWNER));
   }
+
+  function test_assetPrice() public {
+
+    uint256 x = contracts.unboundedKerosineVault.assetPrice();
+    console.log(x);
+
+  }
 }

Tools Used

Foundry

Recommended Mitigation Steps

Add the old WETH vault and wstETH vault to the VaultManagerV2 when migrating.

Assessed type

Upgradable

Normal withdraw could be blocked via deposit()

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L118-L144

Vulnerability details

Impact

Hacker can block normal user's withdraw() via deposit() function.

Proof of Concept

In VaultManagerV2, we will revert withdraw operation if idToBlockOfLastDeposit[id] == block.number. This aims to prevent flashloan attack. However, this could block normal withdraw case if hacker deposit 0 amount for withdrawer via frontrun.

For example, Alice wants to withdraw some assets. Bob monitors and call one deposit() with alice's NFT id and 0 amount. And idToBlockOfLastDeposit[id] will be updated to the latest.

  function deposit(
    uint    id,
    address vault,
    uint    amount
  ) 
    external 
      isValidDNft(id)
  {
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
  }

When Alice withdraws her assets, the transaction will be reverted because of idToBlockOfLastDeposit[id] has already updated to the latest.

function withdraw(
    uint    id,
    address vault,
    uint    amount,
    address to
  ) 
    public
      isDNftOwner(id)
  {
    if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
    ...
  }

Tools Used

Manual

Recommended Mitigation Steps

Add one modifier for function deposit(), only NFT owner or owner's delegator can deposit() for the related NFT id.

Assessed type

DoS

Potential Underflow Vulnerability in UnboundedKerosineVault Contract

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/Vault.kerosine.unbounded.sol#L30

Vulnerability details

Description

The withdraw function in the contract allows for the withdrawal of assets linked to a specific ID. The function adjusts the asset balance for a given ID by subtracting the withdrawal amount from the current balance held in id2asset[id]. However, the function lacks a critical check to ensure that the balance is sufficient to cover the withdrawal request before proceeding with the subtraction.

Impact

  1. An attacker could exploit this vulnerability to withdraw more assets than they are entitled to, leading to financial losses for the contract and its users.
  2. The id2asset[id] could end up reflecting a very high balance due to the underflow, far exceeding actual owned assets.

Proof of Concept

  1. An attacker discovers the vulnerability and decides to exploit it by calling the withdraw function with an amount greater than the current balance of id2asset[id].
  2. The attacker calls the withdraw function with a carefully crafted amount that is greater than the current balance of id2asset[id].
  3. The subtraction operation underflows, leading to an incorrect balance being recorded in id2asset[id]. This could allow the attacker to withdraw more assets than they are entitled to.
  4. The attacker can withdraw more assets than they are entitled to, leading to financial losses for the contract and its users. The integrity of the contract's state could also be compromised, affecting the overall functionality of the contract

Tools Used

manual review

Recommended Mitigation Steps

Add a require statement at the beginning of the withdraw function to ensure that the balance is sufficient before proceeding with the withdrawal. Here's how you could modify the withdraw function to include this check:

function withdraw(
 uint    id,
 address to,
 uint    amount
) 
 external 
    onlyVaultManager
{
 require(id2asset[id] >= amount, "Insufficient balance");
 id2asset[id] -= amount;
 asset.safeTransfer(to, amount); 
 emit Withdraw(id, to, amount);
}

Assessed type

Under/Overflow

Missing check for the max/min price in `assetPrice()`

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.sol#L91-L103

Vulnerability details

Summary

The assetPrice() function uses the aggregatorV3 to get/call the latestRoundData(). The function should check for the min and max amount return to prevent some case happen, something like this:

If a case like LUNA happens then the oracle will return the minimum price and not the crashed price.

Proof of Concept

  function assetPrice() 
    public 
    view 
    returns (uint) {
      (
        ,
        int256 answer,
        , 
        uint256 updatedAt, 
      ) = oracle.latestRoundData();
      if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData();
      return answer.toUint256();
  }

The function tries to get latest price data here:

IAggregatorV3.latestRoundData()

  function latestRoundData()
    external
    view
    returns (
      uint80 roundId,
      int256 answer,
      uint256 startedAt,
      uint256 updatedAt,
      uint80 answeredInRound
    );

The function checks if block.timestamp > updatedAt + STALE_DATA_TIMEOUT and reverts if true.
However, it does not check for the min/max price allowable and therefore when the assetPrice() returns the price value, this value can be anything absurdly odd.

The assetPrice() function is called and used in various instances across different contracts such in:

  1. calculating the number of assets to withdraw in VaultManagerV2.redeemDyad()
  2. calculating value in VaultManagerV2.withdraw()

Impact

Without a check for minimum and maximum allowable prices, the function might return prices that are unreasonably high or low. If the function returns a price that is significantly outside the expected range, it could lead to financial losses for users or the system itself.

Tools Used

Manual Review

Recommended Mitigation Steps

As shown in this Documentation:
A circuit breaker should be implemented on the oracle so that when the price edges close to minAnswer or maxAnswer it starts reverting.

Configure your application to detect when the reported answer is close to reaching reasonable minimum and maximum limits so it can alert you to potential market events. Separately, configure your application to detect and respond to extreme price volatility or prices that are outside of your acceptable limits.

Some check like this can be added to avoid returning of the min price or the max price in case of the price crashes.

require(answer < _maxPrice, "Upper price bound breached");          
require(answer > _minPrice, "Lower price bound breached");

Assessed type

Oracle

Staking.sol - Synthetix-like behavior contains the inefficient reward distribution issue

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/staking/Staking.sol#L107-L126

Vulnerability details

Impact

The Staking.sol contract forks the same logic as Synthetix's Staking Rewards and as a result also forks an issue with it's reward calculations for the initial period of staking.
The issue being that if there aren't any stakes done in the same block as the invocation of notifyRewardAmount(), then depending on the delay, those rewards for that period would be lost, since there would be a time gap between the reward allocation and the first stake that would start the reward earning.

Proof of Concept

Let's say we call notifyRewardAmount() with a duration of a month and a rate of 1 at timestamp X. Some time passes and we have our first stake occur at timestamp X+Y. Meaning that for that first staker, his accumulation starts at timestamp X+Y.
Noting that the end of the period is X + duration and not X+Y+duration, this means that the rewards for the period between X and X+Y would be locked.

Whenever a new rewards cycle is started, these old locked/unused tokens would be back in circulation for the new period, but the same above logic applies and it creates a cycle of always having unused rewards, as there is always the likelihood of having a delay between the notifyRewardAmount() and first stake.

Consider checking out this write-up on the matter:
https://0xmacro.com/blog/synthetix-staking-rewards-issue-inefficient-reward-distribution/

Tools Used

Manual Review

Recommended Mitigation Steps

The linked write-up provides a handy live solution, it being functionality that defines the end of the period on the first stake instead of the reward notification. This way reward accumulation would end at X+Y+duration instead of X+duration and no rewards would be left lost/unused.

Assessed type

Other

Liquidator's bounded kerosine profit will be locked forever

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L205-L228

Vulnerability details

Impact

Some bounded kerosine profits from liquidation will be locked in contract.

Proof of Concept

When liquidator liquidate one NFT position, all collateral assets will be moved to liquidator. If liquidator has no debt, liquidator can withdraw all profits. However, bounded kerosine vault is not allowed to withdraw. Liquidator may lose some profits.

For example, Alice borrow 100 DYAD via collateral WETH(100$) and bounded kerosine(50$). Now collateral price drops down, Bob wants to liquidate Alice's position. Bob will pay 100 DYAD and get WETH and bounded kerosine. Considering these bounded kerosine are not withdraw-able and transferable, Bob will lose some profit.

Tools Used

Manual

Recommended Mitigation Steps

It's not easy to mitigate this case. Even if we allow liquidator to withdraw related bounded kerosine, it's also unfair for liquidators. Liquidator liquidate bounded kerosine, valued 50$(doubled because of bounded), liquidator will get nearly 25$ value kerosine even if we allow liquidator to withdraw.

Assessed type

Error

Kerosine Deposited Into DNfts Will Be Lost and Can not Be Withdrawn

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L134

Vulnerability details

Impact

Users can use VualtManagerV2::deposit to deposit their Kerosene to their DNfts. However, if they attempt to withdraw it, the process will always revert.This is because the VualtManagerV2::withdraw function uses _vault.oracle().decimals() to get the oracle decimals, but the Kerosine Vaults does not have the oracle state variable.

withdraw function
function withdraw(
        uint256 id,
        address vault,
        uint256 amount,
        address to
    ) public isDNftOwner(id) {
        if (idToBlockOfLastDeposit[id] == block.number) {
            revert DepositedInSameBlock();
        } 
        uint256 dyadMinted = dyad.mintedDyad(address(this), id);
        Vault _vault = Vault(vault);
        uint256 value = (amount * _vault.assetPrice() * 1e18) /
@>          10 ** _vault.oracle().decimals() /
@>          //q this will always revert if withdawing kerosene collateral! kerosene vaults doesn't have oracle
            10 ** _vault.asset().decimals();
        if (getNonKeroseneValue(id) - value < dyadMinted) {
            revert NotEnoughExoCollat();
        }
        _vault.withdraw(id, to, amount);
        if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow();
    }

Proof of Concept

Add the following test to exisiting v2.t.sol test suit. Steps:

  1. Deal prank address some ETH and mint WETH.
  2. Mint a DNft for the user and add a vault to it.
  3. Add VaultV2 to vaultManager licenser.
  4. Mint the maximum amount of Dyads.
  5. Try to mint more; this should fail!
  6. Get some Kerosene from mainnetOwner.
  7. Deposit Kerosene into vaults.
  8. Try to mint after Kerosene deposit; this should succeed.
  9. Burn all minted Dyad and withdraw non-Kerosene collaterals.
  10. Withdraw all Kerosene; this will always revert!
PoC
function testKoreseneWithdrawsWillAlwaysFail() public {
        //1. deal prank address some eth and mint weth:
        vm.deal(lp, 11 ether);
        vm.startPrank(lp);
        WETH(payable(MAINNET_WETH)).deposit{value: 10 ether}();
        uint256 balance = WETH(payable(MAINNET_WETH)).balanceOf(lp);
        assertEq(balance, 10 ether);
        //2. Mint a Dnft for the user and add vault to it:
        uint256 id = DNft(MAINNET_DNFT).mintNft{value: 1 ether}(lp);
        assertEq(DNft(MAINNET_DNFT).balanceOf(lp), 1);
        contracts.vaultManager.add(id, address(contracts.ethVault));
        vm.stopPrank();
        //3. add VaultV2 to vaultmanager licenser:
        address VAULTMANAGER_LICENSER = 0xd8bA5e720Ddc7ccD24528b9BA3784708528d0B85;
        vm.prank(Licenser(VAULTMANAGER_LICENSER).owner());
        Licenser(VAULTMANAGER_LICENSER).add(address(contracts.vaultManager));
        //4. try to Mint Dyads:
        vm.startPrank(lp);
        WETH(payable(MAINNET_WETH)).approve(
            address(contracts.vaultManager),
            10 ether
        );
        contracts.vaultManager.deposit(
            id,
            address(contracts.ethVault),
            10 ether
        );
        uint256 maxMintAmount = (contracts.vaultManager.getNonKeroseneValue(
            id
        ) * 2) / 3 ether;
        contracts.vaultManager.mintDyad(id, maxMintAmount, lp);
        vm.stopPrank();
        uint256 DyadBalance = ERC20(MAINNET_DYAD).balanceOf(lp);
        assertEq(DyadBalance, maxMintAmount);
        //5. try to mint more? should fail!
        vm.expectRevert();
        contracts.vaultManager.mintDyad(id, 100 ether, lp);
        //6. get some kerosene from mainWallet
        vm.prank(MAINNET_OWNER); //mainnetOwner
        ERC20(MAINNET_KEROSENE).transfer(lp, 10000 ether);
        //7. deposit kerosene into vaults
        vm.startPrank(lp);
        ERC20(MAINNET_KEROSENE).approve(
            address(contracts.vaultManager),
            10000 ether
        );
        contracts.vaultManager.deposit(
            id,
            address(contracts.unboundedKerosineVault),
            5000 ether
        );
        contracts.vaultManager.deposit(
            id,
            address(contracts.boundedKerosineVault),
            5000 ether
        );
        //8. try to mint after  kerosene deposit=> succeeds
        contracts.vaultManager.mintDyad(id, 100 ether, lp);
        //9. burn all minted dyad and withdraw nonkerosene collaterals
        vm.roll(1);
        vm.warp(1); // let some time and block to pass
        contracts.vaultManager.burnDyad(id, maxMintAmount + 100 ether);
        contracts.vaultManager.withdraw(
            id,
            address(contracts.ethVault),
            10 ether,
            lp
        );
        //10. withdraw some of kerosene => will revert always!
        vm.expectRevert();
        contracts.vaultManager.withdraw(
            id,
            address(contracts.unboundedKerosineVault),
            10 ether,
            lp
        );

        vm.expectRevert();
        contracts.vaultManager.withdraw(
            id,
            address(contracts.boundedKerosineVault),
            10 ether,
            lp
        );
    }

Tools Used

Manual Review, Foundry Test Suit

Recommended Mitigation Steps

To fix this issue, it's recommended to add a check to the withdraw and redeem functions (which have the same problem). In the case of withdrawing from the Kerosene vaults (or in case that vault.asset == Kerosene), do not use the oracle state variable of the vault contract. Instead, use fixed decimals for Kerosene price.

Recommended Mitigations
function withdraw(
        uint256 id,
        address vault,
        uint256 amount,
        address to
    ) public isDNftOwner(id) {
        if (idToBlockOfLastDeposit[id] == block.number) {
            revert DepositedInSameBlock();
        } 
        uint256 dyadMinted = dyad.mintedDyad(address(this), id);
        Vault _vault = Vault(vault);
-       uint256 value = (amount * _vault.assetPrice() * 1e18) /
-           10 ** _vault.oracle().decimals() /
-           10 ** _vault.asset().decimals();
+       address MAINNET_KEROSENE = 0xf3768D6e78E65FC64b8F12ffc824452130BD5394;
+       uint256 FIXED_KEROSENE_PRICE_DECIMALS = 8;
+       if (address(_vault.asset()) == MAINNET_KEROSENE) {
+           uint256 value = (amount * _vault.assetPrice() * 1e18) /
+               10 ** FIXED_KEROSENE_PRICE_DECIMALS /
+               10 ** _vault.asset().decimals();
+       } else {
+           uint256 value = (amount * _vault.assetPrice() * 1e18) /
+               10 ** _vault.oracle().decimals() /
+               10 ** _vault.asset().decimals();
+       }
        if (getNonKeroseneValue(id) - value < dyadMinted) {
            revert NotEnoughExoCollat();
        }
        _vault.withdraw(id, to, amount);
        if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow();
    }

Assessed type

Token-Transfer

Unlicensed vaults can be added in KerosineManager contract

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/KerosineManager.sol#L18-L26

Vulnerability details

Summary

The add() function is used to add a new vault. However, in KerosineManager contract, the function does not check if the vault being added is licensed or not before executing.

Proof of Concept

add()

  function add(
    address vault
  ) 
    external 
      onlyOwner
  {
    if (vaults.length() >= MAX_VAULTS) revert TooManyVaults();
    if (!vaults.add(vault))            revert VaultAlreadyAdded();
  }

Impact

This missing check allows for unlicensed vaults to be added.

Tools Used

Manual Review

Recommended Mitigation Steps

Add a require statement in the add() to ensure that only licensed vaults are added.

  function add(
    address vault
  ) 
    external 
      onlyOwner
  {
    if (vaults.length() >= MAX_VAULTS) revert TooManyVaults();
    require(isLicensed(vault), "Vault not Licenced"); // @audit Check implemented
    if (!vaults.add(vault))            revert VaultAlreadyAdded();
  }

Assessed type

Invalid Validation

Incorrect calculation of liquidation bonus.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L218

Vulnerability details

Impact

Liquidation bonus calculation is incorrect, the impacts are:

  1. Liqudity providers pay smaller penalities for worse collateral ratio, and even no penalty if collateral ratio drops below 1e18.
  2. Liquidators get wrong bonus for liquidation, more specifically, smaller bonus for worse calteration ratio.

Proof of Concept

The worse the collateral ratio, the greater the liquidation equity share. But the calculation of liquidationEquityShare is the opposite, as the liquidationEquityShare is calculated by cr - 1e18, which should be MIN_COLLATERIZATION_RATIO - cr instead.

  function liquidate(
    uint id,
    uint to
  ) 
    external 
      isValidDNft(id)
      isValidDNft(to)
    {
      uint cr = collatRatio(id);
      if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh();
      dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));

      uint cappedCr               = cr < 1e18 ? 1e18 : cr;
@>    uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
      uint liquidationAssetShare  = (liquidationEquityShare + 1e18).divWadDown(cappedCr);

      uint numberOfVaults = vaults[id].length();
      for (uint i = 0; i < numberOfVaults; i++) {
          Vault vault      = Vault(vaults[id].at(i));
          uint  collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
          vault.move(id, to, collateral);
      }
      emit Liquidate(id, msg.sender, to);
  }

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L205-L228

Tools Used

VSCode

Recommended Mitigation Steps

-    uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD);
+    uint liquidationEquityShare = (MIN_COLLATERIZATION_RATIO - cappedCr).mulWadDown(LIQUIDATION_REWARD);

Assessed type

Math

Anybody with a validNFT can burn all the dyad token in the newly vaultManager2 and users likewise

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L172
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L177

Vulnerability details

Impact: High, because the dyad won't be retrievable from the portal anymore and can be exploited in different ways.

Likelihood: High, because it does not require any preconditions to be exploited.

  function burnDyad(
    uint id,
    uint amount
  ) 
    external 
      isValidDNft(id)
  {
    dyad.burn(id, msg.sender, amount); <---- @audit
    emit BurnDyad(id, amount, msg.sender);
  }
  function burn(
      uint    id, 
      address from,
      uint    amount
  ) external 
      licensedVaultManager 
    {
      _burn(from, amount);<------- @audit attacker can burn anyone token and the dyad token in the vault
      mintedDyad[msg.sender][id] -= amount;
  }

Description: The burnDyad method in the VaultManagerV2.sol allows the burning of all dyad tokens in the Vault, given its ID.

The function is callable if you have a valid NFT but lacks access control like the other functions _mintDyad, withdraw, deposit,removeKerosene, remove, addKerosene, isDNftOwner, redeemDyad and liquidate.

  1. If an attacker possesses a valid NFT, they can burn tokens owned by other users, since there is no ownership approval or ownership check in place to prevent such malicious activity.

  2. Reentrancy, An attacker can remove a dyad repeatedly before its removal is finalized, effectively blocking its removal.

Recommended Mitigation Steps

This can reduce the attacker scenario a bit, there should be more restrictions when burning still.

  function burnDyad(
    uint id,
    uint amount
  ) 
    external
      isDNftOwner(id)
      isValidDNft(id)
  {
    dyad.burn(id, msg.sender, amount); <----@audit
    emit BurnDyad(id, amount, msg.sender);
  }

Assessed type

Access Control

Users who do not have a DNFT token can't redeem their `dyad`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L184-L202

Vulnerability details

Impact

Users who do not have a DNFT token can't redeem their dyad.

Proof of Concept

Only the owner of id can call VaultManagerV2::redeemDyad.

    function redeemDyad(
      uint    id,
      address vault,
      uint    amount,
      address to
    )
      external 
191     isDNftOwner(id)
      returns (uint) { 
        dyad.burn(id, msg.sender, amount);
        Vault _vault = Vault(vault);
        uint asset = amount 
                      * (10**(_vault.oracle().decimals() + _vault.asset().decimals())) 
                      / _vault.assetPrice() 
                      / 1e18;
        withdraw(id, vault, asset, to);
        emit RedeemDyad(id, vault, amount, to);
        return asset;
  }

So, users who have received dyads from others but have no id, can't redeem their dyads.

Tools Used

Manual review

Recommended Mitigation Steps

There should be a function for users who have no id to redeem their dyads.

Assessed type

Other

Protocol doesn't have any backing ether for the NFTs minted

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/params/DNftParameters.sol#L6-L7
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/DNft.sol#L26

Vulnerability details

Impact

DNft will be minted for zero cost. The proposed pricing of each DNft token will be non-existent as the prices are zeroed out.

Proof of Concept

function mintNft(address to)
    external 
    payable
    returns (uint) {
@>    uint price = START_PRICE + (PRICE_INCREASE * publicMints++); // @note calc price
      if (msg.value < price) revert InsufficientFunds();
      uint id = _mintNft(to);
@>    if (msg.value > price) to.safeTransferETH(msg.value - price); // @note refunds excess
      emit MintedNft(id, to);
      return id;
  }

From the mintNft function snippet above, we can see that the protocol calculates the prices of each DNft token in this line:

uint price = START_PRICE + (PRICE_INCREASE * publicMints++);

Then proceeds to check that the caller sends enough ethers, removes the price's worth, and refunds the excess to the caller:

if (msg.value < price) revert InsufficientFunds();
uint id = _mintNft(to);
if (msg.value > price) to.safeTransferETH(msg.value - price);

Well, since the START_PRICE & PRICE_INCREASE are hardcoded as 0, the price will always resolve to 0 ether

Tools Used

Manual review + foundry

Recommended Mitigation Steps

Since it seems like the intention of the protocol (from the code comments) is to steadily increase the floor prices of DNft tokens, the protocol is not implementing this pricing correctly because the supposed 0.1 ether START_PRICE is set as 0 ether as well as the supposed 0.1% PRICE_INCREASE for calculating DNft mint costs.

Code snippets from the codebase:

uint public constant START_PRICE    = 0 ether; // 0.1 ether
uint public constant PRICE_INCREASE = 0 ether; // 0.001 ether

The intention is to steadily increase the price for the NFT mints, so set the START_PRICE and PRICE_INCREASE appropriately:

- uint public constant START_PRICE    = 0 ether;
- uint public constant PRICE_INCREASE = 0 ether;

+ uint public constant START_PRICE    = 0.1 ether;
+ uint public constant PRICE_INCREASE = 0.001 ether;

Assessed type

Payable

Precision loss in asset price calculation.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.sol#L69-L74

Vulnerability details

Summary

Both BoundedKerosineVault & UnboundedKerosineVault contracts declare is KerosineVault and override the assetPrice() function from it.
The UnboundedKerosineVault actually does the implementation of this function while the BoundedKerosineVault just doubles the result from UnboundedKerosineVault in its implementation.

However, this final operation involves a division before multiplication.

Proof of Concept

KerosineVault.assetPrice()

  function assetPrice() 
    public 
    view 
    virtual
    returns (uint); 
}

This function is overriden in UnboundedKerosineVault as follows.

UnboundedKerosineVault.assetPrice()

  function assetPrice() 
    public 
    view 
    override
    returns (uint) {
      uint tvl;
      address[] memory vaults = kerosineManager.getVaults();
      uint numberOfVaults = vaults.length;
      for (uint i = 0; i < numberOfVaults; i++) {
        Vault vault = Vault(vaults[i]);
        tvl += vault.asset().balanceOf(address(vault)) 
                * vault.assetPrice() * 1e18
                / (10**vault.asset().decimals()) 
                / (10**vault.oracle().decimals());
      }
      uint numerator   = tvl - dyad.totalSupply();
      uint denominator = kerosineDenominator.denominator();
      return numerator * 1e8 / denominator;
  }

As seen above, this function returns numerator * 1e8 / denominator, in an operation that ends with division.

In the BoundedKerosineVault contract, the above result is then multiplied by 2:

BoundedKerosineVault.assetPrice()

  function assetPrice() 
    public 
    view 
    override
    returns (uint) {
      return unboundedKerosineVault.assetPrice() * 2; // @audit Here
  }

Therefore, the whole operation in BoundedKerosineVault.assetPrice() is as follows:

numerator * 1e8 / denominator * 2;

Evidently, there is presence of an intermediate division before multiplication.

Impact

This operation could result in precision loss due to truncation during the division process.

Tools Used

Manual Review

Recommended Mitigation Steps

By multiplying the price by 2 before any division, you reduce the risk of precision loss.

function assetPrice() 
    public 
    view 
    override
    returns (uint) {
      return 2 * unboundedKerosineVault.assetPrice(); // @audit Multiply before division
}

Assessed type

Math

Analysis

See the markdown file with the details of this report here.

Missing Post-Removal License Check in removeKerosene Function

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/76ee752fd9b05edbbf7c7a476bd46d72fa86fd2c/src/core/VaultManagerV2.sol#L106

Vulnerability details

Impact

The absence of a post-removal license check in the removeKerosene function could potentially lead to scenarios where unlicensed vaults remain associated with the system without being actively tracked or managed. This could undermine the integrity of the licensing system and may lead to operational inconsistencies.

Proof of Concept

  1. Vault A is added to vaultsKerosene and is licensed by the Kerosene Manager.
  2. Vault A's license is later revoked by the Kerosene Manager, but it remains in the vaultsKerosene set.
  3. removeKerosene is called, successfully removing Vault A without checking its current license status.
  4. The system now has no record of Vault A being previously managed, despite its prior involvement and revoked license.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Adding a function to update the Kerosene Manager about the removal of a vault.
  2. Emitting an event after removal that includes the license status of the vault for off-chain tracking.

Assessed type

Other

Oracle returns stale price due to extended Chainlink Heartbeat

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.sol#L19

Vulnerability details

Summary

NOTE: I acknowledge that the Vault.sol is out of scope but the fact that it is imported and used inside contracts that are in scope, pretty much makes it a part of the scope.

The Vault contract utilizes a STALE_DATA_TIMEOUT constant set to 90 minutes (1.5hrs). This duration is 30 minutes longer than the Chainlink heartbeat that is 3600 seconds (1 hour), potentially introducing a significant delay in recognizing stale or outdated price data.

Proof of Concept

The assetPrice() returns the price of assets using the Chainlink oracle as follows:

  function assetPrice() 
    public 
    view 
    returns (uint) {
      (
        ,
        int256 answer,
        , 
        uint256 updatedAt, 
      ) = oracle.latestRoundData();
      if (block.timestamp > updatedAt + STALE_DATA_TIMEOUT) revert StaleData();
      return answer.toUint256();
  }

The function checks if the block.timestamp > updatedAt + STALE_DATA_TIMEOUT and reverts if this condition is true. Here is the STALE_DATA_TIMEOUT definition:

  uint public constant STALE_DATA_TIMEOUT = 90 minutes; 

The assetPrice() function is called and used in various instances across different contracts such as in:

  1. calculating the number of assets to withdraw in VaultManagerV2.redeemDyad()
  2. calculating value in VaultManagerV2.withdraw()

Impact

The Chainlink heartbeat indicates the expected frequency of updates from the oracle. The Chainlink heartbeat on Ethereum for Eth/Usd is 3600 seconds (1 hour).

---> (Refference)

But the defined STALE_DATA_TIMEOUT in the Vault contract is 90 minutes (1.5 hours).

A STALE_DATA_TIMEOUT that is longer than the heartbeat can lead to scenarios where the contract accepts data that may no longer reflect current market conditions increasing the risk of price slippage.

Tools Used

Manual Review

Recommended Mitigation Steps

Consider reducing the STALE_DATA_TIMEOUT to align more closely with the Chainlink heartbeat on Ethereum, enhancing the relevance of the price data.

Assessed type

Oracle

incorrect getKeroseneValue() implementation

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L269-L286

Vulnerability details

Impact

Function getKeroseneValue() is expected to calculate kerosene collateral value. Actual calculation includes exogenous collateral value.When we calculate getTotalUsdValue(), some assets might be calculated twice.

Proof of Concept

From Deploy.V2.s.sol, ethVault and wstEth are added into both vaultLicenser and kerosineManager. Then users can add ethVault and wstEth vault into both vaults[id] and vaultsKerosene[id].

  function add(
      uint    id,
      address vault
  ) 
    external
      isDNftOwner(id)
  {
    if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults();
    if (!vaultLicenser.isLicensed(vault))  revert VaultNotLicensed();
    if (!vaults[id].add(vault))            revert VaultAlreadyAdded();
    emit Added(id, vault);
  }

  function addKerosene(
      uint    id,
      address vault
  ) 
    external
      isDNftOwner(id)
  {
    if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults();
    if (!keroseneManager.isLicensed(vault))                 revert VaultNotLicensed();
    if (!vaultsKerosene[id].add(vault))                     revert VaultAlreadyAdded();
    emit Added(id, vault);
  }

When VaultManagerV2 tries to calculate getTotalUsdValue(), value for weth and wsteth will be calculated for twice. Because weth and wstvault exist both in vaults[id] and vaultsKerosene[id], they will be taken as nonkerosene and kerosene at the same time.

  function getTotalUsdValue(
    uint id
  ) 
    public 
    view
    returns (uint) {
      return getNonKeroseneValue(id) + getKeroseneValue(id);
  }

Tools Used

Manual

Recommended Mitigation Steps

For vaultsKerosene(), we need calculate vaults which belong to vaults, and not belong to vaultsKerosene. In current design, vaults includes all possible vaults and vaultsKerosene include exogenous vaults.

Assessed type

Error

the protocol doesnt handle fee on transfer tokens

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119-L130

Vulnerability details

Impact

in VaultManagerV2 when we deposit we transfer amount of the vault asset token from msg sender to the vault but if this asset token is fee on transfer the vault will get less than this amount but on the next line vault.deposit is called and the amount is added to the given id, so vault might have less tokens that it is added

Proof of Concept

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119-L130

Tools Used

Manual review

Recommended Mitigation Steps

handle fee on transfer tokens

Assessed type

ERC20

User can deposit in unlicensed vault

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119-L131

Vulnerability details

Impact

VaultManagerV2 doesnt check if the vault is licensed when deposit() is called
and this vault cannot be liquidated because when luidate() is called it takes the vault from vaults SET by id but such vault doesnt exist there

Proof of Concept

https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119-L131

Tools Used

Manual review

Recommended Mitigation Steps

when you want to use a Vault take it from vaults Set by given id

Assessed type

Error

Insufficient Balance Check in burnDyad Function of VaultManagerV2 Contract

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L172

Vulnerability details

Description

The burnDyad function in the VaultManagerV2 contract calls the burn method on the dyad token contract to destroy a specified amount of dyad tokens. The function assumes that the dyad.burn method will validate whether the msg.sender has an adequate balance of dyad tokens to burn. However, the VaultManagerV2 contract itself does not independently verify the msg.sender's token balance before calling burn. If the dyad contract's burn method lacks this validation, it could lead to transaction reverts or unintended behavior.

Impact

This could result in an underflow in the user's token balance if the Dyad token contract does not perform this check, potentially allowing users to burn tokens they do not own.

Proof of Concept

  1. User A has 50 Dyad tokens.
  2. User A calls burnDyad with amount 100.
  3. If the Dyad token contract doesn't check for sufficient balance, the burn proceeds.
  4. User A's Dyad token balance could underflow or revert based on the token contract's implementation.

Tools Used

manual review

Recommended Mitigation Steps

  1. Modify the burnDyad function to include a require statement that checks if msg.sender's balance is greater than or equal to the amount to be burned.
require(dyad.balanceOf(msg.sender) >= amount, "Insufficient balance to burn");

Assessed type

Invalid Validation

Incorrect precision MAX_VAULTS creates multiple issues

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L88

Vulnerability details

Impact

As the VaultManagerV2 contract only verify whether a specific vault address is licensed according to the data stored in the vaults data structure. If five vaults are sufficient for the project's needs, it's unnecessary to create ten, even though considering the price calculation. Specifically, the deposit and withdrawal transactions from the bounded vault could influence price calculations. Introducing an additional adjustment would add complexity and uncertainty. Therefore, it is better to align with the overall code style of the project, i.e., certainty, clarity and efficiency.

Proof of Concept

The KerosineManager::isLicensed function, available in the audit repo here, provides VaultManagerV2 contracts the necessary check of if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed() for addKerosene and getKeroseneValue functions.

VaultManagerV2::addKerosene#L87-89

  function addKerosene(
      uint    id,
      address vault
  ) 
    external
      isDNftOwner(id)
  {
// @audit check whether Kerosene vaults >= MAX_VAULTS_KEROSENE), isLicensed and then add them
if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults();
    if (!keroseneManager.isLicensed(vault))                 revert VaultNotLicensed();
    if (!vaultsKerosene[id].add(vault))                     revert VaultAlreadyAdded();
    emit Added(id, vault);

VaultManagerV2::getKeroseneValue#L280-282

  function getKeroseneValue(
    uint id
  ) 
    public 
    view
    returns (uint) {
      uint totalUsdValue;
      uint numberOfVaults = vaultsKerosene[id].length(); 
      for (uint i = 0; i < numberOfVaults; i++) {
        Vault vault = Vault(vaultsKerosene[id].at(i));
        uint usdValue;
// @audit check whether Kerosene vaults isLicensed and then get value
        if (keroseneManager.isLicensed(address(vault))) {
          usdValue = vault.getUsdValue(id);        
        }
        totalUsdValue += usdValue;
      }
      return totalUsdValue;
  }

The issue is that the VaultManagerV2 contracts expect the MAX_VAULTS_KEROSENE values to be 5. But the returned available MAX_VAULTS by the KerosineManager is actually 10.

VaultManagerV2::MAX_VAULTS_KEROSENE#L23

  uint public constant MAX_VAULTS_KEROSENE = 5;

KerosineManager::MAX_VAULTS#L14

uint public constant MAX_VAULTS = 10;

Recommended Mitigation Steps

KerosineManager::MAX_VAULTS#L14

-  uint public constant MAX_VAULTS = 10;
+  uint public constant MAX_VAULTS = 5;

Assessed type

Other

DOS attack targeting `VaultManagerV2::withdraw`.

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L131 https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L153

Vulnerability details

Impact

Detailed description of the impact of this finding.

Proof of Concept

Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.

Tools Used

Recommended Mitigation Steps

Assessed type

DoS

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.