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: 17/183
Findings: 7
Award: $513.93
🌟 Selected for report: 0
🚀 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
Users can add and remove vaults through the add and remove functions.
function add(uint256 id, address vault) external isDNftOwner(id) { if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); } function remove(uint256 id, address vault) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); <------- if (!vaults[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
The remove function is checking is the nft has balance associate in the vault to remove (see the arrow above).
The problem is that an attacker can deposit on behalf of any nft id, so attacker can front run the remove transaction of a user sending 1 wei to the same vault that user is trying to remove, successfully making revert the user transaction.
Remove function can be DOS to any honest user trying to remove a vault of his nft.
See the deposit function:
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) <----- { idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); <------ }
As we can see the deposit functions is allowing any user(or attacker) to deposit in any vault and any nft id, the only validation associate with the nft is if the nft id is valid (see the first arrow above). see the vault deposit call (second arrow above):
function deposit( uint id, uint amount ) external onlyVaultManager { id2asset[id] += amount; <----- emit Deposit(id, amount); }
As you can see the deposit function is incrementing the id2asset[id] in the vault(see arrow above) same variable that have to be zero to remove an vault from a nft id.(see arrow above)
function remove(uint256 id, address vault) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); <------- if (!vaults[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
So an attacker can:
Manual review.
The most straightforward solution is changing the isValidDNft(id)
modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).
DoS
#0 - c4-pre-sort
2024-04-27T13:34:34Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:29:52Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:38:19Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:39:24Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:39:57Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:44:11Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:44:17Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-08T15:27:54Z
koolexcrypto marked the issue as duplicate of #1001
#8 - c4-judge
2024-05-11T19:49:45Z
koolexcrypto marked the issue as satisfactory
#9 - c4-judge
2024-05-13T18:34:30Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 Selected for report: Limbooo
Also found by: 0xabhay, 0xleadwizard, AM, ArmedGoose, Evo, HChang26, Infect3d, Jorgect, MiniGlome, SpicyMeatball, TheSchnilch, ahmedaghadi, favelanky, pontifex
238.0297 USDC - $238.03
User can deposit and withdraw from a vault through the deposit and withdraw functions
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) { idToBlockOfLastDeposit[id] = block.number; <----- Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); } /// @inheritdoc IVaultManager function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); <----- uint dyadMinted = dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
As you can see in the arrows above users can just use deposit and withdraw function one time each block.number.
The problem is that an attacker can deposit on behalf of a nft setting the idToBlockOfLastDeposit[id] = block.number
so he can see the withdraw function in the mempool and front run depositing one wei in any vault, this vault can be even a fake vault.
withdraw function can be complete DoS by an attacker stucking the funds of the user in the contract.
As you can see in the deposit function any user(or attacker) can deposit on any vault and set the idToBlockOfLastDeposit[id]
variable to the last block.number (see the first arrow above):
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) { idToBlockOfLastDeposit[id] = block.number; <----- Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); } /// @inheritdoc IVaultManager function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); <----- uint dyadMinted = dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
So an attacker can:
Manual review
The most straightforward solution is changing the isValidDNft(id) modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).
DoS
#0 - c4-pre-sort
2024-04-27T11:53:43Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:30:01Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:38:18Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:39:23Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:39:27Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:41:13Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:41:17Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-05T21:41:41Z
koolexcrypto marked the issue as not a duplicate
#8 - c4-judge
2024-05-05T21:41:50Z
koolexcrypto marked the issue as duplicate of #1266
#9 - c4-judge
2024-05-11T12:18:29Z
koolexcrypto marked the issue as satisfactory
#10 - c4-judge
2024-05-28T19:12:39Z
koolexcrypto marked the issue as duplicate of #930
🌟 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
There are two types of vault in the protocol kerosene(vaultsKerosene
) and nonkerosene(vaults
), the collateral allocate in nonkerosene behave as exogenous collateral which have to be at least equal to the dyad minted:
function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); uint dyadMinted =+ dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); <----- _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
The withdraw function is checking is user have enough exocollateral left in the NonKeroseneVault if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
the problem is that this value is substrate no matter is the collateral is keroseneVault or NonkeroseneVault.
Imagine that a user have exact quantity of getNonKeroseneValue(id)
and dyadMinted
(which is correct) when this user want to withdraw his kerosene vaults assets(supossing that his collatRatio is major than the minimum obviously) the transaction will revert because the check is resting the value to the nonKeroseneValue even if the collateral to withdraw is kerosene.
the kerosene collateral assets of users may get stuck in the contract.
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 you can see in the arrow above the function is checking is the user exocollateral is major or equal of the dyadminted, if not revert, the problem is that this condition is not checking if the value is from a NonkeroseneVault of keroseneVault.
See the next example:
The user is trying to withdraw 10 dollar from his kerosene vault but he can not because the function is assuming that user is withdrawing exocollateral.
Consider subtract the value in getNonKeroseneValue(id) just is the collateral is in NonKeroseneVaults:
function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); uint dyadMinted =+ dyad.mintedDyad(address(this), id); Vault _vault = Vault(vault); uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); if (vaults[id].contains(vault){ if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); } <----- _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); }
Other
#0 - c4-pre-sort
2024-04-26T21:18:30Z
JustDravee marked the issue as duplicate of #397
#1 - c4-pre-sort
2024-04-29T08:48:16Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:22:44Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:33:04Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 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
User have two type of vaults to deposit, kerosene and non kerosene each type have his own vaults to deposit for (vaults[id]
for non kerosene and vaultsKerosene[id]
for kerosene).
mapping (uint => EnumerableSet.AddressSet) internal vaults; mapping (uint => EnumerableSet.AddressSet) internal vaultsKerosene;
non Kerosene vaults are used as exocollateral, users need to have at least the same amount of nonKerosene of the minted dyad, the rest of the collateral can be collateral allocate in kerosene vaults because you need at least 1.5 of collateral for 1 unit of dyad:
function mintDyad( uint id, uint amount, address to ) external isDNftOwner(id) { uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount; if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat();<------- dyad.mint(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow(); <------ emit MintDyad(id, amount, to); }
The problem is that liquidation function is just taking the collateral allocate in the vaults[id]
(non kerosene vaults) but is missing taking the collateral from vaultsKerosene[id]
this is so bad because liquidator are not taking any reward from this.
function liquidate(uint256 id, uint256 to) external isValidDNft(id) isValidDNft(to) { ... uint256 numberOfVaults = vaults[id].length(); <------- for (uint256 i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[id].at(i)); uint256 collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare); vault.move(id, to, collateral); } emit Liquidate(id, msg.sender, to); }
Liquidator have not incentive to liquidate users, which mean bad debt, which mean that the protocol is unsustainable.
As you can see in the liquidate function the vaultsKerosene
are not been taking in consideration when a user get liquidated which is a mistake because users may have his dyad backed for vaultsKerosene
(kerosene vaults) and vaults
(non kerosene vaults)
function liquidate( uint id, uint to ) external isValidDNft(id) isValidDNft(to) { uint cr = collatRatio(id); if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh(); dyad.burn(id, msg.sender, dyad.mintedDyad(address(this), id)); uint cappedCr = cr < 1e18 ? 1e18 : cr; uint liquidationEquityShare = (cappedCr - 1e18).mulWadDown(LIQUIDATION_REWARD); uint liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(cappedCr); uint numberOfVaults = vaults[id].length(); <----- for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[id].at(i)); uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare); vault.move(id, to, collateral); } emit Liquidate(id, msg.sender, to); }
To explain this imagine the next scenario:
At this point we get that user have 100 usd in non kerosene vaults, 30 usd in kerosene vaults, and 100 dyad minted, if liquidator liquidate this position he has to liquidate the 100 dyad and get rewarded just the collaterals in the vaults[id] which is just 100 usd, liquidator liquidate 100 dyad and get 100 usd so instead of the liquidator get rewarded they just lost his gas.
in practice there is a liquidationAssetShare
that is multiplied by the amount that liquidatee has in the nft id so the rewarded amount for liquidator is even lower.
Manual
Take the collateral in vaultsKerosene[id]
variable
Error
#0 - c4-pre-sort
2024-04-28T10:22:40Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:03:33Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:39:29Z
koolexcrypto marked the issue as satisfactory
🌟 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
Users can add and remove kerosene vaults through the addKerosene and removeKerosene functions.
function addKerosene( uint id, address vault ) external isDNftOwner(id) { if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); } function removeKerosene( uint id, address vault ) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); if (!vaultsKerosene[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
The removeKerosene function is checking is the nft has balance associate in the vault to remove (see the arrow above).
The problem is that an attacker can deposit on behalf of any nft id, so attacker can front run the remove transaction of a user sending 1 wei to the same vault that user is trying to remove, successfully making revert the user transaction.
removeKerosene function can be DOS to any honest user trying to remove a vault of his nft.
See the deposit function:
function deposit( uint id, address vault, uint amount ) external isValidDNft(id) <----- { idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); <------ }
As we can see the deposit functions is allowing any user(or attacker) to deposit in any vault and any nft id, the only validation associate with the nft is if the nft id is valid (see the first arrow above). see the vault deposit call (second arrow above):
function deposit( uint id, uint amount ) external onlyVaultManager { id2asset[id] += amount; <----- emit Deposit(id, amount); }
As you can see the deposit function is incrementing the id2asset[id] in the vault(see arrow above) same variable that have to be zero to remove an vault from a nft id.(see arrow above)
function removeKerosene( uint id, address vault ) external isDNftOwner(id) { if (Vault(vault).id2asset(id) > 0) revert VaultHasAssets(); <------ if (!vaultsKerosene[id].remove(vault)) revert VaultNotAdded(); emit Removed(id, vault); }
So an attacker can:
Manual review.
The most straightforward solution is changing the isValidDNft(id)
modifier in the deposit function for the isDNftOwner(id) modifier. (This have to be carefully review and may be different from the design of the project).
DoS
#0 - c4-pre-sort
2024-04-27T13:34:39Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:29:56Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:38:19Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:39:24Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T20:39:39Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-05T21:43:21Z
koolexcrypto marked the issue as nullified
#6 - c4-judge
2024-05-05T21:43:26Z
koolexcrypto marked the issue as not nullified
#7 - c4-judge
2024-05-05T21:43:32Z
koolexcrypto marked the issue as not a duplicate
#8 - c4-judge
2024-05-06T08:54:22Z
koolexcrypto marked the issue as duplicate of #118
#9 - c4-judge
2024-05-11T12:24:02Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: Pataroff
Also found by: Egis_Security, Evo, Jorgect, MrPotatoMagic, SBSecurity, T1MOH, carrotsmuggler, ljj
223.9474 USDC - $223.95
The vaultManagerV2 is exposing a burnDay and redeemDay functions to burn their dyad and clear his debt.
function burnDyad( uint id, uint amount ) external isValidDNft(id) { dyad.burn(id, msg.sender, amount); <------ emit BurnDyad(id, amount, msg.sender); } /// @inheritdoc IVaultManager function redeemDyad( uint id, address vault, uint amount, address to ) external isDNftOwner(id) returns (uint) { dyad.burn(id, msg.sender, amount); <----- Vault _vault = Vault(vault); uint asset = amount * (10**(_vault.oracle().decimals() + _vault.asset().decimals())) / _vault.assetPrice() / 1e18; withdraw(id, vault, asset, to); emit RedeemDyad(id, vault, amount, to); return asset; }
The problem is that burn function can be called by anyone for any id, so honest user trying to burn all his dyad or redeem his dyad, attacker can repay just one wei 1 and make revert for overflow the transaction.
Note that this jus work for transactions that burn all the dyad that a id have, the DoS work because the Dyad contract is maintaining a state of the minted dyad for id:
function burn( uint id, address from, uint amount ) external licensedVaultManager { _burn(from, amount); mintedDyad[msg.sender][id] -= amount; <------ }
burnDyad and redeemDyad can be D.o.S when user trying to repaid all his dyad, that this can be de case in much cases.
The burn function in the dyad contract is maintaining the above state(see the arrow):
function burn( uint id, address from, uint amount ) external licensedVaultManager { _burn(from, amount); mintedDyad[msg.sender][id] -= amount; <------ }
If someone try to burn a amount major than mintedDyad[msg.sender][id] the execution will revert for overflow.
Now see the next scenario:
Manual review
The most straightforward solution is change isValidDNft(id) for isDNftOwner(id) in the burnDyad function.
DoS
#0 - c4-pre-sort
2024-04-28T05:29:40Z
JustDravee marked the issue as duplicate of #74
#1 - c4-pre-sort
2024-04-29T11:58:29Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-10T10:12:25Z
koolexcrypto marked the issue as not a duplicate
#3 - c4-judge
2024-05-10T10:12:50Z
koolexcrypto marked the issue as primary issue
#4 - c4-judge
2024-05-10T10:15:27Z
koolexcrypto marked the issue as satisfactory
#5 - c4-judge
2024-05-10T10:15:35Z
koolexcrypto marked the issue as selected for report
#6 - 0xNentoR
2024-05-16T04:09:18Z
Hi @koolexcrypto,
It's assumed that the burn()
function can be called by anyone. I believe this assumption is wrong:
VaultManagerV2.sol:
function burnDyad( uint id, uint amount ) external isValidDNft(id) { dyad.burn(id, msg.sender, amount); emit BurnDyad(id, amount, msg.sender); }
Dyad.sol:
function burn( uint id, address from, uint amount ) external licensedVaultManager { _burn(from, amount); mintedDyad[msg.sender][id] -= amount; }
You can see the call that is made to Dyad::burn()
always uses the msg.sender
as the address to burn from. And it's not possible to circumvent that from VaultManagerV2
. It's also not possible to call directly Dyad::burn()
because it expects to have a licensed vault as its caller:
modifier licensedVaultManager() { if (!licenser.isLicensed(msg.sender)) revert NotLicensed(); _; }
#7 - PetarTolev
2024-05-16T09:00:57Z
Hi @koolexcrypto,
The burn()
function can be called by anyone as it is missing an isDNftOwner
modifier. @0xNentoR is stating that "Dyad::burn
always uses msg.sender
as the address to burn from", but msg.sender
that is being passed to the mintedDyad
mapping inside the Dyad::burn
function represents the address of the VaultManagerV2
, which is always licensed, effectively bypassing the licensedVaultManager
modifier.
// vault manager => (dNFT ID => dyad) mapping (address => mapping (uint => uint)) public mintedDyad;
I think the confusion stems from the fact that we are passing a msg.sender
as a second argument to the Dyad::burn
function.
function burnDyad(uint256 id, uint256 amount) external isValidDNft(id) { @> dyad.burn(id, msg.sender, amount); emit BurnDyad(id, amount, msg.sender); }
However, the msg.sender
that is being passed represents the from address, which is the address of the caller of VaultManagerV2::burnDyad
function.
function burn( uint id, @> address from, uint amount ) external licensedVaultManager { _burn(from, amount); mintedDyad[msg.sender][id] -= amount; }
Due to VaultManagerV2::burnDyad
making an external call to the Dyad contract, the msg.sender
inside Dyad::burn
represents the address of the VaultManager
contract, which is licensed vault.
The first line in the implementation logic of Dyad::burn
is the one responsible for burning the user tokens from his address.
_burn(from, amount);
The second one is responsible for burning the user tokens from the internal accounting of the vault.
mintedDyad[msg.sender][id] -= amount;
I believe this issue is a duplicate of 100, which has a greater impact due to creation of bad debt that cannot be liquidated nor redeemed. Additionally, it describes a further exploitation of the vulnerability, causing an user to be able to clear DYAD debt from another position while retaining the DYAD balance associated with it, effectively tricking the protocol into allowing him to mint more DYAD, as the position no longer has DYAD debt according to the internal accounting of the vault.
#8 - 0xNentoR
2024-05-16T09:38:16Z
@PetarTolev You're correct actually. Seems like I overlooked this. It burns the Dyad of the malicious caller but the mintedDyad
mapping counts it towards the NFT holder. The DOS here can be utilized together with the DOS for deposit()
to prevent a user from keeping their collateral ratio in the healthy range.
#9 - adamidarrha
2024-05-16T10:11:40Z
This issue should be classified as low/QA. According to code4rena docs, a medium classification requires a significant impact on protocol functionality or availability, which is not the case here since the issue can be easily avoided:
The issue is confined to the burn function and does not affect the redeem function, which is exclusive to the DNFT owner.
The attacker effectively repays a small portion of the victim's debt. The victim can easily circumvent this by resubmitting the transaction, burning slightly less than 100% of their debt. For example, if the user burns 90%, the attacker must repay 10% plus 1 wei for the transaction to revert.
it is different than the other two DOS issues , in which this can just be avoided entirely by burning less than 100%. the other two cannot be avoided.
#10 - shafu0x
2024-05-16T20:12:52Z
All of these issues should be fixed by making burnDyad
gated by the isDNftOwner
modifier
#11 - c4-judge
2024-05-28T10:29:46Z
koolexcrypto marked the issue as duplicate of #100
#12 - c4-judge
2024-05-28T10:30:07Z
koolexcrypto marked the issue as not selected for report
#13 - koolexcrypto
2024-05-28T10:31:12Z
This is a valid medium issue, #100 is selected for report as it demonstrates a further impact.
#14 - koolexcrypto
2024-05-28T10:36:27Z
The issue is confined to the burn function and does not affect the redeem function, which is exclusive to the DNFT owner.
redeem depends on burn, so if you DoS burn, redeem follows.
The attacker effectively repays a small portion of the victim's debt. The victim can easily circumvent this by resubmitting the transaction, burning slightly less than 100% of their debt. For example, if the user burns 90%, the attacker must repay 10% plus 1 wei for the transaction to revert. it is different than the other two DOS issues , in which this can just be avoided entirely by burning less than 100%. the other two cannot be avoided.
Still a DoS, the attacker prevents the target from burning their full DYAD. Also 1 WEI is just an example, it can be more.
🌟 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
UnboundedKerosineVault is a vault that the main asset is kerosene token, this could be checked in the deploy script:
UnboundedKerosineVault unboundedKerosineVault = new UnboundedKerosineVault( vaultManager, Kerosine(MAINNET_KEROSENE), Dyad (MAINNET_DYAD), kerosineManager );
This unboundedKerosineVault from part of the Licenser vaults so users can uses them to borrow dyad. The problem is that the asset price of this unboundedKerosineVault is relaying in a balanceOf the kerosineManager vaults which are easily manipulable:
function assetPrice() public view override returns (uint) { uint tvl; address[] memory vaults = kerosineManager.getVaults(); uint numberOfVaults = vaults.length; for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[i]); tvl += vault.asset().balanceOf(address(vault)) <------- * vault.assetPrice() * 1e18 / (10**vault.asset().decimals()) / (10**vault.oracle().decimals()); } uint numerator = tvl - dyad.totalSupply(); uint denominator = kerosineDenominator.denominator(); return numerator * 1e8 / denominator; }
Asset price of unboundedKerosineVault are easily manipulable and attacker can exploit this minting a lot Dyad possibly draining the contract and letting it with bad debt.
The assetPrice function is relaying in a vault.asset().balanceOf(address(vault))
which is easy manipulable, since the protocol is not allowing deposit and withdraw in the same block.number attacker can not use a flash loan but he can use his own liquidity (there are plenty of attacker with bunch of liquidity to perform hacks):
function assetPrice() public view override returns (uint) { uint tvl; address[] memory vaults = kerosineManager.getVaults(); uint numberOfVaults = vaults.length; for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaults[i]); tvl += vault.asset().balanceOf(address(vault)) <------- * vault.assetPrice() * 1e18 / (10**vault.asset().decimals()) / (10**vault.oracle().decimals()); } uint numerator = tvl - dyad.totalSupply(); uint denominator = kerosineDenominator.denominator(); return numerator * 1e8 / denominator; }
To manipulate the price attacker just have to:
Manual
balanceOf are susceptible to this kind of manipulation consider implement another mechanism to prices. Other option is lock the liqudity on a vault for more time.
Other
#0 - c4-pre-sort
2024-04-28T05:50:48Z
JustDravee marked the issue as duplicate of #67
#1 - c4-pre-sort
2024-04-29T09:06:15Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T09:59:12Z
koolexcrypto changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-05-08T11:50:06Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-08T12:51:59Z
koolexcrypto marked the issue as satisfactory