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: 79/183
Findings: 4
Award: $40.01
🌟 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
https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/script/deploy/Deploy.V2.s.sol#L93-L94 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L67-L91 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L241-L248
VaultManagerV2 allows adding vaults as regular vaults
or as vaultsKerosene
for a given NFT.
The contract does however not prevent adding a single vault to both collection, causing its assets to be double-counted in the collateral calculations, because when adding a vault, the only check that is made is that it's eligible and not added already to the list:
File: VaultManagerV2.sol 67: function add( 68: uint id, 69: address vault 70: ) 71: external 72: isDNftOwner(id) 73: { 74: if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); 75: if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); 76: if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); 77: emit Added(id, vault); 78: } 79: 80: function addKerosene( 81: uint id, 82: address vault 83: ) 84: external 85: isDNftOwner(id) 86: { 87: if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); 88: if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); 89: if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); 90: emit Added(id, vault); 91: } 92:
It is therefore possible that a vault that is at the same time in the allowlist of keroseneManager
and vaultLicenser
is added to both collections of the same NFT id
.
This is an extremely likely scenario because if we look at the deployment script, we find two examples of vaults in both allowlists:
File: Deploy.V2.s.sol 64: kerosineManager.add(address(ethVault)); 65: kerosineManager.add(address(wstEth)); --- 93: vaultLicenser.add(address(ethVault)); 94: vaultLicenser.add(address(wstEth));
When this happens, the VaultManagerV2 will double-count the value locked in the vault, because the calculations on vaults
and vaultsKerosene
happen separately:
File: VaultManagerV2.sol 230: function collatRatio( 231: uint id 232: ) 233: public 234: view 235: returns (uint) { 236: uint _dyad = dyad.mintedDyad(address(this), id); 237: if (_dyad == 0) return type(uint).max; 238: return getTotalUsdValue(id).divWadDown(_dyad); 239: } 240: 241: function getTotalUsdValue( 242: uint id 243: ) 244: public 245: view 246: returns (uint) { 247: return getNonKeroseneValue(id) + getKeroseneValue(id); 248: }
Malicious actors can add a vault to both vaults
and vaultsKerosene
to open undercollateralized borrower positions.
add
and addKerosene
for the same vaultNotEnoughExoCollat
extra check would prevent that)Code review, Foundry
VaultManagerV2.add
, add a cross-check on vaultsKerosene[id]
to ensure the vault to be added is not there alreadyVaultManagerV2.addKerosene
, add a cross-check on vaults[id]
to ensure the vault to be added is not there alreadyMath
#0 - c4-pre-sort
2024-04-28T06:57:32Z
JustDravee marked the issue as primary issue
#1 - c4-pre-sort
2024-04-28T07:00:31Z
JustDravee marked the issue as duplicate of #966
#2 - c4-pre-sort
2024-04-29T08:37:25Z
JustDravee marked the issue as sufficient quality report
#3 - c4-judge
2024-05-04T09:46:31Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-28T15:28:20Z
koolexcrypto marked the issue as duplicate of #1133
#5 - c4-judge
2024-05-29T14:03:05Z
koolexcrypto marked the issue as satisfactory
🌟 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/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L119-L131 https://github.com/code-423n4/2024-04-dyad/blob/4a987e536576139793a1c04690336d06c93fca90/src/core/VaultManagerV2.sol#L143-L143
The VaultManagerV2 withdraw
function prevents withdraws from positions that have received a deposit in the same block:
File: VaultManagerV2.sol 134: function withdraw( 135: uint id, 136: address vault, 137: uint amount, 138: address to 139: ) 140: public 141: isDNftOwner(id) 142: { 143: if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); 144: uint dyadMinted = dyad.mintedDyad(address(this), id);
However, there is almost no check on the deposit
function which creates the condition for the withdraw
to revert:
File: VaultManagerV2.sol 119: function deposit( 120: uint id, 121: address vault, 122: uint amount 123: ) 124: external 125: isValidDNft(id) 126: { 127: idToBlockOfLastDeposit[id] = block.number; 128: Vault _vault = Vault(vault); 129: _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); 130: _vault.deposit(id, amount); 131: }
In particular, only id
is validated, while:
vault
can be anything, including a malicious contractvault.asset()
can therefore return anything, including a mock ERC-20amount
can also be 0
, allowing the exploit to work even with a legitimate vault
msg.sender
is not validated to be related to the NFT id
Anybody can DoS withdraw
operations on any id
by simply front-running them with a deposit
call for that same id
, with amount = 0
, or a dummy vault
. This can also be an effective way to prevent liquidations in case the liquidator withdraws
transactionally.
withdraw
transaction to the mempooldeposit
with amount = 0
withdraw
failsCode review, Foundry
Consider removing the DepositedInSameBlock
check, or changing the modifier of deposit
from isValidDNft
to isDNftOwner
.
DoS
#0 - c4-pre-sort
2024-04-27T11:56:35Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:25:49Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:39:59Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:45:36Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T21:51:57Z
koolexcrypto marked the issue as nullified
#5 - c4-judge
2024-05-05T21:52:00Z
koolexcrypto marked the issue as not nullified
#6 - c4-judge
2024-05-08T15:26:41Z
koolexcrypto marked the issue as duplicate of #1001
#7 - c4-judge
2024-05-11T19:49:09Z
koolexcrypto marked the issue as satisfactory
#8 - c4-judge
2024-05-13T18:34:30Z
koolexcrypto changed the severity to 3 (High Risk)
🌟 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
VaultManagerV2 has a protection in place to make sure users don't withdraw non-kerosene collateral backing their debt positions:
File: VaultManagerV2.sol 134: function withdraw( --- 144: uint dyadMinted = dyad.mintedDyad(address(this), id); 145: Vault _vault = Vault(vault); 146: uint value = amount * _vault.assetPrice() 147: * 1e18 148: / 10**_vault.oracle().decimals() 149: / 10**_vault.asset().decimals(); 150: if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
This implementation can become problematic when one attempts to withdraw an amount from a vault
that is not registered in vaults[id]
(as the code assumes) but rather in vaultsKerosene[id]
. It is therefore possible that the value removed is higher than the total value locked in the vaults[id]
vaults. This situation will cause
Withdraws from kerosene vaults will fail when the amount withdrawn is higher than the collateral locked in the non-kerosene vaults.
id
that has only one kerosene vault attachedCode review, Foundry
Consider enforcing the L150 requirement only in case vault
is counted in the getNonKeroseneValue
calculation:
uint value = amount * _vault.assetPrice() * 1e18 / 10**_vault.oracle().decimals() / 10**_vault.asset().decimals(); - if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); + if (vaults[id].contains(vault) && vaultLicenser.isLicensed(vault) && + getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat(); _vault.withdraw(id, to, amount); if (collatRatio(id) < MIN_COLLATERIZATION_RATIO) revert CrTooLow();
Under/Overflow
#0 - c4-pre-sort
2024-04-26T21:25:26Z
JustDravee marked the issue as duplicate of #397
#1 - c4-pre-sort
2024-04-29T08:48:24Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:22:11Z
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
The key concept of this finding is the inconsistency in VaultManagerV2 between what backs the value of the lent dyad.mintedDyad(address(this), id)
tokens:
collatRatio
function that counts assets in both vaultsKerosene[id]
and vaults[id]
liquidate
function only awards the liquidator the tokens in vaults[id]
liquidationAssetShare
calculation on the collatRatio
as if also vaultsKerosene[id]
tokens were to be distributedFile: VaultManagerV2.sol 230: function collatRatio( 231: uint id 232: ) 233: public 234: view 235: returns (uint) { 236: uint _dyad = dyad.mintedDyad(address(this), id); 237: if (_dyad == 0) return type(uint).max; 238: @> return getTotalUsdValue(id).divWadDown(_dyad); 239: } 240: 241: function getTotalUsdValue( 242: uint id 243: ) 244: public 245: view 246: returns (uint) { 247: @> return getNonKeroseneValue(id) + getKeroseneValue(id); 248: }
File: VaultManagerV2.sol 205: function liquidate( 206: uint id, 207: uint to 208: ) 209: external 210: isValidDNft(id) 211: isValidDNft(to) 212: { --- 221: @> uint numberOfVaults = vaults[id].length(); 222: for (uint i = 0; i < numberOfVaults; i++) { 223: Vault vault = Vault(vaults[id].at(i)); 224: uint collateral = vault.id2asset(id).mulWadUp(liquidationAssetShare); 225: vault.move(id, to, collateral); 226: } 227: emit Liquidate(id, msg.sender, to); 228: }
This means that if at any point in time, the value of tokens in vaultsKerosene[id]
becomes significantly higher than that of vaults[id]
, the lending position id
can be significantly insolvent at the time it enters liquidation territory, and who attempts to liquidate it, will inevitably incur a loss.
A borrower can open positions that are impossible to liquidate for a profit in general, and even more effectively when picking sizable (vaults
) and non-sizable (vaultsKerosene
) assets that are correlated with one guaranteed to appreciate in value over the other.
CrTooLow
and NotEnoughExoCollat
checks)liquidationAssetShare
(100%), of wETH onlyThe liquidator loss can be further exacerbated if, in addition to the price drop of both tokens, wStEth increased in value over wETH:
liquidationAssetShare
of 84%, of wETH onlyCode review, Foundry
There are different ways to solve this issue:
getNonKeroseneValue(id) < dyadMinted
(that is already enforced in several places) as an extra criteria for allowing liquidationsliquidationAssetShare
in a way that takes into account only the seizable assetsMath
#0 - c4-pre-sort
2024-04-28T10:24:26Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:03:40Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:42:51Z
koolexcrypto marked the issue as satisfactory