DYAD - ahmedaghadi'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: 51/183

Findings: 2

Award: $241.85

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Findings Information

Labels

bug
3 (High Risk)
high quality report
satisfactory
upgraded by judge
:robot:_09_group
duplicate-930

Awards

238.0297 USDC - $238.03

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134

Vulnerability details

Impact

An attacker can call VaultManagerV2::deposit function with a fake vault ( or deposit 1 wei in a vault ) for a particular DNft token every block which will lead to user not being able to withdraw due DepositedInSameBlock revert in VaultManagerV2::withdraw function.

The VaultManagerV2::deposit function is as follows ( https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L119 ):

    function deposit(
    uint    id,
    address vault,
    uint    amount
    )
    external
        isValidDNft(id)
    {
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
    }

Here, there's no check for whether msg.sender is the owner of the DNft token or not. So, anyone can call VaultManagerV2::deposit function for a particular DNft token. Also, there's no check for whether the vault is a valid vault or not ( i.e vault is licensed or not ). So anyone can call this function with arbitrary amount as attacker's fake vault will have a dummy asset::transferFrom function which will always return true. And even if there's check for whether the vault is a valid vault or not, attacker can deposit 1 wei in the vault every block to restrict the availability of VaultManagerV2::withdraw function for a particular DNft token.

The VaultManagerV2::withdraw function is as follows ( https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134 ):

    function withdraw(
    uint    id,
    address vault,
    uint    amount,
    address to
    )
    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()
                    * 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();
    }

Here, the if (idToBlockOfLastDeposit[id] == block.number) revert DepositedInSameBlock(); check will revert if user has deposited in the same block. So, attacker can restrict the availability of VaultManagerV2::withdraw function for a particular DNft token by depositing in the same block every block.

The cost of attack is only gas fees for calling VaultManagerV2::deposit function every block.

Proof of Concept

Consider the following scenario:

  • (Optional step) Attacker create a dummy contract similar to a Vault contract. If this step is to be skipped then the cost of attack will be increased by 1 wei ( which kinda worth zero value ).
  • Attacker calls VaultManagerV2::deposit function for a particular DNft token with a dummy vault ( or deposit 1 wei in a legit vault ) every block.
  • Thus, user won't be able to call VaultManagerV2::withdraw function for that particular DNft token due to DepositedInSameBlock revert in VaultManagerV2::withdraw function.

Tools Used

Foundry and Manual Review

It can be fixed by only allowing the owner of the DNft token to call VaultManagerV2::deposit function.

So the VaultManagerV2::deposit function can be updated as follows:

    function deposit(
    uint    id,
    address vault,
    uint    amount
    )
    external
        isValidDNft(id)
    {
+    require(dNft.ownerOf(id) == msg.sender, "VaultManagerV2: Only owner can deposit");
    idToBlockOfLastDeposit[id] = block.number;
    Vault _vault = Vault(vault);
    _vault.asset().safeTransferFrom(msg.sender, address(vault), amount);
    _vault.deposit(id, amount);
    }

Assessed type

DoS

#0 - JustDravee

2024-04-27T11:27:45Z

Interesting take on the fact that the vault may even be any arbitrary contract. For the judge: this is one of the candidate for best report as, even without a coded POC, this is thorough

#1 - c4-pre-sort

2024-04-27T11:27:50Z

JustDravee marked the issue as high quality report

#2 - c4-pre-sort

2024-04-27T11:28:00Z

JustDravee marked the issue as duplicate of #1103

#3 - c4-pre-sort

2024-04-27T11:51:52Z

JustDravee marked the issue as duplicate of #489

#4 - c4-judge

2024-05-05T20:06:02Z

koolexcrypto marked the issue as not a duplicate

#5 - c4-judge

2024-05-05T20:16:07Z

koolexcrypto marked the issue as primary issue

#6 - koolexcrypto

2024-05-06T08:39:47Z

Making this (and its duplicates) a separate issue due to the reason that it mentions the use of arbitrary address, this could be a fake vault or any other address as long as it implement the vault interface. The implication of this, is to make calls to external addresses (although seems limited atm) using VaultManagerV2 as msg.sender.

Please note that, the mitigation would be different as well. As the check for NFTOwner will not protect from the mentioned implication. Instead, the vault address should be checked if it’s licensed or known by the protocol.

cc: @c4-sponsor

#7 - c4-judge

2024-05-08T08:04:37Z

koolexcrypto marked the issue as selected for report

#8 - c4-judge

2024-05-08T15:37:49Z

koolexcrypto changed the severity to 3 (High Risk)

#9 - mcgrathcoutinho

2024-05-16T02:15:14Z

Hi @koolexcrypto,

Maybe I'm missing something but how does checking the DNft owner not solve this issue? The warden mentioned that the attacker can use a dummy vault which can be used to block withdraws for no cost (other than gas), which is also what #1001 mentions but with 0 value transfers.

Adding isDNFTOwner() solves the issue since the attacker cannot call on behalf of the user's id anymore.

I believe this issue should be dupped under #1001.

#10 - 0xNentoR

2024-05-16T03:58:15Z

Hi @koolexcrypto,

I don't think this should be re-duped from #1001 solely because it mentions the possibility of arbitrary calls. It's true that it can make arbitrary calls, but that call is restricted only to one selector, deposit(uint, uint). It's unlikely this could cause any damage to this contract. Additionally, it's not possible to use it to steal funds from this vault because the funds would have to be approved from this contract first. I think the external call shouldn't be judged as anything more than QA.

#11 - AtanasDimulski

2024-05-16T06:19:24Z

Hi @koolexcrypto , I believe your assumption that allowing only the NFT owner to deposit, won't fix this problem is incorrect. No matter what vault is used, by allowing only the NFT owner to deposit, completely mitigates this issue. This issue is a duplicate of 1001 and should be a partial 50 dup because it only describes the griefing of the withdraw function, however 1001 additionally describes the griefing of vault removal as well.

#12 - koolexcrypto

2024-05-28T18:51:43Z

Thank you everyone for your input.

Making calls to external addresses (although seems limited atm) using VaultManagerV2 as msg.sender is still possible even if you put the NFTOwner check.

As an example, if the protocol has a Vault which is not meant to be used by VaultManagerV2. A malicious party can still use it. Please take into account that Vaults can not be called directly, all calls to Vaults goes through VaultManagerV2.

#13 - c4-judge

2024-05-28T19:06:05Z

koolexcrypto marked the issue as not selected for report

#14 - c4-judge

2024-05-28T19:13:00Z

koolexcrypto marked the issue as duplicate of #930

#15 - c4-judge

2024-05-29T07:04:58Z

koolexcrypto marked the issue as satisfactory

Awards

3.8221 USDC - $3.82

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
:robot:_28_group
duplicate-830

External Links

Lines of code

https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134

Vulnerability details

Impact

The VaultManagerV2::withdraw function is as follows ( https://github.com/code-423n4/2024-04-dyad/blob/main/src/core/VaultManagerV2.sol#L134 ):

    function withdraw(
    uint    id,
    address vault,
    uint    amount,
    address to
    )
    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()
                    * 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();
    }

KerosineVault such as BoundedKerosineVault and UnboundedKerosineVault doesn't have any function named oracle thus the VaultManagerV2::withdraw function will revert if user tries to withdraw any amount of KEROSENE assets from the vault.

Proof of Concept

NOTE: There's another issue due to which BoundedKerosineVault and UnboundedKerosineVault can't be added to VaultManagerV2 contract ( I've made another submission for that in QA report titled "Can't add KerosineVault through VaultManagerV2::addKerosene function" ). So, the following changes are done in VaultManagerV2::addKerosene function to create a working POC:

    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);
    }

NOTE: This is just a temporary change to create a working POC. Actual fix is different and more complex ( thus out of scope for this current submission ) and is mentioned in the QA report ( as mentioned in previous NOTE ).

Add this to a newly created test 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 {VaultManager} from "../src/core/VaultManager.sol";
import {Vault} from "../src/core/Vault.sol";
import {Payments} from "../src/periphery/Payments.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 {Kerosine} from "../src/staking/Kerosine.sol";
import {VaultManagerV2} from "../src/core/VaultManagerV2.sol";
import {VaultWstEth} from "../src/core/Vault.wsteth.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 {KerosineDenominator} from "../src/staking/KerosineDenominator.sol";

contract AuditTest is Test, Parameters {
    address owner = address(0x123AABB);
    address user = address(0x844);

    DNft dNft;
    Licenser vaultManagerLicenser;
    Licenser vaultLicenser;
    Dyad dyad;
    VaultManagerV2 vaultManager;
    Payments payments;

    // weth
    Vault wethVault;
    ERC20Mock weth;
    OracleMock wethOracle;

    // wsteth
    VaultWstEth wstEthVault;
    ERC20Mock wstEth;
    OracleMock wstEthOracle;

    Kerosine kerosine;
    KerosineManager kerosineManager;
    UnboundedKerosineVault unboundedKerosineVault;
    BoundedKerosineVault boundedKerosineVault;
    KerosineDenominator kerosineDenominator;

    function setUp() public {
        vm.startPrank(owner);
        dNft = new DNft();

        weth = new ERC20Mock("WETH-TEST", "WETHT");

        wethOracle = new OracleMock(1000e8);

        wstEth = new ERC20Mock("WSTETH-TEST", "WSTETHT");

        wstEthOracle = new OracleMock(1e6);

        vaultManagerLicenser = new Licenser();

        vaultLicenser = new Licenser();

        dyad = new Dyad(vaultManagerLicenser);

        kerosine = new Kerosine();

        vaultManager = new VaultManagerV2(dNft, dyad, vaultLicenser);

        wethVault = new Vault(
            vaultManager,
            weth,
            IAggregatorV3(address(wethOracle))
        );

        wstEthVault = new VaultWstEth(
            vaultManager,
            wstEth,
            IAggregatorV3(address(wstEthOracle))
        );

        kerosineManager = new KerosineManager();

        kerosineManager.add(address(wethVault));
        kerosineManager.add(address(wstEth));

        vaultManager.setKeroseneManager(kerosineManager);

        unboundedKerosineVault = new UnboundedKerosineVault(
            vaultManager,
            kerosine,
            dyad,
            kerosineManager
        );

        boundedKerosineVault = new BoundedKerosineVault(
            vaultManager,
            kerosine,
            kerosineManager
        );

        kerosineDenominator = new KerosineDenominator(kerosine);

        unboundedKerosineVault.setDenominator(kerosineDenominator);

        vaultLicenser.add(address(wethVault));
        vaultLicenser.add(address(wstEth));
        vaultLicenser.add(address(unboundedKerosineVault));
        // vaultLicenser.add(address(boundedKerosineVault));

        vaultManagerLicenser.add(address(vaultManager));
        vm.stopPrank();
    }

    function mint_dNft(address to) public returns (uint256) {
        return dNft.mintNft(to);
    }

    modifier mint_weth(address to, uint256 amount) {
        weth.mint(to, amount);
        _;
    }

    modifier mint_wsteth(address to, uint256 amount) {
        wstEth.mint(to, amount);
        _;
    }

    modifier mint_kerosine(address to, uint256 amount) {
        vm.prank(owner);
        kerosine.transfer(to, amount);
        _;
    }

    // This test will fail as withdraw function in VaultManagerV2 expect kerosineVault to have function named `oracle` whereas UnboundedKerosineVault doesn't have any function `oracle` ( or any public variable named `oracle` )
    function test_withdraw_kerosine()
        public
        mint_weth(user, 10e18)
        mint_kerosine(user, 10e18)
    {
        uint tokenId = mint_dNft(user);
        vm.prank(user);
        vaultManager.add(tokenId, address(wethVault));
        vm.prank(user);
        vaultManager.addKerosene(tokenId, address(unboundedKerosineVault));

        vm.prank(user);
        kerosine.approve(address(vaultManager), 10e18);

        vm.prank(user);
        weth.approve(address(vaultManager), 10e18);

        vm.prank(user);
        vaultManager.deposit(tokenId, address(unboundedKerosineVault), 10e18);

        assertEq(kerosine.balanceOf(address(unboundedKerosineVault)), 10e18);

        vm.roll(block.number + 1);

        vm.prank(user);
        vaultManager.deposit(tokenId, address(wethVault), 10e18);

        assertEq(weth.balanceOf(address(wethVault)), 10e18);

        vm.roll(block.number + 1);

        vm.prank(user);
        vaultManager.withdraw(
            tokenId,
            address(unboundedKerosineVault),
            10e18,
            user
        );

        assertEq(kerosine.balanceOf(address(unboundedKerosineVault)), 0);
        assertEq(kerosine.balanceOf(user), 10e18);
    }

    receive() external payable {}

    function onERC721Received(
        address,
        address,
        uint256,
        bytes calldata
    ) external pure returns (bytes4) {
        return 0x150b7a02;
    }
}

You can run the test by:

forge test --mt test_withdraw_kerosine -vvvv

And it will fail with the following error:

β”‚ β”œβ”€ [249] Kerosine::decimals() [staticcall] β”‚ β”‚ └─ ← 18 β”‚ β”œβ”€ [214] UnboundedKerosineVault::oracle() [staticcall] β”‚ β”‚ └─ ← EvmError: Revert β”‚ └─ ← EvmError: Revert └─ ← EvmError: Revert

Tools Used

Foundry and Manual Review

It can be fixed by deploying an Oracle contract for KerosineVault and that Oracle contract should have a function named decimals which will returns 8 due to the fact that in UnboundedKerosineVault::assetPrice function, 1e8 is multiplied with numerator.

Assessed type

Error

#0 - c4-pre-sort

2024-04-26T21:32:41Z

JustDravee marked the issue as duplicate of #1048

#1 - c4-pre-sort

2024-04-28T18:39:32Z

JustDravee marked the issue as duplicate of #830

#2 - c4-pre-sort

2024-04-29T08:44:34Z

JustDravee marked the issue as sufficient quality report

#3 - c4-judge

2024-05-11T20:05:44Z

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