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
Rank: 4/69
Findings: 4
Award: $3,616.11
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: adriro
Also found by: cccz, minhquanym, peanuts, zaskoh
3272.2647 USDC - $3,272.26
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L707-L727
The function completeRedemptions
present in the CashManager
contract is used by the manager to complete redemptions requested by users and also to process refunds.
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L707-L727
function completeRedemptions( address[] calldata redeemers, address[] calldata refundees, uint256 collateralAmountToDist, uint256 epochToService, uint256 fees ) external override updateEpoch onlyRole(MANAGER_ADMIN) { _checkAddressesKYC(redeemers); _checkAddressesKYC(refundees); if (epochToService >= currentEpoch) { revert MustServicePastEpoch(); } // Calculate the total quantity of shares tokens burned w/n an epoch uint256 refundedAmt = _processRefund(refundees, epochToService); uint256 quantityBurned = redemptionInfoPerEpoch[epochToService] .totalBurned - refundedAmt; uint256 amountToDist = collateralAmountToDist - fees; _processRedemption(redeemers, amountToDist, quantityBurned, epochToService); collateral.safeTransferFrom(assetSender, feeRecipient, fees); emit RedemptionFeesCollected(feeRecipient, fees, epochToService); }
The total refunded amount that is returned from the internal call to _processRefund
is then used to calculate the effective amount of CASH burned (redemptionInfoPerEpoch[epochToService].totalBurned - refundedAmt
). This resulting value is then used to calculate how much each user should receive based on how much CASH they redeemed and the total amount that was burned.
The main issue here is that the refunded amount is not updated in the totalBurned
storage variable for the given epoch. Any subsequent call to this function won't take into account refunds from previous calls.
If the manager completes the refunds and redemptions at different steps or stages for a given epoch, using multiple calls to the completeRedemptions
, then any refunded amount won't be considered in subsequent calls to the function.
Any redemption that is serviced in a call after a refund will be calculated using the total burned without subtracting the previous refunds. The function completeRedemptions
will call the internal function _processRedemption
passing the burned amount as the quantityBurned
argument, the value is calculated in line 755:
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L755
uint256 collateralAmountDue = (amountToDist * cashAmountReturned) / quantityBurned;
This means that redemptions that are processed after one or more previous refunds will receive less collateral tokens even if they redeemed the same amount of CASH tokens (i.e. greater quantityBurned
, less collateralAmountDue
), causing loss of funds for the users.
In the following test, Alice, Bob and Charlie request a redemption. The admin first calls completeRedemptions
to process Alice's request and refund Charlie. The admin then makes a second call to completeRedemptions
to process Bob's request. Even though they redeemed the same amount of CASH (each 200e18
), Alice gets 150e6
tokens while Bob is sent ~133e6
.
contract TestAudit is BasicDeployment { function setUp() public { createDeploymentCash(); // Grant Setter vm.startPrank(managerAdmin); cashManager.grantRole(cashManager.SETTER_ADMIN(), address(this)); cashManager.grantRole(cashManager.SETTER_ADMIN(), managerAdmin); vm.stopPrank(); // Seed address with 1000000 USDC vm.prank(USDC_WHALE); USDC.transfer(address(this), INIT_BALANCE_USDC); } function test_CashManager_completeRedemptions_BadReedem() public { _setupKYCStatus(); // Seed alice and bob with 200 cash tokens _seed(200e18, 200e18, 50e18); // Have alice request to withdraw 200 cash tokens vm.startPrank(alice); tokenProxied.approve(address(cashManager), 200e18); cashManager.requestRedemption(200e18); vm.stopPrank(); // Have bob request to withdraw 200 cash tokens vm.startPrank(bob); tokenProxied.approve(address(cashManager), 200e18); cashManager.requestRedemption(200e18); vm.stopPrank(); // Have charlie request to withdraw his tokens vm.startPrank(charlie); tokenProxied.approve(address(cashManager), 50e18); cashManager.requestRedemption(50e18); vm.stopPrank(); // Move forward to the next epoch vm.warp(block.timestamp + 1 days); vm.prank(managerAdmin); cashManager.setMintExchangeRate(2e6, 0); // Approve the cashMinter contract from the assetSender account _seedSenderWithCollateral(300e6); // First call, withdraw Alice and refund Charlie address[] memory withdrawFirstCall = new address[](1); withdrawFirstCall[0] = alice; address[] memory refundFirstCall = new address[](1); refundFirstCall[0] = charlie; vm.prank(managerAdmin); cashManager.completeRedemptions( withdrawFirstCall, // Addresses to issue collateral to refundFirstCall, // Addresses to refund cash 300e6, // Total amount of money to dist incl fees 0, // Epoch we wish to process 0 // Fee amount to be transferred to ondo ); // Alice redemption is calculated taking the refund into account uint256 aliceExpectedBalance = 200e18 * 300e6 / ((200e18 + 200e18 + 50e18) - 50e18); assertEq(USDC.balanceOf(alice), aliceExpectedBalance); assertEq(USDC.balanceOf(bob), 0); assertEq(tokenProxied.balanceOf(charlie), 50e18); // Second call, withdraw Bob address[] memory withdrawSecondCall = new address[](1); withdrawSecondCall[0] = bob; address[] memory refundSecondCall = new address[](0); vm.prank(managerAdmin); cashManager.completeRedemptions( withdrawSecondCall, // Addresses to issue collateral to refundSecondCall, // Addresses to refund cash 300e6, // Total amount of money to dist incl fees 0, // Epoch we wish to process 0 // Fee amount to be transferred to ondo ); // But here, Bob's redemption doesn't consider the previous refund. uint256 bobBadBalance = uint256(200e18 * 300e6) / (200e18 + 200e18 + 50e18); assertEq(USDC.balanceOf(bob), bobBadBalance); } function _setupKYCStatus() internal { // Add KYC addresses address[] memory addressesToKYC = new address[](5); addressesToKYC[0] = guardian; addressesToKYC[1] = address(cashManager); addressesToKYC[2] = alice; addressesToKYC[3] = bob; addressesToKYC[4] = charlie; registry.addKYCAddresses(kycRequirementGroup, addressesToKYC); } function _seed( uint256 aliceAmt, uint256 bobAmt, uint256 charlieAmt ) internal { vm.startPrank(guardian); tokenProxied.mint(alice, aliceAmt); tokenProxied.mint(bob, bobAmt); tokenProxied.mint(charlie, charlieAmt); vm.stopPrank(); } function _seedSenderWithCollateral(uint256 usdcAmount) internal { vm.prank(USDC_WHALE); USDC.transfer(assetSender, usdcAmount); vm.prank(assetSender); USDC.approve(address(cashManager), usdcAmount); } }
Update the totalBurned
amount to consider refunds resulting from the call to _processRefund
:
function completeRedemptions( address[] calldata redeemers, address[] calldata refundees, uint256 collateralAmountToDist, uint256 epochToService, uint256 fees ) external override updateEpoch onlyRole(MANAGER_ADMIN) { _checkAddressesKYC(redeemers); _checkAddressesKYC(refundees); if (epochToService >= currentEpoch) { revert MustServicePastEpoch(); } // Calculate the total quantity of shares tokens burned w/n an epoch uint256 refundedAmt = _processRefund(refundees, epochToService); uint256 quantityBurned = redemptionInfoPerEpoch[epochToService] .totalBurned - refundedAmt; + redemptionInfoPerEpoch[epochToService].totalBurned = quantityBurned; uint256 amountToDist = collateralAmountToDist - fees; _processRedemption(redeemers, amountToDist, quantityBurned, epochToService); collateral.safeTransferFrom(assetSender, feeRecipient, fees); emit RedemptionFeesCollected(feeRecipient, fees, epochToService); }
#0 - c4-judge
2023-01-22T16:34:29Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-01-22T16:34:33Z
trust1995 marked the issue as satisfactory
#2 - c4-sponsor
2023-01-31T15:45:38Z
ali2251 marked the issue as sponsor confirmed
#3 - ypatil12
2023-01-31T15:49:08Z
PR Merged here: https://github.com/ondoprotocol/flux/issues/166
#4 - c4-judge
2023-02-01T07:49:27Z
trust1995 marked the issue as selected for report
🌟 Selected for report: AkshaySrivastav
Also found by: 0xjuicer, Bauer, Tajobin, adriro, csanuragjain, gzeon, immeas, rbserver
275.2478 USDC - $275.25
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/kyc/KYCRegistry.sol#L79-L112
The function addKYCAddressViaSignature
of the KYCRegistry
contract allows a user to be granted a KYC status using a signature provided by Ondo. The function validates that the signer has the corresponding role for the requirement group and adds the user to the given KYC group if successful.
While the signed digest contains a deadline, if this deadline is long enough, a user may be able to reuse the provided signature.
The signature can be reused to add the user back to the registry within the limits of the deadline.
If the user is first KYC approved off-chain and given a signature, and is later removed from the registry by a privileged role using removeKYCAddresses
, then the user can resubmit the signature to addKYCAddressViaSignature
and be readded to the KYC registry.
The following test illustrates the issue.
contract TestAudit is BasicDeployment { function setUp() public { createDeploymentCash(); // Grant Setter vm.startPrank(managerAdmin); cashManager.grantRole(cashManager.SETTER_ADMIN(), address(this)); cashManager.grantRole(cashManager.SETTER_ADMIN(), managerAdmin); vm.stopPrank(); // Seed address with 1000000 USDC vm.prank(USDC_WHALE); USDC.transfer(address(this), INIT_BALANCE_USDC); } function test_KYCRegistry_addKYCAddressViaSignature_ReuseSignature() public { uint256 signerPrivateKey = 0x123; address signer = vm.addr(signerPrivateKey); // Add signer KEY group role vm.prank(registryAdmin); registry.grantRole(KYC_GROUP_2_ROLE, signer); // Alice is not in the registry assertFalse(registry.getKYCStatus(kycRequirementGroup, alice)); // // Alice is KYC off-chain and provided a signature uint256 deadline = block.timestamp + 30 days; bytes32 structHash = keccak256(abi.encode(registry._APPROVAL_TYPEHASH(), kycRequirementGroup, alice, deadline)); bytes32 hash = ECDSA.toTypedDataHash(registry.DOMAIN_SEPARATOR(), structHash); (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, hash); vm.prank(alice); registry.addKYCAddressViaSignature(kycRequirementGroup, alice, deadline, v, r, s); // Alice is now KYC approved assertTrue(registry.getKYCStatus(kycRequirementGroup, alice)); // Simulate time passes... vm.warp(block.timestamp + 15 days); // Admin removes Alice KYC status address[] memory addressesToRemoveKYC = new address[](1); addressesToRemoveKYC[0] = alice; vm.prank(signer); registry.removeKYCAddresses(kycRequirementGroup, addressesToRemoveKYC); assertFalse(registry.getKYCStatus(kycRequirementGroup, alice)); // Alice reuses the signature and gets back her KYC status vm.prank(alice); registry.addKYCAddressViaSignature(kycRequirementGroup, alice, deadline, v, r, s); assertTrue(registry.getKYCStatus(kycRequirementGroup, alice)); } }
Use a nonce for each account and include it as part of the signed message to prevent reusing the same signature more than once. When calling addKYCAddressViaSignature
, validate that the nonce is the current nonce for the given user. If successful, consume and increment the nonce.
#0 - c4-judge
2023-01-22T15:54:57Z
trust1995 marked the issue as duplicate of #187
#1 - c4-judge
2023-01-23T14:48:00Z
trust1995 marked the issue as satisfactory
🌟 Selected for report: CodingNameKiki
Also found by: 0x1f8b, 0x52, 0x5rings, 0xAgro, 0xSmartContract, 0xcm, 0xkato, 2997ms, Aymen0909, BClabs, BPZ, BRONZEDISC, Bauer, Bnke0x0, Deekshith99, IllIllI, Josiah, Kaysoft, RaymondFam, Rolezn, SaeedAlipoor01988, Tajobin, Udsen, Viktor_Cortess, adriro, arialblack14, betweenETHlines, btk, chaduke, chrisdior4, cryptphi, csanuragjain, cygaar, defsec, descharre, erictee, gzeon, hansfriese, horsefacts, joestakey, koxuan, lukris02, luxartvinsec, nicobevi, oyc_109, pavankv, peanuts, rbserver, scokaf, shark, tnevler, tsvetanovv, zaskoh
36.2377 USDC - $36.24
CashManager
constructorhttps://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L168
The currentEpoch
variable is initialized with the same variable.
setPendingMintBalance
doesn't check for mint limithttps://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L336-L350
The function setPendingMintBalance
of the CashManager
contract allows the manager to set the mint balance for a user, but fails to check and update the mint limit (mintLimit
variable).
overrideExchangeRate
fails to validate the new exchange ratehttps://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L366-L385
The overrideExchangeRate
function of the CashManager
contract should validate that the new rate is not 0 (correctExchangeRate != 0
).
address(0)
Occurrences:
sweepToken
function is missing in the CErc20DelegatorKYC
contractThe sweepToken
function which is implemented in the original Compound contract (https://github.com/compound-finance/compound-protocol/blob/3affca87636eecd901eb43f81a4813186393905d/contracts/CErc20Delegator.sol#L330-L336) is missing in the CErc20DelegatorKYC
contract.
#0 - c4-judge
2023-01-23T14:54:07Z
trust1995 marked the issue as grade-b
🌟 Selected for report: c3phas
Also found by: 0x1f8b, 0xSmartContract, Aymen0909, Bnke0x0, Diana, IllIllI, RaymondFam, Rolezn, Sathish9098, SleepingBugs, Viktor_Cortess, adriro, arialblack14, chaduke, cryptostellar5, cygaar, descharre, dharma09, eyexploit, halden, pavankv, saneryee, tsvetanovv
32.3616 USDC - $32.36
_checkAndUpdateRedeemLimit
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L642
The function _checkAndUpdateRedeemLimit
in the CashManager
contract validates that the amount is not 0, but this is redundant since the same amount has been validated against in the minimumRedeemAmount
variable as part of the requestRedemption
function.
CashManager line 865 can be unchecked due to the if guard in line 864.
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L865
CashManager line 867 can be unchecked due to the if guard in line 866.
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L867
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashFactory.sol#L79 https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashKYCSenderFactory.sol#L81 https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashKYCSenderReceiverFactory.sol#L81
All three factories (CashFactory
, CashKYCSenderFactory
and deployCashKYCSenderReceiver
) deploy an individual implementation contract when deploying a new proxy.
This is not needed and can be safely removed. New proxies can point to the same implementation to save gas.
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashFactory.sol#L49-L51 https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashKYCSenderFactory.sol#L49-L51 https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/factory/CashKYCSenderReceiverFactory.sol#L49-L51
Each of the three factories (CashFactory
, CashKYCSenderFactory
and deployCashKYCSenderReceiver
) will store a reference to each deployed contract (implementation, proxy and admin) in storage when a new token is deployed.
This shouldn't be needed and probably doesn't make any sense, and can be removed to save gas.
The KYCRegistry
contract keeps a mapping between group level and the role identifier assigned to that group.
This logic may be simplified by taking the role identifier as a predefined function of the group level. For example, using bytes32 GROUP_ROLE = keccak(kycRequirementGroup)
.
This eliminates the need to maintain the kycGroupRoles
, which can be removed, and saves one storage lookup when calling the function of the KYCRegistry contract.
#0 - c4-judge
2023-01-23T14:54:20Z
trust1995 marked the issue as grade-b