Platform: Code4rena
Start Date: 31/01/2023
Pot Size: $90,500 USDC
Total HM: 47
Participants: 169
Period: 7 days
Judge: LSDan
Total Solo HM: 9
Id: 211
League: ETH
Rank: 48/169
Findings: 3
Award: $319.58
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: 0xdeadbeef0x
Also found by: 0Kage, 0xNazgul, 0xRobocop, Aymen0909, KIntern_NA, Kenshin, KingNFT, Krace, Kumpa, SadBase, aashar, apvlki, btk, cccz, critical-or-high, eccentricexit, fs0c, gjaldon, hansfriese, immeas, mert_eren, mgf15, mrpathfindr, orion, peanuts, rvi0x, rvierdiiev, supernova, ulqiorra, waldenyan20, y1cunhui
4.6115 USDC - $4.61
https://github.com/code-423n4/2023-01-popcorn//blob/main/src/utils/MultiRewardStaking.sol#L170-L188
Reentrancy attack allows to drain MultiRewardStaking.sol
funds due to non-adherence with CEI pattern.
Note: There is no re-entry risk on true ERC-20 tokens that work according to the spec, however ERC777 tokens can and they can maskerade as ERC20. So if a contract interacts with unknown ERC20 tokens it is better to be safe and consider that transfers can create reentrancy problems.
When the attacker call claimRewards()
, the function will revert if any of the rewardTokens have zero rewards accrued, and a percentage of each reward can be locked in an escrow contract if this was previously configured.
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); } else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } accruedRewards[user][_rewardTokens[i]] = 0; } }
What is interesting for us is this check if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]);
, this check is done in the top of function. Therefore, when the attacker escrowInfo.escrowPercentage > 0
, the _lockToken
function will get executed:
function _lockToken( address user, IERC20 rewardToken, uint256 rewardAmount, EscrowInfo memory escrowInfo ) internal { uint256 escrowed = rewardAmount.mulDiv(uint256(escrowInfo.escrowPercentage), 1e18, Math.Rounding.Down); uint256 payout = rewardAmount - escrowed; rewardToken.safeTransfer(user, payout); escrow.lock(rewardToken, user, escrowed, escrowInfo.escrowDuration, escrowInfo.offset); }
Which will than transfer the payout
to the attacker through SafeERC20.safeTransfer
, thus the ERC777 hook get triggered which will allow the attacker to reenter and claim again because the claimRewards()
didn't update its state before making an external call as you can see on line 186.
Manual Review
Follow the CEI pattern:
function claimRewards(address user, IERC20[] memory _rewardTokens) external accrueRewards(msg.sender, user) { for (uint8 i; i < _rewardTokens.length; i++) { uint256 rewardAmount = accruedRewards[user][_rewardTokens[i]]; if (rewardAmount == 0) revert ZeroRewards(_rewardTokens[i]); EscrowInfo memory escrowInfo = escrowInfos[_rewardTokens[i]]; accruedRewards[user][_rewardTokens[i]] = 0; if (escrowInfo.escrowPercentage > 0) { _lockToken(user, _rewardTokens[i], rewardAmount, escrowInfo); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, true); } else { _rewardTokens[i].transfer(user, rewardAmount); emit RewardsClaimed(user, _rewardTokens[i], rewardAmount, false); } } }
or use OpenZeppelin ReentrancyGuardUpgradeable.sol
.
#0 - c4-judge
2023-02-16T07:39:34Z
dmvt marked the issue as duplicate of #54
#1 - c4-sponsor
2023-02-18T12:11:05Z
RedVeil marked the issue as sponsor confirmed
#2 - c4-judge
2023-02-23T00:50:17Z
dmvt marked the issue as partial-50
#3 - c4-judge
2023-02-23T01:09:53Z
dmvt changed the severity to 3 (High Risk)
#4 - c4-judge
2023-03-01T00:33:23Z
dmvt marked the issue as full credit
#5 - c4-judge
2023-03-01T00:43:10Z
dmvt marked the issue as satisfactory
🌟 Selected for report: csanuragjain
Also found by: 0xNazgul, 0xNineDec, 0xSmartContract, 0xdeadbeef0x, Bauer, Deivitto, Josiah, KIntern_NA, RaymondFam, Rolezn, UdarTeam, Viktor_Cortess, btk, joestakey, koxuan, pavankv, rbserver, rvi0x
9.1667 USDC - $9.17
Judge has assessed an item in Issue #92 as 2 risk. The relevant finding follows:
[L-09] ERC4626 does not work with fee-on-transfer tokens Description The ERC4626 deposit/mint functions do not work well with fee-on-transfer tokens as the assets variable is the pre-fee amount, including the fee, whereas the totalAssets do not include the fee anymore.
This can be abused to mint more shares than desired.
Lines of code AdapterBase.sol Vault.sol Recommended Mitigation Steps assets should be the amount excluding the fee (i.e the amount the contract actually received), therefore it's recommended to use the balance change before and after the transfer instead of the amount.
#0 - c4-judge
2023-03-01T01:12:55Z
dmvt marked the issue as duplicate of #503
#1 - c4-judge
2023-03-01T01:31:21Z
dmvt marked the issue as satisfactory
🌟 Selected for report: IllIllI
Also found by: 0x3b, 0xAgro, 0xBeirao, 0xMirce, 0xNineDec, 0xRobocop, 0xSmartContract, 0xTraub, 0xWeiss, 2997ms, 41i3xn, Awesome, Aymen0909, Bauer, Bnke0x0, Breeje, Cryptor, DadeKuma, Deathstore, Deekshith99, DevABDee, DevTimSch, Dewaxindo, Diana, Ermaniwe, Guild_3, H0, IceBear, Inspectah, JDeryl, Kaiziron, Kaysoft, Kenshin, Mukund, Praise, RaymondFam, Rickard, Rolezn, Ruhum, Sathish9098, SkyWalkerMan, SleepingBugs, UdarTeam, Udsen, Walter, aashar, adeolu, apvlki, arialblack14, ast3ros, btk, chaduke, chandkommanaboyina, chrisdior4, climber2002, codetilda, cryptonue, cryptostellar5, csanuragjain, ddimitrov22, descharre, dharma09, doublesharp, eccentricexit, ethernomad, fs0c, georgits, halden, hansfriese, hashminer0725, immeas, lukris02, luxartvinsec, matrix_0wl, merlin, mookimgo, mrpathfindr, nadin, olegthegoat, pavankv, rbserver, rebase, savi0ur, sayan, scokaf, seeu, shark, simon135, tnevler, tsvetanovv, ulqiorra, ustas, waldenyan20, y1cunhui, yongskiws, yosuke
305.798 USDC - $305.80
Total Low issues |
---|
Risk | Issues Details | Number |
---|---|---|
[L-01] | No Storage Gap for Upgradeable contracts | 4 |
[L-02] | Loss of precision due to rounding | 2 |
[L-03] | Allows malleable SECP256K1 signatures | 3 |
[L-04] | Integer overflow by unsafe casting | 17 |
[L-05] | Misleading NatSpec | 1 |
[L-06] | Value is not validated to be different than the existing one | 5 |
[L-07] | Unhandled return values of transfer and transferFrom | 3 |
[L-08] | Not all tokens support approve-max | 8 |
[L-09] | ERC4626 does not work with fee-on-transfer tokens | 2 |
[L-10] | ERC4626 implmentation is not fully up to EIP-4626's specification | 2 |
[L-11] | Missing checks for address(0) | 2 |
[L-12] | Missing Event for initialize | 4 |
Total Non-Critical issues |
---|
Risk | Issues Details | Number |
---|---|---|
[NC-01] | Open TODO | 1 |
[NC-02] | Include @return parameters in NatSpec comments | All Contracts |
[NC-03] | Lines are too long | 7 |
[NC-04] | Signature Malleability of EVM's ecrecover() | 3 |
[NC-05] | Use scientific notation rather than exponentiation | 3 |
[NC-06] | Use bytes.concat() and string.concat() | 7 |
[NC-07] | Use a more recent version of solidity | All Contracts |
[NC-08] | Lock pragmas to specific compiler version | All Contracts |
[NC-09] | Constants in comparisons should appear on the left side | 15 |
[NC-10] | Function writing does not comply with the Solidity Style Guide | All Contracts |
[NC-11] | NatSpec comments should be increased in contracts | All Contracts |
[NC-12] | Follow Solidity standard naming conventions | All Contracts |
[NC-13] | Consider using delete rather than assigning zero to clear values | 2 |
[NC-14] | Critical changes should use-two step procedure | 1 |
[NC-15] | Contracts should have full test coverage | All Contracts |
[NC-16] | Using vulnerable dependency of solmate | 1 |
[NC-17] | Use SMTChecker | |
[NC-18] | Not using the named return variables anywhere in the function is confusing | 1 |
[NC-19] | Using vulnerable dependency of OpenZeppelin | 1 |
[NC-20] | Events that mark critical parameter changes should contain both the old and the new value | 1 |
[NC-21] | Add a timelock to critical functions | 7 |
[NC-22] | Need Fuzzing test | All Contracts |
[NC-23] | Add NatSpec comment to mapping | 9 |
For upgradeable contracts, inheriting contracts may introduce new variables. In order to be able to add new variables to the upgradeable contract without causing storage collisions, a storage gap should be added to the upgradeable contract.
See this for a description of this storage variable.
/** * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ uint256[50] private __gap;
Loss of precision due to the nature of arithmetics and rounding errors.
lockedProfit - ((lockedFundsRatio * lockedProfit) / DEGRADATION_COEFFICIENT);
SECP256K1
signaturesHomestead (EIP-2) added this limitation, however the precompile remained unaltered. The majority of libraries, including OpenZeppelin, do this check.
Use OpenZeppelin's ECDSA
library for signature verification.
Keep in mind that the version of solidity used, despite being greater than 0.8, does not prevent integer overflows during casting, it only does so in mathematical operations.
It is necessary to safely convert between the different numeric types.
Math.min( (escrow.balance * (block.timestamp - uint256(escrow.lastUpdateTime))) / (uint256(escrow.end) - uint256(escrow.lastUpdateTime)), escrow.balance );
Use OpenZeppelin safeCast library.
This line is missliding because the function should look at canCreate()
and not the onlyOwner()
:
/** * @notice Deploy a new staking contract. Caller must be owner. * @param asset The staking token for the new contract. * @dev Deploys `MultiRewardsStaking` based on the latest templateTemplateKey. */ function deployStaking(IERC20 asset) external canCreate returns (address) { _verifyToken(address(asset)); return _deployStaking(asset, deploymentController); }
Fix the NtaSpec comment:
/** * @notice Deploy a new staking contract. The caller must be added as an allowed creator. * @param asset The staking token for the new contract. * @dev Deploys `MultiRewardsStaking` based on the latest templateTemplateKey. */ function deployStaking(IERC20 asset) external canCreate returns (address) { _verifyToken(address(asset)); return _deployStaking(asset, deploymentController); }
While the value is validated to be in the constant bounds, it is not validated to be different than the existing one. Queueing the same value will cause multiple abnormal events to be emitted, will ultimately result in a no-op situation.
function setPerformanceFee(uint256 newFee) external onlyOwner { // Dont take more than 20% performanceFee if (newFee > 2e17) revert InvalidPerformanceFee(newFee); emit PerformanceFeeChanged(performanceFee, newFee); performanceFee = newFee; }
function setPerformanceFee(uint256 newFee) external onlyOwner { if (newFee == performanceFee) revert(); // Dont take more than 20% performanceFee if (newFee > 2e17) revert InvalidPerformanceFee(newFee); emit PerformanceFeeChanged(performanceFee, newFee); performanceFee = newFee; }
transfer
and transferFrom
ERC20 implementations are not always consistent. Some implementations of transfer
and transferFrom
could return false on failure instead of reverting. It is safer to check the return value and revert on false to prevent these failures.
This is a known issue with solmate and Opanzeppelin ERC20 libraries, For example, Some tokens do not implement the ERC20 standard properly but are still accepted by most code that accepts ERC20 tokens, hence Tether (USDT) transfer()
and transferFrom()
functions do not return booleans as the specification requires, and instead have no return value.
Check the return value and revert on false.
Some tokens consider uint256 as just another value. If one of these tokens is used and the approval balance eventually reaches only a couple wei, nobody will be able to send funds because there won't be enough approval.
rewardToken.safeApprove(address(escrow), type(uint256).max);
The ERC4626 deposit/mint functions do not work well with fee-on-transfer tokens as the assets
variable is the pre-fee amount, including the fee, whereas the totalAssets
do not include the fee anymore.
This can be abused to mint more shares than desired.
assets
should be the amount excluding the fee (i.e the amount the contract actually received), therefore it's recommended to use the balance change before and after the transfer instead of the amount.
Must return the maximum amount of shares mint would allow to be deposited to receiver and not cause a revert, which must not be higher than the actual maximum that would be accepted (it should underestimate if necessary).
This assumes that the user has infinite assets, i.e. must not rely on balanceOf of asset.
function maxDeposit(address) public view virtual override returns (uint256) { return paused() ? 0 : type(uint256).max; }
function maxMint(address) public view virtual override returns (uint256) { return paused() ? 0 : type(uint256).max; }
maxMint()
and maxDeposit()
should reflect the limitation of maxSupply.
address(0)
Check of address(0)
to protect the code from (0x0000000000000000000000000000000000000000)
address problem just in case. This is best practice or instead of suggesting that they verify _address != address(0)
, you could add some good NatSpec comments explaining what is valid and what is invalid and what are the implications of accidentally using an invalid address.
constructor( address _owner, ICloneFactory _cloneFactory, ICloneRegistry _cloneRegistry, ITemplateRegistry _templateRegistry ) Owned(_owner) { cloneFactory = _cloneFactory; cloneRegistry = _cloneRegistry; templateRegistry = _templateRegistry; }
Add checks for address(0)
when assigning values to address state variables.
Events help non-contract tools to track changes, and events prevent users from being surprised by changes Issuing event-emit during initialization is a detail that many projects skip.
function initialize( bytes memory adapterInitData, address registry, bytes memory beefyInitData ) external initializer { (address _beefyVault, address _beefyBooster) = abi.decode( beefyInitData, (address, address) ); __AdapterBase_init(adapterInitData); if (!IPermissionRegistry(registry).endorsed(_beefyVault)) revert NotEndorsed(_beefyVault); if (IBeefyVault(_beefyVault).want() != asset()) revert InvalidBeefyVault(_beefyVault); if ( _beefyBooster != address(0) && IBeefyBooster(_beefyBooster).stakedToken() != _beefyVault ) revert InvalidBeefyBooster(_beefyBooster); _name = string.concat( "Popcorn Beefy", IERC20Metadata(asset()).name(), " Adapter" ); _symbol = string.concat("popB-", IERC20Metadata(asset()).symbol()); beefyVault = IBeefyVault(_beefyVault); beefyBooster = IBeefyBooster(_beefyBooster); beefyBalanceCheck = IBeefyBalanceCheck( _beefyBooster == address(0) ? _beefyVault : _beefyBooster ); IERC20(asset()).approve(_beefyVault, type(uint256).max); if (_beefyBooster != address(0)) IERC20(_beefyVault).approve(_beefyBooster, type(uint256).max); }
Add Event-Emit
The code that contains "open todos" reflects that the development is not finished and that the code can change a posteriori, prior release, with or without audit.
// TODO use deterministic fee recipient proxy
@return
parameters in NatSpec commentsIf Return parameters are declared, you must prefix them with /// @return
. Some code analysis programs do analysis by reading NatSpec details, if they can't see the @return
tag, they do incomplete analysis.
Include the @return
argument in the NatSpec comments.
Usually lines in source code are limited to 80 characters. Today's screens are much larger so it's reasonable to stretch this in some cases. Since the files will most likely reside in GitHub, and GitHub starts using a scroll bar in all cases when the length is over 164 characters, the lines below should be split when they reach that length.
Reference: https://docs.soliditylang.org/en/v0.8.10/style-guide.html#maximum-line-length
ecrecover()
The function calls the Solidity ecrecover()
function directly to verify the given signatures. However, the ecrecover()
EVM opcode allows malleable (non-unique) signatures and thus is susceptible to replay attacks.
Although a replay attack seems not possible for this contract, I recommend using the battle-tested OpenZeppelin's ECDSA library.
Use the ecrecover function from OpenZeppelin's ECDSA library for signature verification. (Ensure using a version > 4.7.3 for there was a critical bug >= 4.1.0 < 4.7.3).
While the compiler knows to optimize away the exponentiation, it's still better coding practice to use idioms that do not require compiler optimization, if they exist.
uint256 constant DEGRADATION_COEFFICIENT = 10**18;
Use scientific notation (e.g. 1e18)
rather than exponentiation (e.g. 10**18)
.
bytes.concat()
and string.concat()
bytes.concat()
vs abi.encodePacked(<bytes>,<bytes>)
string.concat()
vs abi.encodePacked(<string>,<string>)
Use bytes.concat()
and string.concat()
.
For security, it is best practice to use the latest Solidity version.
pragma solidity ^0.8.15;
Old version of Solidity is used (0.8.15)
, newer version can be used (0.8.17)
.
Pragma statements can be allowed to float when a contract is intended for consumption by other developers, as in the case with contracts in a library or EthPM package. Otherwise, the developer would need to manually update the pragma in order to compile locally.
pragma solidity ^0.8.15;
Ethereum Smart Contract Best Practices: Lock pragmas to specific compiler version.
Constants in comparisons should appear on the left side, doing so will prevent typo bug.
if (amount == 0) revert ZeroAmount();
if (0 == amount) revert ZeroAmount();
Solidity Style Guide
Ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier. But there are contracts in the project that do not comply with this.
Functions should be grouped according to their visibility and ordered:
constructor()
receive()
fallback()
external
/ public
/ internal
/ private
view
/ pure
Follow Solidity Style Guide.
It is recommended that Solidity contracts are fully annotated using NatSpec, it is clearly stated in the Solidity official documentation.
In complex projects such as Defi, the interpretation of all functions and their arguments and returns is important for code readability and auditability.
Some code analysis programs do analysis by reading NatSpec details, if they can't see the tags (@param, @dev, @return)
, they do incomplete analysis.
Include NatSpec comments in the codebase.
The protocol don't follow solidity standard naming convention.
Ref: https://docs.soliditylang.org/en/v0.8.17/style-guide.html#naming-conventions
Follow solidity standard naming convention.
delete
rather than assigning zero to clear valuesThe delete
keyword more closely matches the semantics of what is being done, and draws more attention to the changing of state, which may lead to a more thorough audit of its associated logic.
accruedRewards[user][_rewardTokens[i]] = 0;
delete accruedRewards[user][_rewardTokens[i]];
Critical changes should use-two step procedure:
/** * @notice Change `feeRecipient`. Caller must be Owner. * @param _feeRecipient The new fee recipient. * @dev Accrued fees wont be transferred to the new feeRecipient. */ function setFeeRecipient(address _feeRecipient) external onlyOwner { if (_feeRecipient == address(0)) revert InvalidFeeRecipient(); emit FeeRecipientUpdated(feeRecipient, _feeRecipient); feeRecipient = _feeRecipient; }
Consider adding two step procedure on the critical functions where the first is announcing a pendingFeeRecipient
and the new address should then claim its role.
While 100% code coverage does not guarantee that there are no bugs, it often will catch easy-to-find bugs, and will ensure that there are fewer regressions when the code invariably has to be modified. Furthermore, in order to get full coverage, code authors will often have to re-organize their code so that it is more modular, so that each component can be tested separately, which reduces interdependencies between modules and layers, and makes for code that is easier to reason about and audit.
- What is the overall line coverage percentage provided by your tests?: 94.52%
Line coverage percentage should be 100%.
The package.json
configuration file says that the project is using 6.6.2
of solmate which has a not last update version.
"name": "solmate", "license": "AGPL-3.0-only", "version": "6.6.2", "description": "Modern, opinionated and gas optimized building blocks for smart contract development.", "files": [ "src/**/*.sol" ],
Use solmate latest version 6.7.0
.
The highest tier of smart contract behavior assurance is formal mathematical verification. All assertions that are made are guaranteed to be true across all inputs → The quality of your asserts is the quality of your verification.
Ref: https://twitter.com/0xOwenThurm/status/1614359896350425088?t=dbG9gHFigBX85Rv29lOjIQ&s=19
Use SMTChecker.
Not using the named return variables anywhere in the function is confusing:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256 shares) { uint256 _totalSupply = totalSupply(); uint256 _totalAssets = totalAssets(); return (assets == 0 || _totalSupply == 0 || _totalAssets == 0) ? assets : assets.mulDiv(_totalSupply, _totalAssets, rounding); }
Consider changing the variable to be an unnamed one:
function _convertToShares(uint256 assets, Math.Rounding rounding) internal view virtual override returns (uint256) { uint256 _totalSupply = totalSupply(); uint256 _totalAssets = totalAssets(); return (assets == 0 || _totalSupply == 0 || _totalAssets == 0) ? assets : assets.mulDiv(_totalSupply, _totalAssets, rounding); }
The package.json configuration file says that the project is using 4.7.1 of OpenZeppelin which has a not last update version.
{ "name": "@openzeppelin/contracts-upgradeable", "description": "Secure Smart Contract library for Solidity", "version": "4.7.1", "files": [ "**/*.sol", "/build/contracts/*.json", "!/mocks/**/*" ],
Use patched versions Latest non vulnerable version 4.8.0.
Events that mark critical parameter changes should contain both the old and the new value:
function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { if (_quitPeriod < 1 days || _quitPeriod > 7 days) revert InvalidQuitPeriod(); quitPeriod = _quitPeriod; emit QuitPeriodSet(quitPeriod); }
event QuitPeriodSet(uint256 previousQuitPeriod, uint256 newQuitPeriod); function setQuitPeriod(uint256 _quitPeriod) external onlyOwner { if (_quitPeriod < 1 days || _quitPeriod > 7 days) revert InvalidQuitPeriod(); emit QuitPeriodSet(quitPeriod, _quitPeriod); quitPeriod = _quitPeriod; }
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate.
Consider adding a timelock to the critical changes.
As Alberto Cuesta Canada said: Fuzzing is not easy, the tools are rough, and the math is hard, but it is worth it. Fuzzing gives me a level of confidence in my smart contracts that I didn’t have before. Relying just on unit testing anymore and poking around in a testnet seems reckless now.
Ref: https://medium.com/coinmonks/smart-contract-fuzzing-d9b88e0b0a05
Use fuzzing test like Echidna.
Add NatSpec comments describing mapping keys and values.
mapping(address => uint256) public nonces;
/// @dev address(user) => uint256(nonce) mapping(address => uint256) public nonces;
#0 - c4-judge
2023-03-01T00:13:34Z
dmvt marked the issue as grade-a