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: 39/183
Findings: 4
Award: $298.21
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
VaultManagerV2.withdraw
calculates the USD value of the amount of collateral that the user is attempting to withdraw. It does this to ensure that the exogenous collateral value does not fall below the value of the user's mintedDyad
_vault.oracle().decimals()
oracle()
)deposit
, users will be able to deposit kerosene into the unbounded vault, but will never be able to withdraw itThe standard Vault.sol sets its oracle as a public state variable in the contructor:
IAggregatorV3 public immutable oracle; // ... constructor( IVaultManager _vaultManager, ERC20 _asset, IAggregatorV3 _oracle ) { vaultManager = _vaultManager; asset = _asset; oracle = _oracle; }
The constructor of Vault.kerosine.sol does not:
constructor( IVaultManager _vaultManager, ERC20 _asset, KerosineManager _kerosineManager ) { vaultManager = _vaultManager; asset = _asset; kerosineManager = _kerosineManager; }
This simple test also eliminates any doubt:
function testOracle() public { // defined like it is in `withdraw` Vault vault = Vault(address(contracts.unboundedKerosineVault)); vm.expectRevert(); vault.oracle(); }
Foundry, manual review
_vault.oracle()
is only called because of the check on the NonKeroseneAmount
, which would be unaffected by a kerosene withdrawal. All this logic can therefore be applied only on the condition that vault.asset() !== kerosene
, like so:
function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); Vault _vault = Vault(vault); // define the kerosene address as a state variable and set it in the constructor if (_vault.asset() !== kerosene) { uint dyadMinted = dyad.mintedDyad(address(this), id); 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(); }
Error
#0 - c4-pre-sort
2024-04-26T21:42:36Z
JustDravee marked the issue as duplicate of #1048
#1 - c4-pre-sort
2024-04-28T18:38:47Z
JustDravee marked the issue as duplicate of #830
#2 - c4-pre-sort
2024-04-29T08:45:12Z
JustDravee marked the issue as sufficient quality report
#3 - c4-judge
2024-05-11T20:05:02Z
koolexcrypto marked the issue as satisfactory
🌟 Selected for report: shikhar229169
Also found by: 0x486776, 0xSecuri, 0xfox, 3th, Circolors, Honour, KupiaSec, Maroutis, Sancybars, Stormreckson, Strausses, ke1caM, kennedy1030
283.3687 USDC - $283.37
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L156 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L134
VaultManagerV2
requires that, in order for a DNFT holder to mint DYAD against their position, the position must have enough value locked in exogenous collateral vaults (i.e., WETH and wstETH) to cover the full value of the minted debtassetPrice
of kerosene// mintDyad: if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat(); // withdraw: if (getNonKeroseneValue(id) - value < dyadMinted) revert NotEnoughExoCollat();
It is not, however, considered in any other context where the collateral ratio is checked. This means that, over time, this requirement will not necessarily prevent kerosene from being overrepresented in terms of DYAD's total collateral composition. Consider the following scenario:
$100
worth of WETH and $50
worth of kerosenegetNonKeroseneValue(id) - value < dyadMinted
, but the risks to DYAD will persist until they do so (or until the position is liquidated)Manual review
Despite the added complexity for the end user to consider, it would be better for the protocol if positions could be liquidated both when their total collateral ratio falls below the minimum and when getNonKeroseneValue(id) < dyadMinted
.
Other
#0 - c4-pre-sort
2024-04-28T17:05:27Z
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-08T09:35:27Z
koolexcrypto marked the issue as not a duplicate
#3 - c4-judge
2024-05-08T09:35:37Z
koolexcrypto marked the issue as duplicate of #338
#4 - c4-judge
2024-05-11T12:20:20Z
koolexcrypto marked the issue as satisfactory
#5 - c4-judge
2024-05-13T18:44:15Z
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
move
more than the value of the position's deposits would revert, but it is still important to establish.function test_liquidationCalc(uint256 collateralRatio, uint256 totalCollateral) public { collateralRatio = bound(collateralRatio, 1e18, 1.5e18); vm.assume(collateralRatio < 1.5e18); totalCollateral = bound(totalCollateral, 0, 1000000 * 1e18); // calculation taken from VaultManagerV2.liquidate uint256 liquidationEquityShare = (collateralRatio - 1e18).mulWadDown(0.2e18); uint256 liquidationAssetShare = (liquidationEquityShare + 1e18).divWadDown(collateralRatio); assertLe(liquidationAssetShare, 1e18); assertLe(totalCollateral.mulWadUp(liquidationAssetShare), totalCollateral); }
Having confirmed this, it can also be established that:
With these basic rules defined, imagine the following scenario:
Foundry
Most importantly, consider liquidating a position's kerosene deposits along with their exogenous collateral. This would allow liquidators to at least be compensated for the full cost of liquidation in most situations.
For a larger-scale approach, Dutch auctions could be implemented for liquidations, allowing for collateral to be liquidated more efficiently.
Math
#0 - c4-pre-sort
2024-04-28T10:24:15Z
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:39:35Z
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
VaultManagerV2
: vaults
and vaultsKerosene
. vaults
is meant to include the vaults for exogenous collateral (WETH and wstETH), and is used to getNonKeroseneValue
. vaultsKerosene
is meant to include the vaults for bounded and unbounded kerosene, and is used to getKeroseneValue
vaults
array using the add
functionKeroseneManager
in the deploy script, they can be erroneously added to the vaultsKerosene
array using the function addKerosene
KeroseneManager
, they cannot be added to the vaultsKerosene
array as intendedvaults
array will cause its value to be included in the position's NonKeroseneValue
, which will allow for DYAD to be minted against it. This allows malicious users to mint DYAD against kerosene (which undermines the value of both)vaultsKerosene
array via VaultManagerV2.addKerosene
, but they are not meant to be licensed by the KeroseneManager
(confirmed by the team). This is because the KeroseneManager
licensing is meant to track the vaults contributing to the TVL in kerosene's assetPrice
calculation — i.e., all the vaults except the ones that are meant to be added to the vaultsKerosene
array in VaultManagerV2
. So, when addKerosene
checks the following, it excludes the vaults it is meant to include, and includes the vaults it is meant to exclude:if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed();
KeroseneManager
in the deploy script, so they can be added to vaultsKerosene
via addKerosene
. By providing the same WETH or wstETH vault to both add
and addKerosene
, the value of the position's collateral in that vault will be counted twice when the total USD value of the DNFT is calculated: once when getNonKeroseneValue
calculates the USD value of the collateral in each vault in the vaults
array, and again when getKeroseneValue
calculates the USD value of the collateral in each vault in the vaultsKerosene
array. This inflates the position's collatRatio
, and can be used maliciously to avoid liquidation:function getTotalUsdValue( uint id ) public view returns (uint) { return getNonKeroseneValue(id) + getKeroseneValue(id); }
Licenser
along with the WETH and wstETH vaults in the deploy script. Since vaultLicenser.isLicensed(unboundedKeroseneVault)
is true
, the kerosene vault can be added to the normal vaults
array and treated like normal collateral. This is catastrophic, because it allows users to bypass the NotEnoughExoCollat
requirement and mintDyad against kerosene instead:function mintDyad( uint id, uint amount, address to ) external isDNftOwner(id) { // getNonKeroseneValue iterates over `vaults` array, which can include unbounded kerosene vault uint newDyadMinted = dyad.mintedDyad(address(this), id) + amount; if (getNonKeroseneValue(id) < newDyadMinted) revert NotEnoughExoCollat(); dyad.mint(id, to, amount); // ... }
Manual review
addKerosene
should be changed from if (!keroseneManager.isLicensed(vault))
to if (!vaultLicenser.isLicensed(vault))
address public immutable kerosene
should be defined in state and set in the constructoraddKerosene
should require that vault.asset() == kerosene
add
should require that vault.asset() != kerosene
Invalid Validation
#0 - c4-pre-sort
2024-04-28T20:06:24Z
JustDravee marked the issue as high quality report
#1 - c4-pre-sort
2024-04-28T20:06:46Z
JustDravee marked the issue as duplicate of #70
#2 - c4-judge
2024-05-11T20:00:07Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:36:27Z
koolexcrypto changed the severity to 2 (Med Risk)