GithubHelp home page GithubHelp logo

2024-03-ondo-finance-findings's Introduction

Ondo Finance Audit

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.


Audit findings are submitted to this repo

Sponsors have three critical tasks in the audit process:

  1. Respond to issues.
  2. Weigh in on severity.
  3. Share your mitigation of findings.

Let's walk through each of these.

High and Medium Risk Issues

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Respond to issues

For each High or Medium risk finding that appears in the dropdown at the top of the chrome extension, please label as one of these:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue.

Note that when the repo is public, after all issues are mitigated, wardens will read these comments; they may also be included in your C4 audit report.

Weigh in on severity

If you believe a finding is technically correct but disagree with the listed severity, select the disagree with severity option, along with a comment indicating your reasoning for the judge to review. You may also add questions for the judge in the comments. (Note: even if you disagree with severity, please still choose one of the sponsor confirmed or sponsor acknowledged options as well.)

For a detailed breakdown of severity criteria and how to estimate risk, please refer to the judging criteria in our documentation.

QA reports, Gas reports, and Analyses

All warden submissions in these three categories are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.
  • Gas reports include all gas optimization recommendations from an individual warden.
  • Analyses contain high-level advice and review of the code: the "forest" to individual findings' "trees.”

For QA reports, Gas reports, and Analyses, sponsors are not required to weigh in on severity or risk level. We ask that sponsors:

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • For QA and Gas reports only: add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once labelling is complete

When you have finished labelling findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.

Share your mitigation of findings

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-03-ondo-finance-findings's People

Contributors

c4-bot-2 avatar c4-bot-1 avatar c4-bot-5 avatar c4-bot-4 avatar c4-bot-6 avatar c4-bot-10 avatar c4-bot-8 avatar c4-bot-9 avatar c4-bot-3 avatar c4-bot-7 avatar c4-judge avatar code4rena-id[bot] avatar

Stargazers

Victor Cañada Ojeda avatar  avatar  avatar  avatar maryam avatar

Watchers

Ashok avatar

2024-03-ondo-finance-findings's Issues

QA Report

See the markdown file with the details of this report here.

Risk of Users Incurring Higher Fees Than Anticipated During Redemption

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L388
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L599

Vulnerability details

Vulnerability details:

The OUSGInstantManager contract implements a minimum redemption amount that is enforced in the _redeem function, dictating the minimum amount a user can redeem at one time. This limit can be modified at any time by an admin with the CONFIGURER_ROLE using the setMinimumRedemptionAmount function.

    function _redeem(uint256 ousgAmountIn) internal returns (uint256 usdcAmountOut) {
        ...
        require(
            usdcAmountToRedeem >= minimumRedemptionAmount, "OUSGInstantManager::_redeem: Redemption amount too small"
        );
        ...
    }

However, this can be problematic because users having deposited enough or left in an amount over the minimum could be forced to now make an extra deposit to be able to redeem their shares if the minimum is raised. Since there is a fee paid in deposits this will mean users will end up paying an extra amount to redeem their funds. This can also be abused by an admin to get users to pay more fees or be unable to redeem their shares.

    function setMinimumRedemptionAmount(uint256 _minimumRedemptionAmount) external override onlyRole(CONFIGURER_ROLE) {
        require(_minimumRedemptionAmount >= FEE_GRANULARITY, "setMinimumRedemptionAmount: Amount too small");
        emit MinimumRedemptionAmountSet(minimumRedemptionAmount, _minimumRedemptionAmount);
        minimumRedemptionAmount = _minimumRedemptionAmount;
    }

Impact:

  • Severity: Low/Medium. This will cause a loss of funds for users as they will have to pay an extra fee.
  • Likelihood: Low/Medium. This can be inadvertently done when an admin changes the minimumRedemptionAmount.

Tools Used:

  • Manual analysis

Recommendation:

Set a reasonable ceiling for what the minimumRedemptionAmount can be set to, that way even if the minimum amount is increased users can have an idea of the maximum it can be set. Furthermore, this can’t be abused by an admin to get a user to pay more fees.

Assessed type

Other

QA Report

See the markdown file with the details of this report here.

Excessive usdc allowance can lead to loss of funds

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L298-L301

Vulnerability details

Impact

Granting excessive usdc allowance may lead to loss of funds if the contract is compromised or contains other bugs.

Proof of Concept

The bug is in this line:

  require(
      usdc.allowance(msg.sender, address(this)) >= usdcAmountIn,
      "OUSGInstantManager::_mint: Allowance must be given to OUSGInstantManager"
    );

Using the greater than or equal to (>=) operator in this require statement means that the user must have approved the contract to spend at least usdcAmountIn USDC tokens, but it also allows the user to have approved a higher amount than usdcAmountIn.

This is unnecessary because the contract only needs to spend usdcAmountIn USDC tokens during the mint function. By requiring a higher allowance than usdcAmountIn, the contract is effectively asking the user to grant it approval to spend more than the required tokens or even their entire USDC balance.

Tools Used

Manual review

Recommended Mitigation Steps

The correct approach would be to replace the >= operator with the strict equality operator (==):

require(usdc.allowance(msg.sender, address(this)) == usdcAmountIn, "OUSGInstantManager::_mint: Allowance must be equal to the deposit amount");

Assessed type

Access Control

Admin can't burn tokens of users who are not KYC'd

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L597-L600

Vulnerability details

There is a burn() function which allows the admin to burn rOUSG tokens from any accouunt. The problem is that the admin can't burn tokens of users who are not KYC'd.

Proof of Concept

Here is the implementation of the burn() function and _burnShares() which is called by it:
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L618-L640

function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();

    _burnShares(_account, ousgSharesAmount);

    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
  }

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L554-L570

function _burnShares(
    address _account,
    uint256 _sharesAmount
  ) internal whenNotPaused returns (uint256) {
    require(_account != address(0), "BURN_FROM_THE_ZERO_ADDRESS");

    _beforeTokenTransfer(_account, address(0), _sharesAmount);

    uint256 accountShares = shares[_account];
    require(_sharesAmount <= accountShares, "BURN_AMOUNT_EXCEEDS_BALANCE");

    totalShares -= _sharesAmount;

    shares[_account] = accountShares - _sharesAmount;

    return totalShares;
  }

As we can see _burnShares() calls the internal _beforeTokenTransfer() function which has the following check:

function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
    ...
    if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }
    ...

This check prevents the admin from burning tokens from users who are not KYC'd

Impact

If the admin wants to burn the tokens of a user he has to add him back to the KYC list which would allow the user to send his tokens to another account thus preventing the burn from happening.

Recommended Mitigation Steps

Perhaps you can add a check to see if msg.sender is the admin and allow him to burn without KYC checks for the account whose tokens are being burned.

Assessed type

Invalid Validation

Rug-Pull possibility in `OUSGInstantManager`

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L819-L825

Vulnerability details

Impact

The function retrieveTokens in OUSGInstantManager gives too much power in the hands of DEFAULT_ADMIN_ROLE, which could lead to users loosing trust in the protocol and not using it.

Proof of Concept

The contract is supposed to work only with OUSG, rOUSG, USDC and BUIDL. The purpose of retrieveTokens function is to get any tokens that are stuck in the contract. However there should be certain restrictions.

  function retrieveTokens(
    address token,
    address to,
    uint256 amount
  ) external onlyRole(DEFAULT_ADMIN_ROLE) {
    IERC20(token).transfer(to, amount);
  }

As we can see this function can transfer any token to a specified address by the admin.

Tools Used

Manual Review

Recommended Mitigation Steps

My suggestion will not disrupt the protocol's functionality and it will make it more decentralised.

  1. Add a mapping that will be used as internal accounting. This mapping should be updated only when the tokens mentioned above are used.
  2. Add checks to retrieveTokens to only work with the mentioned tokens.
  3. Make sure that amount <= token.balanceOf(address(this)) - mapping[token] so that only the tokens sent by mistake will be rescued. If amount is bigger, it can be set to token.balanceOf(address(this)) - mapping[token].
  4. Add another function called retrieveTokensOther that will work only with tokens that are not the mentioned above and keep the same logic as now.

Assessed type

Rug-Pull

QA Report

See the markdown file with the details of this report here.

No slippage protect in the function `OUSGInstantManager.mint`

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L278-L324

Vulnerability details

Impact

When minting OUSG via function mint, there is no slippage protection and the users may receive less amount of OUSG than they expected.

Proof of Concept

When users mint OUSG, the ousgPrice might change before the mint function executes, resulting in users receiving an unexpected amount of OUSG and incurring a loss of funds.

  function mint(
    uint256 usdcAmountIn
  )
    external
    override
    nonReentrant
    whenMintNotPaused
    returns (uint256 ousgAmountOut)
  {
    ousgAmountOut = _mint(usdcAmountIn, msg.sender);
    emit InstantMintOUSG(msg.sender, usdcAmountIn, ousgAmountOut);
  }
  function _mint(
    uint256 usdcAmountIn,
    address to
  ) internal returns (uint256 ousgAmountOut) {
    require(
      IERC20Metadata(address(usdc)).decimals() == 6,
      "OUSGInstantManager::_mint: USDC decimals must be 6"
    );
    require(
      usdcAmountIn >= minimumDepositAmount,
      "OUSGInstantManager::_mint: Deposit amount too small"
    );
    _checkAndUpdateInstantMintLimit(usdcAmountIn);
    if (address(investorBasedRateLimiter) != address(0)) {
      investorBasedRateLimiter.checkAndUpdateMintLimit(
        msg.sender,
        usdcAmountIn
      );
    }

    require(
      usdc.allowance(msg.sender, address(this)) >= usdcAmountIn,
      "OUSGInstantManager::_mint: Allowance must be given to OUSGInstantManager"
    );

    uint256 usdcfees = _getInstantMintFees(usdcAmountIn);
    uint256 usdcAmountAfterFee = usdcAmountIn - usdcfees;

    // Calculate the mint amount based on mint fees and usdc quantity
    uint256 ousgPrice = getOUSGPrice();
    ousgAmountOut = _getMintAmount(usdcAmountAfterFee, ousgPrice);

    require(
      ousgAmountOut > 0,
      "OUSGInstantManager::_mint: net mint amount can't be zero"
    );

    // Transfer USDC
    if (usdcfees > 0) {
      usdc.transferFrom(msg.sender, feeReceiver, usdcfees);
    }
    usdc.transferFrom(msg.sender, usdcReceiver, usdcAmountAfterFee);

    emit MintFeesDeducted(msg.sender, feeReceiver, usdcfees, usdcAmountIn);

    ousg.mint(to, ousgAmountOut);
  }

POC

Here's an example: Alice intends to mint OUSG with 100_000e6 USDC, expecting to receive 666666666666666666666 OUSG. However, if the price of OUSG increases before the mint transaction is executed, Alice may only receive 1/10 of the expected amount of OUSG.

Add the test to forge-tests/ousg/OUSGInstantManager/mint.t.sol and run it with:

forge test --fork-url $(grep -w ETHEREUM_RPC_URL .env | cut -d '=' -f2) --fork-block-number $(grep -w FORK_FROM_BLOCK_NUMBER_MAINNET .env | cut -d '=' -f2) --nmc ASSERT_FORK --match-test test_instant_mint__slippage -vvv
diff --git a/forge-tests/ousg/OUSGInstantManager/mint.t.sol b/forge-tests/ousg/OUSGInstantManager/mint.t.sol
index 2ec26e9..0d7ba02 100644
--- a/forge-tests/ousg/OUSGInstantManager/mint.t.sol
+++ b/forge-tests/ousg/OUSGInstantManager/mint.t.sol
@@ -106,6 +106,25 @@ contract Test_OUSGInstant_mint_ETH is OUSG_BasicDeployment, BUIDLHelper {
     assertEq(rOUSGToken.balanceOf(alice), 0);
   }

+  function test_instant_mint__slippage() public {
+    deal(address(USDC), alice, 100_000e6);
+
+    vm.startPrank(alice);
+    USDC.approve(address(ousgInstantManager), 100_000e6);
+    vm.stopPrank();
+
+    // The price goes up
+    oracleCheckHarnessOUSG.setPrice(1500e18);
+
+    // alice only get 66666666666666666666  rather than 666666666666666666666
+    // alice only get 1/10 OUSG than expected
+    vm.startPrank(alice);
+    ousgInstantManager.mint(100_000e6);
+    console.log("balance of alice ",ousg.balanceOf(alice));
+    vm.stopPrank();
+
+  }
+
   function test_instant_mint__fees() public {
     deal(address(USDC), alice, 100_000e6);

Tools Used

Foundry

Recommended Mitigation Steps

Add a slippage check to the mint function.

Assessed type

Context

Oracle sanity check missing in ROUSG contract

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L378
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L479
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L63

Vulnerability details

Summary:

The getOUSGPrice function in the OUSGInstantManager contract incorporates a sanity check. If the price returned by the oracle is below the MINIMUM_OUSG_PRICE, the function reverts. This check safeguards users from engaging in transactions with an unexpectedly low price, which could result in a loss of funds.

    /**
     * @notice Returns the current price of OUSG in USDC
     *
     * @dev Sanity check: this function will revert if the price is unexpectedly low
     *
     * @return price The current price of OUSG in USDC
     */
    function getOUSGPrice() public view returns (uint256 price) {
        (price,) = oracle.getPriceData();
        require(price > MINIMUM_OUSG_PRICE, "OUSGInstantManager::getOUSGPrice: Price unexpectedly low");
    }

The getOUSGPrice function in the ROUSG contract utilizes the same oracle but lacks the sanity check. Consequently, if the oracle returns an unexpectedly low price, users' transactions will proceed, potentially resulting in losses.

    function getOUSGPrice() public view returns (uint256 price) {
        (price,) = oracle.getPriceData();
    }

More specifically, since the oracle is used for the rebase and conversion process, a user could lose a significant number of tokens if the oracle provides an incorrect price.

Impact:

  • Severity: High. The absence of the sanity check exposes users to potential losses if the oracle returns an incorrect price.
  • Likelihood: Low. The chance of a malfunction in the oracle is moderately low.

Tools Used:

  • Manual analysis

Recommendation:

Add the MINIMUM_OUSG_PRICE variable and sanity check to the getOUSGPrice function in the ROUSG contract.

    // Safety circuit breaker in case of Oracle malfunction
    uint256 public constant MINIMUM_OUSG_PRICE = 105e18; //add here
    
    function getOUSGPrice() public view returns (uint256 price) {
        (price,) = oracle.getPriceData();
        require(price > MINIMUM_OUSG_PRICE, "OUSGInstantManager::getOUSGPrice: Price unexpectedly low"); //add here
    }

Assessed type

Invalid Validation

User tokens are sent to the admin instead of being returned to the source _account after burning shares.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L634-L637

Vulnerability details

Summary

The rOUSG::burn() function is restricted to be called only by addresses with the BURNER_ROLE. This function reduces the share balance of the _account by ousgSharesAmount, effectively removing those shares from circulation.

Once the shares are burned, the function transfers an equivalent amount of tokens to the msg.sender. The amount of tokens transferred is calculated by dividing ousgSharesAmount by OUSG_TO_ROUSG_SHARES_MULTIPLIER. This is done to convert shares back to tokens.

Since msg.sender is the address with the BURNER_ROLE, the tokens are transferred to this address and not to the _account that own them.

Proof of Concept

  function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();


    _burnShares(_account, ousgSharesAmount);


    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    ); // @audit Tokens sent to the caller(BURNER_ROLE)

    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
  }

The vulnerability lies in the line:

ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );

In this line, the burned tokens are transferred to msg.sender, which is the address with the BURNER_ROLE (admin). As a result, the tokens are sent to the admin instead of being returned to the _account from which they were burned.

Impact

Sending the tokens to the admin means that the user who is the owner of the _account loses their shares through the burning process as well as their tokens permanently.

Tools Used

Manual Review

Recommended Mitigation Steps

Adjust the burn() function to ensure that the burned tokens are returned to the _account as shown here:

function burn(
    address _account,
    uint256 _amount
) external onlyRole(BURNER_ROLE) {
    
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
        revert UnwrapTooSmall();

    _burnShares(_account, ousgSharesAmount);

    // @audit Return the burned tokens to the _account instead of the admin
    ousg.transfer(
        _account,
        ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), _account, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
}

Assessed type

Other

`rOUSG::unwrap` `rOUSG` funds lost due to failed transfer check

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L431

Vulnerability details

Impact

All of a user's rOUSG is lost as a result of a successful burn of the tokens and a failed transfer of OUSG back to the user for the amount of rOUSG burnt.

A scenario is present where the following can be true:

  • Bob calls unwrap(uint256 _rOUSGAmount).
  • The ousgSharesAmount to burn from Bob is calculated and then burnt via _burnShares(msg.sender, ousgSharesAmount);
  • The contract attempts to transfer equivalent oUSG back to Bob for the amount of rOUSG burnt via this function call ousg.transfer(msg.sender, ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER)
  • The return value is false because the transfer back to Bob fails.

As a result, Bob has burnt ousgSharesAmount amount of rOUSG and received 0 oUSG in return.

The issue will stem when one of the following has occurred in the oUSG contract:

  • Since oUSG is an ERC-20 token that mimics OZ's implementation, it returns true/false that determines whether a transfer is successful or not.
  • The oUSG token contract would need to be set to paused/locked during the time of the rOUSG burn.

Proof of Concept

function unwrap(uint256 _rOUSGAmount) external whenNotPaused {
    require(_rOUSGAmount > 0, "rOUSG: can't unwrap zero rOUSG tokens");
    uint256 ousgSharesAmount = getSharesByROUSG(_rOUSGAmount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();
    _burnShares(msg.sender, ousgSharesAmount);
@>    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    ); // @audit if/when this returns false, the burn will have been for nothing
    emit Transfer(msg.sender, address(0), _rOUSGAmount);
    emit TransferShares(msg.sender, address(0), ousgSharesAmount);
  }

Tools Used

Manual review + foundry

Recommended Mitigation Steps

We recommend to handle the return case of the ousg.transfer() call by enforcing a revert of the transaction when the return value is false. Also, the function call can be modified to use safeTransfer.

Assessed type

Token-Transfer

Lack of consideration for slippage.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L254

Vulnerability details

Impact

mintRebasingOUSG () minting rOUSG for a given amount of USDC.

The calculation of ousgAmountOut uses ousgPrice.

 // Calculate the mint amount based on mint fees and usdc quantity
    uint256 ousgPrice = getOUSGPrice();
    ousgAmountOut = _getMintAmount(usdcAmountAfterFee, ousgPrice);

Only check if ousgAmountOut is >0.


    require(
      ousgAmountOut > 0,
      "OUSGInstantManager::_mint: net mint amount can't be zero"
    );

However it could also happen that ousgAmountOut is > 0 , but still lower than expected due to slippage / sandwiching / MEV.

You can refer to the "Check slippage of swaps" section in the following report to see similar issues.:

https://github.com/spearbit/portfolio/blob/master/pdfs/LIFI-Spearbit-Security-Review.pdf

Users may mint less rousg than expected using USDC.

Proof of Concept

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L254

Tools Used

Recommended Mitigation Steps

The calculation of rousgAmountOut uses both ousgAmountOut and OUSGPrice. Considering parameters like rousgMinAmountOut would ensure that users receive the expected rousg.

Assessed type

MEV

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

"_approve()" function inside of "rOUSG.sol" has exposure for approval race protections

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L472

Vulnerability details

Impact

"_approve()" function inside of "rOUSG.sol" has exposure for approval race protections, which might lead to malicious spender using the old and the new allowance one after another, leading to sufficient lost of tokens for the owner.

Proof of Concept

Here is a possible attack scenario:

1.Alice allows Bob to transfer N of Alice's tokens (N>0)  by calling the approve method on a Token smart contract, passing the Bob's address and N as the method arguments
2.After some time, Alice decides to change from N to M (M>0) the number of Alice's tokens Bob is allowed to transfer, so she calls the approve method again, this time passing the Bob's address and M as the method arguments
3.Bob notices the Alice's second transaction before it was mined and quickly sends another transaction that calls the transferFrom method to transfer N Alice's tokens somewhere
4.If the Bob's transaction will be executed before the Alice's transaction, then Bob will successfully transfer N Alice's tokens and will gain an ability to transfer another M tokens
5.Before Alice noticed that something went wrong, Bob calls the transferFrom method again, this time to transfer M Alice's tokens.

So, an Alice's attempt to change the Bob's allowance from N to M (N>0 and M>0) made it possible for Bob to transfer N+M of Alice's tokens,
while Alice never wanted to allow so many of her tokens to be transferred by Bob.

Tools Used

Manual review.

Recommended Mitigation Steps

Suggestion as of "Openzeppelin"s docs:https://docs.openzeppelin.com/contracts/2.x/api/token/erc20#IERC20-approve-address-uint256-:~:text=Beware%20that%20changing,20%23issuecomment%2D263524729
is to first reduce the spender’s allowance to 0 and set the desired value afterwards.

Assessed type

ERC20

`rOUSG::wrap` can be leveraged by a malicious user to mint `rOUSG` for zero cost under certain conditions

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L437

Vulnerability details

Impact

A malicious user can leverage the wrap() function in the rOUSG contract to mint rOUSG for no cost engineering a loss for the Ondo protocol under certain conditions.

Consider the scenario presented below for this attack:

  • Alice executes wrap(uint256 _rOUSGAmount).
  • The ousgSharesAmount amount of rOUSG shares calculated for the wrap() transaction is minted to Alice's balance
  • The contract attempts to transfer oUSG from Alice into the rOUSG contract as payment for wrapping into rOUSG but the transfer fails
  • The return value is false because the transferFrom() doesn't revert if it fails for any reason according to the EIP specification and implementation the oUSG contract utilizes.

As a result, Alice has gained rOUSG for zero cost. She can repeat this for as long as the transfers continue to return false essentially minting rOUSG each time free of charge.

This issue rather rare, will occur when one of the following is true in the oUSG contract. Note that the transferFrom() call returning false is not limited to the scenarios described below:

  • Since oUSG is an ERC-20 token that mimics OZ's implementation, it returns true/false that determines whether a transferFrom() call is successful or not.
  • The oUSG token contract would need to be set to paused/locked during the time of the rOUSG mint.

Proof of Concept

function wrap(uint256 _OUSGAmount) external whenNotPaused {
    require(_OUSGAmount > 0, "rOUSG: can't wrap zero OUSG tokens");
    uint256 ousgSharesAmount = _OUSGAmount * OUSG_TO_ROUSG_SHARES_MULTIPLIER;
    _mintShares(msg.sender, ousgSharesAmount); // @audit mint succeeds but the transferFrom() below fails hence free mint of `rOUSG`
@>  ousg.transferFrom(msg.sender, address(this), _OUSGAmount); // call could return false with no `oUSG` transferred
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(address(0), msg.sender, ousgSharesAmount);
  }

Notice that the return value of the ousg.transferFrom(msg.sender, address(this), _OUSGAmount); call above in the wrap() function is not checked. Rather yet, it is not implementing a safeTransferFrom() for the call that can revert the transaction when the transfer fails hence preventing this exploit.

Tools Used

Manual review + foundry

Recommended Mitigation Steps

Our recommendation is that the Ondo team should handle the return value of the ousg.transferFrom() call by enforcing a revert of the transaction when the return value is false. Or, the ousg.transferFrom(msg.sender, address(this), _OUSGAmount); function can be further protected to use safeTransferFrom() as can be seen below:

+ ousg.safeTransferFrom(msg.sender, address(this), _OUSGAmount);
- ousg.transferFrom(msg.sender, address(this), _OUSGAmount);

Assessed type

Token-Transfer

Calling mint function instead of internal _mint function

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L323

Vulnerability details

Impact

The mint in "ousg.mint(...)" does not exist, so calling it directly will fail since the ERC20 base contract does not have a mint function.

Proof of Concept

There is a bug in the _mint function calling ousg.mint(...) instead of the super _mint.

  ousg.mint(to, ousgAmountOut);

This will fail since the ERC20 base contract does not have a mint function. It only has an internal "_mint" function.

Tools Used

Manual review

Recommended Mitigation Steps

The fix would be to call the internal _mint function instead:

ousg._mint(to, ousgAmountOut);

Assessed type

DoS

CONTROLLED LOW-LEVEL CALL

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSGFactory.sol#L126

Vulnerability details

Impact

The contract was using delegatecall() or call() which was accepting address controlled by a user. This can have devastating effects on the contract as a delegate call allows the contract to execute code belonging to other contracts but using it’s own storage. This can very easily lead to a loss of funds and compromise of the contract.

Proof of Concept

function multiexcall(ExCallData[] calldata exCallData) external payable 
override onlyGuardian returns (bytes[] memory results) {
    results = new bytes[](exCallData.length);
       for (uint256 i = 0; i < exCallData.length; ++i) {
        address target = exCallData[i].target;
        // Ensure that only trusted contracts can be called
        require(trustedContracts[target], "ROUSGFactory: Only trusted contracts allowed");

        try target.call{value: exCallData[i].value}(exCallData[i].data) returns (bytes memory ret) {
            results[i] = ret;
        } catch Error(string memory errorMsg) {
            revert(errorMsg);
        } catch (bytes memory) {
            revert("External call failed");
        }
    }
}

Tools Used

SolidityScan

Recommended Mitigation Steps

Do not allow user-controlled data inside the delegatecall() and the call() function.

Assessed type

call/delegatecall

Functions returning `true` without their success checked may result in inconsistencies.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/78779c30bebfd46e6f416b03066c55d587e8b30b/contracts/ousg/rOUSG.sol#L220-L223

Vulnerability details

Summary

According to the functions' Natspec:

@return a boolean value indicating whether the operation succeeded.

These functions are expected to return true if the operations are successful and false otherwise.
However, they do not perform this check and simply returns true after attempting the operations.

Proof of Concept

These functions include:

  1. transfer()
  function transfer(address _recipient, uint256 _amount) public returns (bool) {
    _transfer(msg.sender, _recipient, _amount);
    return true; // @audit Returned without check
  }
  1. approve()
  function approve(address _spender, uint256 _amount) public returns (bool) {
    _approve(msg.sender, _spender, _amount);
    return true; // @audit Returned without check
  }
  1. transferFrom()
  function transferFrom(
    address _sender,
    address _recipient,
    uint256 _amount
  ) public returns (bool) {
    uint256 currentAllowance = allowances[_sender][msg.sender];
    require(currentAllowance >= _amount, "TRANSFER_AMOUNT_EXCEEDS_ALLOWANCE");


    _transfer(_sender, _recipient, _amount);
    _approve(_sender, msg.sender, currentAllowance - _amount);
    return true; // @audit Returned without check
  }
  1. increaseAllowance()
  function increaseAllowance(
    address _spender,
    uint256 _addedValue
  ) public returns (bool) {
    _approve(
      msg.sender,
      _spender,
      allowances[msg.sender][_spender] + _addedValue
    );
    return true; // @audit Returned without check
  }
  1. decreaseAllowance()
  function decreaseAllowance(
    address _spender,
    uint256 _subtractedValue
  ) public returns (bool) {
    uint256 currentAllowance = allowances[msg.sender][_spender];
    require(
      currentAllowance >= _subtractedValue,
      "DECREASED_ALLOWANCE_BELOW_ZERO"
    );
    _approve(msg.sender, _spender, currentAllowance - _subtractedValue);
    return true; // @audit Returned without check
  }

The return values are not checked during these operations.

Impact

This could lead to a situation where a operations fail, but the functions still returns true, indicating success.
This discrepancy between the expected behavior and the actual behavior could cause issues in areas that rely on the these functions to accurately report the success or failure of a their respective operation.

Tools Used

Manual Review

Recommended Mitigation Steps

These functions should be modified to check the success of their operations and return the appropriate boolean value.

Example: transfer()
function transfer(address _recipient, uint256 _amount) public returns (bool) {
    bool success = _transfer(msg.sender, _recipient, _amount); // @audit Check for the success
    return success;
}

The same should be done for all the other functions.

Assessed type

Other

Inaccurate Update of `currentInstantMintAmount` in Mint Function

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L290
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/InstantMintTimeBasedRateLimiter.sol#L108

Vulnerability details

Impact

The purpose of currentInstantMintAmoun is to track the value of ousg, which represents the tokens minted over a set duration of time, However the current implementation updates currentInstantMintAmount using usdcAmountIn, which is the initial USDC amount received. Later, a fee percentage will be deducted from this amount. Unfortunately, this approach leads to a significant inflation of currentInstantMintAmount, surpassing the actual quantity of minted tokens. Since a portion of the USDC will be deducted as fees, currentInstantMintAmount increases rapidly, potentially reaching the instantMintLimit prematurely.

For instance, consider a scenario where the protocol imposes a 5 USDC mint fee. If a user mints with 100 USDC, the function _checkAndUpdateInstantMintLimit updates currentInstantMintAmount by adding the full 100 USDC to its value. Consequently, the calculated mint amount will be 100 - 5 = 95 USDC. The issue lies in _checkAndUpdateInstantMintLimit updating currentInstantMintAmount with the entire usdcAmount of 100, rather than using the minted ousgAmountOut value of 95.

Proof of Concept

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L278-L324

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/InstantMintTimeBasedRateLimiter.sol#L93-L109

Tools Used

Manual

Recommended Mitigation Steps

currentInstantMintAmount should be updated with mint ousgAmountOut
rather than the usdcAmountIn since the fee will later be deducted.

Assessed type

Other

Check for `OUSG` price drop is not enforced in `rOUSG` contract.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L479-L485
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L373-L376
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L378-L380

Vulnerability details

Impact

In ousgInstantManager.sol, a check is implemented in the getOUSGPrice function to protect users from low prices during mints and redemptions. This is to protect users from losing funds.

  function getOUSGPrice() public view returns (uint256 price) {
    (price, ) = oracle.getPriceData();
    require(
      price > MINIMUM_OUSG_PRICE,
      "OUSGInstantManager::getOUSGPrice: Price unexpectedly low"
    );
  }

This check however is not extended to the same function in rOUSG.sol.

  function getOUSGPrice() public view returns (uint256 price) {
    (price, ) = oracle.getPriceData();
  }

There are two possible impacts here:

  1. Users holding rOUSG can call the unwrap function to unwrap their rOUSG for more tokens, because the value returned by the getSharesByROUSG function will be more, causing that their ousgSharesAmount be more. They'll do this instead of directly calling the redeemRebasingOUSG function which will revert because of low OUSG price. The users can then wait till prices gets normalized, before calling the redeem function to redeem the OUSG tokens.
  function getSharesByROUSG(
    uint256 _rOUSGAmount
  ) public view returns (uint256) {
    return
      (_rOUSGAmount * 1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER) / getOUSGPrice();
  }
  function unwrap(uint256 _rOUSGAmount) external whenNotPaused {
    require(_rOUSGAmount > 0, "rOUSG: can't unwrap zero rOUSG tokens");
    uint256 ousgSharesAmount = getSharesByROUSG(_rOUSGAmount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();
    _burnShares(msg.sender, ousgSharesAmount);
    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(msg.sender, address(0), _rOUSGAmount);
    emit TransferShares(msg.sender, address(0), ousgSharesAmount);
  }
  1. Second issue is loss of funds when wrapping OUSG for unsuspecting users as they make way less than they would have made if they had bought with USDC instead. Due to the now reduced value returned by the getROUSGByShares function, the protection offered to users swapping from USDC is not available to the users wrapping causing a loss of funds for them.
  function getROUSGByShares(uint256 _shares) public view returns (uint256) {
    return
      (_shares * getOUSGPrice()) / (1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER);
  }
  function wrap(uint256 _OUSGAmount) external whenNotPaused {
    require(_OUSGAmount > 0, "rOUSG: can't wrap zero OUSG tokens");
    uint256 ousgSharesAmount = _OUSGAmount * OUSG_TO_ROUSG_SHARES_MULTIPLIER;
    _mintShares(msg.sender, ousgSharesAmount);
    ousg.transferFrom(msg.sender, address(this), _OUSGAmount);
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(address(0), msg.sender, ousgSharesAmount);
  }

Tools Used

Manual code review

Recommended Mitigation Steps

Include the same price check for minimum price in the rOUSG contract.

  function getOUSGPrice() public view returns (uint256 price) {
    (price, ) = oracle.getPriceData();
    require(
      price > MINIMUM_OUSG_PRICE,
      "OUSGInstantManager::getOUSGPrice: Price unexpectedly low"
    );
  }

Assessed type

Other

Flash Loans can be used to convert all of BUIDL tokens to USDC

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L426-L429
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L458-L469

Vulnerability details

According to the Attack ideas section a user should not be able to convert all BUIDL tokens sitting in the OUSGInstantManager contract to USDC:

Any ways for malicious investors to use redemptions to convert all of Ondo's BUIDL tokens to USDC sitting in the OUSGInstantManager contract."

However if there are 0 fees (which the contract should be able to handle according to one of the sponsors) a malicious user can use a flash loan to mint himself OUSG tokens and use them to reedeem a USDC amount bigger than minBUIDLRedeemAmount.

This way an amount of BUIDL tokens corresponding to the usdcAmountToRedeem will be converted to USDC to be sent back to the user.

Proof of Concept

According to the sponsors the initial amount of BUIDL tokens the contract will hold is 100 million. There exists an instant redemption limit which again according to the sponsor could be 50 million and resets after 1 day.

The following scenario is if mintFee and redeemFee are both 0:

  1. A malicious user uses a flash loan to borrow 50 million USDC.
  2. Let's say the price of OUSG is 100$ and he mints himself 500,000 OUSG tokens
    by depositing the borrowed USDC amount (minimumDepositAmount = 100_000e6)
  3. He then redeems the 500,000 OUSG tokens for which he should get the 50 million USDC back which is bigger than minBUIDLRedeemAmount = 250_000e6 so we enter this if statement:
if (usdcAmountToRedeem >= minBUIDLRedeemAmount) {
      // amount of USDC needed is over minBUIDLRedeemAmount, do a BUIDL redemption
      // to cover the full amount
      _redeemBUIDL(usdcAmountToRedeem);
  1. In _redeemBUIDL() the buidlRedeemer will take 50 million BUIDL tokens from the contract's balance and convert it to USDC to give back to the user.
  2. User waits 1 day and repeats the steps above
  3. There are now 0 BUIDL tokens in the contract and only USDC.

Recommended Mitigation Steps

Perhaps remove the check mentioned above and do BUIDL redemptions only if there isn't enough USDC in the contract to give back to the user.

Assessed type

Other

Lack of slippage in mints and redemptions

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L230
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L254
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L335
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L362

Vulnerability details

Impact

The amount of OUSG and rOUSG tokens a user gets upon minting is highly dependent on the price of OUSG as gotten from the oracle, which is updated based on the transactions. This causes that a user looking to mint with a certain amount of USDC has no control of the amount of tokens he gets in return. This opens up the users to various griefing vectors through sandwich attacks, MEVs, as they can be frontrun and receive worse prices than expected when they initially submitted the transaction. The same goes for redemptions, users can redeem OUSG and rOUSG for way less USDC than they were intially expecting, especially when there are large price movements.
This is because there's no available slippage protection, no minimum return amount or deadline for the trade transaction to be valid which means the trade can be delayed by miners or users congesting the network, as well as being sandwich attacked - ultimately leading to loss of user funds.

The mint, mintRebasingOUSG, redeem, and redeemRebasingOUSG functions lack a deadline check, subjecting users' transaction to be on hold for longer periods by malicious miners, and lack a minAmountOut parameter, opening users up to sandwich attacks and loss of funds.

Proof of Concept

  • User wants to redeem 100_000 OUSG tokens for 150_000 USDC at 150 USDC per OUSG;
  • Due to the lack of deadline, the transaction is held "hostage" by malicious miners for longer than normal;
  • During that time, MEVs and malicious users frontrun and redeem the OUSG dropping the price to 120_000 USDC;
  • User's transaction finally executes, and instead of 150_000, he makes 120_000, losing 30_000 USDC

Tools Used

Manual code review

Recommended Mitigation Steps

Add a minimum return amount and a deadline that users can specify upon minting and redemption.

Assessed type

MEV

"multiexcall()" does not have reentrancy protection leading to DoS

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSGFactory.sol#L121

Vulnerability details

Impact

"multiexcall()" function inside of "ROUSGFactory.sol" does not have reentrancy protection and while doing the batch call reentrancy is possible, even though there is no state changes and funds at risk, it can introduce excessive calls by triggering the function repeatedly, and therefore introduce DoS (Denial of Service) by consuming gas and slowing down the contract’s execution, and potentially transaction running out of gas therefore reverting.

Proof of Concept

contract Attack {
    ROUSGFactory public factory;

    constructor(address _factory) {
        factory= ROUSGFactory(_factory);
    }

    // Fallback is called when ROUSGFactory sends native token to this contract 
    // via call and adding value attribute
    fallback() external payable {
        factory.multiexcall(array full of dummy data);
    }
}

Tools Used

Manual review

Recommended Mitigation Steps

Add reentrancy mechanism to the function, one solution would be:

    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy allowed!");
        locked = true;
        _;
        locked = false;
    }

Assessed type

DoS

Admins can arbitrarily burn shares from any address without users' consent

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624-L640
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L554-L570

Vulnerability details

Summary

Burning of balances from arbitrary addresses is a dangerous form of admin privilege.

Proof of Concept

  function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();


    _burnShares(_account, ousgSharesAmount); // @audit Share burning


    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
  }

Here is the implementation of the _burnShares():

  function _burnShares(
    address _account,
    uint256 _sharesAmount
  ) internal whenNotPaused returns (uint256) {
    require(_account != address(0), "BURN_FROM_THE_ZERO_ADDRESS");


    _beforeTokenTransfer(_account, address(0), _sharesAmount);


    uint256 accountShares = shares[_account];
    require(_sharesAmount <= accountShares, "BURN_AMOUNT_EXCEEDS_BALANCE");


    totalShares -= _sharesAmount;


    shares[_account] = accountShares - _sharesAmount;


    return totalShares;
  }

Exploit scenario:

  • Alice holds a significant number of shares within the platform.
  • However, an admin named Bob, with BURNER_ROLE arbitrarily selects Alice's address and burns a substantial portion of her shares in the investment pool.
  • As a result of Bob's actions, the total supply of tokens associated with the investment pool decreases, leading to an artificial scarcity. This scarcity drives up the price of the remaining tokens in the pool, resulting in a significant profit for other investors who did not have their shares burned.
  • Unfortunately, Alice missed out on the opportunity to profit from the increased token value because a portion of her shares was arbitrarily burned by Bob.

Impact

Allowing admins to burn shares from arbitrary addresses means they have unchecked power over the system. They can tamper with the distribution of shares without any accountability or validation process.

Since the value of the remaining tokens may have increased, the overall profit potential for Alice is significantly diminished due to the reduction in her shareholding caused by the admin's actions while the other shareholders stand to profit from this.

Tools Used

Manual Review

Recommended Mitigation Steps

Modify the burn() function so it only allows msg.sender to burn their own tokens:

function burn(uint256 _amount) external {
    address _account = msg.sender;
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
        revert UnwrapTooSmall();

    _burnShares(_account, ousgSharesAmount);

    ousg.transfer(
        msg.sender,
        ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
}

Assessed type

Governance

RedeemFee and MintFee are set by Addresses with CONFIGURER_ROLE instead of those with DEFAULT_ADMIN_ROLE

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L556
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L569

Vulnerability details

Summary

According to ousgInstantManager.sol contract Natspec:

 * @notice This contract is responsible for minting
 *         and redeeming OUSG and rOUSG against USDC. Addresses
 *         with the DEFAULT_ADMIN_ROLE able to set optional mint and
 *         redeem fees.

However, in the setMintFee() and setRedeemFee(), addresses with CONFIGURER_ROLE are the ones used instead of those with DEFAULT_ADMIN_ROLE.

Proof of Concept

  function setMintFee(
    uint256 _mintFee
->  ) external override onlyRole(CONFIGURER_ROLE) {
    require(mintFee < 200, "OUSGInstantManager::setMintFee: Fee too high");
    emit MintFeeSet(mintFee, _mintFee);
    mintFee = _mintFee;
  }
  function setRedeemFee(
    uint256 _redeemFee
-->  ) external override onlyRole(CONFIGURER_ROLE) {
    require(redeemFee < 200, "OUSGInstantManager::setRedeemFee: Fee too high");
    emit RedeemFeeSet(redeemFee, _redeemFee);
    redeemFee = _redeemFee;
  }

This oversight creates access control breach and is against the protocol requirement.

Tools Used

Manual Review

Recommended Mitigation Steps

Configure the setRedeemFee() and setMintFee() functions to be callable only by addresses with the DEFAULT_ADMIN_ROLE.

This can be done as follows:

  function setMintFee(
    uint256 _mintFee
  ) external override onlyRole(DEFAULT_ADMIN_ROLE) {
    require(mintFee < 200, "OUSGInstantManager::setMintFee: Fee too high");
    emit MintFeeSet(mintFee, _mintFee);
    mintFee = _mintFee;
  }
  function setRedeemFee(
    uint256 _redeemFee
  ) external override onlyRole(DEFAULT_ADMIN_ROLE) {
    require(redeemFee < 200, "OUSGInstantManager::setRedeemFee: Fee too high");
    emit RedeemFeeSet(redeemFee, _redeemFee);
    redeemFee = _redeemFee;
  }

Assessed type

Access Control

"ROUSGFactory.sol" missing zero address check

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSGFactory.sol#L48

Vulnerability details

Impact

As seen inside of "ROUSGFactory.sol" there is a missing zero address check, which potentially might result of losing ownership of the contract being initialized with the address(0).

Proof of Concept

Take for example this piece of code:

funciton initialize() external{
ROUSGFactory factory = new ROUSGFactory(address(0)); //instead of //address(msg.sender);
}

Tools Used

Manual review.

Recommended Mitigation Steps

Add a simple check as follow:

  constructor(address _guardian) {
    require(_guardian != address(0), "Zero address not allowed!");
    guardian = _guardian;
  }

Assessed type

Access Control

The `BURNER` cannot burn tokens from accounts not KYC verified due to the check in `_beforeTokenTransfer`.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L586-L606
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624-L640

Vulnerability details

Impact

The BURNER_ROLE cannot burn tokens if the target account has been removed from the KYC list.

Proof of Concept

When the BURNER_ROLE burns tokens of _account, it invokes _burnShares and then calls _beforeTokenTransfer to verify the KYC status of _account.

In accordance with a previous audit report, the BURNER_ROLE should have the capability to burn tokens of any account, even if the account is blacklisted or, in this case, not KYC verified. However, there is no mechanism that allows BURNER_ROLE to burn tokens of accounts that are removed from KYC list.

  function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();

    _burnShares(_account, ousgSharesAmount);

    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
  }
  function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
    // Check constraints when `transferFrom` is called to facliitate
    // a transfer between two parties that are not `from` or `to`.
    if (from != msg.sender && to != msg.sender) {
      require(_getKYCStatus(msg.sender), "rOUSG: 'sender' address not KYC'd");
    }
// When from is not KYC, BURNER can not burn their tokens
    if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }

    if (to != address(0)) {
      // If not burning
      require(_getKYCStatus(to), "rOUSG: 'to' address not KYC'd");
    }
  }

POC

Add the test to forge-tests/ousg/rOUSG.t.sol and run it with:

forge test --fork-url $(grep -w ETHEREUM_RPC_URL .env | cut -d '=' -f2) --fork-block-number $(grep -w FORK_FROM_BLOCK_NUMBER_MAINNET .env | cut -d '=' -f2) --nmc ASSERT_FORK --match-test test_burn_with_NOKYC
diff --git a/forge-tests/ousg/rOUSG.t.sol b/forge-tests/ousg/rOUSG.t.sol
index 67faa15..b39b4ac 100644
--- a/forge-tests/ousg/rOUSG.t.sol
+++ b/forge-tests/ousg/rOUSG.t.sol
@@ -13,6 +13,7 @@ contract Test_rOUSG_ETH is OUSG_BasicDeployment {
     CashKYCSenderReceiver ousgProxied = CashKYCSenderReceiver(address(ousg));
     vm.startPrank(OUSG_GUARDIAN);
     ousgProxied.grantRole(ousgProxied.MINTER_ROLE(), OUSG_GUARDIAN);
+    ousgProxied.grantRole(ousgProxied.BURNER_ROLE(), OUSG_GUARDIAN);
     vm.stopPrank();

     // Sanity Asserts
@@ -26,6 +27,15 @@ contract Test_rOUSG_ETH is OUSG_BasicDeployment {
     assertTrue(registry.getKYCStatus(OUSG_KYC_REQUIREMENT_GROUP, alice));
   }

+  function test_burn_with_NOKYC() public dealAliceROUSG(1e18) {
+      vm.startPrank(OUSG_GUARDIAN);
+      _removeAddressFromKYC(OUSG_KYC_REQUIREMENT_GROUP, alice);
+      vm.stopPrank();
+
+      vm.startPrank(OUSG_GUARDIAN);
+      rOUSGToken.burn(alice, 1e18);
+      vm.stopPrank();
+  }
   /*//////////////////////////////////////////////////////////////
                         rOUSG Metadata Tests
   //////////////////////////////////////////////////////////////*/

Result:

Ran 1 test for forge-tests/ousg/rOUSG.t.sol:Test_rOUSG_ETH
[FAIL. Reason: revert: rOUSG: 'from' address not KYC'd] test_burn_with_NOKYC() (gas: 246678)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 11.44ms (1.15ms CPU time)

Ran 1 test suite in 1.12s (11.44ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in forge-tests/ousg/rOUSG.t.sol:Test_rOUSG_ETH
[FAIL. Reason: revert: rOUSG: 'from' address not KYC'd] test_burn_with_NOKYC() (gas: 246678)

Encountered a total of 1 failing tests, 0 tests succeeded

Tools Used

Foundry

Recommended Mitigation Steps

Allow the BURNER to burn tokens without checking the KYC of from address.

diff --git a/contracts/ousg/rOUSG.sol b/contracts/ousg/rOUSG.sol
index 29d9112..6809a28 100644
--- a/contracts/ousg/rOUSG.sol
+++ b/contracts/ousg/rOUSG.sol
@@ -594,7 +594,7 @@ contract ROUSG is
       require(_getKYCStatus(msg.sender), "rOUSG: 'sender' address not KYC'd");
     }

-    if (from != address(0)) {
+    if (from != address(0) && !hasRole(BURNER_ROLE, msg.sender)) {
       // If not minting
       require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
     }

Assessed type

Invalid Validation

Mint limit incorrectly applied on fees

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L278
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L290

Vulnerability details

Vulnerability details:

The OUSGInstantManager contract implements a mint limit that is enforced in the _mint function, dictating how much USDC can be transferred for minting in a specified duration. The function charges an instant mint fee that is deducted from the usdcAmountIn before minting, but the fee is not actually minted. Instead, it is directly transferred to the usdcReceiver address. When incrementing the mint limit through the _checkAndUpdateInstantMintLimit function the usdcAmountIn value is used.

    function _mint(uint256 usdcAmountIn, address to) internal returns (uint256 ousgAmountOut) {
        ...
        _checkAndUpdateInstantMintLimit(usdcAmountIn);
        if (address(investorBasedRateLimiter) != address(0)) {
            investorBasedRateLimiter.checkAndUpdateMintLimit(msg.sender, usdcAmountIn);
        }
	...
        uint256 usdcfees = _getInstantMintFees(usdcAmountIn);
        uint256 usdcAmountAfterFee = usdcAmountIn - usdcfees;
        ...
        // Transfer USDC
        if (usdcfees > 0) {
            usdc.transferFrom(msg.sender, feeReceiver, usdcfees);
        }
        usdc.transferFrom(msg.sender, usdcReceiver, usdcAmountAfterFee);
	...
        ousg.mint(to, ousgAmountOut);
    }

This means that the mint limit is incorrectly being incremented by the fee amount as well, making it impossible for the minted tokens to reach the mint limit for a specific duration, even if there is enough demand.

Impact:

  • Severity: Low. The mint limit will not be able to be reached for a specific duration.
  • Likelihood: High. This issue will occur for every cycle.

Tools Used:

  • Manual analysis

Recommendation:

Modify the _mint function to call the _checkAndUpdateInstantMintLimit function with the usdcAmountAfterFee instead of usdcAmountIn. This way, only the tokens being minted will be accounted for in the mint limit calculation.

    function _mint(uint256 usdcAmountIn, address to) internal returns (uint256 ousgAmountOut) {
        ...
        uint256 usdcfees = _getInstantMintFees(usdcAmountIn);
        uint256 usdcAmountAfterFee = usdcAmountIn - usdcfees;
        
        _checkAndUpdateInstantMintLimit(usdcAmountAfterFee); // change here
        ...
    }

Assessed type

Other

REENTRANCY

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSGFactory.sol#L70
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSGFactory.sol#L108

Vulnerability details

Impact

The provided code is not directly vulnerable to reentrancy attacks. However, there are still potential risks associated with external calls made within the deployRebasingOUSG function, particularly the call to rOUSGProxied.initialize(). Depending on the implementation of the initialize function in the ROUSG contract, it could potentially trigger reentrancy if it makes external calls to untrusted contracts or if it doesn't follow best practices to avoid reentrancy.

Recommended Mitigation Steps

To mitigate these risks, it's essential to ensure that the initialize function in the ROUSG contract and any other external calls within the deployRebasingOUSG function are carefully audited and follow best practices for secure contract design.

Assessed type

Reentrancy

Return value of token transfer not checked

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L276

Vulnerability details

Impact

There are a lot of token transfers in the code, and all of them are just calling transfer or transferFrom without checking the return value. Ideally, due to the ERC-20 token standard, these functions should always return True or False (or revert). If a token returns False, the code will process the transfer as if it succeeds.

As an example of the code snippet bellow from OUSGInstantManager::mintRebasingOUSG a transfer of USDC from the user to the protocol may silently fail. This could lead to loss of funds for the protocol.

    if (usdcfees > 0) {
      usdc.transferFrom(msg.sender, feeReceiver, usdcfees);
    }
    usdc.transferFrom(msg.sender, usdcReceiver, usdcAmountAfterFee);

https://github.com/code-423n4/2024-03-ondo-finance/blob/78779c30bebfd46e6f416b03066c55d587e8b30b/contracts/ousg/ousgInstantManager.sol#L317

https://github.com/code-423n4/2024-03-ondo-finance/blob/78779c30bebfd46e6f416b03066c55d587e8b30b/contracts/ousg/ousgInstantManager.sol#L455

Proof of Concept

This may not be the case for OUSG and rOUSG, but USDC is pausable https://github.com/circlefin/stablecoin-evm?tab=readme-ov-file#pausable

Tools Used

Manual review

Recommended Mitigation Steps

User OZ's SafeERC20.safeTransfer() or check the transfer's return value.

Assessed type

Token-Transfer

Wrong token amount validation

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L460

Vulnerability details

Impact

In ousgInstantManager::_redeemBUIDL() is used to redeem BUIDL tokens. This function is internal and is called in 2 places - first with usdcAmountToRedeem, second with minBUIDLRedeemAmount as parameters.

Proof of Concept

The functions is as follows:

  function _redeemBUIDL(uint256 buidlAmountToRedeem) internal {
    require(
      buidl.balanceOf(address(this)) >= minBUIDLRedeemAmount,
      "OUSGInstantManager::_redeemBUIDL: Insufficient BUIDL balance"
    );
    uint256 usdcBalanceBefore = usdc.balanceOf(address(this));
    buidl.approve(address(buidlRedeemer), buidlAmountToRedeem);
    buidlRedeemer.redeem(buidlAmountToRedeem);
    require(
      usdc.balanceOf(address(this)) == usdcBalanceBefore + buidlAmountToRedeem,
      "OUSGInstantManager::_redeemBUIDL: BUIDL:USDC not 1:1"
    );
  }

The require statement will validate if the contract has more than the minBUIDLRedeemAmount. However if the buidlAmountToRedeem is bigger than buidl.balanceOf(address(this)) the check will pass and eventually revert later on with a different error message, which could confuse users.

Tools Used

Manual Review

Recommended Mitigation Steps

Use buidlAmountToRedeem to validate balance instead of minBUIDLRedeemAmount.

  function _redeemBUIDL(uint256 buidlAmountToRedeem) internal {
    require(
-      buidl.balanceOf(address(this)) >= minBUIDLRedeemAmount,
+      buidl.balanceOf(address(this)) >= buidlAmountToRedeem,
      "OUSGInstantManager::_redeemBUIDL: Insufficient BUIDL balance"
    );
    uint256 usdcBalanceBefore = usdc.balanceOf(address(this));
    buidl.approve(address(buidlRedeemer), buidlAmountToRedeem);
    buidlRedeemer.redeem(buidlAmountToRedeem);
    require(
      usdc.balanceOf(address(this)) == usdcBalanceBefore + buidlAmountToRedeem,
      "OUSGInstantManager::_redeemBUIDL: BUIDL:USDC not 1:1"
    );
  }

Assessed type

Context

`rOUSG::_getKYCStatus` can be subverted by any user due to be delisted

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L594
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L431-L443
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L335-L351

Vulnerability details

Impact

Any user can subvert the KYC whitelist authority of the rOUSG contract when they're due to be delisted from being able to perform regular rOUSG transaction actions such as transfers or redeems.

Proof of Concept

The issue stems from the fact that the Ethereum mempool is public and there is a transaction to delist (i.e modify their KYC status from true to false in Ondo Finance's centralized list) a user first before their rOUSG actions such as transfer(), wrap(), redeem(), and redeemRebasingOUSG() are stripped.

For example, in the code blocks below we can see the enforcement in the _beforeTokenTransfer() function that tries to make sure a user is still KYC'd whenever such user attempts to transfer or wrap between oUSG & rOUSG

function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
    ...

<@:1  if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }
   ...
  }

The code block below makes sure the sender is still KYC'd to perform transfers of rOUSG:

if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }

Consider the scenario below:

  • Alice becomes KYC'd for rOUSG
  • She wraps her oUSG to rOUSG
  • Keep in mind oUSG & rOUSG share the same centralized list which also makes sure before a user can redeem back to USDC, their KYC status is still set to true
  • The Ondo team decides to remove her KYC status by sending a transaction to set her status to false hence stripping her rOUSG token privileges
  • She's on the lookout for such transactions and already automates a transaction to redeem her rOUSG to USDC first.
  • Her KYC status is now set to false but that doesn't have any effect on her USDC token privileges.

What effectively happens is, that she watches the mempool for a transaction to set her KYC status to false > redeems to USDC first before that transaction is mined. At this point, she can continue to transfer the USDC in the same transaction or the next transaction after her KYC status for rOUSG & oUSG token privileges is set to false.

Tools Used

Manual review + foundry

Recommended Mitigation Steps

When a user decides to redeem, enforce a short lockup period that ensues before they're then able to pull the USDC - such lockup duration can be as low as 7 hours, for example, to enable the delist transaction to get a chance to take effect even though users frontrun it as the withdrawal would still be on hold.

Assessed type

Context

QA Report

See the markdown file with the details of this report here.

Absence of Slippage Protection in OUSF/rOUSG Minting and Redeeming Processes

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L230-L276
https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L335-L386

Vulnerability details

Impact

The omission of slippage protection mechanisms in the OUSG/rOUSG minting and redeeming processes introduces a notable risk to investors due to possible adverse price movements. Given the contract's reliance on the getOUSGPrice() function, which can be subject to significant fluctuations in a live mainnet environment, investors may face unexpected financial losses due to market volatility.

Proof of Concept

Here's a typical scenario:

  1. Bob uses the mint() function to convert his USDC to OUSG. The contract uses the current OUSG price to determine the amount of OUSG shares Bob receives.

Transaction Details:
USDC Amount: 100,000 USDC
OUSG Price: 105.5 USDC (per OUSG)
OUSG Minted: 947 (assuming no fees for simplicity)

  1. Due to traffic congestion and a high volatility is experienced during the delay, OUSG price increases to 110 USDC (per OUSG) by the time Bob's transaction is processed.

Transaction Details:
USDC Amount: 100,000 USDC
OUSG Price: 110 USDC (per OUSG)
OUSG Minted: 909 (assuming no fees for simplicity)

  1. Bob, the investor, receives 947 - 909 = 38 OUSG less which is equivalent to an unanticipated loss of 4%. The situation could have been worse if the price had increased to a higher value.

The same is true in the reverse manner when applicable to and concerning redeem().

Tools Used

Manual

Recommended Mitigation Steps

Implement an optional parameter in minting and redeeming functions allowing investors to specify a maximum acceptable slippage percentage. This caters to both investors desiring protection and those prioritizing transaction certainty. For instance, the following function may be refactored as follows:

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L230-L241

  function mint(
    uint256 usdcAmountIn
+    uint256 minimumOusgAmountOut
  )
    external
    override
    nonReentrant
    whenMintNotPaused
    returns (uint256 ousgAmountOut)
  {
    ousgAmountOut = _mint(usdcAmountIn, msg.sender);
+    (require ousgAmountOut >= minimumOusgAmountOut);
    emit InstantMintOUSG(msg.sender, usdcAmountIn, ousgAmountOut);
  }

Assessed type

Timing

The vulnerability resides in the `ousgInstantManager.sol` contract where the `mintFee` variable is initially set to 0, and there exists a function named `setMintFee` to adjust this fee. However, due to the absence of proper initialization or protection mechanisms, an attacker can exploit the delay between the owner's execution of the 'setMintFee' function and its confirmation on the blockchain. During this window, the attacker can front-run the transaction, manipulating the minting process to avoid paying the required fee.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L99
https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L567

Vulnerability details

Impact

This vulnerability allows attackers to mint tokens without paying fees, leading to financial losses for the protocol, undermining user trust, and potentially attracting regulatory scrutiny.

Proof of Concept

  • The contract OUSGInstantManager.sol initializes with a variable called mintFee set to 0 . This variable determines the fee required for minting tokens.

  • The protocol owner intends to set a mint fee using the setMintFee function provided by the protocol.

  • The transaction will go in mempool. There attacker can frontrun the owners transaction and can execute his function for minting the tokens.

  • Before the owner's transaction confirming the new mint fee is processed, the attacker exploits the vulnerability. They initiate a transaction to mint tokens without paying the fee. As the mintFee variable will be still 0.

Tools Used

Manual Review

Recommended Mitigation Steps

  • The mintFee variable should be get intialized in the constructor.

Assessed type

MEV

User escape having their tokens burned by frontrun-transferring, wrapping or redeeming the tokens.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624

Vulnerability details

Impact

The burner is given the role of burning any rOUSG from any account. The issue is that users can frontrun this call to either transfer their tokens to another approved user, wrap the tokens to OUSG or by redeeming USDC. The users can do this because before their tokens can be burned, their kyc status is needed to be left as true, i.e not yet sanctioned. Hence, by frontrunning the transaction and calling the transfer function in the rOUSG contract, or by calling the redeemRebasingOUSG function. The user is able to rescue or redeem their funds before they're seized or being sanctioned.

By calling the transfer function, the user can transfer rOUSG to another approved address.

  function transfer(address _recipient, uint256 _amount) public returns (bool) {
    _transfer(msg.sender, _recipient, _amount);
    return true;
  }

And by calling the redeemRebasingOUSG function, they're able to get USDC instead.

  function redeemRebasingOUSG(
    uint256 rousgAmountIn
  )
    external
    override
    nonReentrant
    whenRedeemNotPaused
    returns (uint256 usdcAmountOut)
  {
    require(
      rousg.allowance(msg.sender, address(this)) >= rousgAmountIn,
      "OUSGInstantManager::redeemRebasingOUSG: Insufficient allowance"
    );
    rousg.transferFrom(msg.sender, address(this), rousgAmountIn);
    rousg.unwrap(rousgAmountIn);
    uint256 ousgAmountIn = rousg.getSharesByROUSG(rousgAmountIn) /
      OUSG_TO_ROUSG_SHARES_MULTIPLIER;
    usdcAmountOut = _redeem(ousgAmountIn);
    emit InstantRedemptionRebasingOUSG(
      msg.sender,
      rousgAmountIn,
      ousgAmountIn,
      usdcAmountOut
    );
  }

Proof of Concept

Tools Used

Manual code review

Recommended Mitigation Steps

The potential workaround for usdc redemption is to implement a requestRedeem method with a short time delay, in place of direct redemption, and and admin controlled cancelRedeem function.
This can be combined with the mitigation proposed in my other issue, in which the check for kyc status of from address is skipped if the burner holds the BURNER_ROLE.
That way, the user's kyc status can be revoked before burning, attempt to transfer to another address will fail, and attempts to redeem for USDC will be delayed and can later be cancelled by the admin.

Assessed type

Other

BURNER_ROLE can't burn user's tokens if user's kyc status is revoked.

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624

Vulnerability details

Impact

The BURNER_ROLE is given the role of burning any rOUSG from any account. The issue here is that if an account has been sanctioned or removed from the kyc arroved users. Burning from such an account will be impossible.
Looking at the burn function, it makes a call to the internal _burnShares function.

  function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();

    _burnShares(_account, ousgSharesAmount);

    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
  }

The _burnShares function calls the _beforeTokenTransfer hook to see if the accounts transferring tokens have a valid kyc status.

  function _burnShares(
    address _account,
    uint256 _sharesAmount
  ) internal whenNotPaused returns (uint256) {
    require(_account != address(0), "BURN_FROM_THE_ZERO_ADDRESS");

    _beforeTokenTransfer(_account, address(0), _sharesAmount);
...
    return totalShares;
  }

Now because the user whose tokens are being burnt no longer has a valid kyc status, the check will fail and the tokens will not be burned.

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
...
    if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }

...
  }

Proof of Concept

  • Account1 holds rOUSG tokens.
  • He gets removed from KYC listing.
  • BURNER_ROLE calls the burn function to burn Account1's tokens.
  • The call fails and the tokens are still left intact.

Tools Used

Manual code review

Recommended Mitigation Steps

In the _burnShares function, include a check for msg.sender != BURNER_ROLE. If this the caller has the BURNER_ROLE, skip the _ beforeTokenTransfer hook.

  function _burnShares(
    address _account,
    uint256 _sharesAmount
  ) internal whenNotPaused returns (uint256) {
    require(_account != address(0), "BURN_FROM_THE_ZERO_ADDRESS");
    if (msg.sender != BURNER_ROLE) {
    _beforeTokenTransfer(_account, address(0), _sharesAmount);
    }
    uint256 accountShares = shares[_account];
    require(_sharesAmount <= accountShares, "BURN_AMOUNT_EXCEEDS_BALANCE");

    totalShares -= _sharesAmount;

    shares[_account] = accountShares - _sharesAmount;

    return totalShares;
  }

Assessed type

Other

Evasion of Sanctions and KYC Status Checks in rOUSG Token Transfer

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L586-L606
https://etherscan.io/address/0x40C57923924B5c5c5455c48D93317139ADDaC8fb#code#L131

Vulnerability details

Impact

The capability for token rOUSG holders to bypass sanctions prior to updates to their status presents a significant risk, especially given the rOUSG token's 1:1 peg to USDC. This equivalence implies rOUSG operates within a financially sensitive ecosystem, possibly attracting stringent regulatory oversight akin to that of traditional financial instruments and fiat-pegged stablecoins.

Proof of Concept

rOUSG._beforeTokenTransfer() is designed to enforce KYC checks on the from, to, and, in certain scenarios, the msg.sender addresses involved in a token transfer. However, the potential for a user to preemptively transfer tokens prior to an update to their KYC status or inclusion in a sanctions list could circumvent these checks. This scenario hinges on the timely update of KYC statuses and the system's ability to enforce these updates before transactions are processed on the blockchain.

Here's a typical scenario:

  1. Bob has been transferred 100k rOUSG tokens (after mint fee) and assigned supposed shares.
  2. Bob sees in the mainnet mempool that the following function the contract owner is calling has his address in the address array that will have SanctionsList.isSanctioned() return true:

https://etherscan.io/address/0x40C57923924B5c5c5455c48D93317139ADDaC8fb#code#L131

  function addToSanctionsList(address[] memory newSanctions) public onlyOwner {
    for (uint256 i = 0; i < newSanctions.length; i++) {
      sanctionedAddresses[newSanctions[i]] = true;  
    }
    emit SanctionedAddressesAdded(newSanctions);
  }
  1. Said function call will make rOUSG._beforeTokenTransfer() (invoked by _transferShares() and transferShares) revert when _getKYCStatus(msg.sender) and/or _getKYCStatus(from) is called:

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/rOUSG.sol#L586-L606

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
    // Check constraints when `transferFrom` is called to facliitate
    // a transfer between two parties that are not `from` or `to`.
    if (from != msg.sender && to != msg.sender) {
      require(_getKYCStatus(msg.sender), "rOUSG: 'sender' address not KYC'd");
    }

    if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }

    if (to != address(0)) {
      // If not burning
      require(_getKYCStatus(to), "rOUSG: 'to' address not KYC'd");
    }
  }
  1. This is because KYCRegistry.getKYCStatus() will return false due to the second conditional check:

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/kyc/KYCRegistry.sol#L128-L135

  function getKYCStatus(
    uint256 kycRequirementGroup,
    address account
  ) external view override returns (bool) {
    return
      kycState[kycRequirementGroup][account] &&
      !sanctionsList.isSanctioned(account);
  }
  1. Nevertheless, Bob front runs it with higher gas and transfers all shares to another unsanctioned address and thereby dodging the intended sanction.

Tools Used

Manual

Recommended Mitigation Steps

Consider adding to the list of permissions to ousgInstantManager.multiexcall() where such obnoxious act described in the POC may be penalized e.g. forfeiting the transferred shares by getting them assigned to a custody address.

Alternatively, engage an off chain mechanism that allows tracking on chain using a mapping for the Chainalsysis sanctions list. This will be more sophisticated but is effective circumventing the exploit.

Assessed type

Timing

Missing zero address check for "defaultAdmin" inside of "ousgInstantManager.sol" constructor

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/ousgInstantManager.sol#L145

Vulnerability details

Impact

As noticed in the constructor logic all of the other method arguments of type address get validated if they are address(0) except the
"defaultAdmin" which actually receives all the important roles to operate with the contract, thus leaving the contract ownership compromised.

Proof of Concept

Take for example this piece of code:

funciton initialize() external{
OUSGInstantManager manager = new OUSGInstantManager (address(0),..,..,..); //instead of address(msg.sender);
}

Tools Used

Manual review

Recommended Mitigation Steps

Add a simple check inside the constructor as follow:

   require(
      address(defaultAdmin) != address(0),
      "OUSGInstantManager: defaultAdmin cannot be 0x0"
    );

Assessed type

Access Control

Uninitialized `mintFee` Causes Minting Revert

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

Lack of slippage control in ROUSG::burn() & ROUSG::unwrap()

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624-L640

Vulnerability details

Summary

The ROUSG.burn() function allows admin to burn rOUSG tokens from any _account thereby getting OUSG in return.
The ROUSG.unwrap() on the other hand is called by users to unwrap their rOUSG tokens.

However, these functions do not have any type of slippage control since the amount of tokens received by the user is determined by an interaction with an oracle, meaning that the amount received in return may vary indefinitely while the request is waiting to be executed.

Also the users will have no defense against price manipulation attacks, if they were to be found after the protocol's deployment.

Proof of Concept

Instance 1: ROUSG.burn()

  function burn(
    address _account,
    uint256 _amount
  ) external onlyRole(BURNER_ROLE) {
    uint256 ousgSharesAmount = getSharesByROUSG(_amount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();


    _burnShares(_account, ousgSharesAmount);


    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(address(0), msg.sender, getROUSGByShares(ousgSharesAmount));
    emit TransferShares(_account, address(0), ousgSharesAmount);
  }

The ousgSharesAmount to be burned is determined by the getSharesByROUSG() function of the same contract:

  function getSharesByROUSG(
    uint256 _rOUSGAmount
  ) public view returns (uint256) {
    return
      (_rOUSGAmount * 1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER) / getOUSGPrice();
  }

As can be observed, this function uses oracle interaction through the division by getOUSGPrice(). The getOUSGPrice() function queries the external oracle for the asset price:

  function getOUSGPrice() public view returns (uint256 price) {
    (price, ) = oracle.getPriceData();
  }

To determine the amount of OUSG to be received after share burning, the ousgSharesAmount calculated above is divided by OUSG_TO_ROUSG_SHARES_MULTIPLIER:

    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );

This means that the user has no way to predict how many OUSG they will get back at the moment of burning shares, as the price could be updated while the request is in the mempool.

Instance 2: ROUSG.unwrap()

  function unwrap(uint256 _rOUSGAmount) external whenNotPaused {
    require(_rOUSGAmount > 0, "rOUSG: can't unwrap zero rOUSG tokens");
    uint256 ousgSharesAmount = getSharesByROUSG(_rOUSGAmount);
    if (ousgSharesAmount < OUSG_TO_ROUSG_SHARES_MULTIPLIER)
      revert UnwrapTooSmall();
    _burnShares(msg.sender, ousgSharesAmount);
    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );
    emit Transfer(msg.sender, address(0), _rOUSGAmount);
    emit TransferShares(msg.sender, address(0), ousgSharesAmount);
  }

ousgSharesAmount is detamined by calling getSharesByROUSG():

  function getSharesByROUSG(
    uint256 _rOUSGAmount
  ) public view returns (uint256) {
    return
      (_rOUSGAmount * 1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER) / getOUSGPrice();
  }

which also uses getOUSGPrice() in division as above. This invokes the external oracle to get the asset price:

  function getOUSGPrice() public view returns (uint256 price) {
    (price, ) = oracle.getPriceData();
  }

And finally to determine the amount of OUSG to be received after unwrapping, the ousgSharesAmount calculated above is divided by OUSG_TO_ROUSG_SHARES_MULTIPLIER:

    ousg.transfer(
      msg.sender,
      ousgSharesAmount / OUSG_TO_ROUSG_SHARES_MULTIPLIER
    );

Similarly, the user has no way to predict how many OUSG they will get back at the moment of unwrapping, as the price could be updated while the request is in the mempool.

Tools Used

Manual Review

Recommended Mitigation Steps

Add additional parameter that could be added in these functions, to decide the minimum amount of tokens to be received, with a relative check after burning/unwrapping.

Assessed type

Oracle

User funds may be locked if minimumRedemptionAmount is changed

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L599-L611

Vulnerability details

There is a function setMinimumRedemptionAmount() that allows the user to change the minimum redemption amount. However this is dangerous because if it is set to a higher value a user might not be able to redeem and his funds will be locked.

Proof of Concept

This scenario assumes there are 0 fees for simplicity:

  1. A user deposits 250,000 USDC and at a price of OUSG = 100$ he should get 2,500 OUSG tokens
  2. Initially minimum redemption amount is set to 50,000 USDC which means the user can get back all deposited USDC used to mint his OUSG tokens.
  3. Admin changes minimumRedemptionAmount to 300,000 USDC
  4. User now cannot redeem his USDC because he requests 250,000 USDC but the minimum is 300,000 USDC

There is a check that reverts if usdcAmountToRedeem is less than minimumRedemptionAmount:

require(
      usdcAmountToRedeem >= minimumRedemptionAmount,
      "OUSGInstantManager::_redeem: Redemption amount too small"
    );

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/ousg/ousgInstantManager.sol#L402-L405

Recommended Mitigation Steps

Remove the setMinimumRedemptionAmount() function if it's not essential for the protocol. Or remove the check for minimum redemption amount.

Assessed type

Other

QA Report

See the markdown file with the details of this report here.

QA Report

See the markdown file with the details of this report here.

`rOUSG::burn()` always revert if the account is not on the KYC list

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/be2e9ebca6fca460c5b0253970ab280701a15ca1/contracts/ousg/rOUSG.sol#L624-L640

Vulnerability details

Impact

rOUSG::burn() will always revert, BURNER_ROLE never got OUSG from the account and rOUSG amount will be locked in the account.

Proof of Concept

The scenario below :

  1. BURNER_ROLE call burn function for account which have been removed or subject to sanctions and enter the rOUSG value
  2. Then for burning, _burnShares will be called
  3. Before making any changes, _beforeTokenTransfer will be called. This is to ensure that the relevant account is on the KYC list or is not subject to sanctions.
  function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
    // Check constraints when `transferFrom` is called to facliitate
    // a transfer between two parties that are not `from` or `to`.
    if (from != msg.sender && to != msg.sender) {
      require(_getKYCStatus(msg.sender), "rOUSG: 'sender' address not KYC'd");
    }

    if (from != address(0)) {
      // If not minting
      require(_getKYCStatus(from), "rOUSG: 'from' address not KYC'd");
    }

    if (to != address(0)) {
      // If not burning
      require(_getKYCStatus(to), "rOUSG: 'to' address not KYC'd");
    }
  }

Since the account have been remove from KYC list, rOUSG::burn() will always revert.

Note :

  1. From conversations with the team, this function is intended for certain situations, especially in security matters. If this function is called to forcefully withdraw the OUSG from the account (perhaps the account was a problem account at the end and was forcibly removed from the KYC List or was subject to sanctions) by burning the OUSG then this cannot be done.
  2. Regarding known issues in the README :

Sanction or KYC related edge cases - specifically when a user’s KYCRegistry or Sanction status changes in between different actions, leaving them at risk of their funds being locked. If someone gets sanctioned on the Chainalysis Sanctions Oracle or removed from Ondo Finance’s KYC Registry their funds are locked.

This issue is not only locked user funds but it makes BURNER_ROLE never get the desired OUSG amount and makes this function always revert, so the impact of this issue is more than written from a known issue.

Coded POC

Copy this test code to rOUSG.t.sol and run npm run test-forge

function testrOUSGAlwaysRevertIfAccountRemovedFromKYCList()
    public
    dealAliceROUSG(1e18) //add alice rOUSG
  {
    //remove alice from KYC List
    vm.prank(OUSG_GUARDIAN);
    _removeAddressFromKYC(OUSG_KYC_REQUIREMENT_GROUP, alice);

    //use GUARDIAN as BURNER ROLE 
    vm.prank(OUSG_GUARDIAN);
    vm.expectRevert("rOUSG: 'from' address not KYC'd");
    rOUSGToken.burn(alice, 100e18);
  }

Result

[PASS] testrOUSGAlwaysRevertIfAccountRemovedFromKYCList() (gas: 202125)

Tools Used

Manual review

Recommended Mitigation Steps

  1. If BURNER_ROLE very trusted, in _burnShares function skip the _beforeTokenTransfer part
  2. From conversations with the team, they will adding back the account the KYC List → call burn function → remove the account. In my opinion, this not safe. Malicious actor can prepare for some attack and may front-run the transaction

Assessed type

Invalid Validation

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.