xanoutas/solidity-security-findings huggingface dataset:
attacker can steal user funds by reentrancy attack in refundSpan()'s token transfer calls
Audit: 2023-01-reserve-findings
Severity: MEDIUM

# Lines of code

https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L655-L714
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L532-L544
https://github.com/reserve-protocol/protocol/blob/df7ecadc2bae74244ace5e8b39e94bc992903158/contracts/p1/RToken.sol#L186-L280


# Vulnerability details

## Impact
Function `refundSpan()` loops through tokens and transfer them to caller and if one of tokens were ERC777 or any other 3rd party protocol token with hook, attacker can perform reentrancy attack during token transfers. Attacker can cause multiple impacts by choosing the reentrancy function:
1. in one scenario because code uses queue's ERC20 token list from storage attacker can change list during the token transfers and steal users funds, to do this attacker can cancel all his vesting issuance which is for old basket nonce and during the `refundSpan()`'s token transfer external calls, attacker can perform reentrancy and call issue() and set new basket nonce (because queue is empty) and erc20 list for queue and then `refundSpan()` would transfer another tokens to attacker in the rest of the execution as it reads ERC20 tokens from storage. and attacker would steal other users funds.
2. in one scenario because `refundSpan()` sets the value of the `liabilities[]` but during the token transfers, the balance of the tokens didn't get updated so attacker can call `sweepRewards()` by reentrancy, and make contract send collateral vesting tokens as rewards to BackingManager and some users deposits would be lost by this.

## Proof of Concept
Function `refundSpan()` refunds all the deposits in the specified span. This is `refundSpan()` code:
```
    function refundSpan(
        address account,
        uint256 left,
        uint256 right
    ) private {
        if (left >= right) return; // refund an empty span

        IssueQueue storage queue = issueQueues[account];

        // compute total deposits to refund
        uint256 tokensLen = queue.tokens.length;
        uint256[] memory amt = new uint256[](tokensLen);
        uint256 amtRToken; // {qRTok}
        IssueItem storage rightItem = queue.items[right - 1];

        // compute item(right-1) - item(left-1)
        // we could dedup this logic for the zero item, but it would take more SLOADS
        if (left == 0) {
            amtRToken = rightItem.amtRToken;
            for (uint256 i = 0; i < tokensLen; ++i) {
                amt[i] = rightItem.deposits[i];

                // Decrement liabilities
                liabilities[IERC20(queue.tokens[i])] -= amt[i];
            }
        } else {
            IssueItem storage leftItem = queue.items[left - 1];
            amtRToken = rightItem.amtRToken - leftItem.amtRToken;
            for (uint256 i = 0; i < tokensLen; ++i) {
                amt[i] = rightItem.deposits[i] - leftItem.deposits[i];

                // Decrement liabilities
Kerosene once deposited into the vaults cannot be withdrawn.
Audit: 2024-04-dyad-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L148


# Vulnerability details

Deposited kerosine cannot be withdrawn from the vaults because of an issue in the `VaultManagerV2::withdraw(...)` function.

## Impact

The `VaultManagerV2::withdraw(...)` function facilitates token withdrawal from the vaults. This function relies on reading certain states from the vault contracts:

```solidity
File: VaultManagerV2.sol

  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(); 
  }

```

GitHub: [[134-154](https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L134C1-L154C1)]

However, an issue arises when attempting to withdraw from Unbounded kerosene vaults due to the absence of an `oracle` variable in these vaults. Consequently, users will encounter an inability to withdraw any deposited kerosene from these vaults, resulting in the loss of tokens.

Additionally, if the changes has been made in the vaults contract to rectify the issue, the function will still cause problems as the value to be withdrawn from the contract is checked against the non kerosene collateral value here:

```solidity
File: VaultManagerV2.sol

  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(); 
  }

```

So, if there is no non kerosene collateral then, this will give overflow/underflow error.


## Proof of Concept

The below given test will not work as there is an issue in the `VaultManager::addKerosene(...)` function. Before running the tests make the following changes:

```diff
  function addKerosene(
      uint    id,
No incentives for liquidation of small or not profitable positions which can trigger liquidity crisis
Audit: 2024-04-dyad-findings
Severity: HIGH

# Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L215
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L224-L225


# Vulnerability details

## Impact
Two similar but different issues are being discussed in this finding. The first one  is that the protocol allows users to mint Dyad without any lower limit. Small positions like this can accumulate fast. And the second one is that liquidators will not engage in positions where the usd collateral value is smaller or close the the dyad loan value + any gas costs. The latter can happen frequently since a liquidator cannot chose to liquidate a specific amount but the whole position only. Also, the second issue concerns any position not just smaller ones. Unliquidatable positions like these can quickly accumulate and create a liquidity crisis event.

This makes the impact high and the likelihood of the event medium to high as well.

## Proof of Concept
- A position could exist with a very low collateral value. This user is undercollateralized and must be liquidated in order to ensure that the protocol remains overcollateralized. If a liquidator wishes to liquidate this user, they will first need to stake some collateral (weth or other) which involves gas cost. Because the value of the collateral is so low, after gas costs, liquidators will not make a profit. In the end these low value vaults will never get liquidated, leaving the protocol with bad debt. Also malicious users could knowingly creates multiple small positions in order to attack the protocol and depeg the Dyad token.

- The function `liquidate` expects the liquidator to fully liquidate a position, even if it's not economically viable. The absence of partial liquidation and loan interests could deter liquidators from engaging with less profitable positions. This particular issue regards all sizes small, medium and larges positions. An accumulation of these bad debts can actually trigger a liquidity crisis.

In both of these cases, the rule is simple, liquidators will only liquidate a position if :

`vaultsAssets * liquidationAssetShare > mintedDyadLoan + Gas costs`

By default any position with a `CR=100%` or less will not be liquidated.

Not having the flexibity of partially liquidating a position so that a non profitable position becomes profitable increases the risk of bad debt. 
Similarly, not having any interest payments also participates in the lack of incentives. As increasing interests worsens `CR` and increases incentives.



## Tools Used

Manual review

## Recommended Mitigation Steps

Consider implementing the following changes to significantly minimize bad debt :

- Introduce partial liquidations
- Introduce interest payements that increase as the time passes
- Set a minimum threshold for collateral value which has to be exceeded in order for
Upgraded Q -> 2 from #758 [1715598671095]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #758 as 2 risk. The relevant finding follows:

 Low -05 In normal conditions, well-collateralized users might not be able to withdraw kerosine deposits
Instances(1) User who deposited both kerosine and non-kerosine collaterals, minted dyad and well over collateralized might not be able to withdraw kerosine.

In VaultMangerV2::withdraw, there is a check if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();

However, it’s problematic to directly subtract withdrawal value from getNonKeroseneValue(id) without checking whether the withdrawal asset is kerosine or non-kerosene.

If the user is withdrawing kerosine asset then, whenever a user has greater kerosine collaterals than non-kerosene collaterals, this will trigger reverts due to underflow even though the user can be well over-collateralized.

Recommendations: Add a check to see whether vault is a kerosine vault. Only subtract value from getNonKeroseneValue(id) when checking first that value is non-kerosene assets.
Upgraded Q -> 2 from #1288 [1715598063843]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #1288 as 2 risk. The relevant finding follows:

 [L-03] withdraw() function does not consider kerosene value
Kerosene value is not considered here in the check above the withdraw() call on the vault contract. This is problematic for kerosene vaults because getNonKeroseneValue() for users only using kerosene vaults would lead to revert due to 0 - non-zero amount occurring.

  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(); 
  }
Upgraded Q -> 2 from #902 [1715425359077]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #902 as 2 risk. The relevant finding follows:

 [L-1] Possible underflow in 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;
}
The following line might cause an underflow error to occur if the DYAD minted is more than the TVL:

uint numerator = tvl - dyad.totalSupply();
This might happen in the case where a large position was liquidated and the DYAD is still held by the liquidated holder.
Upgraded Q -> 2 from #1206 [1715425266818]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #1206 as 2 risk. The relevant finding follows:

 [LOW-01] - No possibility for the user to withdraw funds
Impact
While using the withdrawal function, there is a check that “Withdrawal should not be in the same block as deposit”.

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(); 
  }
the deposit() function sets the block.number

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);
  }
so the attacker can browse the mempool or run a loop for each block by specifying someone else’s token id and passing amount=0, thus preventing the attacker from withdrawing funds to the user.

Proof of Concept
function testDeposit() public {
        deal(alice, 1 ether);
        uint256 id = contracts.vaultManager.dNft().mintNft{value: 1 ether}(
            alice
        );
        assertEq(alice, contracts.vaultManager.dNft().ownerOf(644));

        vm.prank(attacker);

        contracts.vaultManager.deposit(644, address(contracts.ethVault), 0);
        vm.prank(alice);

        contracts.vaultManager.withdraw(
            644,
            address(contracts.ethVault),
            10,
            alice
        );
    }
Failing tests:
Encountered 1 failing test in test/fork/v2.t.sol:V2Test
[FAIL. Reason: DepositedInSameBlock()] testDeposit() (gas: 213595)
Tools Used
forge

Recommended Mitigation Steps
Сonsider adding the isDNftOwner(uint id) modifier to the deposit() function
Upgraded Q -> 2 from #389 [1715340608138]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #389 as 2 risk. The relevant finding follows:

 15. Protection against kerosine price manipulation can potentially be bypassed
Links to affected code * https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L205

Impact
The vault manager implements a flashloan protection to prevent users from depositing and withdrawing in the same block. This is done to prevent kerosene price manipulation.This protection can be bypassed by users depositing and liquidating (possibly to self) in one block to manipulate kerosine prices. The process only involves making sure their NFT is at a state at which it can be liquidated, depositing just enough to still put the NFT at a liquidatable state, but not enough to take it out and in the same block liquidating the NFT, to another NFT they own or are in cohorts with. At its core, the liquidate function acts like the a withdraw and deposit function due to move which reduces amoount from one nft and moves it to another. So by aggregating a multicall involving deposiing to an nft A in a liquitable state (depositing enough to keep it in that state), liquidating nft A to another nft B and withdrawing from nft B, all in one block, a user can successfully manipulate kerosene token prices.

Recommended Mitigation Steps
Include the same check against withdrawal in the same block in the liquidate function.
Upgraded Q -> 2 from #615 [1715339973059]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #615 as 2 risk. The relevant finding follows:

 [01] Flash loan attack may be possible to be used to liquidate users
The price of kerosine is directly linked with the degree of overcollateralization.

It increases when:

Collateral is deposited in vaults
DYAD is burned
dNFT positions are liquidated
And decreases when:

Collateral is withdrawn / DYAD is redeemed
DYAD is minted
Lets assume the protocol is running at an average collateral ratio (CR) of 170%. Bob deposits into his vault at a ratio of 160%, a bit above the minimum ratio of 150%. His collateral consists of 60% kerosine and 100% exogenous collateral.

Alice wishes to liquidate Bob. She takes out a flash loan of kerosine and a exogenous collateral, for example, wstETH. She deposits wstETH and kerosine into vaults, with 100% wstETH and 50% kerosine. She then mints as much DYAD as she can, which is equal to 100% of her collateral (all of her wstETH).

As she minted DYAD at a CR of 150%, which is below the average CR of 170%, this decreases the average CR, to lets say, 165%. As the price of kerosine is directly linked to how much overcollateralization there is, the price of kerosine decreases. Bob’s 60% kerosine as collateral drops to <50%, allowing him to be liquidated. Alice liquidates bob, transferring his collateral over into her own dNFT position. She uses this to again, mint DYAD using Bob’s collateral, and repays the kerosine debt by swapping the minted DYAD to kerosine.

This situation is very specific in terms of constraints required, therefore is unlikely to occur, but I think it is still a scenario to consider.

Recommended mitigations would be to disallow liquidations in the same block (or same transaction) as a deposit.
Upgraded Q -> 2 from #858 [1715338884985]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #858 as 2 risk. The relevant finding follows:

 [L-02] DNfts can still deposit unbounded kerosine when TVL is less than Dyad total supply, in which the unbounded kerosine tokens become unwithdrawable for an undefined amount of time
Impact
The asset price of unbounded kerosene tokens depends on the total USD value of all exogenous collateral in the protocol (TVL) as seen by the UnboundedKerosineVault::assetPrice function:

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;
}
DNfts can deposit unbounded or bounded kerosine tokens as collateral by calling VaultManagerV2::deposit. The issue is that the function does not prevent DNfts from depositing unbounded Kerosine tokens when the TVL value goes below the total Dyad supply. In such situation, DNfts can not withdraw their unbounded kerosene or redeem their dyad tokens because both functions call UnboundedKerosineVault::assetPrice, and assetPrice will revert if TVL is less than dyad’s total supply because of underflow:

uint numerator   = tvl - dyad.totalSupply();
This issue will be more likely to happen a lot after the upgrade using the deploy script, as both ethVault and wstEth vaults will be newly deployed and the total supply of dyad is already 632967400000000000000000.

The amount of time for the TVL value to become more than dyad’s total supply(and similarly, the amount time for DNfts to be able to withdraw their unbounded kerosene tokens) is undefined and can not be known, as it depends on the total USD value of all exogenous collateral in the protocol.

In addition, when the TVL value is lower than dyad’s total supply, it is more secure to prevent any deposits of unbounded tokens as the value of these tokens themselves depends strongly on TVL being greater than dyad’s total supply.

Recommended Mitigation Steps
Consider preventing deposits of unbounded tokens when tvl < dyad.totalSupply()
Upgraded Q -> 2 from #1211 [1715337724872]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #1211 as 2 risk. The relevant finding follows:

 [L-01] No partial repayment option will be disadvantageous for the protocol. Whales can potentially not get liquidated at all.
When a user’s collateral ratio is below 150%, the user can be liquidated. The liquidator will burn all the dyad minted in exchange for collateral funds.

  /// @inheritdoc IVaultManager
  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));
The issue is that the liquidator MUST burn all the dyad that the user had minted. If the user is a whale, and has minted 1 million dyad worth ~1M, only other whales can liquidate him because most people don’t have the capital to do so.

The other whales is not incentivize to liquidate also since if whales are starting to fall below the collateral ratio, it means that there is a flash crash happening to the price of the collateral, which will drive the price of DYAD down.

For whales to liquidate other whales, they have to bear the burden of having ~1M DYAD that they need to burn in order to get back their original collateral. Having this debt in a time of flash crashes is not a good idea.

Consider having partial liquidation so that users with smaller balances can still liquidate without needed to burn so much DYAD.

https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L205
Upgraded Q -> 2 from #1288 [1715337234064]
Audit: 2024-04-dyad-findings
Severity: HIGH

Judge has assessed an item in Issue #1288 as 2 risk. The relevant finding follows:

 [L-03] withdraw() function does not consider kerosene value
Kerosene value is not considered here in the check above the withdraw() call on the vault contract. This is problematic for kerosene vaults because getNonKeroseneValue() for users only using kerosene vaults would lead to revert due to 0 - non-zero amount occurring.

  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(); 
  }