DYAD - Jorgect's results

The first capital efficient overcollateralized stablecoin.

General Information

Platform: Code4rena

Start Date: 18/04/2024

Pot Size: $36,500 USDC

Total HM: 19

Participants: 183

Period: 7 days

Judge: Koolex

Id: 367

League: ETH

DYAD

Findings Distribution

Researcher Performance

Rank: 17/183

Findings: 7

Award: $513.93

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

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

Vulnerability details

Users can add and remove vaults through the add and remove functions.

 function add(uint256 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 remove(uint256 id, address vault) external isDNftOwner(id) {
        if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); <-------
        if (!vaults[id].remove(vault)) revert VaultNotAdded();
        emit Removed(id, vault);
    }

[Link]

The remove function is checking is the nft has balance associate in the vault to remove (see the arrow above).

The problem is that an attacker can deposit on behalf of any nft id, so attacker can front run the remove transaction of a user sending 1 wei to the same vault that user is trying to remove, successfully making revert the user transaction.

Impact

Remove function can be DOS to any honest user trying to remove a vault of his nft.

Proof of Concept

See the deposit function:

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

[Link]

As we can see the deposit functions is allowing any user(or attacker) to deposit in any vault and any nft id, the only validation associate with the nft is if the nft id is valid (see the first arrow above). see the vault deposit call (second arrow above):

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

[Link]

As you can see the deposit function is incrementing the id2asset[id] in the vault(see arrow above) same variable that have to be zero to remove an vault from a nft id.(see arrow above)


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

[Link]

So an attacker can:

  1. See the a honest remove transaction of a user.
  2. Front running this transaction sending a deposit transaction with more amount of gas than the remove transaction, the attacker has to pass the vault that honest user want to remove, the id of the nft and 1 wei of the asset into the deposit transaction.
  3. Remove transaction of the honest user revert.

Tools Used

Manual review.

The most straightforward solution is changing the isValidDNft(id) modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).

Assessed type

DoS

#0 - c4-pre-sort

2024-04-27T13:34:34Z

JustDravee marked the issue as duplicate of #489

#1 - c4-pre-sort

2024-04-29T09:29:52Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T20:38:19Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-05-05T20:39:24Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-05T20:39:57Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#5 - c4-judge

2024-05-05T21:44:11Z

koolexcrypto marked the issue as nullified

#6 - c4-judge

2024-05-05T21:44:17Z

koolexcrypto marked the issue as not nullified

#7 - c4-judge

2024-05-08T15:27:54Z

koolexcrypto marked the issue as duplicate of #1001

#8 - c4-judge

2024-05-11T19:49:45Z

koolexcrypto marked the issue as satisfactory

#9 - c4-judge

2024-05-13T18:34:30Z

koolexcrypto changed the severity to 3 (High Risk)

Findings Information

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_09_group
duplicate-930

Awards

238.0297 USDC - $238.03

External Links

Lines of code

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

Vulnerability details

User can deposit and withdraw from a vault through the deposit and withdraw functions

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

  /// @inheritdoc IVaultManager
  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(); 
  }

[Link]

As you can see in the arrows above users can just use deposit and withdraw function one time each block.number.

The problem is that an attacker can deposit on behalf of a nft setting the idToBlockOfLastDeposit[id] = block.number so he can see the withdraw function in the mempool and front run depositing one wei in any vault, this vault can be even a fake vault.

Impact

withdraw function can be complete DoS by an attacker stucking the funds of the user in the contract.

Proof of Concept

As you can see in the deposit function any user(or attacker) can deposit on any vault and set the idToBlockOfLastDeposit[id] variable to the last block.number (see the first arrow above):

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

  /// @inheritdoc IVaultManager
  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(); 
  }

[Link]

So an attacker can:

  1. See the a honest withdraw transaction of a user.
  2. Front run this transaction sending a deposit transaction with more amount of gas than the withdraw transaction, the attacker has to pass a vault(this vault can be a fake vault), the id of the nft and 1 wei of the asset into the deposit transaction.
  3. withdraw transaction of the honest user revert.

Tools Used

Manual review

The most straightforward solution is changing the isValidDNft(id) modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).

Assessed type

DoS

#0 - c4-pre-sort

2024-04-27T11:53:43Z

JustDravee marked the issue as duplicate of #489

#1 - c4-pre-sort

2024-04-29T09:30:01Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T20:38:18Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-05-05T20:39:23Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-05T20:39:27Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#5 - c4-judge

2024-05-05T21:41:13Z

koolexcrypto marked the issue as nullified

#6 - c4-judge

2024-05-05T21:41:17Z

koolexcrypto marked the issue as not nullified

#7 - c4-judge

2024-05-05T21:41:41Z

koolexcrypto marked the issue as not a duplicate

#8 - c4-judge

2024-05-05T21:41:50Z

koolexcrypto marked the issue as duplicate of #1266

#9 - c4-judge

2024-05-11T12:18:29Z

koolexcrypto marked the issue as satisfactory

#10 - c4-judge

2024-05-28T19:12:39Z

koolexcrypto marked the issue as duplicate of #930

Awards

32.4128 USDC - $32.41

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
edited-by-warden
:robot:_28_group
duplicate-397

External Links

Lines of code

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

Vulnerability details

There are two types of vault in the protocol kerosene(vaultsKerosene) and nonkerosene(vaults), the collateral allocate in nonkerosene behave as exogenous collateral which have to be at least equal to the dyad minted:

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

[Link]

The withdraw function is checking is user have enough exocollateral left in the NonKeroseneVault if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); the problem is that this value is substrate no matter is the collateral is keroseneVault or NonkeroseneVault.

Imagine that a user have exact quantity of getNonKeroseneValue(id) and dyadMinted(which is correct) when this user want to withdraw his kerosene vaults assets(supossing that his collatRatio is major than the minimum obviously) the transaction will revert because the check is resting the value to the nonKeroseneValue even if the collateral to withdraw is kerosene.

Impact

the kerosene collateral assets of users may get stuck in the contract.

Proof of Concept

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

[Link]

As you can see in the arrow above the function is checking is the user exocollateral is major or equal of the dyadminted, if not revert, the problem is that this condition is not checking if the value is from a NonkeroseneVault of keroseneVault.

See the next example:

  1. User have 100 usd in exocollateral (depositing in a nonKeoseneVault).
  2. User have 65 usd in kerosene Vaults (no matter the assets).
  3. User mint 100 dyad (min collateral ratio is 1.5 so is good).
  4. User want to withdraw 10 usd from his kerosene Vaults.(it can because his collateral ratio is still more than 1.5).
  5. withdraw Transaction revert because exocollateral is 100 subtracting the 10 to withdraw we get 90 this is less than the 100 dyad minted.

The user is trying to withdraw 10 dollar from his kerosene vault but he can not because the function is assuming that user is withdrawing exocollateral.

Tools Used

Consider subtract the value in getNonKeroseneValue(id) just is the collateral is in NonKeroseneVaults:

 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 (vaults[id].contains(vault){
    if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); } <-----
    _vault.withdraw(id, to, amount);
    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
  }

Assessed type

Other

#0 - c4-pre-sort

2024-04-26T21:18:30Z

JustDravee marked the issue as duplicate of #397

#1 - c4-pre-sort

2024-04-29T08:48:16Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-11T19:22:44Z

koolexcrypto marked the issue as satisfactory

#3 - c4-judge

2024-05-13T18:33:04Z

koolexcrypto changed the severity to 3 (High Risk)

Awards

7.3026 USDC - $7.30

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_97_group
duplicate-128

External Links

Lines of code

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

Vulnerability details

User have two type of vaults to deposit, kerosene and non kerosene each type have his own vaults to deposit for (vaults[id] for non kerosene and vaultsKerosene[id] for kerosene).

  mapping (uint => EnumerableSet.AddressSet) internal vaults; 
  mapping (uint => EnumerableSet.AddressSet) internal vaultsKerosene; 

[Link]

non Kerosene vaults are used as exocollateral, users need to have at least the same amount of nonKerosene of the minted dyad, the rest of the collateral can be collateral allocate in kerosene vaults because you need at least 1.5 of collateral for 1 unit of dyad:

 function mintDyad(
    uint    id,
    uint    amount,
    address to
  )
    external 
      isDNftOwner(id)
  {
    uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount;
    if (getNonKeroseneValue(id) < newDyadMinted)     revert NotEnoughExoCollat();<-------
    dyad.mint(id, to, amount);
    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); <------
    emit MintDyad(id, amount, to);
  }

[Link]

The problem is that liquidation function is just taking the collateral allocate in the vaults[id] (non kerosene vaults) but is missing taking the collateral from vaultsKerosene[id] this is so bad because liquidator are not taking any reward from this.

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

[Link]

Impact

Liquidator have not incentive to liquidate users, which mean bad debt, which mean that the protocol is unsustainable.

Proof of Concept

As you can see in the liquidate function the vaultsKerosene are not been taking in consideration when a user get liquidated which is a mistake because users may have his dyad backed for vaultsKerosene(kerosene vaults) and vaults(non kerosene vaults)

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

To explain this imagine the next scenario:

  1. User have 100 usd in exocollateral (depositing in a nonKeoseneVault).
  2. User have 50 usd in kerosene Vaults (no matter the assets).
  3. User mint 100 dyad (min collateral ratio is 1.5 so is good).
  4. The price of asset in kerosene vaults drop to 30 usd.
  5. User now can get liquidated.

At this point we get that user have 100 usd in non kerosene vaults, 30 usd in kerosene vaults, and 100 dyad minted, if liquidator liquidate this position he has to liquidate the 100 dyad and get rewarded just the collaterals in the vaults[id] which is just 100 usd, liquidator liquidate 100 dyad and get 100 usd so instead of the liquidator get rewarded they just lost his gas.

in practice there is a liquidationAssetShare that is multiplied by the amount that liquidatee has in the nft id so the rewarded amount for liquidator is even lower.

Tools Used

Manual

Take the collateral in vaultsKerosene[id] variable

Assessed type

Error

#0 - c4-pre-sort

2024-04-28T10:22:40Z

JustDravee marked the issue as duplicate of #128

#1 - c4-pre-sort

2024-04-29T09:03:33Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-11T19:39:29Z

koolexcrypto marked the issue as satisfactory

Awards

7.3512 USDC - $7.35

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
:robot:_39_group
duplicate-118

External Links

Lines of code

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

Vulnerability details

Users can add and remove kerosene vaults through the addKerosene and removeKerosene functions.

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

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

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

The removeKerosene function is checking is the nft has balance associate in the vault to remove (see the arrow above).

The problem is that an attacker can deposit on behalf of any nft id, so attacker can front run the remove transaction of a user sending 1 wei to the same vault that user is trying to remove, successfully making revert the user transaction.

Impact

removeKerosene function can be DOS to any honest user trying to remove a vault of his nft.

Proof of Concept

See the deposit function:

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

[Link]

As we can see the deposit functions is allowing any user(or attacker) to deposit in any vault and any nft id, the only validation associate with the nft is if the nft id is valid (see the first arrow above). see the vault deposit call (second arrow above):

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

[Link]

As you can see the deposit function is incrementing the id2asset[id] in the vault(see arrow above) same variable that have to be zero to remove an vault from a nft id.(see arrow above)


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

[Link]

So an attacker can:

  1. See the a honest remove transaction of a user.
  2. Front running this transaction sending a deposit transaction with more amount of gas than the remove transaction, the attacker has to pass the vault that honest user want to remove, the id of the nft and 1 wei of the asset into the deposit transaction.
  3. Remove transaction of the honest user revert.

Tools Used

Manual review.

The most straightforward solution is changing the isValidDNft(id) modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).

Assessed type

DoS

#0 - c4-pre-sort

2024-04-27T13:34:39Z

JustDravee marked the issue as duplicate of #489

#1 - c4-pre-sort

2024-04-29T09:29:56Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T20:38:19Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#3 - c4-judge

2024-05-05T20:39:24Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-05T20:39:39Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#5 - c4-judge

2024-05-05T21:43:21Z

koolexcrypto marked the issue as nullified

#6 - c4-judge

2024-05-05T21:43:26Z

koolexcrypto marked the issue as not nullified

#7 - c4-judge

2024-05-05T21:43:32Z

koolexcrypto marked the issue as not a duplicate

#8 - c4-judge

2024-05-06T08:54:22Z

koolexcrypto marked the issue as duplicate of #118

#9 - c4-judge

2024-05-11T12:24:02Z

koolexcrypto marked the issue as satisfactory

Findings Information

🌟 Selected for report: Pataroff

Also found by: Egis_Security, Evo, Jorgect, MrPotatoMagic, SBSecurity, T1MOH, carrotsmuggler, ljj

Labels

bug
2 (Med Risk)
satisfactory
sufficient quality report
:robot:_74_group
duplicate-100

Awards

223.9474 USDC - $223.95

External Links

Lines of code

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

Vulnerability details

The vaultManagerV2 is exposing a burnDay and redeemDay functions to burn their dyad and clear his debt.


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

  /// @inheritdoc IVaultManager
  function redeemDyad(
    uint    id,
    address vault,
    uint    amount,
    address to
  )
    external 
      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;
  }

The problem is that burn function can be called by anyone for any id, so honest user trying to burn all his dyad or redeem his dyad, attacker can repay just one wei 1 and make revert for overflow the transaction.

Note that this jus work for transactions that burn all the dyad that a id have, the DoS work because the Dyad contract is maintaining a state of the minted dyad for id:

function burn(
      uint    id, 
      address from,
      uint    amount
  ) external 
      licensedVaultManager 
    {
      _burn(from, amount);
      mintedDyad[msg.sender][id] -= amount;  <------ 
  }

Impact

burnDyad and redeemDyad can be D.o.S when user trying to repaid all his dyad, that this can be de case in much cases.

Proof of Concept

The burn function in the dyad contract is maintaining the above state(see the arrow):

function burn(
      uint    id, 
      address from,
      uint    amount
  ) external 
      licensedVaultManager 
    {
      _burn(from, amount);
      mintedDyad[msg.sender][id] -= amount;  <------ 
  }

If someone try to burn a amount major than mintedDyad[msg.sender][id] the execution will revert for overflow.

Now see the next scenario:

  1. User have 100 dyad minted.
  2. He wants to burn the 100 dyad to get his collateral
  3. User send a transaction trying to burn his 100 dyad in the vault manager(this transaction could be burnDyad or redeemDyad ).
  4. Atacker see this transaction and send burnDyad with more gas than the user burning just 1 wei of dyad on behalf of a id.
  5. Transaction of the honest user revert for overflow becuase the amount that he passed is major than the minted day that he have because an attacker burn one wei.
  6. Now honest user try to burn 100 dyad - 1 wei, attacker can do the same again, a user then have to burn some amount and let dust in the protocol.

Tools Used

Manual review

The most straightforward solution is change isValidDNft(id) for isDNftOwner(id) in the burnDyad function.

Assessed type

DoS

#0 - c4-pre-sort

2024-04-28T05:29:40Z

JustDravee marked the issue as duplicate of #74

#1 - c4-pre-sort

2024-04-29T11:58:29Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-10T10:12:25Z

koolexcrypto marked the issue as not a duplicate

#3 - c4-judge

2024-05-10T10:12:50Z

koolexcrypto marked the issue as primary issue

#4 - c4-judge

2024-05-10T10:15:27Z

koolexcrypto marked the issue as satisfactory

#5 - c4-judge

2024-05-10T10:15:35Z

koolexcrypto marked the issue as selected for report

#6 - 0xNentoR

2024-05-16T04:09:18Z

Hi @koolexcrypto,

It's assumed that the burn() function can be called by anyone. I believe this assumption is wrong:

VaultManagerV2.sol:

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

Dyad.sol:

  function burn(
      uint    id, 
      address from,
      uint    amount
  ) external 
      licensedVaultManager 
    {
      _burn(from, amount);
      mintedDyad[msg.sender][id] -= amount;
  }

You can see the call that is made to Dyad::burn() always uses the msg.sender as the address to burn from. And it's not possible to circumvent that from VaultManagerV2. It's also not possible to call directly Dyad::burn() because it expects to have a licensed vault as its caller:

  modifier licensedVaultManager() {
    if (!licenser.isLicensed(msg.sender)) revert NotLicensed();
    _;
  }

#7 - PetarTolev

2024-05-16T09:00:57Z

Hi @koolexcrypto,

The burn() function can be called by anyone as it is missing an isDNftOwner modifier. @0xNentoR is stating that "Dyad::burn always uses msg.sender as the address to burn from", but msg.sender that is being passed to the mintedDyad mapping inside the Dyad::burn function represents the address of the VaultManagerV2, which is always licensed, effectively bypassing the licensedVaultManager modifier.

  // vault manager => (dNFT ID => dyad)
  mapping (address => mapping (uint => uint)) public mintedDyad; 

I think the confusion stems from the fact that we are passing a msg.sender as a second argument to the Dyad::burn function.

    function burnDyad(uint256 id, uint256 amount) external isValidDNft(id) {
@>      dyad.burn(id, msg.sender, amount);
        emit BurnDyad(id, amount, msg.sender);
    }

However, the msg.sender that is being passed represents the from address, which is the address of the caller of VaultManagerV2::burnDyad function.

function burn(
      uint    id, 
@>    address from,
      uint    amount
  ) external 
      licensedVaultManager 
    {
      _burn(from, amount);
      mintedDyad[msg.sender][id] -= amount;
  }

Due to VaultManagerV2::burnDyad making an external call to the Dyad contract, the msg.sender inside Dyad::burn represents the address of the VaultManager contract, which is licensed vault.

The first line in the implementation logic of Dyad::burn is the one responsible for burning the user tokens from his address.

_burn(from, amount);

The second one is responsible for burning the user tokens from the internal accounting of the vault.

mintedDyad[msg.sender][id] -= amount;

I believe this issue is a duplicate of 100, which has a greater impact due to creation of bad debt that cannot be liquidated nor redeemed. Additionally, it describes a further exploitation of the vulnerability, causing an user to be able to clear DYAD debt from another position while retaining the DYAD balance associated with it, effectively tricking the protocol into allowing him to mint more DYAD, as the position no longer has DYAD debt according to the internal accounting of the vault.

#8 - 0xNentoR

2024-05-16T09:38:16Z

@PetarTolev You're correct actually. Seems like I overlooked this. It burns the Dyad of the malicious caller but the mintedDyad mapping counts it towards the NFT holder. The DOS here can be utilized together with the DOS for deposit() to prevent a user from keeping their collateral ratio in the healthy range.

#9 - adamidarrha

2024-05-16T10:11:40Z

This issue should be classified as low/QA. According to code4rena docs, a medium classification requires a significant impact on protocol functionality or availability, which is not the case here since the issue can be easily avoided:

  1. The issue is confined to the burn function and does not affect the redeem function, which is exclusive to the DNFT owner.

  2. The attacker effectively repays a small portion of the victim's debt. The victim can easily circumvent this by resubmitting the transaction, burning slightly less than 100% of their debt. For example, if the user burns 90%, the attacker must repay 10% plus 1 wei for the transaction to revert.

  3. it is different than the other two DOS issues , in which this can just be avoided entirely by burning less than 100%. the other two cannot be avoided.

#10 - shafu0x

2024-05-16T20:12:52Z

All of these issues should be fixed by making burnDyad gated by the isDNftOwner modifier

#11 - c4-judge

2024-05-28T10:29:46Z

koolexcrypto marked the issue as duplicate of #100

#12 - c4-judge

2024-05-28T10:30:07Z

koolexcrypto marked the issue as not selected for report

#13 - koolexcrypto

2024-05-28T10:31:12Z

This is a valid medium issue, #100 is selected for report as it demonstrates a further impact.

#14 - koolexcrypto

2024-05-28T10:36:27Z

The issue is confined to the burn function and does not affect the redeem function, which is exclusive to the DNFT owner.

redeem depends on burn, so if you DoS burn, redeem follows.


The attacker effectively repays a small portion of the victim's debt. The victim can easily circumvent this by resubmitting the transaction, burning slightly less than 100% of their debt. For example, if the user burns 90%, the attacker must repay 10% plus 1 wei for the transaction to revert. it is different than the other two DOS issues , in which this can just be avoided entirely by burning less than 100%. the other two cannot be avoided.

Still a DoS, the attacker prevents the target from burning their full DYAD. Also 1 WEI is just an example, it can be more.

Awards

4.8719 USDC - $4.87

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
:robot:_67_group
duplicate-67

External Links

Lines of code

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

Vulnerability details

UnboundedKerosineVault is a vault that the main asset is kerosene token, this could be checked in the deploy script:

UnboundedKerosineVault unboundedKerosineVault = new UnboundedKerosineVault(
      vaultManager,
      Kerosine(MAINNET_KEROSENE), 
      Dyad    (MAINNET_DYAD),
      kerosineManager
    );

This unboundedKerosineVault from part of the Licenser vaults so users can uses them to borrow dyad. The problem is that the asset price of this unboundedKerosineVault is relaying in a balanceOf the kerosineManager vaults which are easily manipulable:

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

[Link]

Impact

Asset price of unboundedKerosineVault are easily manipulable and attacker can exploit this minting a lot Dyad possibly draining the contract and letting it with bad debt.

Proof of Concept

The assetPrice function is relaying in a vault.asset().balanceOf(address(vault)) which is easy manipulable, since the protocol is not allowing deposit and withdraw in the same block.number attacker can not use a flash loan but he can use his own liquidity (there are plenty of attacker with bunch of liquidity to perform hacks):

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

[Link]

To manipulate the price attacker just have to:

  1. Deposit kerosene in the unboundedKerosineVault to later mint against them.
  2. Deposit a lot liquidity for a kerosene vault (at this moment kerosene vault are ethVault and wstEth).
  3. Mint Dyad with a manipulated unboundedKerosineVault.
  4. withdraw his liquidity in the next block.number from the kerosene vault.

Tools Used

Manual

balanceOf are susceptible to this kind of manipulation consider implement another mechanism to prices. Other option is lock the liqudity on a vault for more time.

Assessed type

Other

#0 - c4-pre-sort

2024-04-28T05:50:48Z

JustDravee marked the issue as duplicate of #67

#1 - c4-pre-sort

2024-04-29T09:06:15Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-05T09:59:12Z

koolexcrypto changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-05-08T11:50:06Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-08T12:51:59Z

koolexcrypto marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter