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
Rank: 20/183
Findings: 10
Award: $485.93
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Maroutis
Also found by: 0x486776, 0xShitgem, 0xabhay, 0xleadwizard, 0xlemon, 0xnilay, 0xtankr, 3docSec, AM, Aamir, Abdessamed, Al-Qa-qa, AlexCzm, Circolors, CodeWasp, Daniel526, Egis_Security, Emmanuel, Giorgio, Honour, Hueber, Infect3d, Krace, KupiaSec, LeoGold, Limbooo, PoeAudits, SBSecurity, SpicyMeatball, T1MOH, The-Seraphs, TheSavageTeddy, TheSchnilch, Topmark, VAD37, ZanyBonzy, adam-idarrha, bhilare_, btk, carlitox477, cinderblock, dimulski, falconhoof, grearlake, gumgumzum, iamandreiski, itsabinashb, josephdara, ke1caM, kennedy1030, ljj, n0kto, n4nika, neocrao, oakcobalt, petro_1912, pontifex, poslednaya, shaflow2, shikhar229169, web3km, ych18, zhaojohnson, zigtur
0.2831 USDC - $0.28
A vault can be licensed (added) by both the Licenser
and KeroseneManager
, DeployV2
confirms this.
If the same vault is licensed by both licensers, then one vault
can be added from both add
and addKerosene
.
Because the same vault is in both vaults
and vaultsKerosene
, the value of id2assets
will be calculated twice for collatRatio
, doubling the collatRatio
of an id
. This will make positions very hard to liquidate, as the ratio is effectively doubled.
The PoC showcases the issue well.
In DeployV2.sol
the vaultLicenser
and keroseneManager
add both stEthVault
and wstEth
here and here
For the following test, we are using project's current configuration with a few changes, so we can use VaultManagerV2
Inisde test/BaseTest.sol
make the following changes:
+ import {VaultManagerV2} from "../src/core/VaultManagerV2.sol"; + import {KerosineManager} from "../src/core/KerosineManager.sol"; contract BaseTest is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManager; Payments payments; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; // dai Vault daiVault; ERC20Mock dai; OracleMock daiOracle; function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(1000e8); Contracts memory contracts = new DeployBase().deploy( msg.sender, address(dNft), address(weth), address(wethOracle), GOERLI_FEE, GOERLI_FEE_RECIPIENT ); + VaultManagerV2 vaultManagerv2 = new VaultManagerV2(dNft, contracts.dyad, contracts.vaultLicenser); + KerosineManager kerosineManager = new KerosineManager(); + vaultManagerv2.setKeroseneManager(kerosineManager); vaultManagerLicenser = contracts.vaultManagerLicenser; vaultLicenser = contracts.vaultLicenser; dyad = contracts.dyad; + vaultManager = vaultManagerv2; wethVault = contracts.vault; payments = contracts.payments; ... // add the DAI vault vm.prank(vaultLicenser.owner()); vaultLicenser.add(address(daiVault)); + kerosineManager.add(address(daiVault)); + vm.prank(vaultLicenser.owner()); + vaultManagerLicenser.add(address(vaultManagerv2)); }
Inside test/VaultManager.t.sol
add the following test and run it using forge test --mt "test_collatRatioManupulationWhenSameVaultIsAddedForKerosineAndNormal" -vv
function test_collatRatioManupulationWhenSameVaultIsAddedForKerosineAndNormal() public { uint id = mintDNft(); uint cr = vaultManager.collatRatio(id); assertEq(cr, type(uint).max); deposit(dai, id, address(daiVault), 1e22); vaultManager.mintDyad(id, 1e15, address(this)); cr = vaultManager.collatRatio(id); // We only call `addKerosene` with the same address and our CR is doubled vaultManager.addKerosene(id, address(daiVault)); assertEq(cr * 2, vaultManager.collatRatio(id)); }
Manual Review Foundry
License only non-kerosene vaults from the Licenser
and kerosene vaults from KeroseneManager
. The alternative is to not allow the same vault to be in both vaults
and vaultsKerosene
Invalid Validation
#0 - c4-pre-sort
2024-04-28T07:03:36Z
JustDravee marked the issue as duplicate of #966
#1 - c4-pre-sort
2024-04-29T08:37:17Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-04T09:46:30Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-28T15:28:23Z
koolexcrypto marked the issue as duplicate of #1133
#4 - c4-judge
2024-05-29T14:03:12Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: MrPotatoMagic
Also found by: 0xtankr, ArmedGoose, Egis_Security, Giorgio, KYP, Maroutis, NentoR, OMEN, Sabit, Shubham, SpicyMeatball, T1MOH, d3e4, dimulski, peanuts
200.8376 USDC - $200.84
When calling liquidate
, the liquidator is forced to burn all the minted Dyad tokens that are attached to a DNft id
.
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); }
If a whale has a lot of Dyad tokens and then becomes liquidatable, it may be impossible to liquidate him. In the edge case where 1 DNft id
has > 50% of the Dyad total supply, it's impossible to liquidate him, as there isn't enough Dyad tokens to cover his debt, which can cause a massive amount of bad debt, as the whale has no incentive of repaying his debt as he know he can't be liquidated.
Manual Review
Not sure how this can be fixed, limiting the mintable dyad based on an id
won't fix the edge case. One way is allowing for partial liquidations, as this way users can chip away at the position, instead of forcing liquidators to liquidate the entire position at once.
Other
#0 - c4-pre-sort
2024-04-28T10:04:47Z
JustDravee marked the issue as duplicate of #1097
#1 - c4-pre-sort
2024-04-29T08:34:47Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T12:22:09Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:37:45Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: Circolors
Also found by: 0x175, 0x486776, 0xAlix2, 0xSecuri, 0xShitgem, 0xfox, 0xlemon, 0xnilay, 3th, 4rdiii, Aamir, Al-Qa-qa, AlexCzm, Egis_Security, Evo, Honour, Infect3d, Josh4324, Limbooo, Mahmud, SBSecurity, TheSchnilch, ahmedaghadi, alix40, amaron, bbl4de, bhilare_, btk, carrotsmuggler, cinderblock, d3e4, dimulski, dinkras, ducanh2706, iamandreiski, itsabinashb, ke1caM, ljj, sashik_eth, shaflow2, steadyman, web3km, y4y
3.8221 USDC - $3.82
When withdraw/redeemDyad
is called, the code uses _vault.oracle().decimals()
in order to have the correct decimal precision of assets/value
.
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(); } 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 the Vault.kerosene.unbounded.sol
(which can be withdrawn from, as shown in the pre-audit video and by the docs) does not have an oracle
state variable as it relies getting it's price based on the assets that were licensed by the kerosene manager, so if someone attempts to call withdraw/redeemDyad
with vault = unbounded kerosene vault
then the tx will simply revert.
This will cause all Kerosene tokens that were deposited to the vault to be stuck, effectively the unbounded vault will act like a bounded vault, but it won't 2x it's usd value.
Manual review
The two functions have to be reworded so they work with Kerosene vaults or create 2 new functions in the vault manager to handle withdraw/redeem from kerosene vaults.
Other
#0 - c4-pre-sort
2024-04-26T21:28:37Z
JustDravee marked the issue as duplicate of #1048
#1 - c4-pre-sort
2024-04-28T18:38:30Z
JustDravee marked the issue as duplicate of #830
#2 - c4-pre-sort
2024-04-29T08:44:50Z
JustDravee marked the issue as sufficient quality report
#3 - c4-judge
2024-05-11T20:06:10Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: 0xAlix2
Also found by: 0xfox, 0xlemon, 0xnev, 3docSec, Aamir, Abdessamed, Dudex_2004, Egis_Security, Evo, FastChecker, Honour, Jorgect, KupiaSec, Limbooo, MrPotatoMagic, SpicyMeatball, TheSchnilch, alix40, bhilare_, favelanky, forgebyola, ke1caM, kennedy1030, koo, oakcobalt, petro_1912, shikhar229169
32.4128 USDC - $32.41
The protocol has two types of vaults, non-kerosene vaults, which allows users to mint Dyad against their usd value, and kerosene vaults, which only affect the collatRatio
and are not accounted for when a user is attempting to mint Dyad.
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(); }
You'll notice this check:
if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
This invariant check if the new usd value of non-kerosene vault assets is less than the dyadMinted
by the id
.
This assumes that withdraw
is always called with a non-kerosene vault
, but that isn't the case.
If we look at the deploy scripts we see that weth
and wstETH
are kerosene vaults.
When withdrawing from a kerosene vault, withdraw
will always assume that we are withdrawing from a non-kerosene vault and always checks the following, even though kerosene vaults only affect collatRatio
and are not taken into account when attempting to mint Dyad tokens.
if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
Because of this, users might not be able to withdraw
from a kerosene vault, because the protocol always assumes they are withdrawing from a non-kerosene vault.
Example:
collatRatio = 200%
to make sure she is safe.withdraw
some assets from her kerosene vault, because she needs the assets and knows that she has wiggle room in her collatRatio
, so she decides to withdraw assets that are going to leave her collatRatio = 170%
.withdraw
and the tx reverts, as the protocol assumes that she is withdrawing from a non-kerosene vault and subtracts value
, which is in kerosene vault terms, from her non-kerosene value, which is incorrect as kerosene vaults and Dyad aren't linked in any way.Manual Review
Rework withdraw
to look like so:
vault.withdraw(id, to, amount); if (getNonKeroseneValue(id) < dyadMinted) revert NotEnoughExoCollat();
The check is technically the same, but it will only affect users when they are withdrawing from a non-kerosene vault, as that's when getNonKeroseneValue
will be lowered.
Other
#0 - c4-pre-sort
2024-04-26T21:25:00Z
JustDravee marked the issue as duplicate of #397
#1 - c4-pre-sort
2024-04-29T08:48:22Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:22:19Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:33:04Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: 0xAlix2
Also found by: 0x486776, 0xabhay, 0xlucky, 0xtankr, Abdessamed, Circolors, CodeWasp, DarkTower, Egis_Security, Giorgio, Infect3d, Krace, KupiaSec, Limbooo, Maroutis, NentoR, Ryonen, SpicyMeatball, T1MOH, TheFabled, TheSavageTeddy, TheSchnilch, VAD37, XDZIBECX, btk, carrotsmuggler, cu5t0mpeo, dimulski, gumgumzum, iamandreiski, imare, itsabinashb, ke1caM, kennedy1030, lian886, n4nika, oakcobalt, sashik_eth, shaflow2, steadyman, web3km, windhustler, zhaojohnson
3.8221 USDC - $3.82
wsthETH
, which is the main token used in the system, deviation is 1% and heartbeat is 1 hour. This may not seem harmful, but in specific edge cases, system may reach state, where dyad.totalSupply() > tvl
, which will lead to reverts in calculating kersoine price, which is calculation done in almost all operations (deposit, withdraw, liquidate)
Chain of functions for liquidate:rETH
have a deviation of 2% and a heartbeat of 24 hours, which may have major impact on position liquidations.Imagine the following scenario:
wsthEth
is valued at $4001 and we have 10 users, each has deposited 1 wsthETH
and X kerosine tokens (X is valued at $2000)wstETH
slowly decrease in value over 1 hour and becomes $3965 (0.09% of 4001)$3965
and currently TVL = 10 * 3965 = \$39 650
, while dyad.totalSupply() = \$40 000
, which is breaking the invariant tvl > dyad.totalSupply()
uint numerator = tvl - dyad.totalSupply();
Manual Review
Oracle
#0 - c4-pre-sort
2024-04-28T19:20:57Z
JustDravee marked the issue as duplicate of #25
#1 - c4-pre-sort
2024-04-29T12:01:23Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-06T12:20:17Z
koolexcrypto marked the issue as not a duplicate
#3 - c4-judge
2024-05-06T13:00:28Z
koolexcrypto marked the issue as unsatisfactory: Out of scope
#4 - NicolaMirchev
2024-05-16T19:21:22Z
Hey, @koolexcrypto
First thank you for your time judging this contest. I want to escalate your decision here and point out why the following is not OOS:
enforce non-kerosine TVL to be at least 110%
affects in-scope code and if it is implemented, the vulnerability won't be present./// @inheritdoc IVaultManager 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); // 110% for example + if (getNonKeroseneCollateralCR(id) < nonKerosineThreshold) revert NotEnoughExoCollat(); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); emit MintDyad(id, amount, to); }
Please review again the problem, because I believe that the problem has really big impact (with low probability), which may be very unpleasant.
#5 - koolexcrypto
2024-05-23T11:12:50Z
Hi @NicolaMirchev
Thank you for your feedback.
This would be a dup of #308
#6 - c4-judge
2024-05-29T10:07:58Z
koolexcrypto removed the grade
#7 - c4-judge
2024-05-29T10:08:07Z
koolexcrypto marked the issue as duplicate of #308
#8 - c4-judge
2024-05-29T10:08:13Z
koolexcrypto marked the issue as satisfactory
#9 - c4-judge
2024-05-29T11:25:32Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: dimulski
Also found by: 0xleadwizard, 0xlemon, Aamir, Al-Qa-qa, AvantGard, Bauchibred, Cryptor, DarkTower, Egis_Security, Giorgio, Maroutis, MrPotatoMagic, OMEN, Ocean_Sky, Ryonen, SBSecurity, Sabit, SpicyMeatball, Stefanov, T1MOH, Tigerfrake, WildSniper, atoko, bhilare_, darksnow, fandonov, grearlake, iamandreiski, igdbase, pontifex, web3km, xiao
4.8719 USDC - $4.87
Liquidators are incentivized to liquidate
other users, because of profits they will make. If there is no profit/incentive, then liquidators have no reason to liquidate other users.
Because the protocol is deployed on Ethereum where gas costs are high, there is a case where the gas costs to liquidate
a positions is higher than the profit that the liquidator will make. Thus liquidators will not liquidate
such positions, which will leave undercollateralized positions which can potentially turn into bad debt.
If many such positions accumulate in the protocol, it can lead to a lot of bad debt that no one wants to liquidate
, giving malicious users incentive to create such positions, as they know they won't be liquidated ever and they can just run away with the minted Dyad.
Manual Review
Add a minimum amount of collateral that an id
needs to have in order to mint dyad.
Other
#0 - c4-pre-sort
2024-04-27T13:31:40Z
JustDravee marked the issue as duplicate of #1258
#1 - c4-pre-sort
2024-04-29T09:16:48Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-03T14:07:47Z
koolexcrypto changed the severity to QA (Quality Assurance)
#3 - c4-judge
2024-05-12T09:47:05Z
koolexcrypto marked the issue as grade-c
#4 - c4-judge
2024-05-22T14:26:07Z
This previously downgraded issue has been upgraded by koolexcrypto
#5 - c4-judge
2024-05-28T16:51:26Z
koolexcrypto marked the issue as satisfactory
#6 - c4-judge
2024-05-28T20:05:59Z
koolexcrypto marked the issue as duplicate of #175
🌟 Selected for report: TheSavageTeddy
Also found by: 0x175, 0x486776, 0xnev, AamirMK, AlexCzm, ArmedGoose, BiasedMerc, CaeraDenoir, Egis_Security, Jorgect, KYP, MrPotatoMagic, PoeAudits, SBSecurity, SovaSlava, VAD37, adam-idarrha, alix40, carrotsmuggler, d_tony7470, dimulski, grearlake, josephdara, ljj, n0kto, okolicodes, sashik_eth, sil3th, turvy_fuzz
7.3512 USDC - $7.35
Anyone can deposit, but only the owner can withdraw/redeemDyad/mintDyad
, effectively anyone that isn't an owner and calls deposit, will lose funds.
A malicious way to use the lack of access control is that anyone can DoS remove
and removeKerosene
as both functions rely that id2assets
of the vault
are > 0. Since anyone can call deposit
, a malicious user can front-run a remove/removeKerosene
, depositing dust amounts of vault.asset()
using the id
of the user that he wants to DoS.
Alice has a DNft with id = 1
.
remove
a vault that she no longer uses.remove(1, emptyVault)
.deposit(1, emtpyVault, 1)
.deposit
only checks if the id
is valid (not owner by address(0))modifier isValidDNft(uint id) { if (dNft.ownerOf(id) == address(0)) revert InvalidDNft(); _; }
The function will increase id2assets[1]
.
5. Alice's tx executes and reverts on the following line:
if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets();
The natspec inside IVaultManager
states that only the DNft owner can call deposit
Manual Review
Replace isValidDNft
with isDNftOwner
.
Access Control
#0 - c4-pre-sort
2024-04-27T13:34:19Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:25:32Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:39:26Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:45:35Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T21:48:23Z
koolexcrypto marked the issue as nullified
#5 - c4-judge
2024-05-05T21:48:27Z
koolexcrypto marked the issue as not nullified
#6 - c4-judge
2024-05-05T21:48:39Z
koolexcrypto marked the issue as not a duplicate
#7 - c4-judge
2024-05-06T08:53:16Z
koolexcrypto marked the issue as duplicate of #118
#8 - c4-judge
2024-05-11T12:23:43Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: TheSavageTeddy
Also found by: 0x175, 0x486776, 0xnev, AamirMK, AlexCzm, ArmedGoose, BiasedMerc, CaeraDenoir, Egis_Security, Jorgect, KYP, MrPotatoMagic, PoeAudits, SBSecurity, SovaSlava, VAD37, adam-idarrha, alix40, carrotsmuggler, d_tony7470, dimulski, grearlake, josephdara, ljj, n0kto, okolicodes, sashik_eth, sil3th, turvy_fuzz
7.3512 USDC - $7.35
remove
is used to remove a vault
that is attached to a DNft id
. The function checks if the id
has any attached assets to the vault
.
function remove( uint 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); }
This can easily be exploited by anyone, by just calling deposit
with 1 wei, as that will increase id2asset
in the Vault
.
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); }
Paste the following inside VaultManager.t.sol
and run forge test --mt test_withdrawCanBeDoSed -vv
function test_withdrawCanBeDoSed() public { uint id = mintDNft(); deposit(dai, id, address(daiVault), 1e22); vm.expectRevert(); vaultManager.withdraw(id, address(daiVault), 1, address(this)); }
Manual review Foundry
Do not allow deposit
to be called by anyone
DoS
#0 - c4-pre-sort
2024-04-27T13:34:23Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:25:32Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:39:26Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:45:34Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T21:47:54Z
koolexcrypto marked the issue as nullified
#5 - c4-judge
2024-05-05T21:47:58Z
koolexcrypto marked the issue as not nullified
#6 - c4-judge
2024-05-05T21:48:04Z
koolexcrypto marked the issue as not a duplicate
#7 - c4-judge
2024-05-06T08:53:26Z
koolexcrypto marked the issue as duplicate of #118
#8 - c4-judge
2024-05-11T12:23:49Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: TheSavageTeddy
Also found by: 0x175, 0x486776, 0xnev, AamirMK, AlexCzm, ArmedGoose, BiasedMerc, CaeraDenoir, Egis_Security, Jorgect, KYP, MrPotatoMagic, PoeAudits, SBSecurity, SovaSlava, VAD37, adam-idarrha, alix40, carrotsmuggler, d_tony7470, dimulski, grearlake, josephdara, ljj, n0kto, okolicodes, sashik_eth, sil3th, turvy_fuzz
7.3512 USDC - $7.35
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L101 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L113
In order to call remove
or removeKerosene
, the vault
has to have id2assets = 0
, basically the id
must not have any assets in the vault.
Note that this attack doesn't rely on simply calling deposit
with dust amounts for an id
, this attack can still be pulled off after that issue is fixed.
There are 2 ways for id2assets
to increase, through deposit
and through move
.
move
is used exclusively in liquidate
. It is possible to weaponize liquidate
in such a way as to DoS a removal of an unbounded kerosene vault.
The crux of the issue is that the price of Kerosene is derived from the assets that are licensed by the Kerosene manager.
Prerequisites:
id = 1
for her real assets, she deposits in that vault, but doesn't mint anything.id = 2
she has a kerosene asset vault with no assets and no dyad minted. Bob also uses this vault.id = 1
is licensed by the KeroseneManager
so the price and amounts of the assets in the vault determine the price of Kerosene.Because of another issue, it's currently impossible to withdraw assets from a kerosene asset vault due to a missing state variable, but this attack will work regardless.
id2assets = 0
.remove
so he can remove the vault and then add
another one in it's place, as Bob currently has MAX_VAULTS
.remove
.id = 2
and then mintDyad
, up to the collateralization limit.mintDyad
using the non-kerosene vault using id = 1
so she pushes the price of kerosene down, in order to make id = 2
liquidatable.liquidate(2, Bob's id)
.liquidate
will move
the dust amounts of collateral from her vault to Bob's vault.remove
is called and reverts, as his id2assets > 0
/Manual Review
Change the modifiers that liquidate
uses to these:
function liquidate( uint id, uint to ) external isValidDNft(id) -> isDNftOwner(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); emit Test(cappedCr, liquidationEquityShare, liquidationAssetShare, collateral); vault.move(id, to, collateral); } emit Liquidate(id, msg.sender, to); }
DoS
#0 - c4-pre-sort
2024-04-28T19:18:29Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:25:34Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:39:25Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:45:34Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T21:47:24Z
koolexcrypto marked the issue as nullified
#5 - c4-judge
2024-05-05T21:47:29Z
koolexcrypto marked the issue as not nullified
#6 - c4-judge
2024-05-05T21:47:46Z
koolexcrypto marked the issue as not a duplicate
#7 - c4-judge
2024-05-06T08:53:37Z
koolexcrypto marked the issue as duplicate of #118
#8 - c4-judge
2024-05-11T12:23:52Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: Pataroff
Also found by: Egis_Security, Evo, Jorgect, MrPotatoMagic, SBSecurity, T1MOH, carrotsmuggler, ljj
223.9474 USDC - $223.95
Currently, the only restriction to call burnDyad
is for the id
to be valid (owner != address(0)).
This means that users can burn their Dyad tokens and specify whatever id
they wish so that it subtracts from their debt.
This can easily be weaponized by a malicious user, who can front-run a full burnDyad
with a very small byrnDyad
of 1 wei, which cause an underflow in Dyad.sol#burn()
function burn( uint id, address from, uint amount ) external licensedVaultManager { _burn(from, amount); mintedDyad[msg.sender][id] -= amount; }
Example:
id = 1
.burnDyad(1, 100e18)
.burnDyad(1, 1)
.mintedDyad[vaultManager][1] = 100e18 - 1
.mintedDyad[vaultManager][1] = 100e18 - 1 - 100e18
, which panic reverts with an underflow.In the worst case scenario this can affect liquidations, as a user that is about to get liquidated may attempt to burn all his Dyad tokens so he doesn't get liquidated, but a malicious user can DoS him enough times so that the liquidation goes through.
Manual Review
Add the following code inside burnDyad
.
if (amount > dyad.mintedDyad(address(this), id)) amount = dyad.mintedDyad(address(this), id);
DoS
#0 - c4-pre-sort
2024-04-27T13:13:42Z
JustDravee marked the issue as duplicate of #409
#1 - c4-pre-sort
2024-04-29T09:31:02Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T12:01:30Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-09T11:35:56Z
koolexcrypto marked the issue as not a duplicate
#4 - c4-judge
2024-05-09T11:36:10Z
koolexcrypto removed the grade
#5 - c4-judge
2024-05-09T11:36:29Z
koolexcrypto marked the issue as duplicate of #74
#6 - c4-judge
2024-05-10T10:14:45Z
koolexcrypto marked the issue as duplicate of #992
#7 - c4-judge
2024-05-11T12:16:24Z
koolexcrypto marked the issue as satisfactory
#8 - c4-judge
2024-05-28T10:29:40Z
koolexcrypto marked the issue as duplicate of #100
🌟 Selected for report: carrotsmuggler
Also found by: 0xAlix2, 0xSecuri, 0xleadwizard, 0xlemon, 0xtankr, 3th, Aamir, Abdessamed, Bauchibred, Circolors, Egis_Security, Evo, Hueber, Mahmud, SBSecurity, TheSavageTeddy, TheSchnilch, Tychai0s, alix40, bbl4de, btk, d3e4, ducanh2706, falconhoof, itsabinashb, ke1caM, lian886, n4nika, oakcobalt, pontifex, sashik_eth, steadyman, tchkvsky, zhuying
3.7207 USDC - $3.72
If we take a look into DeployV2 script, we notice that kerosine vault is added only to vaultLicenser
, which is fine, because if we add it to kerosineManager
, another problem arrises, which we will point out later.
Now lets assume kerosine vaults are added to vaultLicenser
(which is the case, looking into in-scope deploy script).
In VaultManagerV2 there are two mappings to collection for vaults (normal and kerosine vaults) and corresponding functions to add such instances for given nftId
. We can notice that addKerosine
function check whether provided vault address is licensed by the kerosineManager
, which we saw is not done in the deploy
script and now we will mention why putting such vault into kerosineManager
would lead to even major problems:
Kersoine vaults added to kerosineManager
breaks kerosineVault.aassetPrice()
If we look in the function of how UnboundedKerosineVault
is calculating price of a single kerosine token, we notice that the vault is iterating over all entities inside kerosineManager and is calling vault.assetPrice()
for each of those.
So lets suppose we have inserter UnboundedKerosineVault
or BoundedKerosineVault
inside kerosineManager
. This leads to infinite recurrsion on the following line when we reach this kerosine vault, because we reenter the function. This is guaranteed OOG revert on each call of assetPrice
(called inside vault.getUsdValue
). Impact is DOS of the system, as it would revert on almost all functions inside VaultManagerV2
, because they are querying prices from kerosineVaults
Kersoine vaults added to vaultLicenser
breaks invariant tvl > dyad.totalSupply()
, which may also lead to unexpected reverts in kerosineVault.aassetPrice()
Lets examine the other possible option. Having kerosineVaults
only inside vaultLicenser
, so there is no recurrsion inside KersoineVault.assetPrice()
.
We will add kerosine vaults using add function and they will go inside vaults
mapping variable for nft. Now this is a problem, because kerosine vault are counted as non-kerosine vaults. This is a workaround for the following check:
if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat();
which means that user is able to mint dyad
valued more than his non-kerosine collateral (If his kerosine collateral meets the collateral ratio requirements)
This may break the system invariant, which is tvl(in non-kerosine tokens) > mintedDyad
, which may further lead to reverts in calculating bounded/unboundedKersoine.assetPrice
here
Lets examine the following scenario: For simplicity of the calculations we would assume that 1 kersoine = $1:
add
function -> it is successfully added to vaults
getNonKeroseneValue
is returning 1000 (his kersoine collateral)dyad.totalSupply
after this operation is 660, lets see what will happen inside UnboundedKersoineVault.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; }
tvl
would be 0, because user collateral is a kersoineVault
, which is licenzed only by vaultLicenzer
and kerosineManager
vaults still don't hold any value. On the other hand dyad.totalSupply()
is 660, because user kersoine collateral allowed user to mint it.
As a result uint numerator = tvl - dyad.totalSupply() = 0 - 660
= panic revert.
As we can see protocol haven't taught about where should kersoine manager vaults be licensed
Manual Review
vaultLicenser
put only non-kerosine vaults and make UnboundedKerosineVault
iterate over vaults inside vaultLicenser
, instead of kerosineManager
.kerosineManager
put only kersoine vaultsContext
#0 - c4-pre-sort
2024-04-28T19:19:10Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T09:37:06Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:58:41Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:36:27Z
koolexcrypto changed the severity to 2 (Med Risk)
🌟 Selected for report: carrotsmuggler
Also found by: 0xAlix2, 0xSecuri, 0xblack_bird, 0xnev, AM, Al-Qa-qa, AlexCzm, Dudex_2004, Egis_Security, GalloDaSballo, Infect3d, Jorgect, KupiaSec, Ryonen, SpicyMeatball, T1MOH, VAD37, adam-idarrha, amaron, cu5t0mpeo, d3e4, darksnow, forgebyola, foxb868, itsabinashb, jesjupyter, nnez, peanuts, pontifex, wangxx2026, windhustler, zhuying
4.8719 USDC - $4.87
Protocol's innovative mechanism for dynamically calculating CR using Kerosine
ERC20 may open a door for an entirely new liquidation strategy, where liquidators with big capital can create honeypots and ensure they would be liquidators because they can combine withdrawing big part of the TVL(inflating the price of kerosine
) and calling liquidate
on all victims.
Let's give an example with whale, who is holding 20% of the TVL, but has never minted dyad
, so his funds are staying as surplus and is increasing the price of single kerosine
token. Now all users which deposit kerosine and mint close dyad
close to the liquidation threshold can easily and guaranteed to be liquidated by the whale who would mint dyad
for all his collateral deposited, and call liquidate
in the same transaction.
This is possible because of the formula for calculating kerosine
price in the protocol:
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(); // Kerosine totalSupply return numerator * 1e8 / denominator;
After increasing dyad.totalSupply()
with 20% for the same tvl
, we see that the price would be lower than before.
While this is expected behavior in some terms, it is not fair for searchers, which role is to race for liquidating a user, when his position become unhealthy. By the following concern, there would arise a problem, which is single entity responsible for liquidations and so less searchers in the system.
Manual Review
Obtain kerosine
price from previous block
Context
#0 - c4-pre-sort
2024-04-28T05:53:03Z
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-08T11:50:08Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-08T12:39:40Z
koolexcrypto marked the issue as satisfactory