Platform: Code4rena
Start Date: 09/12/2022
Pot Size: $36,500 USDC
Total HM: 9
Participants: 69
Period: 3 days
Judge: Picodes
Total Solo HM: 2
Id: 190
League: ETH
Rank: 6/69
Findings: 3
Award: $1,354.04
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: zaskoh
Also found by: Tricko, deliriusz, rvierdiiev, unforgiven
1273.0771 USDC - $1,273.08
WithdrawalHook::lastUserPeriodReset
is global for all users, which means that each time that lastUserPeriodReset + userPeriodLength < block.timestamp
, only one user will have a chance to update lastUserPeriodReset
and reset theirs userToAmountWithdrawnThisPeriod
. In extreme cases, either by malicious user, or high amount of withdrawals spread accross long time, a user that reached userToAmountWithdrawnThisPeriod
in one period, would suffer tokens locked accross multiple periods. In an edge case, malicious user can DoS others for days, weeks or even months, depending on their will, as minimum withdrawal is relatively small.
Proof of concept is prepared based on the test files:
Drop following code in WithdrawHook.test.js under line 372 and add line await provider.send("evm_mine", [])
in smart-contracts-utils.ts::setNextTimestamp()`
describe('# exploitsUserWithdrawal', () => { it('is able to block user from withdrawal', async () => { let previousResetTimestamp = await getLastTimestamp(ethers.provider) // actually this function does not work properly, as increasing timestamp has to be followed by: // await provider.send("evm_mine", []) // Otherwise the timestamp doesn't increase. I added it in the utils file for the test to work properly await setNextTimestamp( ethers.provider, previousResetTimestamp + TEST_USER_PERIOD_LENGTH - 1 ) const secondUserWithdrawLimit = TEST_USER_WITHDRAW_LIMIT.div(3); //This is a prerequisite for this issue - user has to reach limit in one withdrawal period await withdrawHook .connect(collateralSigner) .hook(user.address, TEST_USER_WITHDRAW_LIMIT, TEST_USER_WITHDRAW_LIMIT) await withdrawHook .connect(collateralSigner) .hook(collateralSigner.address, secondUserWithdrawLimit, secondUserWithdrawLimit) expect(await withdrawHook.getAmountWithdrawnThisPeriod(user.address)).to.eq( TEST_USER_WITHDRAW_LIMIT ) expect(await withdrawHook.getAmountWithdrawnThisPeriod(collateralSigner.address)).to.eq( secondUserWithdrawLimit ) await expect( withdrawHook.connect(collateralSigner).hook(user.address, 100, 100) ).to.revertedWith('user withdraw limit exceeded') previousResetTimestamp = await getLastTimestamp(ethers.provider) await setNextTimestamp( ethers.provider, previousResetTimestamp + TEST_USER_PERIOD_LENGTH + 1 ) await expect( withdrawHook.connect(collateralSigner).hook(collateralSigner.address, 1, 1) ).to.not.be.reverted await expect( withdrawHook.connect(collateralSigner).hook(user.address, 1, 1) ).to.revertedWith('user withdraw limit exceeded') //Now some time passes, and all users should have their personal limits cleared previousResetTimestamp = await getLastTimestamp(ethers.provider) await setNextTimestamp( ethers.provider, previousResetTimestamp + TEST_USER_PERIOD_LENGTH + 1 ) await expect( withdrawHook.connect(collateralSigner).hook(collateralSigner.address, 1, 1) ).to.not.be.reverted expect(await withdrawHook.getAmountWithdrawnThisPeriod(collateralSigner.address)).to.eq(1) //However because the user was not the first in the block, his limit persists await expect( withdrawHook.connect(collateralSigner).hook(user.address, 1, 1) ).to.revertedWith('user withdraw limit exceeded') // As long as the user won't be the first to withdraw after new period starts, his // current limit will persist, which can lead to DoS for very long time by a malicious user expect(await withdrawHook.getAmountWithdrawnThisPeriod(user.address)).to.eq( TEST_USER_WITHDRAW_LIMIT ) }) })
VS Code, hardhat
I see two options here:
Probably the first option is better, as single user withdrawal may not be such an issue compared to the global withdrawal, and tracking it per user basis would increase gas usage.
#0 - c4-judge
2022-12-17T21:31:12Z
Picodes marked the issue as duplicate of #325
#1 - c4-judge
2023-01-01T17:10:04Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-01-07T11:22:58Z
Picodes changed the severity to 3 (High Risk)
🌟 Selected for report: obront
Also found by: 8olidity, HE1M, Madalad, Trust, cccz, csanuragjain, deliriusz, hansfriese, hihen, joestakey, rvierdiiev, wait, zaskoh
52.8446 USDC - $52.84
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L80-L83 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/WithdrawHook.sol#L58
Manager for Collateral
smart contract has complete role over collateral funds and is able to withdraw all of it
The assumption is that the manager is honest, even though there is a big risk with wrongly configured MultiSig (e.g. admin that can circumvent M of N votes to take an action), keys compromise, even moving funds to unepected address which would lead to funds being lost.
Manager can call managerWithdraw to get all the funds:
80: function managerWithdraw(uint256 _amount) external override onlyRole(MANAGER_WITHDRAW_ROLE) nonReentrant { 81: if (address(managerWithdrawHook) != address(0)) managerWithdrawHook.hook(msg.sender, _amount, _amount); 82: baseToken.transfer(manager, _amount); 83: }
or halt withdrawals:
Colateral.sol 73: withdrawHook.hook(msg.sender, _baseTokenAmount, _baseTokenAmountAfterFee);
WithdrawalHook.sol //withdrawalsAllowed is set by manager account 58: require(withdrawalsAllowed, "withdrawals not allowed");//@audit-issue LOW - by default it's false, any withdrawal will fail
VS Code
If only function of this is to move funds to some yield bearing protocol (e.g. Aave, DEXes), please implement supposed functionality in the smart contract. Otherwise, it's best to remove the function completely.
#0 - hansfriese
2022-12-14T17:48:48Z
duplicate of #254
#1 - c4-judge
2022-12-17T09:55:26Z
Picodes marked the issue as duplicate of #254
#2 - Picodes
2022-12-17T10:27:06Z
Also dup of #305
#3 - c4-judge
2023-01-01T17:42:42Z
Picodes marked the issue as satisfactory
🌟 Selected for report: 0xSmartContract
Also found by: 0Kage, 0x52, 0xAgro, 0xNazgul, 0xTraub, 0xhacksmithh, Awesome, Aymen0909, Bnke0x0, Englave, Janio, Mukund, Parth, RaymondFam, Rolezn, SmartSek, Tointer, UdarTeam, Udsen, Zarf, caventa, chaduke, csanuragjain, deliriusz, gz627, idkwhatimdoing, izhelyazkov, joestakey, neumo, obront, oyc_109, rvierdiiev, shark, trustindistrust, wait, yongskiws
28.124 USDC - $28.12
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L29 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/PrePOMarketFactory.sol#L16
While the smart contracts in scope of this issue are initialized in proxy contract, leaving them uninitalized may lead to unexpected behaviours, as anyone may initialize them. Best practise in this case is addimg "initializer" midifier to the constructor to disallow that. Please reffer to the documentation: https://docs.openzeppelin.com/contracts/4.x/api/proxy#TransparentUpgradeableProxy
" ...An uninitialized contract can be taken over by an attacker. This applies to both a proxy and its implementation contract, which may impact the proxy... "
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L28 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/PrePOMarketFactory.sol#L15
Even though the smart contracts in scope are not being inherited by any smart contract, it's a good practise to create uint256[50 - <maxStorageLot>] __gap;
, hwich prevents storage clashing in proxy, in case that the contract was to be inherited from in the future.
Please reffer to https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps for details.
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/Collateral.sol#L53 https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/apps/smart-contracts/core/contracts/DepositHook.sol#L44 if depositsAllowed is false && Collateral address is set, it will brick deposits
https://github.com/prepo-io/prepo-monorepo/blob/feat/2022-12-prepo/packages/prepo-shared-contracts/contracts/SafeAccessControlEnumerableUpgradeable.sol#L65
Even though this contract is not in scope, some contracts that are in scope inherit from it and are directly impacted by this issue.
All storage slots used with __gap
array elements count should sum up to constant number, usually 50 - not updating it whenever storage layout changes may shift whole storage layout and lead to unxepected behaviour. And there are 2 storage slots already used by mappings in this contract, which together with the gap sum up to 52 slots.
Actually, documentation quoted in the comment above the __gap
says just that:
The size of the __gap array is calculated so that the amount of storage used by a contract always adds up to the same number (in this case 50 storage slots).
uint256 _collateralMintAmount = (_amountAfterFee * 1e18) / baseTokenDenominator; uint256 _baseTokenAmount = (_amount * baseTokenDenominator) / 1e18;
This issue concerns all the contracts in scope
15: WithdrawERC20, // TODO: Access control when WithdrawERC20 updated
According to docs https://docs.prepo.io/concepts/prect : "preCT is backed by a basket of yield bearing USD stablecoins", while Collateral allows only for one token. Documentation should be probably updated to reflect that.
#0 - c4-judge
2022-12-19T13:57:28Z
Picodes marked the issue as grade-b
#1 - ghost
2022-12-22T10:41:16Z
L-04 is invalid