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: 2/183
Findings: 9
Award: $1,417.44
π Selected for report: 1
π Solo Findings: 0
π Selected for report: MrPotatoMagic
Also found by: 0x175, 0x486776, 0x77, 0xAkira, 0xAsen, 0xDemon, 0xabhay, 0xblack_bird, 0xlemon, 0xloscar01, 0xtankr, 3docSec, 4rdiii, Abdessamed, AlexCzm, Angry_Mustache_Man, BiasedMerc, Circolors, Cryptor, DMoore, DPS, DedOhWale, Dinesh11G, Dots, GalloDaSballo, Giorgio, Honour, Imp, Jorgect, Krace, KupiaSec, Mrxstrange, NentoR, Pechenite, PoeAudits, Ryonen, SBSecurity, Sabit, T1MOH, TheFabled, TheSavageTeddy, Tychai0s, VAD37, Vasquez, WildSniper, ZanyBonzy, adam-idarrha, alix40, asui, blutorque, btk, c0pp3rscr3w3r, caglankaan, carrotsmuggler, d_tony7470, dimulski, dinkras, djxploit, falconhoof, forgebyola, grearlake, imare, itsabinashb, josephdara, kartik_giri_47538, ke1caM, kennedy1030, koo, lionking927, ljj, niser93, pep7siup, poslednaya, ptsanev, sashik_eth, shaflow2, steadyman, turvy_fuzz, ubl4nk, valentin_s2304, web3km, xyz, y4y, zhaojohnson, zigtur
0.0234 USDC - $0.02
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119-L131 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L134-L142
The protocol allows every user to deposit funds in any dnft (no restrictions for deposits). And as a new Feature, the protocol also implemented a mechanism that is aimed at blocking flashloan attacks and the mechanism will simply revert if a withdrawal happens if there was a deposit to the same DNft in the same Block. Those 2 Facts could be abused by attackers to block other users from withdrawing funds from the protocol: This is done by simply frontruning the withdrawal and depositing 1 wei in a vault on behalf of the user to the user DNft, that he wants to withdraw funds from and the withdraw transaction will revert because of the flashloan guard.
This Attack Vectors is highly desirable for Attackers, as they could benefit from users not being able to withdraw their funds. (For example, to pump up the price of Kerosene)
The deposit()
function in the VaultManagerV2 contract is the method that is used to deposit funds into the vaults.
As we can see from the code snippet, only the modifier isValidDNft()
is used to check if the DNft is valid and doesn't check if the sender is the DNft owner.
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119-L131
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); }
We could also see that idToBlockOfLastDeposit[id]
is set to the current block number in the deposit()
function. With those 2 facts, any attacker could frontrun user withdrawals and deposit 1 wei in any of the user Vaults and the user withdrawal transaction will revert because of the flashloan guard. (see code snippet below)
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L134-L142
function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock();
We Recommend allowing only DNft owners to deposit funds in their vaults to mitigate this attack vector.
function deposit( uint id, address vault, uint amount ) external - isValidDNft(id) + isDNftOwner(id) { idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); }
DoS
#0 - c4-pre-sort
2024-04-27T18:07:26Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:28:39Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:38:12Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T21:30:38Z
koolexcrypto marked the issue as nullified
#4 - c4-judge
2024-05-05T21:30:42Z
koolexcrypto marked the issue as not nullified
#5 - c4-judge
2024-05-08T15:29:09Z
koolexcrypto marked the issue as duplicate of #1001
#6 - c4-judge
2024-05-11T19:50:52Z
koolexcrypto marked the issue as satisfactory
π 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
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L146-L149
The bug breaks a system invariant, as it makes Kerosene Tokens deposited in an unbounded kerosene Vaults also unwithdrawable and the problem also arises from the fact that in the bounded Kerosene Vaults at least the users deposited Kerosene Tokens will have twice the 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(); }
As it is implemented currently the withdraw() function doesn't differentiate between kerosene Vaults and eth Vaults. Eth vaults have the oracle state variable, while Kerosene Vaults, doesn't require an Oracle and therefore don't need it and doesn't implement it.
This will cause _vault.oracle()
call to actually revert for Kerosene Vaults (for bounded and unbounded) which will prohibit users from withdrawing Kerosene
To run the Poc please add this test to the test/fork/v2.t.sol
file:
function testWithdrawUnboundedPoC() public{ address userPoc = DNft(MAINNET_DNFT).ownerOf(1); console.log("User PoC: ", userPoc); // @audit-info we are using add instead of addKerosene because of the bug in the addKerosene/add functions vm.prank(userPoc); contracts.vaultManager.add(1,address(contracts.unboundedKerosineVault) ); Kerosine kerosine = Kerosine(MAINNET_KEROSENE); console.log("Kerosine balance: ", kerosine.balanceOf(userPoc)); console.log("Kerosine balance: ", kerosine.balanceOf(MAINNET_OWNER)); vm.prank(MAINNET_OWNER); // fund the the wallet of the user used for PoC kerosine.transfer(userPoc, 50 * 1e18); vm.startPrank(userPoc); kerosine.approve(address(contracts.vaultManager), 10 * 1e18); contracts.vaultManager.deposit(1, address(contracts.unboundedKerosineVault),10 * 1e18); console.log("deposit successfull"); // @audit-info we need to skip next block to avoid reverting because of flashloan protection vm.roll(block.number + 1); // @audit-info now withdraw from the deposited kerosene in the vault contracts.vaultManager.withdraw(1, address(contracts.unboundedKerosineVault),5 * 1e18, userPoc); }
Result:
Running 1 test for test/fork/v2.t.sol:V2Test [FAIL. Reason: EvmError: Revert] testWithdrawUnboundedPoC() (gas: 249942) Logs: User PoC: 0x01df211a9c8A9AE434eD2d34404dd7F48b83645C Kerosine balance: 0 Kerosine balance: 950841953470486444914439000 deposit successfull Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 4.72s Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/fork/v2.t.sol:V2Test [FAIL. Reason: EvmError: Revert] testWithdrawUnboundedPoC() (gas: 249942) Encountered a total of 1 failing tests, 0 tests succeeded
the withdraw()
function should differentiate between kerosene and non-kerosene vaults. Instead of using oracle, directly use the assetPrice()
function for kerosene vaults
Error
#0 - c4-pre-sort
2024-04-26T21:47:39Z
JustDravee marked the issue as duplicate of #1048
#1 - c4-pre-sort
2024-04-28T18:38:36Z
JustDravee marked the issue as duplicate of #830
#2 - c4-pre-sort
2024-04-29T08:44:32Z
JustDravee marked the issue as sufficient quality report
#3 - c4-judge
2024-05-11T20:04:31Z
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
As stated in the white paper for V6, the withdraw()
function checks if the user position after withdrawal must fulfill the following invariant check:
$K_{\text{valuewithdraw}} = \frac{(TVL - C_{\text{withdraw}}) - D_{\text{supply}}}{K_{\text{supply}}}
$
But as it is stated in the white paper the withdrawal must fullfill this condition only if the the amount being withdrawn is done for non-kerosene Tokens.
The Problem however arises from the fact, that the implementation doesn't differentiate between Kerosene and non-Kerosene Tokens withdrawals. This bug will lead to users not being able to withdraw their deposited Kerosene Tokens if they have not deposited any exogenous Tokens because the invariant check will cause an underflow that will block users from withdrawing their Kerosene Tokens. (even if the users didn't mint any DYAD)
if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
As we can see in the Code Snippet the implementation will always checks for the invariant check even for Kerosene Tokens withdrawals.
In the case that there was no exogenous Tokens deposited, part getNonKeroseneValue(id) - value
will evaluate to 0 - withdrawl_amount
which because of the implicit underflow check in solidity versions > 8 will cause the withdrawal transaction to revert.
In our Opinion, we recommend that the protocol should simply implement a separate function to withdraw Kerosene Tokens. Trying to only offer a single function may make the `withdraw() function more complex and harder to maintain.
Please Note that the
withdraw()
function have another bug that will always block withdrawal for Kerosene Tokens from unbounded Tokens. please check our Issue with title: Withdrawing Kerosene tokens fromVault.kerosine.unbounded
inVaultManagerV2
will always revert
Invalid Validation
#0 - c4-pre-sort
2024-04-26T21:12:39Z
JustDravee marked the issue as duplicate of #397
#1 - c4-pre-sort
2024-04-29T08:48:14Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:22:51Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:33:03Z
koolexcrypto changed the severity to 3 (High Risk)
π Selected for report: 0xAlix2
Also found by: 0x175, 0x486776, 0xnev, 3docSec, 3th, Aamir, Abdessamed, AlexCzm, Angry_Mustache_Man, Circolors, DedOhWale, Emmanuel, Giorgio, Honour, Jorgect, KupiaSec, Maroutis, Myrault, SBSecurity, Stefanov, T1MOH, VAD37, Vasquez, adam-idarrha, alix40, ducanh2706, falconhoof, iamandreiski, ke1caM, kennedy1030, koo, lian886, ljj, miaowu, pontifex, sashik_eth, shikhar229169, vahdrak1
7.3026 USDC - $7.30
Even though The VaultManagerV2.sol changed how the collateralization ratio is calculated in V2. The protocol still uses the same liquidate()
function which only seizes collateral from non-kerosene Vault
After confirming the problem with the sponsor, it appears to be that the protocol forgot to iterate through the kerosine vaults and only let the liquidators seize collateral from the Non-Kerosine Vaults
To better illustrate, the impact of the missing Collateral sent to liquidator, please consider following example:
A user has amount A of WETH woth 1K USD and and have an amount B of Kerosene worth 0.4K of USD The User has minted 1K DYAD:
As we can see the The liquidation fee mechanism includes the Kerosene value in the fee calculation, but allows the liquidator to only seize non Kerosene collateral.
This structure may have worked perfectly for the first version of the protocol, where kerosene vaults didn't exist. But now if a user puts a relevant amount of kerosene in his position, The liquidation of the position in most cases will be unprofitable for the liquidator Such a rewarding structure could break the protocol and lead to total insolvancy. For this reason we have set the severity as high
the liquidate function in the VaultManagerV2.sol is the exact same as the one in the VaultManager.sol
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); }
collatRatio is calculated following the following formula: => value_usd_Kerosene_Collateral + value_usd_Exo_Collateral / total_minted_dyad
And the liquidation incentive is calculated from this collateral ratio, which includes the kerosene Value in the collateral value. We could later see that in the for loop the protocol iterates on the non kerosene vaults, and takes the calculated seize percantage of the non kerosene collateral.
And because of this discrepancy the liquidationAssetShare
is actually incorrect as the liquidator wouldn't get the (liquidationEquityShare + 1e18
) of the kerosene collateral, but only this percentage of the non kerosene collateral. which leads to less incentive than deserved
As I showed in the e.g in the first section, This would lead to the liquidator being way down in a loss.
The protocol needs to iterate through the kerosine vaults in the liquidate()
function and move the seized kerozen to Dnft of the liquidator. e.g fix would be like this:
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); } + uint numberOfKeroseneVaults = vaultsKerosene[id].length(); + for (uint i = 0; i < numberOfVaults; i++) { + Vault vault = Vault(vaultsKerosene[id].at(i)); + uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare); + vault.move(id, to, collateral); + } emit Liquidate(id, msg.sender, to); }
Other
#0 - c4-pre-sort
2024-04-28T10:24:00Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:07:48Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:43:01Z
koolexcrypto marked the issue as satisfactory
π Selected for report: carrotsmuggler
Also found by: Al-Qa-qa, Emmanuel, TheFabled, TheSavageTeddy, ZanyBonzy, adam-idarrha, alix40, lian886
746.4915 USDC - $746.49
Flash loan attacks are still possible and attackers are still able to perform flash loan attacks to maniputlate Kerosene Price
Performing a flashloan attack is still possible, the attack is not as direct as it was by simply depositing and withdrawing. The attack goes like this:
The attacker will simply keeps on repeating the attack untill the remaining value of Weth in the contract is negligble, with each iteration reducing the amount he have in the vault by 73,33%.
E.g calculations of how many iterations required and how much percentage of weth still left in the contract
Percentage Locked in Vault | Percentage Liquidated (withdrawable to pay flash loan) | |
---|---|---|
Iteration 0 | 100% | 0% |
Iteration 1 | 26,99% | 73,33% |
Iteration 2 | 7,11% | 92,89% |
Iteration 3 | 1,89% | 98,1% |
After only 3 Iteration of the attack the attacker would be able to withdraw 98,1% of his deposited funds without paying any fees other than gas fees. (and the small funds that are left in the contract he can still withdraw in the next block) Following this strategy an attacker is able to bypass the flash loan security guard and perform any price manipulation he wants using flash loans
To mitigate this attack vector, we would recommend tracking dyad minting through mintDyad() and to only allow 1 dyad minting operation per block.
Context
#0 - c4-pre-sort
2024-04-29T06:40:20Z
JustDravee marked the issue as duplicate of #1268
#1 - c4-pre-sort
2024-04-29T08:32:40Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-03T13:35:16Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - aliX40
2024-05-16T03:46:52Z
Dear judge @koolexcrypto , Thanks for judging the contest, I appreciate the hard work.
I want to point out that this issue might have been wrongly labeled, as the primary issue is that liquidators could self liquidate to earn fees, but the issue i described here is how An attacker could avoid the flashloan guard by abusing the volatile price of Kerosene and self liquidations. I have the opinion that this issue should be duped to issue #68 (Flash loan protection mechanism can be bypassed via self-liquidations).
#4 - aliX40
2024-05-16T04:14:49Z
T.B.D:
#5 - koolexcrypto
2024-05-22T09:16:23Z
Hi @aliX40
Thank you for pointing out this. This was a mistake on my end, the steps of the attack could have been better though. for example, MarketManagerV2
gives an impression that this was AI generated since there is no such contract.
#6 - c4-judge
2024-05-22T09:16:35Z
koolexcrypto marked the issue as not a duplicate
#7 - c4-judge
2024-05-22T09:16:46Z
koolexcrypto removed the grade
#8 - c4-judge
2024-05-22T09:17:06Z
koolexcrypto marked the issue as duplicate of #68
#9 - c4-judge
2024-05-22T09:31:14Z
koolexcrypto changed the severity to 2 (Med Risk)
#10 - c4-judge
2024-05-22T09:31:25Z
koolexcrypto marked the issue as satisfactory
#11 - c4-judge
2024-05-28T09:57:10Z
koolexcrypto changed the severity to 3 (High Risk)
π Selected for report: Infect3d
Also found by: 0x486776, 0xAlix2, 0xleadwizard, 0xnilay, Abdessamed, ArmedGoose, Bauchibred, Bigsam, GalloDaSballo, HChang26, Myrault, OMEN, SBSecurity, T1MOH, ZanyBonzy, alix40, atoko, iamandreiski, jesjupyter, ke1caM, miaowu, peanuts, vahdrak1
17.2908 USDC - $17.29
It might have been less risky to ignore this issue in the prior version of the protocol, As before the addition of Kerosene the minimum Collateral Ratio was 150%. and the liquidation incentive is 20% which is really high and would lead in most cases to positions being liquidated before the collateralization ratio drops under 100%.
With the addition of kerosene which would allow the users to mint dyads worth 1:1 to the value of their exo collateral. The risk of positions going under (debt worth more than collateral) is significantly higher. This is also due to the fact that kerosene value is highly dependent of the value of excess collateral and the number of kerosene tokens being distributed (not locked in the main account). This could lead to a volatile price of kerosene and will lead to actually higher volatility in the market.
E.g: as it is now only ETH is presented as collateral: meaning the price of kerosene corrolate indirectly with the price of ETH. A drop of 10% in ETH could result in a drop of 10% in the price of kerosene too. The less the excess collateral is, the more the value of Kerosene will drop. Please also note that in the case where prices drop. Users tend to rush in withdrawing their Collateral and for example exchanging it for Stable Coins to avoid risk. This will also lead in the price of Kerosene dropping significantly (less deposits == less excess collateral but number of kerosene Tokens supply stays the system) and also increasing the risk of liquidations
Due to the amount of risk assossiated with the addition of Kerosene, having no mechanism or structure in place to handle underwater debts, could lead to the insolvancy of the protocol in times of high volatility
With the addition of Kerosene, the protocol in our opinion must implement a structure to handle underwater debt. The option we would recommend is to implement a stability pool (recomended most) or/with socializing bad debt
Context
#0 - c4-pre-sort
2024-04-28T10:11:02Z
JustDravee marked the issue as duplicate of #193
#1 - c4-pre-sort
2024-04-29T09:34:07Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T11:47:41Z
koolexcrypto marked the issue as duplicate of #1097
#3 - c4-judge
2024-05-08T08:38:07Z
koolexcrypto marked the issue as not a duplicate
#4 - c4-judge
2024-05-08T14:57:35Z
koolexcrypto marked the issue as duplicate of #193
#5 - c4-judge
2024-05-09T12:21:17Z
koolexcrypto marked the issue as duplicate of #977
#6 - c4-judge
2024-05-12T09:23:59Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#7 - c4-judge
2024-05-29T07:02:13Z
koolexcrypto marked the issue as satisfactory
599.0364 USDC - $599.04
Bounded Kerosene is not attractive for liquidation. First of all bounded kerosene has twice the value of unbounded kerosene. E.g if 1 Kerosene is worth 10 usd in the unbounded vault then 0.5 Kerosen will be worth the same amount in a bounded vault. Twice the valuation is justified for users and Liquidity Providers. The problem however is if we take the prespective of a liquidator. If he liquidates a position that has a significant percentage of the position value in bounded Kerosene Vault. The liquidator will recieve the amount locked in the bounded kerosene vault, which he can't withdraw so the liquidator actually wouldn't be necessary be able to swap the kerosene against stable coins to make a profit for example. He will also recieve half the amount of kerosene if this was an unbounded kerosene Vault. This might not be clear, so we encourage the reader to see the following example to understand the need to rework liquidations for bounded kerosene vaults.
N.B please note that there is a bug in the liquidate() function and Kerosene Collateral are not sent to the liquidator, but as I have confirmed with the sponsor, The protocol intended to send the Kerosene Tokens to the liquidators along side the seized Eth
As an example to show the huge disadvantage a liquidator will face when liquidating bounded kerosene vs unbounded kerosen vs exo collateral (like eth)
let's take similar context but with different composition of collateral
Collateral worth 1.4k and minted dyad is worth 1k usd: -> collRatio =140 % which is elligible for liquidation. percentage of coll to seize is (100%+ 40%*20%)/140% => 77.14% of collateral to seize
For simplicity let's say 1 unbounded kerosene Token is worth 100 usd and 1 bounded kerosene Token is worth 200 usd (twice the asset price) in the vaults as collateral ( however the value of Kerosene outside the vaults in exchanges is gonna be near the 100 usd valuation)
The liquidator needs to pay down 1k dyads (1k usd)
Bounded Kerosene | Unbounded Kerosene | Eth (no Kerosen) | |
---|---|---|---|
Liquidation cost for liquidators | 1k DYAD | 1k DYAD | 1k DYAD |
collateral composition | 1k worth of eth + 2 kerosene Tokens | 1k worth of eth + 4 kerosene Tokens | 1.4k usd worth of eth |
seized coll | 771 usd worth of eth + 1.54 Kerosene Token (not withdrawable) | 771 usd worth of eth + 3.08 Kerosene Tokens(withdrawable) | 1.08k usd worth of eth |
usd worth of seized collateral | 925 usd (154 usd value is locked, not tradable) | 1.08k usd | 1.08 k usd |
Having twice the value for kerosene if locked, might make sense for Protocol user, this however will result in problems for liquidators. We recommend the protocol to implement a solution that addresses the following issues:
Context
#0 - c4-pre-sort
2024-04-28T10:24:08Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:07:53Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-08T09:56:28Z
koolexcrypto marked the issue as not a duplicate
#3 - c4-judge
2024-05-08T14:50:52Z
koolexcrypto marked the issue as duplicate of #128
#4 - c4-judge
2024-05-11T19:43:03Z
koolexcrypto marked the issue as satisfactory
#5 - aliX40
2024-05-16T03:49:18Z
Dear judge @koolexcrypto , Thanks for judging the contest, I appreciate the hard work.
I want to point out that this issue is wrongly duped to the primary #128 (Kerosene collateral is not being moved on liquidation, exposing liquidators to loss), to this issue i have already submitted an issue that is already correctly dupplicated to the primary: liquidate() only seizes non-Kerosene Collateral leading to unprofitable liquidations #342
This issue #343 i submitted, showcases that even if Kerosene collateral is correctly moved (if the implementation was actually correct) Liquidators who will liquidate positions with a significant bounded Kerosene in it, will have unprofitable liquidations, due to the following reasons (please also see issue for full details):
Fixing the primary (moving kerosene tokens to liquidators) wouldn't fix this issue and according to C4 documentation this makes the issue I described not a dupplicate but a seperate issue
According to C4 juding criteria described in the official docs (link)[https://docs.code4rena.com/awarding/judging-criteria]:
Findings are duplicates if they share the same root cause.
More specifically, if fixing the Root Cause (in a reasonable manner) would cause the finding to no longer be exploitable, then the findings are duplicates.
As i already described the bug is not a dupplicate because it doesn't have the same root cause and if the root cause of the primary is fixed the bug will actually be exploitable and not fixed
Furthermore to explain why the issue is of high severity:
T.B.D: To summarize:
VaultManagerV2.liquidate()
from Kerosene Valuts (Bounded and Unbounded Vaults)Vault.kerosine.bounded.move()
is faulty along with the concept of liquidating from Bounded Kerosen Vaults, which will lead to unprofitable liquidations ( Vault.kerosine.unbounded.move()
is correctly implemented, this issue only affects Bounded Vaults)#6 - aliX40
2024-05-16T06:34:34Z
Please also note That this issue doesn't only target the implementation of VaultManagerV2.liquidate()
which lacks the move Kerosene Functionality. But it also targets the move()
implementation in /src/core/Vault.kerosine.bounded.sol, which would be called by VaultManagerV2.liquidate()
if the implementation was correct. Also please note that to fix the issue #343 the devs need to override the method move()
inherited in Vault.kerosine.bounded.sol
and add the fixes, in contrast to the primary issue where they need to only update VaultManagerV2.liquidate()
implementation.
#7 - shafu0x
2024-05-22T13:33:35Z
great find and description. making liquidated kerosene unbounded is a good idea.
#8 - c4-judge
2024-05-28T16:57:41Z
koolexcrypto marked the issue as not a duplicate
#9 - c4-judge
2024-05-28T16:58:36Z
koolexcrypto marked the issue as primary issue
#10 - c4-judge
2024-05-28T17:10:16Z
koolexcrypto marked the issue as selected for report
#11 - c4-judge
2024-05-28T17:47:11Z
koolexcrypto changed the severity to 2 (Med Risk)
#12 - aliX40
2024-05-28T20:03:49Z
Dear judge @koolexcrypto, Thanks for reevaluating this issue, I still think this is a high severity bug and for the following reasons:
Dear judge, i urge u to reconsider the severity of this bug, as in my opinion this is of high severity due to the high likeability and the high impact.
#13 - McCoady
2024-05-28T23:05:43Z
Hi, sorry for the comment so far outside of PJQA but hopefully due to the status change it's understandable.
I'm unsure how this issue is in scope given it first requires speculating on how the sponsor will mitigate the underlying issue (that neither Kerosene type is handled during liquidations).
In fact KeroseneVault
(inherited by bounded & unbounded vaults) has a move
function which assumedly was supposed to be used to move Kerosene on liquidation meaning the liquidators would still receive bounded Kerosene.
function move( uint from, uint to, uint amount ) external onlyVaultManager { id2asset[from] -= amount; id2asset[to] += amount; emit Move(from, to, amount); }
The profitability of taking on BoundedKerosene is a decision to be made by the liquidator, and given that the liquidator is expected to also own a Note nft rather than a traditional liquidation bot, it's likely they would also value owning bounded Kerosene.
The issue basically boils down to the opinion that "liquidators don't want bounded Kerosene" and I don't think is our job to decide whether that's the case or not.
Additionally the recommended mitigation of unlocking bounded Kerosene completely defeats the purpose of having bounded/unbounded kerosene and users would be able to enjoy the benefits of 2x valued Kerosene as collateral and then liquidate themselves when they wished to withdraw their "bounded" Kerosene.
#14 - aliX40
2024-05-29T01:14:15Z
Hi @McCoady, thanks for commenting on this issue. I assume u are commenting to assure the fairness of the competition, which i respect. But to keep the conversation short and to respect the judge time, i wish that u would also mention valid points in short form (in bullet proof format) Concerning the points u mentioned:
Point1: Out of scope/ speculations:
Point2: It is not a problem if we leave liquidations unprofitable
Point 3: it defeats the purpose of having bounded Kerosene
#15 - McCoady
2024-05-29T02:36:21Z
Further information given by the sponsor in private is not a valid proof as per C4 rules.
Liquidations are only "unprofitable" under the assumption that the liquidators don't want the locked Kerosene. Locked Kerosene that is moved will still be locked and therefore still be worth 2x value towards the liquidators own collateral (given they must also be a Notes owner to liquidate)
Liquidations may be competitive but smart users can bundle their transactions (either as a contract or via flashbots)
Will leave it up to the judge from here, just wanted to offer the counter arguments as this went astray otherwise.
#16 - aliX40
2024-05-29T03:27:22Z
#17 - koolexcrypto
2024-05-29T13:41:04Z
"Medium" since it is explicitly known (by docs, code) that bounded can't be withdrawn. One could argue that, liquidators could be aware of this and choose not to liquidate. Furthermore, liquidators could liquidate to accumulate bounded kerosene to utilize in the protocol for enhancing the CR. So, it is not completely unprofitable.
#18 - thebrittfactor
2024-06-17T21:45:10Z
For transparency, the DYAD team (shafu) acknowledged this finding outside of github. The appropriate sponsor labeling has been added on their behalf.
π 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
Judge has assessed an item in Issue #1150 as 2 risk. The relevant finding follows:
remove() and removeKerosene() could be Dossed by depositing 1 wei of collateral The VaultManagerV2 allows any one to deposit collateral assocciated with a DNft and doesnβt force the sender to be theDNft owner
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); } remove() and removeKerosene() however checks that the removed Vaults from the MarketManagerV2 must have 0 tokens posted as collateral. And in this case it is possible for an attacker to deposit 1 wei to the vault in the name of the dnft which will make vault removal revert
#0 - c4-judge
2024-05-13T11:43:46Z
koolexcrypto marked the issue as duplicate of #118
#1 - c4-judge
2024-05-13T11:43:55Z
koolexcrypto marked the issue as satisfactory
π 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
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L80 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L67 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L280
The implementation of the adding vaults functions (kerosene and non-kerosene) is not implemented correctly. This is a high severity bug as it will allow users to actually add kerosene-vaults as non-kerosene vaults. Which would make it possible for users to mint dyad fully against Kerosene Tokens (Instead of supplying ETH, to be able to do it).
Another bug in the MarketManagerV2
caused by the same false assumption, is that in getKeroseneValue()
the function will iterate through all the kerosene vaults assossiated with Dnft but will always return 0 as value.
The KerosineManager
is used to deterministaclly determine the price of Kerosene and in order to know the price of the exogenous collateral in the dyad ecosystem. For this the KerosineManager
only track the exogenous collateral vaults, and not the kerosene vaults and because of this the KerosineManager.isLicensed()
function will always return False
for kerosene vaults and will return True
for eth
and steth
vaults.
First Issue:
Proof of concept is straight forward, we will just add the unboundedKerosineVault
using add()
(which is desired for non kerosene vaults) and we will add wstEth
vault using addKerosene()
function. Both of those actions should revert, if both functions where implemented correctly
To run the proof of Concept, please add the following test to the testsuite in test/fork/v2.t.sol
function testAddPoC() public { address userPoc = DNft(MAINNET_DNFT).ownerOf(1); vm.prank(userPoc); contracts.vaultManager.add(1,address(contracts.unboundedKerosineVault) ); vm.prank(userPoc); contracts.vaultManager.addKerosene(1,address(contracts.wstEth) ); address[] memory NonKeroseneVaults = contracts.vaultManager.getVaults(1); assertTrue(NonKeroseneVaults.length == 1); assertTrue(NonKeroseneVaults[0] == address(contracts.unboundedKerosineVault)); }
Result:
Running 1 test for test/fork/v2.t.sol:V2Test [PASS] testAddPoC() (gas: 176259) Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.70s Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Second Issue:
For the second bug in the getKeroseneValue()
function, we can see that the function will iterate over all the kerosene vaults associated with the dnft, and add the value of the vaults to the total. The problem is however that the value of Kerosene Vaults is only added, if the check keroseneManager.isLicensed(vault)
returns True
, which is never the case.
function getKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaultsKerosene[id].length(); // NOTE iterate through all the kerosene vaults for (uint i = 0; i < numberOfVaults; i++) { // NOTE get usd value from kerosene vault Vault vault = Vault(vaultsKerosene[id].at(i)); uint usdValue; // @audit-issue Will always return false for kerosene vaults if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
As a Fix we recommend using KerosineManager.isLicensed(vault)
==True
to check for exogenous collateral, and KerosineManager.isLicensed(vault)
==False
along with KerosineManager.isLicensed(vault)
==True
to check for kerosene vaults.
To give an example on how to fix the add functions:
Please add the following fixes to how the Kerosen and non Kerosene vaults are added in the add
and addKerosene
functions in the VaultManagerV2
contract.
The fix aims for blocking users from adding a kerosene vault as a normal vault and from adding a normal vault as a kerosene vault.
(KerosineManager.isLicensed(vault)
would return
True
for eth
and steth
vaults but will deliver False for every other address)
function add( uint id, address vault ) external isDNftOwner(id) { if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); + if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); } + error NotAKeroseneVault(); function addKerosene( uint id, address vault ) external isDNftOwner(id) { if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); + if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); - if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); + if (keroseneManager.isLicensed(vault)) revert NotAKeroseneVault(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault);
Invalid Validation
#0 - c4-pre-sort
2024-04-29T05:16:46Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T09:36:55Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:59:58Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:36:27Z
koolexcrypto changed the severity to 2 (Med Risk)