DYAD - Infect3d'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: 29/183

Findings: 7

Award: $395.74

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L250-L286 https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L95

Vulnerability details

Vulnerability details

DYAD protocol allow users to deposit collateral and mint a dollar-pegged asset (DYAD) against it. Two types of vaults are proposed:

  • exogenous vaults, where users can deposit assets external to the protocol (like WETH, wstETH, ...)
  • kerosine vaults, where user can deposit the endogenous token from the protocol, Kerosine

The issue is that right now, the deploy script declare WETH and wstETH vaults as both being exogenous and kerosine vaults. Also, unboundedKerosineVault is licensed in Licenser while it should be in KerosineManager This completely mess-up the collatral accounting.

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L64-L65

File: script/deploy/Deploy.V2.s.sol
61: 
62:     KerosineManager kerosineManager = new KerosineManager();
63: 
64:❌	kerosineManager.add(address(ethVault)); //<@audit: should not be added in KerosineManager
65:❌	kerosineManager.add(address(wstEth));	//...

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L95

File: script/deploy/Deploy.V2.s.sol
93:     vaultLicenser.add(address(ethVault));
94:     vaultLicenser.add(address(wstEth));
95:❌	vaultLicenser.add(address(unboundedKerosineVault)); //<@audit: should be added in KerosineManager, not Licenser
96:     // vaultLicenser.add(address(boundedKerosineVault));

This also make it possible for a user to add these vaults both to their vaults and vaultsKerosene enumerableSet.

Which means that when collateral ratio will be estimated, user WETH and wstETH deposit will be counted as Kerosene and non-Kerosine value, effectively double-counting this collateral value:

File: src/core/VaultManagerV2.sol
230:   function collatRatio(
231:     uint id
232:   )
233:     public 
234:     view
235:     returns (uint) {
236:       uint _dyad = dyad.mintedDyad(address(this), id);
237:       if (_dyad == 0) return type(uint).max;
238:       return getTotalUsdValue(id).divWadDown(_dyad);
239:   }
240: 
241:   function getTotalUsdValue(
242:     uint id
243:   ) 
244:     public 
245:     view
246:     returns (uint) {
247:       return getNonKeroseneValue(id) + getKeroseneValue(id); //<@audit: actual deployment script will cause issue here
248:   }

Impact

Loss of funds, user will be able to inflate their collateral value and thus mint more DYAD than system should allow

Tools Used

Manual review

The following diff will not be sufficient to make the whole system function correctly, as there are an issue with how Kerosine price calculate TVL. Right now, the KerosineManager vaults set holds two functions:

  • list licensed kerosine vault
  • list assets that are part of the TVL

These two functions have no link, and shouldn't be mixed in the same set. Sponsor also refactor this part and separate this as two sets.

File: script/deploy/Deploy.V2.s.sol:64

    KerosineManager kerosineManager = new KerosineManager();

-   kerosineManager.add(address(ethVault));
-   kerosineManager.add(address(wstEth));
    vaultManager.setKeroseneManager(kerosineManager);

    kerosineManager.transferOwnership(MAINNET_OWNER);

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

    BoundedKerosineVault boundedKerosineVault     = new BoundedKerosineVault(
      vaultManager,
      Kerosine(MAINNET_KEROSENE), 
      kerosineManager
    );

    KerosineDenominator kerosineDenominator       = new KerosineDenominator(
      Kerosine(MAINNET_KEROSENE)
    );

    unboundedKerosineVault.setDenominator(kerosineDenominator);

    unboundedKerosineVault.transferOwnership(MAINNET_OWNER);
    boundedKerosineVault.  transferOwnership(MAINNET_OWNER);

    vaultLicenser.add(address(ethVault));
    vaultLicenser.add(address(wstEth));
-   vaultLicenser.add(address(unboundedKerosineVault));
+   kerosineManager.add(address(unboundedKerosineVault));
-   // vaultLicenser.add(address(boundedKerosineVault));
+   kerosineManager.add(address(boundedKerosineVault));

Assessed type

Context

#0 - c4-pre-sort

2024-04-28T06:57:41Z

JustDravee marked the issue as duplicate of #152

#1 - c4-pre-sort

2024-04-28T06:58:34Z

JustDravee marked the issue as not a duplicate

#2 - c4-pre-sort

2024-04-28T06:58:38Z

JustDravee marked the issue as primary issue

#3 - c4-pre-sort

2024-04-28T07:04:10Z

JustDravee marked the issue as high quality report

#4 - shafu0x

2024-04-30T11:27:47Z

Unfortunately the role of the KeroseneManager is completely misunderstood here.

#5 - c4-judge

2024-05-04T09:46:33Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#6 - InfectedIsm

2024-05-15T22:00:37Z

Hi @koolexcrypto, I don't understand the sponsor comment here. This is a matter of fact that the actual deployment script will cause collateral to be double counted. The role of KerosineManager is not documented, not correctly implemented, and don't have anything to do with this finding.

#7 - McCoady

2024-05-16T01:06:36Z

Agreeing with the above, the "correct" implementation of KeroseneManager is that it will be a list of valid exogenous collateral sources (WETH, WSTETH), while VaultLicenser will include both exogenous(ETH) and endogenous(Kerosene) collateral sources. This means that a user would be able to successfully add their WETH/WSTETH vaults by calling add and addKerosene creating the double spend:

  function add(
      uint    id,
      address vault
  ) 
    external
      isDNftOwner(id)
  {
    if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults();
>   if (!vaultLicenser.isLicensed(vault))  revert VaultNotLicensed(); // NOTE: WETH/WSTETH will not revert here
    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(); // NOTE: WETH/WSETH will not revert here
    if (!vaultsKerosene[id].add(vault))                     revert VaultAlreadyAdded();
    emit Added(id, vault);
  }

#8 - AtanasDimulski

2024-05-16T06:46:42Z

I agree with the above comments and believe this issue should be judged as a valid high, due to the fact that the Deploy.V2.s.sol file is in the scope of this audit. And the deployment steps in it, are exactly what makes this issue possible and so detrimental for the protocol.

#9 - T1MOH593

2024-05-16T11:11:02Z

Maybe it's duplicate of #70

#10 - Al-Qa-qa

2024-05-17T04:21:32Z

From what the Deployment Script was written, NonKerosine holds all Collaterals and Kerosine holds Kerosine collaterals only.

IDK who such a thing was not documented, I said that the deployment is true for this point. which makes me miss more than one issue as I thought NonKerosineVaults hold all vaults.

If This is a deployment script error, then it should be labeled as HIGH as.

  • script is in-scope
  • THe impact is HIGH

And for issue #70 it is not a duplicate for it as it is a different issue, with different line, different impact, different root cause, and different mitigation.

#11 - ych18

2024-05-17T07:52:12Z

Many warden pointed to this issue. The error is in the deployment script as ethVault & wstEth are both licensed to KeroseneManager and to Licenser. This would make an attacker to add both of this vault as a normal vault and KeroseneVault. As a result, the rule of 150% collateral is no more respected. Other similar issue : #1130 #124

#12 - Emedudu

2024-05-17T11:31:52Z

Here are similar issues: #1142 #1130 #464 #243 #220 #124

#13 - Maroutis

2024-05-17T19:05:39Z

I believe the assumption made by this submission that the script is faulty is wrong. The mitigation given is also wrong and would lead to problems. However, there is an issue. Explained in #1133.

#14 - InfectedIsm

2024-05-28T15:10:48Z

Hi @koolexcrypto, I see my submission #966 has been updated to unsatisfactory based on @Maroutis comments, which made multiple wrong assumptions about my submission.

First claim:

Marouatis: The assuption made by #966 is wrong because the script is correct and the double licensing is necessary. The root cause is in the design due to how the vaults are added in the keroseneManager (for this reason I dont think this should be a dup of that issue). Unlike #966, this submission clearly explains that the current deployment is not faulty as it is necessary to have the double licensing, due to how the contracts are designed. However, it leads to two major issues (check Proof of Concept). The first POC highlights the fact that double counting of collateral is possible due to design.

This is what I say in my "Recommended Mitigation Steps" section. The remediation I proposed (which shouldn't :

The following diff will not be sufficient to make the whole system function correctly, as there are an issue with how Kerosine price calculate TVL. Right now, the KerosineManager vaults set holds two functions: list licensed kerosine vault list assets that are part of the TVL These two functions have no link, and shouldn't be mixed in the same set.

Second claim:

Marouatis: The mitigation given by #966 is wrong also, because not licensing normal vaults via KeroseneManager will not allow UnboundedKerosineVault to use these vaults for the price calculations of the kerosene asset. The logical solution here is to slightly alter the design which is what was proposed in this submission.

Same here,the mitigation I have described clearly states that changing the deployment script will not be sufficient and that the sponsor will have to separate the two functions:

The following diff will not be sufficient to make the whole system function correctly, as there are an issue with how Kerosine price calculate TVL. [...] These two functions have no link, and shouldn't be mixed in the same set. Sponsor should also refactor this part and separate this as two sets.

This is in fact the exact same thing as described by Marouatis :

The design of the licensing part needs to be rethinked. The issue here is that the vaults mapping of the KerosineManager contract which is constructed via the method KerosineManager::add is the same mapping that is used by the UnboundedKerosineVault::assetPrice function. You can consider creating two separate mappings

We all agree to say that the current architecture is highly flawed concerning the accounting. My submission is correct by saying that adding weth and wstEth to both kerosineManager and Licenser cause a double counting issue. There are many way to solve the problem, and a submission shouldn't be invalidated on that sole purpose. In fact, the mitigation section do not interfer with the issue validity as per C4 rules, but only can impact the partial scoring. But again, my remediation clearly states that updating the deployment script will not be sufficient, and that a refactor of the functions of "listing licensed vault" and "counting TVL" should be separated.

#15 - koolexcrypto

2024-05-28T15:25:43Z

Thanks everyone for your input.

The deployment script has no problems. This issue is still valid since it is pointing to the same problem, might consider a partial credit though.


@InfectedIsm

my submission https://github.com/code-423n4/2024-04-dyad-findings/issues/966 has been updated to unsatisfactory based on @Maroutis https://github.com/code-423n4/2024-04-dyad-findings/issues/1133#issuecomment-2116296514,

This was already unsatisfactory, I just didn't change it yet. Please give some time, as I'm working on synchronising multiple issues at the same time.

#16 - c4-judge

2024-05-28T15:26:21Z

koolexcrypto removed the grade

#17 - c4-judge

2024-05-28T20:15:19Z

koolexcrypto marked the issue as satisfactory

#18 - c4-judge

2024-05-29T11:20:26Z

koolexcrypto marked the issue as duplicate of #1133

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/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/VaultManagerV2.sol#L119-L153

Vulnerability details

Vulnerability details

To prevent users from exploiting oracle updates for arbitrage, a safeguard has been implemented. This safeguard makes it impossible to deposit and then withdraw in the same block number.

But there 3 issues how this is implemented in VaultManagerV2::deposit:

  1. No check that vault is licensed
  2. Combined with the fact that user1 can deposit in user2 dNFT
  3. Depositing in vault1 for a dNFT block withdrawals for all vault of this dNFT

This make it possible for a malicious user to call deposit with a empty dummy vault, which will lock withdrawal for the victim for the present block for all vaults Even with no dummy vault, a malicious user could deposit dust amount to any of the victim vaults to prevent him from withdrawing in same block

Impact

Possibility to prevent user from withdrawing

Proof of Concept

File: src/core/VaultManagerV2.sol
119:   function deposit(
120:     uint    id,
121:     address vault,
122:     uint    amount
123:   ) 
124:	external 
125:❌ 	isValidDNft(id) //<(1): missing isLicensed(vault) modifier
126:   {
127:❌	idToBlockOfLastDeposit[id] = block.number; //<(3): locking withdrawal for all vaults for this id
128:    Vault _vault = Vault(vault);
129:    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
130:    _vault.deposit(id, amount);
131:   }
132: 
133:   /// @inheritdoc IVaultManager
134:   function withdraw(
135:     uint    id,
136:     address vault,
137:     uint    amount,
138:     address to
139:   ) 
140:     public
141:     isDNftOwner(id)
142:   {
143:❌	 if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); //<(3)
144:     uint dyadMinted = dyad.mintedDyad(address(this), id); 
145:     Vault _vault = Vault(vault);
146:     uint value = amount * _vault.assetPrice() 
147:                   * 1e18 
148:                   / 10**_vault.oracle().decimals() 
149:                   / 10**_vault.asset().decimals();
150:     if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
151:     _vault.withdraw(id, to, amount);
152:     if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 
153:   }

Tools Used

manual review

  1. Verify vault is licensed (could also use the unused hasVault which might be even better)
  2. Depositing on behalf of someone else should be permissioned, for example by implementing a permission mechanism: mapping(address owner => address) allowed + modifier isAllowed(address))
: src/core/VaultManagerV2.sol:119

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

Assessed type

DoS

#0 - c4-pre-sort

2024-04-27T11:34:24Z

JustDravee marked the issue as duplicate of #1103

#1 - c4-pre-sort

2024-04-27T11:45:54Z

JustDravee marked the issue as duplicate of #489

#2 - c4-pre-sort

2024-04-29T09:30:07Z

JustDravee marked the issue as sufficient quality report

#3 - c4-judge

2024-05-05T20:38:09Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-05T21:14:39Z

koolexcrypto marked the issue as nullified

#5 - c4-judge

2024-05-05T21:14:47Z

koolexcrypto marked the issue as not nullified

#6 - c4-judge

2024-05-05T21:15:05Z

koolexcrypto marked the issue as not a duplicate

#7 - c4-judge

2024-05-05T21:15:17Z

koolexcrypto marked the issue as duplicate of #1266

#8 - c4-judge

2024-05-11T12:18:49Z

koolexcrypto marked the issue as satisfactory

#9 - c4-judge

2024-05-28T19:12:54Z

koolexcrypto marked the issue as duplicate of #930

Awards

3.8221 USDC - $3.82

Labels

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

External Links

Lines of code

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

Vulnerability details

Vulnerability details

In order to withdraw assets from a vault, the collateral ratio (CR) of the position must be sufficient :

    if (collatRatio(id) < MIN_COLLATERIZATION_RATIO)  revert CrTooLow(); 

The same checks are made in liquidate and mintDyad

The thing is, VaultManagerV2::collatRatio calls VaultManagerV2::getTotalUsdValue, which calls VaultManagerV2::getKeroseneValue which calls KerosineVault::getUsdValue ultimately calling UnboundedKerosineVault::assetPrice()

File: src/core/Vault.kerosine.unbounded.sol
51:   function assetPrice() 
52:     public 
53:     view 
54:     override
55:     returns (uint) {
56:       uint tvl;
57:       address[] memory vaults = kerosineManager.getVaults();
58:       uint numberOfVaults = vaults.length;
59:       for (uint i = 0; i < numberOfVaults; i++) {
60:         Vault vault = Vault(vaults[i]);
61:         tvl += vault.asset().balanceOf(address(vault)) 
62:                 * vault.assetPrice() * 1e18
63:                 / (10**vault.asset().decimals()) 
64:                 / (10**vault.oracle().decimals());
65:       }
66:       uint numerator   = tvl - dyad.totalSupply();
67:       uint denominator = kerosineDenominator.denominator();
68:       return numerator * 1e8 / denominator;
69:   }

But neither this contract, or KerosineVault (which it inherit from) has an oracle getter to call.

Impact

Functions calling collatRatio() are: liquidate, withdraw and mintDyad This means user depositing Kerosine will see all their asset stuck in the vault. But not only that, positions cannot be liquidated until the problem is solved (by redeploying a new KerosineVault with an oracle)

Proof of Concept

Tools Used

Manual review

Add an oracle to the KerosineVault (or UnboundedKerosineVault)

abstract contract KerosineVault is IVault, Owned(msg.sender) {
	using SafeTransferLib for ERC20;

	IVaultManager   public immutable vaultManager;
	ERC20           public immutable asset;
	KerosineManager public immutable kerosineManager; //@audit unused state variable
+	IAggregatorV3 public immutable oracle;

	mapping(uint => uint) public id2asset;

Assessed type

DoS

#0 - c4-pre-sort

2024-04-26T21:35:52Z

JustDravee marked the issue as duplicate of #1048

#1 - c4-pre-sort

2024-04-28T18:39:21Z

JustDravee marked the issue as duplicate of #830

#2 - c4-pre-sort

2024-04-29T08:44:46Z

JustDravee marked the issue as sufficient quality report

#3 - c4-judge

2024-05-11T20:05:12Z

koolexcrypto marked the issue as satisfactory

Awards

3.8221 USDC - $3.82

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_52_group
duplicate-308

External Links

Lines of code

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

Vulnerability details

Vulnerability details

DYAD protocol allow users to deposit collateral and mint a dollar-pegged asset (DYAD) against it. Two types of vaults are proposed:

  • exogenous vaults, where users can deposit assets external to the protocol (like WETH, wstETH, ...)
  • kerosine vaults, where user can deposit the endogenous token from the protocol, Kerosine

Minting of DYAD tokens require a minimum total (exo + endo) collateralization ratio >150%, and a minimum exo CR100%. So this assertion should always be verified under good conditions (i.e prices do not changes to quickly): tvl > dyad.totalSupply()

But this is not a realistic that can hold forever.

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

File: src/core/Vault.kerosine.unbounded.sol
50:   function assetPrice() 
51:     public 
52:     view 
53:     override
54:     returns (uint) {
55:       uint tvl;
56:       address[] memory vaults = kerosineManager.getVaults();
57:       uint numberOfVaults = vaults.length;
58:       for (uint i = 0; i < numberOfVaults; i++) {
59:         Vault vault = Vault(vaults[i]);
60:         tvl += vault.asset().balanceOf(address(vault)) 
61:                 * vault.assetPrice() * 1e18
62:                 / (10**vault.asset().decimals()) 
63:                 / (10**vault.oracle().decimals());
64:       }
65:❌	  uint numerator   = tvl - dyad.totalSupply();//<@audit: this can underflow
66:       uint denominator = kerosineDenominator.denominator();
67:       return numerator * 1e8 / denominator;
68:   }
69: }

But if multiple vaults, or a major vault sees its asset price plummet, this relationship will change. In that case, the substraction will underflow resulting in a revert of assetPrice

The issue is that UnboundedKerosineVault::assetPrice is called by VaultManagerV2::collatRatio >> VaultManagerV2::getTotalUsdValue >> VaultManagerV2::getNonKeroseneValue And VaultManagerV2::collatRatio is called by VaultManagerV2::liquidate, which is the mechanism expected to clean up bad positions.

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

File: src/core/VaultManagerV2.sol
205:   function liquidate(
206:     uint id, //The ID of the dNFT to be liquidated.
207:     uint to //The address where the collateral will be sent
208:   ) 
209:     external 
210:       isValidDNft(id)
211:       isValidDNft(to)
212:     {
213:❌	   uint cr = collatRatio(id); //<@audit: this can revert if TVL < dyad.totalSupply()
214:       if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh();
215:       dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id));
216: 

Impact

If total TVL drops below the minimum CR of the protocol, liquidation will revert, preventing protocol to recover.

Tools Used

Manuel review

Mitigating that issue is difficult as the issue is associated to how Kerosine price is evaluated. This require to refactor how the token is priced, in case of such cases, but this surely shouldn't revert when called during liquidations. Collateralization ratio should also probably be revised to higher values to absorb unexpected dumps in value.

Assessed type

DoS

#0 - c4-pre-sort

2024-04-28T19:54:15Z

JustDravee marked the issue as duplicate of #224

#1 - c4-pre-sort

2024-04-29T09:04:42Z

JustDravee marked the issue as sufficient quality report

#2 - c4-judge

2024-05-08T08:31:59Z

koolexcrypto marked the issue as duplicate of #308

#3 - c4-judge

2024-05-11T20:09:48Z

koolexcrypto marked the issue as satisfactory

Awards

22.478 USDC - $22.48

Labels

bug
2 (Med Risk)
high quality report
primary issue
satisfactory
selected for report
sponsor disputed
:robot:_11_group
M-02

External Links

Lines of code

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

Vulnerability details

Vulnerability details

Right now there are no incentives to liquidate a position with a CR<1, as the liquidator will have to burn the full borrowed amount, and will get the full collateral.

But a CR<1 means collateral is worth less than borrowed amount.

So this is a clear loss for the liquidator, meaning no-one will liquidate the position.

File: src/core/VaultManagerV2.sol
205:   function liquidate( 
206:     uint id, //The ID of the dNFT to be liquidated.
207:     uint to //The address where the collateral will be sent
208:   ) 
...: 	// ... some code ...
215:❌	   dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id)); //<@audit: caller need to burn full borrowed amount
216: 
217:       uint cappedCr               = cr < 1e18 ? 1e18 : cr; /// == max(1e18, cr)
218:       uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD); /// if cr<1, this is equal 0
219:       uint liquidationAssetShare  = (liquidationEquityShare + 1e18).divWadDown(cappedCr); /// if cr<1, this is equal 1e18
220: 
221:       uint numberOfVaults = vaults[id].length();
222:       for (uint i = 0; i < numberOfVaults; i++) {
223:           Vault vault      = Vault(vaults[id].at(i));
224:           uint  collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare);
225:           vault.move(id, to, collateral);
226:       }
227:       emit Liquidate(id, msg.sender, to);
228:   }

Impact

If CR<1, position will not be liquidated, protocol possibly incuring a worser bad debt than this could have been.

Tools Used

Manuel review

Refactor the liquidation calculation, to allow liquidator to repay the debt and still get a reward out of this.

Assessed type

Context

#0 - c4-pre-sort

2024-04-28T10:09:20Z

JustDravee marked the issue as primary issue

#1 - c4-pre-sort

2024-04-29T09:24:25Z

JustDravee marked the issue as high quality report

#2 - shafu0x

2024-04-30T11:23:28Z

If the CR < 100, where should the reward come from?

#3 - koolexcrypto

2024-05-04T09:43:05Z

Looks like a systematic risk. If CR drops below 100 before it gets liquidated, there is a much bigger problem.

As the sponsor said, where the reward will come from?

#4 - c4-judge

2024-05-04T09:44:05Z

koolexcrypto changed the severity to QA (Quality Assurance)

#5 - c4-judge

2024-05-10T10:57:32Z

koolexcrypto marked the issue as grade-c

#6 - c4-judge

2024-05-12T09:23:56Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#7 - InfectedIsm

2024-05-15T21:34:11Z

Hi @koolexcrypto, @shafu0x

I don't think this is an unsolvable systemic issue, and this would be a mistake to not implement mechanism to take care underwater loans. This issue, and the remediations are well established in CDP protocols:

  1. if a loan CR is <100%, then it must be cut down as soon as possible to limit the even bigger potential losses.
  2. to do so, the operation must be profitable for the liquidators so that they act quickly (which is not the case right now)
  3. this will indeed create a bad debt into to protocol, but here's come the remediation
  4. that's why protocols like MakerDAO or other CDP build an "insurance fund" (usually through fees, that can come from liquiations) which role is to absorb bad debt in such events

Thanks for taking a second look to the issue

Regards

#8 - shafu0x

2024-05-22T13:37:07Z

we will not implement something like an "insurance fund". do you have another mitigation option?

#9 - InfectedIsm

2024-05-22T14:50:02Z

@shafu0x I believe the other possibilities would be based on a debt socialization :

  • allow liquidator to burn only the DYAD equivalent of collateral
  • award Kerosene from the 1B total supply as a bonus to cover missing part of collateral

But I feel like a fund/balance, generated by fees taken from "good" liquidation, to cover for these rare situations is still the best way to go. Maybe fellow SRs have other ideas.

#10 - koolexcrypto

2024-05-28T16:11:35Z

Upgrading this to Medium due to the following:

1- The absence of a mechanism to allow liquidating bad debts (CR < 1) creates a potential risk for the protocol. 2- I couldn't find in the documentation/code that it is an accepted risk based on an intended design.

#11 - koolexcrypto

2024-05-28T16:19:14Z

award Kerosene from the 1B total supply as a bonus to cover missing part of collateral

This seems to be a good starting point. LP will be incentivised to liquidate, in return, they receive Kerosene. Kerosene then can be sold in the secondary market or re-used to mint DYAD.

However, consequences of this approach should be taken into account and addressed, for example, circulating Kerosene will increase which will influence the price.

CC: @shafu0x

#12 - c4-judge

2024-05-28T16:19:58Z

koolexcrypto removed the grade

#13 - c4-judge

2024-05-28T16:20:20Z

This previously downgraded issue has been upgraded by koolexcrypto

#14 - c4-judge

2024-05-28T16:20:46Z

koolexcrypto marked the issue as satisfactory

#15 - c4-judge

2024-05-28T16:20:57Z

koolexcrypto marked the issue as selected for report

#16 - GiorgioDalla

2024-05-29T10:34:38Z

since this is upgraded, could you consider dupe #288 ? thanks

Findings Information

Labels

2 (Med Risk)
satisfactory
duplicate-829

Awards

122.4433 USDC - $122.44

External Links

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

L04 - Missing setUnboundedKerosenVault initialization in deploy https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/script/deploy/Deploy.V2.s.sol#L89-L89

Vulnerability details boundedKerosineVault is deployed, but the setUnboundedKerosenVault is not called, which will cause a revert when BounderKerosineVault::assetPrice() will be called to price users kerosine collateral:

File: src/core/Vault.kerosine.bounded.sol 22: 23: function setUnboundedKerosineVault( 24: UnboundedKerosineVault _unboundedKerosineVault 25: ) 26: external 27: onlyOwner 28: { 29: unboundedKerosineVault = _unboundedKerosineVault; 30: } 31: ...: ...: /// ... some code ... ...: 44: function assetPrice() 45: public 46: view 47: override 48: returns (uint) { 49: return unboundedKerosineVault.assetPrice() * 2; 50: }

#0 - c4-judge

2024-05-29T08:29:49Z

koolexcrypto marked the issue as duplicate of #829

#1 - c4-judge

2024-05-29T11:23:20Z

koolexcrypto marked the issue as satisfactory

Awards

4.8719 USDC - $4.87

Labels

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

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.unbounded.sol#L65-L66 https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/staking/KerosineDenominator.sol#L17-L22

Vulnerability details

Vulnerability details

The protocol sets Kerosene’s value as DYAD collateral deterministically (documentation):

The implementation can be found here: https://github.com/code-423n4/2024-04-dyad/blob/49fb7174576e5147dc73d3222d16954aa641a2e0/src/core/Vault.kerosine.unbounded.sol#L65-L66

File: src/core/Vault.kerosine.unbounded.sol
50:   function assetPrice() 
51:     public 
52:     view 
53:     override
54:     returns (uint) {
55:       uint tvl;
56:       address[] memory vaults = kerosineManager.getVaults();
57:       uint numberOfVaults = vaults.length;
58:       for (uint i = 0; i < numberOfVaults; i++) {
59:         Vault vault = Vault(vaults[i]);
60:         tvl += vault.asset().balanceOf(address(vault)) 
61:                 * vault.assetPrice() * 1e18
62:                 / (10**vault.asset().decimals()) 
63:                 / (10**vault.oracle().decimals());
64:       }
65:       uint numerator   = tvl - dyad.totalSupply();
66:       uint denominator = kerosineDenominator.denominator();
67:       return numerator * 1e8 / denominator;
68:   }

This price can easily be manipulated by an malicious actor (Alice) with sufficient funds:

  1. Alice deposit the assets into its dNFT position
  2. tvl will increase, dyad.totalSupply() will stay the same, so numerator will increase, thus Kerosene price
  3. denominator will not change
  4. Now Alice waits for victims to use Kerosine as collateral for their borrow
  5. Once Alice see enough interesting positions, she mints DYAD, this will increase dyad.totalSupply(), thus reducing numerator and so Kerosine price
  6. Now, multiple position will become liquidatable (CR < MIN_COLLATERIZATION_RATIO)
  7. It become even worse now, because each liquidation will reduce dyad.totalSupply(), causing a liquidation cascade and crashing Kerosine price.

Please note that this effect is proportional to the supply controlled by the whales, but any amount used to operate that exploit will manipulate the price proportionally.

Impact

Unfair liquidation and Kerosine price crash

Proof of Concept

I have setup a test environment based on existing VaultManager.t.sol and DeployBase.s.sol, but had to make some additions to run the tests against VaultManagerV2 Thus will have to add these two files VaultManagerV2.t.sol and DeloyBaseV2.sol, available in the gist that follows: https://gist.github.com/InfectedIsm/111d71fb5265a1f62e3a248c64d5afa0 These files must be added at the same level as VaultManager.t.sol and DeployBase.s.sol respectively.

Output of the test:

Ran 1 test for test/VaultManagerV2.t.sol:VaultManagerV2Test
[PASS] test_ManipulatePrice() (gas: 1046778)
Logs:
  ker price: 150000
  ker price: 100000
  price change: -33%

Tools Used

Manual review

Do not use spot value to calculate Kerosine price, but rather an oracle (can be a TWAP based on historic balance/supply) The longer the TWAP window will be chosen, the smoother the price change will be, allowing users to anticipate the manipulation and act accordingly on their position.

Assessed type

MEV

#0 - c4-pre-sort

2024-04-28T05:19:32Z

JustDravee marked the issue as duplicate of #67

#1 - c4-pre-sort

2024-04-28T05:19:41Z

JustDravee marked the issue as high quality report

#2 - c4-judge

2024-05-05T09:59:11Z

koolexcrypto changed the severity to 2 (Med Risk)

#3 - c4-judge

2024-05-08T11:50:02Z

koolexcrypto marked the issue as unsatisfactory: Invalid

#4 - c4-judge

2024-05-08T12:06:38Z

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