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
Rank: 16/72
Findings: 2
Award: $91.68
🌟 Selected for report: 1
🚀 Solo Findings: 0
83.3969 USDC - $83.40
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
The BURNER_ROLE cannot burn tokens if the target account
has been removed from the KYC list.
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"); } }
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
Foundry
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"); }
Invalid Validation
#0 - c4-pre-sort
2024-04-04T05:09:41Z
0xRobocop marked the issue as duplicate of #237
#1 - c4-judge
2024-04-09T08:34:50Z
3docSec marked the issue as satisfactory
#2 - c4-judge
2024-04-09T08:38:15Z
3docSec marked the issue as selected for report
#3 - cameronclifton
2024-04-10T16:20:43Z
As discussed in previous audit, an account with the burner role would be able to atomically (1) add an address to KYC registry, (2) perform burn, and then (3) remove the address from the KYC Registry. This is not a reasonable finding when considering the bigger picture.
#4 - Emanueldlvg
2024-04-11T20:26:14Z
Hi, sorry but i'm disagree with you. Here is my explanation as SR why this issue valid :
Thank you and the rest i leave to the judge.
#5 - 3docSec
2024-04-12T07:18:34Z
Hi @cameronclifton I see your point, but the mitigation:
an account with the burner role would be able to atomically (1) add an address to KYC registry, (2) perform burn, and then (3) remove the address from the KYC Registry
would not be feasible for blocklist accounts, because the Ondo admins can't control those like they can with KYC.
Does this change your view on the severity of the issue?
#6 - Henrychang26
2024-04-12T08:16:51Z
This was noted as Automated Findings / Publicly Known Issues on contest page:
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.
#7 - Emanueldlvg
2024-04-12T08:22:04Z
This was noted as Automated Findings / Publicly Known Issues on contest page:
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 is not the case, the main problem is that the admin who is part of Ondo cannot do anything with the funds. This is not just about locked user funds. Of course this problem is different
#8 - cameronclifton
2024-04-15T15:35:04Z
@3docSec, Should a user get blocklisted via the sanctions oracle, Ondo can do a similar atomic transaction if burning is desired. In one atomic txn:
#9 - c4-sponsor
2024-04-21T16:40:58Z
cameronclifton (sponsor) disputed
#10 - 3docSec
2024-04-21T17:12:29Z
Hi @cameronclifton,
the reasons why I opted to keep this as medium is:
I hope this helps clarify the reasons behind judging.
#11 - thebrittfactor
2024-04-23T18:47:10Z
Per further discussion with the sponsor team, adding this final input on their behalf for inclusion in the report:
We will not be addressing this as we have a safe workaround for this exact scenario.
🌟 Selected for report: immeas
Also found by: 0xAkira, 0xCiphky, 0xGreyWolf, 0xJaeger, 0xMosh, 0xabhay, 0xlemon, 0xmystery, 0xweb3boy, Aamir, Abdessamed, Aymen0909, Breeje, DanielArmstrong, DarkTower, Dots, EaglesSecurity, FastChecker, HChang26, Honour, IceBear, JC, K42, Krace, MaslarovK, Omik, OxTenma, SAQ, Shubham, Stormreckson, Tigerfrake, Tychai0s, VAD37, ZanyBonzy, albahaca, arnie, ast3ros, asui, b0g0, bareli, baz1ka, btk, caglankaan, carrotsmuggler, cheatc0d3, dd0x7e8, grearlake, igbinosuneric, jaydhales, kaden, kartik_giri_47538, m4ttm, ni8mare, niser93, nonn_ac, oualidpro, pfapostol, pkqs90, popeye, radev_sw, samuraii77, slvDev, zabihullahazadzoi
8.2807 USDC - $8.28
When minting OUSG
via function mint
, there is no slippage protection and the users may receive less amount of OUSG
than they expected.
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); }
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);
Foundry
Add a slippage check to the mint
function.
Context
#0 - c4-pre-sort
2024-04-04T02:58:44Z
0xRobocop marked the issue as duplicate of #250
#1 - c4-pre-sort
2024-04-04T22:59:38Z
0xRobocop marked the issue as duplicate of #156
#2 - c4-judge
2024-04-09T08:03:18Z
3docSec marked the issue as satisfactory
#3 - c4-judge
2024-04-09T08:04:34Z
3docSec changed the severity to 2 (Med Risk)
#4 - 3docSec
2024-04-11T07:07:03Z
Does not cover the _redeem flow -> 50%
#5 - c4-judge
2024-04-11T07:07:08Z
3docSec marked the issue as partial-50
#6 - c4-judge
2024-04-11T15:13:13Z
3docSec changed the severity to QA (Quality Assurance)
#7 - c4-judge
2024-04-11T15:16:52Z
3docSec marked the issue as grade-b